diff --git a/.github/release.yml b/.github/release.yml index 70852dcbb2..fe88fab0ef 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -19,6 +19,10 @@ changelog: labels: - Ignore for release categories: + - title: 'Security' + labels: + - 'Security' + - title: 'Major Core & Rule Engine' labels: - 'Major Core' diff --git a/.github/workflows/license-header-format.yml b/.github/workflows/license-header-format.yml index 0ac41c7bc8..6be1171b32 100644 --- a/.github/workflows/license-header-format.yml +++ b/.github/workflows/license-header-format.yml @@ -35,7 +35,7 @@ jobs: uses: actions/setup-java@v4 with: distribution: 'corretto' # https://github.com/actions/setup-java?tab=readme-ov-file#supported-distributions - java-version: '21' + java-version: '25' cache: 'maven' # https://github.com/actions/setup-java?tab=readme-ov-file#caching-sbt-dependencies - name: License header format diff --git a/.gitignore b/.gitignore index e9a4e11b85..ee8c78dad3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,5 @@ rebuild-docker.sh */.run/** .run/** .run +.claude +.planning diff --git a/README.md b/README.md index 6267cc3a7a..169a276db4 100644 --- a/README.md +++ b/README.md @@ -8,15 +8,13 @@
-💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • ▶️ [Live demo](https://demo.thingsboard.io/signup) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all) +💡 [Get started](https://thingsboard.io/docs/getting-started-guides/helloworld/) • 🌐 [Website](https://thingsboard.io/) • 📚 [Documentation](https://thingsboard.io/docs/) • 📔 [Blog](https://thingsboard.io/blog/) • 🔗 [LinkedIn](https://www.linkedin.com/company/thingsboard/posts/?feedView=all)
## 🚀 Installation options -* Install ThingsBoard [On-premise](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) -* Try [ThingsBoard Cloud](https://thingsboard.io/installations/) -* or [Use our Live demo](https://demo.thingsboard.io/signup) +Install ThingsBoard [on-premises](https://thingsboard.io/docs/user-guide/install/installation-options/?ceInstallType=onPremise) or use [ThingsBoard Cloud](https://thingsboard.io/installations/). ## 💡 Getting started with ThingsBoard @@ -123,9 +121,9 @@ ThingsBoard is a scalable, user-friendly, and device-agnostic IoT platform that [![SCADA Swimming pool](https://github.com/user-attachments/assets/68fd9e29-99f1-4c16-8c4c-476f4ccb20c0 "SCADA Swimming pool")](https://thingsboard.io/use-cases/scada/) -[**Fleet tracking**](https://thingsboard.io/use-cases/fleet-tracking/) +[**Site fleet tracking**](https://thingsboard.io/use-cases/site-fleet-tracking/) -[![Fleet tracking](https://github.com/user-attachments/assets/9e8938ba-ee0c-4599-9494-d74b7de8a63d "Fleet tracking")](https://thingsboard.io/use-cases/fleet-tracking/) +[![Site fleet tracking](https://github.com/user-attachments/assets/d6ce0766-b138-4a42-86aa-7112a543026c "Site fleet tracking")](https://thingsboard.io/use-cases/site-fleet-tracking/) [**Smart farming**](https://thingsboard.io/use-cases/smart-farming/) diff --git a/TEST_FAST.md b/TEST_FAST.md new file mode 100644 index 0000000000..eb2013c601 --- /dev/null +++ b/TEST_FAST.md @@ -0,0 +1,66 @@ + +## Running tests in parallel with a reasonable memory usage + +```bash +export MAVEN_OPTS="-Xmx1024m" +export NODE_OPTIONS="--max_old_space_size=4096" +export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError" + +# Compile and install all modules, skip packaging artifacts not needed for tests +mvn clean install -T6 -DskipTests -Dpkg.skip=true + +mvn test -pl='!application,!dao,!ui-ngx,!msa/js-executor,!msa/web-ui' -T4 +mvn test -pl dao -Dparallel=packages -DforkCount=4 + +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.controller.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.edge.**' -DforkCount=4 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.service.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.mqtt.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.coap.**' -DforkCount=6 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='!**/nosql/**,org.thingsboard.server.transport.lwm2m.**' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +mvn test -pl application -Dtest='**/*TestSuite.java' -DforkCount=4 -Dparallel=classes -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 + +#the rest of application tests +mvn test -pl application -Dtest=' +!**/nosql/**, +!org.thingsboard.server.controller.**, +!org.thingsboard.server.edge.**, +!org.thingsboard.server.service.**, +!org.thingsboard.server.transport.mqtt.**, +!org.thingsboard.server.transport.coap.**, +!org.thingsboard.server.transport.lwm2m.**, +!**/*TestSuite.java +' -DforkCount=6 -Dparallel=packages -Dsurefire.rerunFailingTestsCount=2 -Dsurefire.failOnFlakeCount=5 +``` + +## pkg.skip.* flags reference + +Use `-Dpkg.skip=true` to skip all packaging at once (equivalent to all four flags below). + +| Flag | Skips | Safe to skip for tests? | +|----------------------------|-------------------------------------------|--------------------------------------------------------------| +| `-Dpkg.skip=true` | All of the below (bootjar + deb + rpm + zip) | Yes | +| `-Dpkg.skip.bootjar=true` | `spring-boot:repackage` (`*-boot.jar`) | Yes — tests use the regular `.jar`, not the fat boot jar | +| `-Dpkg.skip.deb=true` | Gradle `buildDeb` + Maven `attach-artifact` | Yes — MSA docker modules copy the DEB from `target/` directly | +| `-Dpkg.skip.rpm=true` | Gradle `buildRpm` | Yes — no test depends on the RPM | +| `-Dpkg.skip.zip=true` | `maven-assembly-plugin` Windows ZIP | Yes — no test depends on the ZIP | + +## Testcontainers compatibility with the Docker API workaround + +In case your tests failed to run testcontainers due to unsupported Docker API version + +:coffee: testcontainers (Docker API 1.32) + :whale: docker 29 (min API 1.44) workaround + +Add to /etc/docker/daemon.json and restart docker +```json +{ + "min-api-version": "1.32" +} +``` + +Same works on Mac, except `daemon.json` are located in another folder and required to be edited from Docker Desktop UI. + +Tip: If your testcontainer are struggling to find any Docker. You can try to remove the testcontainers property file. It will be recreated on the next testcontainers run. +```bash +rm ~/.testcontainers.properties +``` diff --git a/application/pom.xml b/application/pom.xml index ab26655052..9489c627a1 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard application @@ -35,7 +35,6 @@ UTF-8 ${basedir}/.. java - false process-resources package thingsboard @@ -290,6 +289,11 @@ rest-client test + + org.thingsboard.client + thingsboard-ce-client + test + org.springframework.security spring-security-test @@ -458,6 +462,7 @@ thingsboard + ${project.version} **/nosql/*Test.java @@ -492,10 +497,6 @@ org.apache.maven.plugins maven-assembly-plugin - - org.apache.maven.plugins - maven-install-plugin - org.xolstice.maven.plugins protobuf-maven-plugin @@ -506,6 +507,105 @@ + + + openapi-spec + + true + none + none + + + + + org.springframework.boot + spring-boot-maven-plugin + + org.thingsboard.server.ThingsboardServerApplication + 9001 + + + + build-info + build-info + + false + + + + openapi-start + start + + false + true + -Xmx1024m + + --spring.config.name=thingsboard + --spring.profiles.active=openapi + + 180 + 2000 + + + + openapi-stop + stop + + false + + + + + + org.springdoc + springdoc-openapi-maven-plugin + 1.4 + + + generate-openapi-spec + generate + + + + http://localhost:8080/v3/api-docs/thingsboard + openapi.json + ${project.build.directory} + + + + org.apache.maven.plugins + maven-enforcer-plugin + + + verify-openapi-spec-generated + verify + + enforce + + + + + + ${project.build.directory}/openapi.json + + OpenAPI spec was not generated — target/openapi.json is missing. The springdoc-openapi-maven-plugin logs HTTP failures but does not fail the build; scan the log above for "An error has occured" or a 5xx response from /v3/api-docs/thingsboard and fix the underlying issue (e.g. duplicate @Schema names rejected by SwaggerConfiguration). + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + + true + + + + + + jenkins diff --git a/application/src/main/data/json/edge/instructions/install/centos/instructions.md b/application/src/main/data/json/edge/instructions/install/centos/instructions.md index 1822add6d8..964cdcb887 100644 --- a/application/src/main/data/json/edge/instructions/install/centos/instructions.md +++ b/application/src/main/data/json/edge/instructions/install/centos/instructions.md @@ -1,22 +1,24 @@ -Here is the list of commands that can be used to quickly install ThingsBoard Edge on RHEL/CentOS 7/8 and connect to the server. +Here is the list of commands that can be used to quickly install ThingsBoard Edge on RHEL/CentOS 9/10 and connect to the server. + +**Note:** OpenJDK 25 requires RHEL/CentOS 9+ or RHEL/CentOS 10+. Earlier versions (RHEL/CentOS 7, 8) are not supported. #### Prerequisites Before continuing to installation, execute the following commands to install the necessary tools: ```bash -sudo yum install -y nano wget && sudo yum install -y https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm +sudo dnf install -y nano wget {:copy-code} ``` -#### Step 1. Install Java 17 (OpenJDK) -ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions: +#### Step 1. Install Java 25 (OpenJDK) +ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions: ```bash -sudo dnf install java-17-openjdk +sudo dnf install -y java-25-openjdk {:copy-code} ``` -Configure your operating system to use OpenJDK 17 by default. You can configure the default version by running the following command: +Configure your operating system to use OpenJDK 25 by default. You can configure the default version by running the following command: ```bash sudo update-alternatives --config java @@ -33,7 +35,7 @@ java -version The expected result is: ```text -openjdk version "17.x.xx" +openjdk version "25.x.xx" OpenJDK Runtime Environment (...) OpenJDK 64-Bit Server VM (build ...) ``` @@ -41,7 +43,7 @@ OpenJDK 64-Bit Server VM (build ...) #### Step 2. Configure ThingsBoard Edge Database ThingsBoard Edge supports **SQL** and **hybrid** database configurations. -In this guide, we’ll use an **SQL** database. +In this guide, we'll use an **SQL** database. For more details about the hybrid setup, please refer to the official installation instructions on the ThingsBoard documentation site. To install the PostgreSQL database, run these commands: @@ -54,19 +56,19 @@ sudo dnf update Install the repository RPM: -* **For CentOS/RHEL 8:** +* **For RHEL/CentOS 9:** ```bash -# Install the repository RPM (For CentOS/RHEL 8): -sudo sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-8-x86_64/pgdg-redhat-repo-latest.noarch.rpm +# Install the repository RPM (for RHEL/CentOS 9): +sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm {:copy-code} ``` -* **For CentOS/RHEL 9:** +* **For RHEL/CentOS 10:** ```bash -# Install the repository RPM (for CentOS 9): -sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm +# Install the repository RPM (for RHEL/CentOS 10): +sudo dnf -y install https://download.postgresql.org/pub/repos/yum/reporpms/EL-10-x86_64/pgdg-redhat-repo-latest.noarch.rpm {:copy-code} ``` @@ -91,8 +93,8 @@ sudo -u postgres psql -c "\password" Then, enter and confirm the password. -Since ThingsBoard Edge uses the PostgreSQL database for local storage, configuring MD5 authentication ensures that only authenticated users or -applications can access the database, thus protecting your data. After configuring the password, +Since ThingsBoard Edge uses the PostgreSQL database for local storage, configuring MD5 authentication ensures that only authenticated users or +applications can access the database, thus protecting your data. After configuring the password, edit the pg_hba.conf file to use MD5 hashing for authentication instead of the default method (ident) for local IPv4 connections. To replace ident with md5, run the following command: @@ -102,7 +104,7 @@ sudo sed -i 's/^host\s\+all\s\+all\s\+127\.0\.0\.1\/32\s\+ident/host all {:copy-code} ``` -Then run the command that will restart the PostgreSQL service to apply configuration changes, connect to the database as a postgres user, +Then run the command that will restart the PostgreSQL service to apply configuration changes, connect to the database as a postgres user, and create the ThingsBoard Edge database (tb_edge). To connect to the PostgreSQL database, enter the PostgreSQL password. ```bash diff --git a/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md b/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md index b4782dd46a..6f74c09c08 100644 --- a/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md +++ b/application/src/main/data/json/edge/instructions/install/ubuntu/instructions.md @@ -1,14 +1,16 @@ Here is the list of commands that can be used to quickly install ThingsBoard Edge on Ubuntu Server and connect to the server. -#### Step 1. Install Java 17 (OpenJDK) -ThingsBoard service is running on Java 17. To install OpenJDK 17, follow these instructions: +**Note:** OpenJDK 25 requires Ubuntu 22.04 LTS or newer. Earlier versions (20.04 and below) are not supported. + +#### Step 1. Install Java 25 (OpenJDK) +ThingsBoard service is running on Java 25. To install OpenJDK 25, follow these instructions: ```bash -sudo apt update && sudo apt install openjdk-17-jdk +sudo apt update && sudo apt install openjdk-25-jdk {:copy-code} ``` -Configure your operating system to use OpenJDK 17 by default. You can configure the default version by running the following command: +Configure your operating system to use OpenJDK 25 by default. You can configure the default version by running the following command: ```bash sudo update-alternatives --config java @@ -25,7 +27,7 @@ java -version The expected result is: ```text -openjdk version "17.x.xx" +openjdk version "25.x.xx" OpenJDK Runtime Environment (...) OpenJDK 64-Bit Server VM (build ...) ``` diff --git a/application/src/main/data/json/system/widget_bundles/cards.json b/application/src/main/data/json/system/widget_bundles/cards.json index 81cb2fdbb1..11d9bfbb9d 100644 --- a/application/src/main/data/json/system/widget_bundles/cards.json +++ b/application/src/main/data/json/system/widget_bundles/cards.json @@ -24,6 +24,7 @@ "cards.html_value_card", "cards.markdown_card", "cards.simple_card", - "unread_notifications" + "unread_notifications", + "html_container" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_bundles/html_widgets.json b/application/src/main/data/json/system/widget_bundles/html_widgets.json index c8215d7fe1..e242bb00e7 100644 --- a/application/src/main/data/json/system/widget_bundles/html_widgets.json +++ b/application/src/main/data/json/system/widget_bundles/html_widgets.json @@ -11,6 +11,7 @@ "widgetTypeFqns": [ "cards.html_card", "cards.html_value_card", - "cards.markdown_card" + "cards.markdown_card", + "html_container" ] } \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json b/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json index 7e0bfe1e66..f23747fb52 100644 --- a/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json +++ b/application/src/main/data/json/system/widget_types/bar_chart_with_labels.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.barChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'humidity', label: 'Humidity', type: 'timeseries' }];\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.barChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.barChartWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'humidity', label: 'Humidity', type: 'timeseries' }];\n }\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "latestDataKeySettingsForm": [], diff --git a/application/src/main/data/json/system/widget_types/html_container.json b/application/src/main/data/json/system/widget_types/html_container.json new file mode 100644 index 0000000000..25b9e5ac5d --- /dev/null +++ b/application/src/main/data/json/system/widget_types/html_container.json @@ -0,0 +1,52 @@ +{ + "fqn": "html_container", + "name": "HTML Container", + "deprecated": false, + "image": "tb-image;/api/images/system/html-container.png", + "description": "Configurable HTML, CSS and JavaScript widget with access to the Widget API via WidgetContext and the ability to load external resources or modules. Supports two modes: plain HTML (regular HTML/JS bound to the widget container) and Angular (Angular template with bound variables and functions). Use for custom complex visualizations or actions when system widgets are not enough.", + "descriptor": { + "type": "static", + "sizeX": 9.5, + "sizeY": 5.5, + "resources": [], + "templateHtml": "\n", + "templateCss": "", + "controllerScript": "self.onInit = function() {\n \n}\n\nself.typeParameters = function() {\n return {\n previewWidth: '100%',\n previewHeight: '100%',\n overflowVisible: true\n };\n};\n", + "settingsDirective": "tb-html-container-widget-settings", + "hasBasicMode": true, + "basicModeDirective": "tb-html-container-basic-config", + "defaultConfig": "{\"datasources\":[],\"timewindow\":{\"realtime\":{\"timewindowMs\":60000}},\"showTitle\":false,\"backgroundColor\":\"rgba(255, 255, 255, 0)\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"0\",\"settings\":{\"type\":\"PLAIN\",\"html\":\"\",\"css\":\"\",\"js\":\"\",\"resources\":[]},\"title\":\"HTML Container\",\"dropShadow\":false,\"enableFullscreen\":false,\"widgetStyle\":{},\"widgetCss\":\"\",\"pageSize\":1024,\"noDataDisplayMessage\":\"\",\"configMode\":\"basic\"}" + }, + "resources": [ + { + "link": "/api/images/system/html-container.png", + "title": "\"HTML Container\" system widget image", + "type": "IMAGE", + "subType": "IMAGE", + "fileName": "html-container.png", + "publicResourceKey": "0CBg8htTwiFsclrm44sIp5pQNS4MM35l", + "mediaType": "image/png", + "data": "iVBORw0KGgoAAAANSUhEUgAAAMgAAACgCAYAAABJ/yOpAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAOdEVYdFNvZnR3YXJlAEZpZ21hnrGWYwAADt9JREFUeAHtnU9oHccdx39OnCCVOrYcCESmCKxDITYkUB9aYkNb4tySQ0rsWxzowT600Bwa4h56yMW9tb05t6a3+Bjf7EAP8qEF6RCIAg2VSgtRoCVSHAfbKcbtfrrzy47Wu6NdvSe9Wfn7gZH2zZvdtzsz3/m785t9VrK/cE8W7vHCPWJiUhwI/2+ZmCS3C7dRuHt8QBzfKdwhkzgmzcHgxORAA09YqYn9iGOmcDcL96UJIe5bqQWE8iR/vlW4r0wIEYNIHvcm1X0TQsSgiUfU5xAigQQiRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCfabEP1gecTZwh2N/FhcdKkl/MVwjrNSuCvhnOxRDSL6csE2i6Mv8+Eag0ACEX1AGDM2Olxj3gaAmliiD4ejY28qbcXl6Phc4WbD8TiEtuMMoQYhQs8XbqrHOVPhnFkTO8lG5LqEuWsDI3eBkMFpr3q7tYtIpqJzJBIxEjkLxMUxFX0+0eG841aJYtokEjECuQqkLg64XrgbHc5dDGEdiURsmxwF0iaO690v8UB4iURsi9wEMg5xtJ0nkYje5CSQcYqj7XyJZDRGmSCs87QNgFwEshPiaLuORLI9GBXsMkiS4nZ0fMoGMFn4qJU2eW/a5NhJcTir4b8nyGOFe7Zwn1hehqI9Dr62PDhduGOF+3E4jiF9PrN+ENexyDgm/Z+yMm1WLS8OTVoguyEOZwgiyU0gpM2cbZ71ZsKP9PmL9ccnDGleTQc/F8e87Uy6j8IhvYsltsMoM+IIY58NhEnXIJTcfy3cc1a9F+Yl/Lir29O2uZlwp3DvFG7N8iG3GgRIBwTxVPhMBj8W/Pu+sk6T6hWrag/gna5lK2tyNbEa2A2RDEEckJtAVoP70MqMHPcfpoJ/H162zc01XmT8wPIUB2TTxCKjEllx1V3P1NtlKOLIHTJw/CbDtI3GouUpik3k1AfZCZFIHONlnPGWvTggt076OEUicYiRyXEUaxwikTjEWMh1mLdNJF1mck+axCHGRM7zIHWRfFq4jzqct2SVGCQOMRK5TxS6SBhiJKN3maByUayaxCFGZAhGG8jg7/Q75f8iuWxiJ2GYt/4KShODMM7QhqyaiD6sR8e8R3cxHKcMx/E+V5NIZDhO7Dlotq7b6HCNFRsAEojoi/fvtov3JwfDnIlcOBicyIM51SBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRIMfX3bG3xFqD+HVoXpe+Y82mZu6E//Vz/DwL/n6Nwe2TlymkE5t6Eqf1eHfD4Gsdw2dLjgJxe71vRn5sXI8dJdakk9HdwBqZnaW4HxfujD1o19f3474UrrFi+dl/HSKeRry2TloQ/+9bmTZvRP689XslET57hrZgylcJIgY3ogwIh5Ip3r/C9/QeTGk1INjO+ZqVhuRcFJgPfd5KuwGIghqdBVWI4WSDP+GzXxOScx9kJnJdoDo/YpXZUkSzaGLcYJmdNPG4pQD6g5U1OdshuKV2CqxfWymMlQb/QSyYyrkGuRAdxwnSBiXTQuGesbIqRyCUWNlv0jIwvB8Y9+V8ARU1OoJ51UoL7r6h6vUQ3v2v20AKr5wFEq9xvtAhPAlHlf9W4b6wMgHGsTxUbMabrPQlXCRYe/cO+WJwNHFft2qZ7kJwhD0brqMm1i5D9U01j+G4JRM7ARmbDP9i+EztTp+EESoy/svB3wUzFb53/5XIP3v2olUTSq8XrEyI+qaTsUnSwa2Nzog/Fu41K2sDYESK+EQ47P/oFjDpl9AZ3wjh3d/3BBkEWpOeD0Nbk942gDJlzTVEm3+uzMkulhiFtiH0uz39s0WvmgiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBLyseMC0q1EuDOlN14eBA6pBhEhADXKrcDdN5ITSIw8OqQYRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCCUSIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoE20BHjgLX0x63cItp3wWVznbXgBrtXvQQiRgFhsDnncUsbnPDtoAcnFAlEbBffzrmLJZYTITz71g9m8054tHCHTEYCcsEz29eWN6cKd8Y2F7DsXMs+9X8OjqbVvcI9Fb5/rHDPWblV9z9tGBwaSg0yW7iT0Wc2p19sCPM9q9rAbTSdK7pDPL8UfWarZ7aFrjefiGcEw064bA19OPjTJGMv+xUbAEMRCCXriZpfnMlJhAvWvbpHSNojfXuci44RB/GY2r0W4fzeSpHMBj9qn9/aAHa93SvDvGT6PlYJ5619j2/RDvHs8bZuZc3RJZPTrHonnGPhGidsAGgeRPQhztRto1JT1lxYIZIPos/HbADs1VEsSrZPa360m4+b6AuZ/VRwccZfagl7IRxftgdrl+XgR7hZGwB7VSCUVvXSjQT9rBZGpCETN/XtmjrYLg7P+BzXRUKcr4cwDKbQ1Mp6bmSvCuTpBj8SipEVEmiwM7u7SNPAB8Lw2fGYujjMKnHVRRIf02RbbrheNuwVgdSr8pe3CP++lUOQop1YHBQqTPKtNoRrEofTJhLndHAr4frZFVx7pZPOkG+fUohE0V4c7dRHqxiB6isOp62ZFsOo4i8sw35JTjUIY+Px0GufeQratr8r3NFEGCYavZM+HVz24/ATostoFVBTx5magZEj4Xgt+m42hKWWoDDzPgzp7e9xkR7MsWQ1P5KTQEaZmzgazndIhHqiDmLcPRPiTJ96d+pqCEu6vWtlGhyJzqMp+3rhPg/HUH+LAQH6TDvXORX8siDnPkgslq1eHyFhTkefEUd9pEVNqu54XBGPqdLcJwBJK2qM+dr3NMvof6wnrsNvULP48DDzIxJIA0RULIqLibBbDdGeMTEKPlfRpVC5Y+n06NI3XLVM50dy6qT3eXltyUZH8yDt+Csh1Nzz9hCTk0CoVru8ZUu4eqnEeX06dh+ZOugp4n7HC7bzMG/ltVVWcyK59UF8rD1euulQ4i9b83AjzTNGP07a1v0Vwi6YSMEckb9aQg1Ck5VO9k4UKvzG2eizBLIF212rQca/amIcUBhds2rClRFAhIJw1hrCNmXqGdu6eebrfGaia2XTQQctuRVtIAZqYx8dJBO/1BCOAu1KOI6H1k9Y/6H17GbTJRCRwicJ6Ycc7hDe36vqOxKVepVlouwr3Fzh/mEiBw6G/znaCPDJ2PpkLqOP8agitc4xS7/V4Hi/ElHlOGgy980fkQUHrRKJmDxzWlEoRAIJRIgEEogQCSQQIRJIIEIkkECESCCBCJFAAhEigQQiRAIJRIgEEogQCSQQIRJIIEIkmKRAxrk/B9fquwaB8EM0BTRTc5N4Bpbj1uM7vqc9w6QWTBGJmPW5ZKOtIOM6Z62yEs7/y9btmqyzZkFQm2E0VsPdsfw2nSTe4ufDAEXTUmPWbrAu430bPyyTrS+1je+LdLhumS2f3Q65rCj0hTgsvtkInzFjeTf4swhnrSUcm5BeCtd5zcrEI8P43t1uRI7Ph8O1SNwr4b9ffzqEWbZqB6R1y3Ofb+49NpNEae5bPnjJ/oyVAnG7xfX4gPlwHt/dDceHrYoHi87rUlj4fbFYCkNwC+G68+E6cVzW09IS9+h5wZ8TpqPzreUe23637tfKpHa55eGopt16xvOF+8rK2oAdUFkHvT8cv164L62MgHo4IuRE8CMzs7LtEysj7s1w3g/Db5LwP7UykomYV6wS2U/C733fykyF/7NWRuS/g9sNuuxyS9ywNJVnd/vC3PfJ4P9LK3eZdQPUfM/z/DycTzji4l9WGoz+bvj+VDjnQLjedHQevzUf/JdCWH5rrXZfS+EcnuMHhftT4X5k5ZJddrnFCMSH1pzmbuT6llVphv/F8Dw8x89sc5rNhvtw49jz4RmWwm89G57nRSsLCtL8WM3vnrWTxS63NBG4URKNmydRl8MxD0pp9J6ViRGHwzQQAqO2IFLOhO9pUpyyqumxFL7nfDdyDbGpUkqoK1Y1/bAzS8RvWJ77evu2AUCTkqYMmd1LbZ6V5z4cviPsejjmGc9ZZfrIm6QUQMtReBfXuyEcme/kFveFUQfi+Ei4zt1wLd/xi0zsafd3K9PLbZSdDefcCPdxztJbVPwtXJfr/TccT4d7OBru9VJ079yTC+qadeyz5iAQEobmwMdW2bQi4ohYHt6r4NO1cN4kcoF5wiMOakXfTYrMgmgQyVbrnr3K3cq21qSpN7GAhPdapI4PYrj9qXinrVQzg/glTonjLnGCKG+H31qwqsNODeJxz38KLtLzvFUinInCbFjVhGrDw3pNB9509vXwZ6PrWfgdfpfCZM0qayyt7OYoFhn3fDh29XLjZFw2d6T08PaldwApDdxOVlM4qslz0bUoJUlUMoBHEiLbylavJ/7R6PfNhjPKRUZCHIvWXMoTH8TP5eCuWTe8j0KG7lKTes3F771olfHrq+F3KagQmzevfxPOI2297+K/G+8E5oVhk0h9Sze+9z3ZPf3es6qG5fqnwz3wu0esskTfym7WIEQM7c63rawSY3P4KP1O8Pft00hEmgxeIi7UwhEhPPhL4ZpuRJnEIFHYC/1iCEdJkSoBvWk1bVXTgMSmHfuF5WeJ8Xx0vBj95znfslIk3g4nLM0P4vXtEHbBuhnoI1NR+hM3PqDRpdDwtON3yKSvWmlBh3TDGjzNrXNWDRAsRs8VpxncCNdqM5K9Gp71V1Y9GwK9btXI2rpVgnvDqr0SO9mD3m2rJk1Ww7taEm8L56VHk/9WeFVvI9zXuBi3VZP6/W/3ecYxtzEzgt8oeWOmY7gm5ibRB7nb0a/rue4/ynXbwg/dwPW4nmccw9wbI/h1ue/U/iNdwjUiy4p7YDJL7Bx6F0uIBBKIEAkkECESSCBCJJBAhEgggQiRQAIRIoEEIkQCBHLfJBQh6jzif/5j5QISIUTFtwt3G4F8XrgnrBSJahLxsIMG/KXRjX3Bk3eyfL22mBxek98yMSnoctCqouK49z8r21UdWLc82QAAAABJRU5ErkJggg==", + "public": true + } + ], + "scada": false, + "tags": [ + "html", + "css", + "javascript", + "custom", + "script", + "code", + "container", + "angular", + "template", + "external resources", + "widget api", + "advanced", + "custom visualization", + "custom action", + "web", + "markup" + ] +} \ No newline at end of file diff --git a/application/src/main/data/json/system/widget_types/label_widget.json b/application/src/main/data/json/system/widget_types/label_widget.json index b8bfc94f41..46d448ac1a 100644 --- a/application/src/main/data/json/system/widget_types/label_widget.json +++ b/application/src/main/data/json/system/widget_types/label_widget.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "", "templateCss": "#container {\n overflow: auto;\n}\n\n.tbDatasource-container {\n margin: 5px;\n padding: 8px;\n}\n\n.tbDatasource-title {\n font-size: 1.200rem;\n font-weight: 500;\n padding-bottom: 10px;\n}\n\n.tbDatasource-table {\n width: 100%;\n box-shadow: 0 0 10px #ccc;\n border-collapse: collapse;\n white-space: nowrap;\n font-size: 1.000rem;\n color: #757575;\n}\n\n.tbDatasource-table td {\n position: relative;\n border-top: 1px solid rgba(0, 0, 0, 0.12);\n border-bottom: 1px solid rgba(0, 0, 0, 0.12);\n padding: 0px 18px;\n box-sizing: border-box;\n}", - "controllerScript": "self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==';\n\n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split(':');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith('#')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : '${#0}';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : 'rgba(0,0,0,0)';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || 'Roboto';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : 'normal';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : '500';\n localConfig.font.color = settingsFont.color ? settingsFont.color : '#fff';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $('
');\n labelElement.css('position', 'absolute');\n labelElement.css('display', 'none');\n labelElement.css('top', '0');\n labelElement.css('left', '0');\n labelElement.css('backgroundColor', localConfig.backgroundColor);\n labelElement.css('color', localConfig.font.color);\n labelElement.css('fontFamily', localConfig.font.family);\n labelElement.css('fontStyle', localConfig.font.style);\n labelElement.css('fontWeight', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n \n self.ctx.imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).subscribe(\n function (transformedUrl) {\n self.ctx.$container.css('background', 'url(\"'+transformedUrl+'\") no-repeat');\n self.ctx.$container.css('backgroundSize', 'contain');\n self.ctx.$container.css('backgroundPosition', '50% 50%');\n var bgImg = $('');\n bgImg.hide();\n bgImg.bind('load', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr('src', transformedUrl);\n }\n );\n\n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css('top', labelTop + '%');\n label.element.css('left', labelLeft + '%');\n label.element.css('fontSize', fontSize + 'px');\n if (!label.visible) {\n label.element.css('display', 'block');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split('.');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = '0' + strVal[0];\n }\n\n strVal = (n ? '-' : '') + strVal[0] + '.' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = '0' + strVal;\n }\n\n strVal = (n ? '-' : '') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true\n };\n};\n\nself.onDestroy = function() {\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.varsRegex = /\\$\\{([^\\}]*)\\}/g;\n \n var imageUrl = self.ctx.settings.backgroundImageUrl ? self.ctx.settings.backgroundImageUrl :\n 'data:image/svg+xml;base64,PHN2ZyBpZD0ic3ZnMiIgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGhlaWdodD0iMTAwIiB3aWR0aD0iMTAwIiB2ZXJzaW9uPSIxLjEiIHhtbG5zOmNjPSJodHRwOi8vY3JlYXRpdmVjb21tb25zLm9yZy9ucyMiIHhtbG5zOmRjPSJodHRwOi8vcHVybC5vcmcvZGMvZWxlbWVudHMvMS4xLyIgdmlld0JveD0iMCAwIDEwMCAxMDAiPgogPGcgaWQ9ImxheWVyMSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMCAtOTUyLjM2KSI+CiAgPHJlY3QgaWQ9InJlY3Q0Njg0IiBzdHJva2UtbGluZWpvaW49InJvdW5kIiBoZWlnaHQ9Ijk5LjAxIiB3aWR0aD0iOTkuMDEiIHN0cm9rZT0iIzAwMCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiB5PSI5NTIuODYiIHg9Ii40OTUwNSIgc3Ryb2tlLXdpZHRoPSIuOTkwMTAiIGZpbGw9IiNlZWUiLz4KICA8dGV4dCBpZD0idGV4dDQ2ODYiIHN0eWxlPSJ3b3JkLXNwYWNpbmc6MHB4O2xldHRlci1zcGFjaW5nOjBweDt0ZXh0LWFuY2hvcjptaWRkbGU7dGV4dC1hbGlnbjpjZW50ZXIiIGZvbnQtd2VpZ2h0PSJib2xkIiB4bWw6c3BhY2U9InByZXNlcnZlIiBmb250LXNpemU9IjEwcHgiIGxpbmUtaGVpZ2h0PSIxMjUlIiB5PSI5NzAuNzI4MDkiIHg9IjQ5LjM5NjQ3NyIgZm9udC1mYW1pbHk9IlJvYm90byIgZmlsbD0iIzY2NjY2NiI+PHRzcGFuIGlkPSJ0c3BhbjQ2OTAiIHg9IjUwLjY0NjQ3NyIgeT0iOTcwLjcyODA5Ij5JbWFnZSBiYWNrZ3JvdW5kIDwvdHNwYW4+PHRzcGFuIGlkPSJ0c3BhbjQ2OTIiIHg9IjQ5LjM5NjQ3NyIgeT0iOTgzLjIyODA5Ij5pcyBub3QgY29uZmlndXJlZDwvdHNwYW4+PC90ZXh0PgogIDxyZWN0IGlkPSJyZWN0NDY5NCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgaGVpZ2h0PSIxOS4zNiIgd2lkdGg9IjY5LjM2IiBzdHJva2U9IiMwMDAiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgeT0iOTkyLjY4IiB4PSIxNS4zMiIgc3Ryb2tlLXdpZHRoPSIuNjM5ODYiIGZpbGw9Im5vbmUiLz4KIDwvZz4KPC9zdmc+Cg==';\n\n function processLabelPattern(pattern, data) {\n var match = self.ctx.varsRegex.exec(pattern);\n var replaceInfo = {};\n replaceInfo.variables = [];\n while (match !== null) {\n var variableInfo = {};\n variableInfo.dataKeyIndex = -1;\n var variable = match[0];\n var label = match[1];\n var valDec = 2;\n var splitVals = label.split(':');\n if (splitVals.length > 1) {\n label = splitVals[0];\n valDec = parseFloat(splitVals[1]);\n }\n variableInfo.variable = variable;\n variableInfo.valDec = valDec;\n \n if (label.startsWith('#')) {\n var keyIndexStr = label.substring(1);\n var n = Math.floor(Number(keyIndexStr));\n if (String(n) === keyIndexStr && n >= 0) {\n variableInfo.dataKeyIndex = n;\n }\n }\n if (variableInfo.dataKeyIndex === -1) {\n for (var i = 0; i < data.length; i++) {\n var datasourceData = data[i];\n var dataKey = datasourceData.dataKey;\n if (dataKey.label === label) {\n variableInfo.dataKeyIndex = i;\n break;\n }\n }\n }\n replaceInfo.variables.push(variableInfo);\n match = self.ctx.varsRegex.exec(pattern);\n }\n return replaceInfo;\n }\n\n var configuredLabels = self.ctx.settings.labels;\n if (!configuredLabels) {\n configuredLabels = [];\n }\n \n self.ctx.labels = [];\n\n for (var l = 0; l < configuredLabels.length; l++) {\n var labelConfig = configuredLabels[l];\n var localConfig = {};\n localConfig.font = {};\n \n localConfig.pattern = labelConfig.pattern ? labelConfig.pattern : '${#0}';\n localConfig.x = labelConfig.x ? labelConfig.x : 0;\n localConfig.y = labelConfig.y ? labelConfig.y : 0;\n localConfig.backgroundColor = labelConfig.backgroundColor ? labelConfig.backgroundColor : 'rgba(0,0,0,0)';\n \n var settingsFont = labelConfig.font;\n if (!settingsFont) {\n settingsFont = {};\n }\n \n localConfig.font.family = settingsFont.family || 'Roboto';\n localConfig.font.size = settingsFont.size ? settingsFont.size : 6;\n localConfig.font.style = settingsFont.style ? settingsFont.style : 'normal';\n localConfig.font.weight = settingsFont.weight ? settingsFont.weight : '500';\n localConfig.font.color = settingsFont.color ? settingsFont.color : '#fff';\n \n localConfig.replaceInfo = processLabelPattern(localConfig.pattern, self.ctx.data);\n \n var label = {};\n var labelElement = $('
');\n labelElement.css('position', 'absolute');\n labelElement.css('display', 'none');\n labelElement.css('top', '0');\n labelElement.css('left', '0');\n labelElement.css('backgroundColor', localConfig.backgroundColor);\n labelElement.css('color', localConfig.font.color);\n labelElement.css('fontFamily', localConfig.font.family);\n labelElement.css('fontStyle', localConfig.font.style);\n labelElement.css('fontWeight', localConfig.font.weight);\n \n labelElement.html(localConfig.pattern);\n self.ctx.$container.append(labelElement);\n label.element = labelElement;\n label.config = localConfig;\n label.htmlSet = false;\n label.visible = false;\n self.ctx.labels.push(label);\n }\n \n self.ctx.imagePipe.transform(imageUrl, {asString: true, ignoreLoadingImage: true}).subscribe(\n function (transformedUrl) {\n self.ctx.$container.css('background', 'url(\"'+transformedUrl+'\") no-repeat');\n self.ctx.$container.css('backgroundSize', 'contain');\n self.ctx.$container.css('backgroundPosition', '50% 50%');\n var bgImg = $('');\n bgImg.hide();\n bgImg.bind('load', function()\n {\n self.ctx.bImageHeight = $(this).height();\n self.ctx.bImageWidth = $(this).width();\n self.onResize();\n });\n self.ctx.$container.append(bgImg);\n bgImg.attr('src', transformedUrl);\n }\n );\n\n self.onDataUpdated();\n}\n\nself.onDataUpdated = function() {\n updateLabels();\n}\n\nself.onResize = function() {\n if (self.ctx.bImageHeight && self.ctx.bImageWidth) {\n var backgroundRect = {};\n var imageRatio = self.ctx.bImageWidth / self.ctx.bImageHeight;\n var componentRatio = self.ctx.width / self.ctx.height;\n if (componentRatio >= imageRatio) {\n backgroundRect.top = 0;\n backgroundRect.bottom = 1.0;\n backgroundRect.xRatio = imageRatio / componentRatio;\n backgroundRect.yRatio = 1;\n var offset = (1 - backgroundRect.xRatio) / 2;\n backgroundRect.left = offset;\n backgroundRect.right = 1 - offset;\n } else {\n backgroundRect.left = 0;\n backgroundRect.right = 1.0;\n backgroundRect.xRatio = 1;\n backgroundRect.yRatio = componentRatio / imageRatio;\n var offset = (1 - backgroundRect.yRatio) / 2;\n backgroundRect.top = offset;\n backgroundRect.bottom = 1 - offset;\n }\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var labelLeft = backgroundRect.left*100 + (label.config.x*backgroundRect.xRatio);\n var labelTop = backgroundRect.top*100 + (label.config.y*backgroundRect.yRatio);\n var fontSize = self.ctx.height * backgroundRect.yRatio * label.config.font.size / 100;\n label.element.css('top', labelTop + '%');\n label.element.css('left', labelLeft + '%');\n label.element.css('fontSize', fontSize + 'px');\n if (!label.visible) {\n label.element.css('display', 'block');\n label.visible = true;\n }\n }\n } \n}\n\n\nfunction isNumber(n) {\n return !isNaN(parseFloat(n)) && isFinite(n);\n}\n\nfunction padValue(val, dec, int) {\n var i = 0;\n var s, strVal, n;\n\n val = parseFloat(val);\n n = (val < 0);\n val = Math.abs(val);\n\n if (dec > 0) {\n strVal = val.toFixed(dec).toString().split('.');\n s = int - strVal[0].length;\n\n for (; i < s; ++i) {\n strVal[0] = '0' + strVal[0];\n }\n\n strVal = (n ? '-' : '') + strVal[0] + '.' + strVal[1];\n }\n\n else {\n strVal = Math.round(val).toString();\n s = int - strVal.length;\n\n for (; i < s; ++i) {\n strVal = '0' + strVal;\n }\n\n strVal = (n ? '-' : '') + strVal;\n }\n\n return strVal;\n}\n\nfunction updateLabels() {\n for (var l = 0; l < self.ctx.labels.length; l++) {\n var label = self.ctx.labels[l];\n var text = label.config.pattern;\n var replaceInfo = label.config.replaceInfo;\n var updated = false;\n for (var v = 0; v < replaceInfo.variables.length; v++) {\n var variableInfo = replaceInfo.variables[v];\n var txtVal = '';\n if (variableInfo.dataKeyIndex > -1) {\n var varData = self.ctx.data[variableInfo.dataKeyIndex].data;\n if (varData.length > 0) {\n var val = varData[varData.length-1][1];\n if (isNumber(val)) {\n txtVal = padValue(val, variableInfo.valDec, 0);\n updated = true;\n } else {\n txtVal = val;\n updated = true;\n }\n }\n }\n text = text.split(variableInfo.variable).join(txtVal);\n }\n text = self.ctx.utilsService.customTranslation(text);\n if (updated || !label.htmlSet) {\n label.element.html(text);\n if (!label.htmlSet) {\n label.htmlSet = true;\n }\n }\n }\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n singleEntity: true\n };\n};\n\nself.onDestroy = function() {\n}\n", "dataKeySettingsSchema": "{}\n", "settingsDirective": "tb-label-widget-settings", "defaultConfig": "{\"datasources\":[{\"type\":\"function\",\"name\":\"function\",\"dataKeys\":[{\"name\":\"f(x)\",\"type\":\"function\",\"label\":\"var\",\"color\":\"#2196f3\",\"settings\":{},\"_hash\":0.15479322438769105,\"funcBody\":\"var value = prevValue + Math.random() * 100 - 50;\\nvar multiplier = Math.pow(10, 2 || 0);\\nvar value = Math.round(value * multiplier) / multiplier;\\nif (value < -1000) {\\n\\tvalue = -1000;\\n} else if (value > 1000) {\\n\\tvalue = 1000;\\n}\\nreturn value;\"}]}],\"showTitle\":true,\"backgroundColor\":\"#fff\",\"color\":\"rgba(0, 0, 0, 0.87)\",\"padding\":\"8px\",\"settings\":{\"backgroundImageUrl\":\"tb-image;/api/images/system/here_map_system_widget_map_image.svg\",\"labels\":[{\"pattern\":\"Value: ${#0:2} units.\",\"x\":20,\"y\":47,\"font\":{\"color\":\"#515151\",\"family\":\"Roboto\",\"size\":6,\"style\":\"normal\",\"weight\":\"500\"}}]},\"title\":\"Label widget\",\"dropShadow\":true,\"enableFullscreen\":true,\"titleStyle\":{\"fontSize\":\"16px\",\"fontWeight\":400}}" diff --git a/application/src/main/data/json/system/widget_types/range_chart.json b/application/src/main/data/json/system/widget_types/range_chart.json index 5d8293debe..1e033ecb2b 100644 --- a/application/src/main/data/json/system/widget_types/range_chart.json +++ b/application/src/main/data/json/system/widget_types/range_chart.json @@ -11,7 +11,7 @@ "resources": [], "templateHtml": "\n", "templateCss": ".legend {\n font-size: 13px;\n line-height: 10px;\n}\n\n.legend table { \n border-spacing: 0px;\n border-collapse: separate;\n}\n\n.mouse-events .flot-overlay {\n cursor: crosshair; \n}\n\n", - "controllerScript": "self.onInit = function() {\n self.ctx.$scope.rangeChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries' }];\n }\n };\n}\n", + "controllerScript": "self.onInit = function() {\n self.ctx.$scope.rangeChartWidget.onInit();\n};\n\nself.onDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onDataUpdated();\n}\n\nself.onLatestDataUpdated = function() {\n self.ctx.$scope.rangeChartWidget.onLatestDataUpdated();\n}\n\nself.typeParameters = function() {\n return {\n maxDatasources: 1,\n maxDataKeys: 1,\n singleEntity: true,\n previewWidth: '80%',\n embedTitlePanel: true,\n embedActionsPanel: true,\n supportsUnitConversion: true,\n hasAdditionalLatestDataKeys: false,\n defaultDataKeysFunction: function() {\n return [{ name: 'temperature', label: 'Temperature', type: 'timeseries' }];\n }\n };\n}\n", "settingsForm": [], "dataKeySettingsForm": [], "latestDataKeySettingsForm": [], diff --git a/application/src/main/data/upgrade/basic/schema_update.sql b/application/src/main/data/upgrade/basic/schema_update.sql index 66243461e2..2f9c0cd302 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,69 +14,14 @@ -- limitations under the License. -- --- UPDATE TENANT PROFILE CONFIGURATION START +-- CALCULATED FIELD ADDITIONAL INFO ADDITION START -UPDATE tenant_profile -SET profile_data = jsonb_set( - profile_data, - '{configuration}', - jsonb_build_object( - 'minAllowedScheduledUpdateIntervalInSecForCF', 10, - 'maxRelationLevelPerCfArgument', 2, - 'maxRelatedEntitiesToReturnPerCfArgument', 100, - 'minAllowedDeduplicationIntervalInSecForCF', 10, - 'minAllowedAggregationIntervalInSecForCF', 60, - 'intermediateAggregationIntervalInSecForCF', 300, - 'cfReevaluationCheckInterval', 60, - 'alarmsReevaluationInterval', 60 - ) - || - jsonb_strip_nulls(profile_data -> 'configuration') -) -WHERE NOT ( - jsonb_strip_nulls(profile_data -> 'configuration') ?& ARRAY[ - 'minAllowedScheduledUpdateIntervalInSecForCF', - 'maxRelationLevelPerCfArgument', - 'maxRelatedEntitiesToReturnPerCfArgument', - 'minAllowedDeduplicationIntervalInSecForCF', - 'minAllowedAggregationIntervalInSecForCF', - 'intermediateAggregationIntervalInSecForCF', - 'cfReevaluationCheckInterval', - 'alarmsReevaluationInterval' - ] -); +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; --- UPDATE TENANT PROFILE CONFIGURATION END +-- CALCULATED FIELD ADDITIONAL INFO ADDITION END --- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE START +-- RULE CHAIN NOTES MIGRATION START -ALTER TABLE calculated_field DROP CONSTRAINT IF EXISTS calculated_field_unq_key; -ALTER TABLE calculated_field ADD CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name); +ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000); --- CALCULATED FIELD UNIQUE CONSTRAINT UPDATE END - --- CALCULATED FIELD OUTPUT STRATEGY UPDATE START - -UPDATE calculated_field -SET configuration = jsonb_set( - configuration::jsonb, - '{output}', - (configuration::jsonb -> 'output') - || jsonb_build_object( - 'strategy', - jsonb_build_object( - 'type', 'RULE_CHAIN' - ) - ), - false - ) -WHERE (configuration::jsonb -> 'output' -> 'strategy') IS NULL; - --- CALCULATED FIELD OUTPUT STRATEGY UPDATE END - --- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE START - -DROP TABLE IF EXISTS calculated_field_link; -ANALYZE calculated_field; - --- REMOVAL OF CALCULATED FIELD LINKS PERSISTENCE END +-- RULE CHAIN NOTES MIGRATION END diff --git a/application/src/main/data/upgrade/lts/schema_update.sql b/application/src/main/data/upgrade/lts/schema_update.sql new file mode 100644 index 0000000000..790dc50b52 --- /dev/null +++ b/application/src/main/data/upgrade/lts/schema_update.sql @@ -0,0 +1,25 @@ +-- +-- Copyright © 2016-2026 The Thingsboard Authors +-- +-- Licensed under the Apache License, Version 2.0 (the "License"); +-- you may not use this file except in compliance with the License. +-- You may obtain a copy of the License at +-- +-- http://www.apache.org/licenses/LICENSE-2.0 +-- +-- Unless required by applicable law or agreed to in writing, software +-- distributed under the License is distributed on an "AS IS" BASIS, +-- WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +-- See the License for the specific language governing permissions and +-- limitations under the License. +-- + +-- LTS cumulative schema update file. +-- All statements must be idempotent (use IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, DO $$ ... END $$ guards, etc.). +-- This file is executed by SystemPatchApplier on every version increase within the LTS family. + +-- CALCULATED FIELD ADDITIONAL INFO ADDITION START + +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; + +-- CALCULATED FIELD ADDITIONAL INFO ADDITION END diff --git a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java index 08c1eb3ff0..4266c97303 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -31,10 +31,12 @@ import org.springframework.context.annotation.Lazy; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.SmsService; @@ -42,6 +44,7 @@ import org.thingsboard.rule.engine.api.notification.SlackService; import org.thingsboard.rule.engine.api.sms.SmsSenderFactory; import org.thingsboard.script.api.js.JsInvokeService; import org.thingsboard.script.api.tbel.TbelInvokeService; +import org.thingsboard.server.actors.calculatedField.CalculatedFieldException; import org.thingsboard.server.actors.service.ActorService; import org.thingsboard.server.actors.tenant.DebugTbRateLimits; import org.thingsboard.server.cache.limits.RateLimitService; @@ -143,6 +146,7 @@ import org.thingsboard.server.utils.DebugModeRateLimitsConfig; import java.io.PrintWriter; import java.io.StringWriter; +import java.util.List; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -613,9 +617,27 @@ public class ActorSystemContext { @Getter private boolean localCacheType; + @Value("${actors.rule.external.ssrf_protection_enabled:false}") + private boolean ssrfProtectionEnabled; + + @Value("${actors.rule.external.ssrf_additional_blocked_hosts:}") + private List ssrfAdditionalBlockedHosts; + + @Value("${actors.rule.external.ssrf_allowed_hosts:}") + private List ssrfAllowedHosts; + @PostConstruct public void init() { this.localCacheType = "caffeine".equals(cacheType); + SsrfProtectionValidator.setEnabled(ssrfProtectionEnabled); + SsrfProtectionValidator.setAdditionalBlockedHosts(ssrfAdditionalBlockedHosts); + SsrfProtectionValidator.setAllowedHosts(ssrfAllowedHosts); + if (!ssrfProtectionEnabled) { + log.warn("SSRF protection for external rule nodes is DISABLED. This allows rule chains to make HTTP requests to " + + "internal/private network addresses including cloud metadata endpoints. It is strongly recommended to " + + "enable SSRF protection by setting SSRF_PROTECTION_ENABLED=true. If your rule chains need to access " + + "devices on local networks, use SSRF_ALLOWED_HOSTS to whitelist specific addresses or ranges."); + } } @Value("${actors.tenant.create_components_on_init:true}") @@ -670,6 +692,10 @@ public class ActorSystemContext { @Getter private MqttClientSettings mqttClientSettings; + @Autowired(required = false) + @Getter + private TbHttpClientSettings tbHttpClientSettings; + @Getter @Setter private TbActorSystem actorSystem; @@ -831,6 +857,18 @@ public class ActorSystemContext { Futures.addCallback(future, RULE_CHAIN_DEBUG_EVENT_ERROR_CALLBACK, MoreExecutors.directExecutor()); } + public void persistCalculatedFieldDebugError(CalculatedFieldException cfe) { + String message; + if (cfe.getErrorMessage() != null) { + message = cfe.getErrorMessage(); + } else if (cfe.getCause() != null) { + message = cfe.getCause().getMessage(); + } else { + message = "N/A"; + } + persistCalculatedFieldDebugEvent(cfe.getCtx().getTenantId(), cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + } + public void persistCalculatedFieldDebugEvent(TenantId tenantId, CalculatedFieldId calculatedFieldId, EntityId entityId, JsonNode arguments, UUID tbMsgId, String tbMsgType, String result, String errorMessage) { if (checkLimits(tenantId)) { try { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java index 9f82b536fd..29c7fe6587 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/AbstractCalculatedFieldActor.java @@ -41,15 +41,7 @@ public abstract class AbstractCalculatedFieldActor extends ContextAwareActor { return doProcessCfMsg(cfm); } catch (CalculatedFieldException cfe) { if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { - String message; - if (cfe.getErrorMessage() != null) { - message = cfe.getErrorMessage(); - } else if (cfe.getCause() != null) { - message = cfe.getCause().getMessage(); - } else { - message = "N/A"; - } - systemContext.persistCalculatedFieldDebugEvent(tenantId, cfe.getCtx().getCfId(), cfe.getEventEntity(), cfe.getArguments(), cfe.getMsgId(), cfe.getMsgType(), null, message); + systemContext.persistCalculatedFieldDebugError(cfe); } cause = cfe.getCause(); } catch (Exception e) { diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java index 31852dd12c..b69719f1a0 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldEntityMessageProcessor.java @@ -171,7 +171,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM if (state.isSizeOk()) { processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, msg.getEventType().name(), msg.getCallback()); } else { - throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); } } else { msg.getCallback().onSuccess(); @@ -261,7 +261,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); } } catch (Exception e) { - log.debug("[{}][{}] Failed to initialize CF state", entityId, ctx.getCfId(), e); + log.debug("[{}][{}] Failed to handle relation update", entityId, ctx.getCfId(), e); if (e instanceof CalculatedFieldException cfe) { throw cfe; } @@ -273,31 +273,39 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM CalculatedFieldCtx ctx = msg.getCalculatedField(); CalculatedFieldId cfId = ctx.getCfId(); CalculatedFieldState state = states.get(cfId); - if (state == null) { - msg.getCallback().onSuccess(); - return; - } - if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) { - aggState.cleanupEntityData(msg.getRelatedEntityId()); + try { + if (state == null) { + msg.getCallback().onSuccess(); + return; + } + if (state instanceof RelatedEntitiesAggregationCalculatedFieldState aggState) { + aggState.cleanupEntityData(msg.getRelatedEntityId()); - state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); + state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); - if (state.isSizeOk()) { - processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, TbMsgType.RELATION_DELETED.name(), msg.getCallback()); - } else { - throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + if (state.isSizeOk()) { + processStateIfReady(state, Collections.emptyMap(), ctx, Collections.singletonList(ctx.getCfId()), null, TbMsgType.RELATION_DELETED.name(), msg.getCallback()); + } else { + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); + } + return; } - return; - } - if (state instanceof PropagationCalculatedFieldState propagationState) { - PropagationArgumentEntry entry = new PropagationArgumentEntry(); - entry.setRemoved(msg.getRelatedEntityId()); - propagationState.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, entry), ctx); - if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { - systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), null, TbMsgType.RELATION_DELETED.name(), null, null); + if (state instanceof PropagationCalculatedFieldState propagationState) { + PropagationArgumentEntry entry = new PropagationArgumentEntry(); + entry.setRemoved(msg.getRelatedEntityId()); + propagationState.update(Map.of(PROPAGATION_CONFIG_ARGUMENT, entry), ctx); + if (DebugModeUtil.isDebugAllAvailable(ctx.getCalculatedField())) { + systemContext.persistCalculatedFieldDebugEvent(tenantId, ctx.getCfId(), entityId, state.getArgumentsJson(), null, TbMsgType.RELATION_DELETED.name(), null, null); + } } + msg.getCallback().onSuccess(); + } catch (Exception e) { + log.debug("[{}][{}] Failed to handle relation delete", entityId, ctx.getCfId(), e); + if (e instanceof CalculatedFieldException cfe) { + throw cfe; + } + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } - msg.getCallback().onSuccess(); } public void process(EntityCalculatedFieldTelemetryMsg msg) throws CalculatedFieldException { @@ -366,7 +374,11 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM } catch (Exception e) { log.debug("[{}][{}] Failed to process CF telemetry msg: {}", entityId, ctx.getCfId(), proto, e); if (e instanceof CalculatedFieldException cfe) { - throw cfe; + if (DebugModeUtil.isDebugFailuresAvailable(cfe.getCtx().getCalculatedField())) { + systemContext.persistCalculatedFieldDebugError(cfe); + } + callback.onSuccess(); + return; } throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).cause(e).build(); } @@ -384,7 +396,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM log.debug("[{}][{}] Reevaluating CF state", entityId, cfId); processStateIfReady(state, null, ctx, Collections.singletonList(cfId), null, CalculatedFieldEventType.REEVALUATION_MSG.name(), msg.getCallback()); } else { - throw new RuntimeException(ctx.getSizeExceedsLimitMessage()); + throw CalculatedFieldException.builder().ctx(ctx).eventEntity(entityId).errorMessage(ctx.getSizeExceedsLimitMessage()).build(); } } @@ -473,7 +485,6 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM private void initState(CalculatedFieldState state, CalculatedFieldCtx ctx) { state.setCtx(ctx, actorCtx); - state.init(false); if (ctx.getCfType() == CalculatedFieldType.GEOFENCING && ctx.isCfHasRelationPathQuerySource()) { GeofencingCalculatedFieldState geofencingState = (GeofencingCalculatedFieldState) state; @@ -482,6 +493,7 @@ public class CalculatedFieldEntityMessageProcessor extends AbstractContextAwareM Map arguments = fetchArguments(ctx); state.update(arguments, ctx); + state.init(false); state.checkStateSize(new CalculatedFieldEntityCtxId(tenantId, ctx.getCfId(), entityId), ctx.getMaxStateSize()); states.put(ctx.getCfId(), state); diff --git a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java index 4de50b1ccf..a1d7477a36 100644 --- a/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/calculatedField/CalculatedFieldManagerMessageProcessor.java @@ -425,7 +425,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware try { cfCtx.init(); } catch (Exception e) { - throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage(e.getMessage()).build(); } calculatedFields.put(cf.getId(), cfCtx); // We use copy on write lists to safely pass the reference to another actor for the iteration. @@ -456,7 +456,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware try { newCfCtx.init(); } catch (Exception e) { - throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + throw CalculatedFieldException.builder().ctx(newCfCtx).eventEntity(newCfCtx.getEntityId()).cause(e).errorMessage(e.getMessage()).build(); } finally { calculatedFields.put(newCf.getId(), newCfCtx); List oldCfList = entityIdCalculatedFields.get(newCf.getEntityId()); @@ -819,7 +819,7 @@ public class CalculatedFieldManagerMessageProcessor extends AbstractContextAware try { cfCtx.init(); } catch (Exception e) { - throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage("Failed to initialize CF context").build(); + throw CalculatedFieldException.builder().ctx(cfCtx).eventEntity(cf.getEntityId()).cause(e).errorMessage(e.getMessage()).build(); } finally { calculatedFields.put(cf.getId(), cfCtx); // We use copy on write lists to safely pass the reference to another actor for the iteration. diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java index afe52bf942..76f79bf1f9 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/DefaultTbContext.java @@ -27,6 +27,7 @@ import org.thingsboard.rule.engine.api.DeviceStateManager; import org.thingsboard.rule.engine.api.JobManager; import org.thingsboard.rule.engine.api.MailService; import org.thingsboard.rule.engine.api.MqttClientSettings; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.NotificationCenter; import org.thingsboard.rule.engine.api.RuleEngineAiChatModelService; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; @@ -184,11 +185,24 @@ public class DefaultTbContext implements TbContext { if (!msg.isValid()) { return; } + RuleChainId selfRuleChainId = nodeCtx.getSelf().getRuleChainId(); + RuleNodeId selfId = nodeCtx.getSelf().getId(); + if (msg.isAlreadyInStack(selfRuleChainId, selfId)) { + log.warn("[{}] Detected rule chain processing loop for rule node [{}] in rule chain [{}]. " + + "The message will be failed to prevent infinite loop. " + + "Please check the rule chain configuration for circular references.", + nodeCtx.getTenantId(), selfId, selfRuleChainId); + tellFailure(msg, new RuntimeException( + "Detected rule chain processing loop for rule node [" + selfId + "] " + + "in rule chain [" + selfRuleChainId + "]. " + + "Please check the rule chain configuration for circular references.")); + return; + } TbMsg tbMsg = msg.copy() .ruleChainId(ruleChainId) .resetRuleNodeId() .build(); - tbMsg.pushToStack(nodeCtx.getSelf().getRuleChainId(), nodeCtx.getSelf().getId()); + tbMsg.pushToStack(selfRuleChainId, selfId); TopicPartitionInfo tpi = resolvePartition(msg); doEnqueue(tpi, tbMsg, new SimpleTbQueueCallback(md -> ack(msg), t -> tellFailure(msg, t))); } @@ -1055,6 +1069,11 @@ public class DefaultTbContext implements TbContext { return mainCtx.getMqttClientSettings(); } + @Override + public TbHttpClientSettings getTbHttpClientSettings() { + return mainCtx.getTbHttpClientSettings(); + } + private TbMsgMetaData getActionMetaData(RuleNodeId ruleNodeId) { TbMsgMetaData metaData = new TbMsgMetaData(); metaData.putValue("ruleNodeId", ruleNodeId.toString()); diff --git a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java index 3eecae7f7a..cc74d248d2 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java +++ b/application/src/main/java/org/thingsboard/server/actors/ruleChain/RuleNodeActorMessageProcessor.java @@ -137,7 +137,7 @@ public class RuleNodeActorMessageProcessor extends ComponentMsgProcessor headers) { + if (properties.getXContentTypeOptions().isEnabled()) { + headers.contentTypeOptions(config -> {}); + } + + if (properties.getReferrerPolicy().isEnabled()) { + headers.addHeaderWriter(new StaticHeadersWriter("Referrer-Policy", properties.getReferrerPolicy().getValue())); + } + + if (properties.getXFrameOptions().isEnabled()) { + String value = properties.getXFrameOptions().getValue(); + if ("DENY".equalsIgnoreCase(value)) { + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny); + } else { + if (!"SAMEORIGIN".equalsIgnoreCase(value)) { + log.warn("Unrecognized X-Frame-Options value '{}', falling back to SAMEORIGIN. Valid values: DENY, SAMEORIGIN", value); + } + headers.frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin); + } + } + + if (properties.getContentSecurityPolicy().isEnabled() && StringUtils.hasText(properties.getContentSecurityPolicy().getValue())) { + headers.contentSecurityPolicy(csp -> { + csp.policyDirectives(properties.getContentSecurityPolicy().getValue()); + if (properties.getContentSecurityPolicy().isReportOnly()) { + csp.reportOnly(); + } + }); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java new file mode 100644 index 0000000000..224e2aeea0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/HttpSecurityHeadersProperties.java @@ -0,0 +1,56 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "security.headers") +@Data +public class HttpSecurityHeadersProperties { + + private XContentTypeOptions xContentTypeOptions = new XContentTypeOptions(); + private ReferrerPolicy referrerPolicy = new ReferrerPolicy(); + private XFrameOptions xFrameOptions = new XFrameOptions(); + private ContentSecurityPolicy contentSecurityPolicy = new ContentSecurityPolicy(); + + @Data + public static class XContentTypeOptions { + private boolean enabled = true; + } + + @Data + public static class ReferrerPolicy { + private boolean enabled = true; + private String value = "strict-origin-when-cross-origin"; + } + + @Data + public static class XFrameOptions { + private boolean enabled = false; + private String value = "SAMEORIGIN"; + } + + @Data + public static class ContentSecurityPolicy { + private boolean enabled = false; + private String value = ""; + private boolean reportOnly = false; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java index 1a1a409c1a..db867234e2 100644 --- a/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/SwaggerConfiguration.java @@ -15,11 +15,16 @@ */ package org.thingsboard.server.config; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; import com.fasterxml.jackson.databind.JavaType; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import io.swagger.v3.core.converter.AnnotatedType; import io.swagger.v3.core.converter.ModelConverter; import io.swagger.v3.core.converter.ModelConverters; +import io.swagger.v3.core.converter.ResolvedSchema; +import io.swagger.v3.core.jackson.ModelResolver; import io.swagger.v3.core.util.Json; import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; @@ -31,6 +36,7 @@ import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; import io.swagger.v3.oas.models.media.Content; +import io.swagger.v3.oas.models.media.IntegerSchema; import io.swagger.v3.oas.models.media.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.RequestBody; @@ -39,11 +45,12 @@ import io.swagger.v3.oas.models.responses.ApiResponses; import io.swagger.v3.oas.models.security.SecurityRequirement; import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.tags.Tag; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.customizers.OpenApiCustomizer; import org.springdoc.core.customizers.OperationCustomizer; -import org.springdoc.core.customizers.RouterOperationCustomizer; import org.springdoc.core.discoverer.SpringDocParameterNameDiscoverer; +import org.springdoc.core.utils.SpringDocUtils; import org.springdoc.core.models.GroupedOpenApi; import org.springdoc.core.properties.SpringDocConfigProperties; import org.springdoc.core.properties.SwaggerUiConfigProperties; @@ -54,26 +61,35 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Primary; import org.springframework.context.annotation.Profile; -import org.springframework.core.MethodParameter; import org.springframework.http.HttpStatus; -import org.springframework.web.bind.annotation.RequestParam; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.ai.model.chat.AiChatModelConfig; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; -import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.exception.ThingsboardCredentialsExpiredResponse; import org.thingsboard.server.exception.ThingsboardErrorResponse; import org.thingsboard.server.service.security.auth.rest.LoginRequest; import org.thingsboard.server.service.security.auth.rest.LoginResponse; +import java.lang.annotation.Annotation; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.nio.ByteBuffer; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Arrays; import java.util.Comparator; +import java.util.Deque; +import java.util.HashSet; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.TreeMap; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @@ -83,16 +99,30 @@ import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE; @Profile("!test") public class SwaggerConfiguration { + @PostConstruct + public void configureModelResolver() { + ModelResolver.enumsAsRef = true; + SpringDocUtils.getConfig().replaceWithSchema(ByteBuffer.class, + new Schema().type("string").format("byte")); + } + public static final String LOGIN_ENDPOINT = "/api/auth/login"; public static final String REFRESH_TOKEN_ENDPOINT = "/api/auth/token"; - private static final String LOGIN_PASSWORD_SCHEME = "HTTP login form"; - private static final String API_KEY_SCHEME = "API key form"; + private static final String LOGIN_PASSWORD_SCHEME = "HttpLoginForm"; + private static final String API_KEY_SCHEME = "ApiKeyForm"; private static final ApiResponses loginResponses = loginResponses(); private static final ApiResponses defaultErrorResponses = defaultErrorResponses(false); private static final ApiResponses defaultPostErrorResponses = defaultErrorResponses(true); + // Populated by mapAwareConverter, consumed by customOpenApiCustomizer. + // Keyed by the schema name that swagger-core generates (see resolveSchemaName). + private final Map> schemaPropertyOrders = new ConcurrentHashMap<>(); + private final Map> schemaOwnProps = new ConcurrentHashMap<>(); + // Tracks schema name → fully-qualified class names to detect collisions. + private final Map> schemaNameToClasses = new ConcurrentHashMap<>(); + @Value("${swagger.api_path:/api/**}") private String apiPath; @Value("${swagger.security_path_regex}") @@ -137,6 +167,9 @@ public class SwaggerConfiguration { if (StringUtils.isEmpty(apiVersion)) { apiVersion = appVersion; } + if (apiVersion != null && apiVersion.endsWith("-SNAPSHOT")) { + apiVersion = apiVersion.substring(0, apiVersion.length() - "-SNAPSHOT".length()); + } Info info = new Info() .title(title) @@ -157,9 +190,9 @@ public class SwaggerConfiguration { .in(SecurityScheme.In.HEADER) .description(""" Enter the API key value with 'ApiKey' prefix in format: **ApiKey ** - + Example: **ApiKey tb_5te51SkLRYpjGrujUGwqkjFvooWBlQpVe2An2Dr3w13wjfxDW** - +
**NOTE**: Use only ONE authentication method at a time. If both are authorized, JWT auth takes the priority.
"""); @@ -213,11 +246,12 @@ public class SwaggerConfiguration { private void addLoginOperation(OpenAPI openAPI) { var operation = new Operation(); operation.summary("Login method to get user JWT token data"); + operation.operationId("login"); operation.description(""" Login method used to authenticate user and get JWT token data. - + Value of the response **token** field can be used as **X-Authorization** header value: - + `X-Authorization: Bearer $JWT_TOKEN_VALUE`."""); var requestBody = new RequestBody().description("Login request") @@ -235,11 +269,12 @@ public class SwaggerConfiguration { private void addRefreshTokenOperation(OpenAPI openAPI) { var operation = new Operation(); operation.summary("Refresh user JWT token data"); + operation.operationId("refreshToken"); operation.description(""" Method to refresh JWT token. Provide a valid refresh token to get a new JWT token. - + The response contains a new token that can be used for authorization. - + `X-Authorization: Bearer $JWT_TOKEN_VALUE`"""); var requestBody = new RequestBody().description("Refresh token request") @@ -260,7 +295,6 @@ public class SwaggerConfiguration { return GroupedOpenApi.builder() .group(groupName) .pathsToMatch(apiPath) - .addRouterOperationCustomizer(routerOperationCustomizer(localSpringDocParameterNameDiscoverer)) .addOperationCustomizer(operationCustomizer()) .addOpenApiCustomizer(customOpenApiCustomizer()) .build(); @@ -270,9 +304,34 @@ public class SwaggerConfiguration { @Lazy(false) ModelConverter mapAwareConverter() { return (type, context, chain) -> { + // Strip field-level @JsonIgnoreProperties from context annotations so it + // doesn't pollute the global schema. The OpenAPI schema should show all + // properties; field-level ignore is a serialization concern only. + Annotation[] ctxAnnotations = type.getCtxAnnotations(); + if (ctxAnnotations != null) { + Annotation[] filtered = Arrays.stream(ctxAnnotations) + .filter(a -> !(a instanceof JsonIgnoreProperties)) + .toArray(Annotation[]::new); + if (filtered.length != ctxAnnotations.length) { + type.ctxAnnotations(filtered); + } + } + + JavaType javaType = Json.mapper().constructType(type.getType()); + if (javaType != null) { + Class cls = javaType.getRawClass(); + Schema atomicSchema = switch (cls.getName()) { + case "java.util.concurrent.atomic.AtomicInteger" -> new IntegerSchema().format("int32"); + case "java.util.concurrent.atomic.AtomicLong" -> new IntegerSchema().format("int64"); + case "com.google.common.util.concurrent.AtomicDouble" -> new IntegerSchema().format("double"); + default -> null; + }; + if (atomicSchema != null) { + return atomicSchema; + } + } if (chain.hasNext()) { Schema schema = chain.next().resolve(type, context, chain); - JavaType javaType = Json.mapper().constructType(type.getType()); if (javaType != null) { Class cls = javaType.getRawClass(); if (Map.class.isAssignableFrom(cls)) { @@ -282,6 +341,26 @@ public class SwaggerConfiguration { schema.setProperties(null); } } + } else { + // Precompute property order and own-prop names for this class. + // The actual reordering happens later in the OpenApiCustomizer, + // which has access to the final state of all component schemas + // (including ones where the ModelConverter only sees a $ref). + try { + var beanDesc = Json.mapper().getSerializationConfig().introspect(javaType); + String schemaName = resolveSchemaName(javaType); + Set classes = schemaNameToClasses.computeIfAbsent(schemaName, k -> ConcurrentHashMap.newKeySet()); + if (classes.add(cls.getName()) && classes.size() > 1) { + log.error("Duplicate OpenAPI schema name '{}' mapped by: {}. Use @Schema(name = ...) to disambiguate.", schemaName, classes); + } + schemaPropertyOrders.put(schemaName, resolvePropertyOrder(cls, beanDesc)); + Set ownProps = computeOwnPropNames(cls, beanDesc); + if (!ownProps.isEmpty()) { + schemaOwnProps.put(schemaName, ownProps); + } + } catch (Exception e) { + log.debug("Failed to resolve property order for {}: {}", cls.getName(), e.getMessage()); + } } } return schema; @@ -292,45 +371,32 @@ public class SwaggerConfiguration { } private void addDefaultSchemas(OpenAPI openAPI) { - var jsonNodeSchema = ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(JsonNode.class)).schema; - jsonNodeSchema.setType("any"); - //noinspection unchecked - jsonNodeSchema.setExamples(List.of(JacksonUtil.newObjectNode())); - jsonNodeSchema.setDescription("A value representing the any type (object or primitive)"); - openAPI.getComponents() - .addSchemas("JsonNode", jsonNodeSchema) - .addSchemas("LoginRequest", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginRequest.class)).schema) - .addSchemas("LoginResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(LoginResponse.class)).schema) - .addSchemas("ThingsboardErrorResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardErrorResponse.class)).schema) - .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema); - } - - private RouterOperationCustomizer routerOperationCustomizer(SpringDocParameterNameDiscoverer localSpringDocParameterNameDiscoverer) { - return (routerOperation, handlerMethod) -> { - String[] pNames = localSpringDocParameterNameDiscoverer.getParameterNames(handlerMethod.getMethod()); - String[] reflectionParametersNames = Arrays.stream(handlerMethod.getMethod().getParameters()).map(java.lang.reflect.Parameter::getName).toArray(String[]::new); - if (pNames == null || Arrays.stream(pNames).anyMatch(Objects::isNull)) { - pNames = reflectionParametersNames; - } - MethodParameter[] parameters = handlerMethod.getMethodParameters(); - List requestParams = new ArrayList<>(); - for (var i = 0; i < parameters.length; i++) { - var methodParameter = parameters[i]; - RequestParam requestParam = methodParameter.getParameterAnnotation(RequestParam.class); - if (requestParam != null) { - String pName = StringUtils.isNotBlank(requestParam.value()) ? requestParam.value() : - pNames[i]; - if (StringUtils.isNotBlank(pName)) { - requestParams.add(pName); - } + Schema errorCodeSchema = new Schema<>() + .type("integer") + .description("Platform error code") + ._enum(Arrays.stream(ThingsboardErrorCode.values()) + .map(ThingsboardErrorCode::getErrorCode) + .collect(Collectors.toList())); + Components components = openAPI.getComponents(); + registerSchema(components, "LoginRequest", LoginRequest.class); + registerSchema(components, "LoginResponse", LoginResponse.class); + registerSchema(components, "ThingsboardErrorResponse", ThingsboardErrorResponse.class); + registerSchema(components, "ThingsboardCredentialsExpiredResponse", ThingsboardCredentialsExpiredResponse.class); + components.addSchemas("ThingsboardErrorCode", errorCodeSchema); + registerSchema(components, "AiChatModelConfig", AiChatModelConfig.class); + } + + private static void registerSchema(Components components, String name, Class cls) { + ResolvedSchema resolved = ModelConverters.getInstance() + .readAllAsResolvedSchema(new AnnotatedType().type(cls)); + components.addSchemas(name, resolved.schema); + if (resolved.referencedSchemas != null) { + resolved.referencedSchemas.forEach((refName, refSchema) -> { + if (components.getSchemas() == null || !components.getSchemas().containsKey(refName)) { + components.addSchemas(refName, refSchema); } - } - if (!requestParams.isEmpty()) { - var path = routerOperation.getPath() + "{?" + String.join(",", requestParams) + "}"; - routerOperation.setPath(path); - } - return routerOperation; - }; + }); + } } private OperationCustomizer operationCustomizer() { @@ -347,6 +413,19 @@ public class SwaggerConfiguration { var apiKeyRequirement = createSecurityRequirement(API_KEY_SCHEME); return openAPI -> { + // Fail fast on duplicate schema names — two different classes resolving to the same + // OpenAPI schema name causes one to silently overwrite the other. + List duplicates = schemaNameToClasses.entrySet().stream() + .filter(e -> e.getValue().size() > 1) + .map(e -> "'" + e.getKey() + "' mapped by: " + e.getValue()) + .sorted() + .toList(); + if (!duplicates.isEmpty()) { + throw new IllegalStateException( + "Duplicate OpenAPI schema names detected. Use @Schema(name = ...) to disambiguate:\n " + + String.join("\n ", duplicates)); + } + var paths = openAPI.getPaths(); paths.entrySet().stream() .peek(entry -> { @@ -370,17 +449,137 @@ public class SwaggerConfiguration { }); sortedPaths.setExtensions(paths.getExtensions()); openAPI.setPaths(sortedPaths); + + if (openAPI.getComponents() != null && openAPI.getComponents().getSchemas() != null) { + Map schemas = openAPI.getComponents().getSchemas(); + + // Fix all schemas: if they have additionalProperties but no type, set type to object + schemas.forEach((schemaName, schema) -> { + if (schema.getAdditionalProperties() != null && schema.getType() == null) { + schema.setType("object"); + log.debug("Added type 'object' to schema: {}", schemaName); + } + }); + + // Springdoc creates duplicate schemas with an "Object" suffix when a type is + // resolved through multiple inheritance paths or via generic type resolution. + // Remove the "*Object" duplicate when the base schema exists (either + // pre-registered in addDefaultSchemas or generated by springdoc). + for (String name : new ArrayList<>(schemas.keySet())) { + if (!name.endsWith("Object")) continue; + String baseName = name.substring(0, name.length() - "Object".length()); + if (!schemas.containsKey(baseName)) continue; + + schemas.remove(name); + String refToRemove = "#/components/schemas/" + name; + schemas.values().forEach(s -> { + if (s.getAllOf() != null) { + s.getAllOf().removeIf(allOfEntry -> refToRemove.equals(((Schema) allOfEntry).get$ref())); + } + }); + log.debug("Removed duplicate schema '{}' (base '{}' exists)", name, baseName); + } + + // Remove duplicate or redundant inline entries in allOf. Springdoc can + // generate multiple inline property blocks when resolving a type through + // multiple parent paths (e.g. record + sealed interface). One block may be + // a strict subset of another (same properties, but the superset has extras + // like "modelType"). Keep only the superset in that case. + schemas.values().forEach(schema -> { + if (schema.getAllOf() != null && schema.getAllOf().size() > 1) { + List allOf = schema.getAllOf(); + Set redundant = new HashSet<>(); + for (int i = 0; i < allOf.size(); i++) { + if (redundant.contains(i)) continue; + Schema a = allOf.get(i); + if (a.get$ref() != null || a.getProperties() == null) continue; + for (int j = i + 1; j < allOf.size(); j++) { + if (redundant.contains(j)) continue; + Schema b = allOf.get(j); + if (b.get$ref() != null || b.getProperties() == null) continue; + if (a.getProperties().entrySet().containsAll(b.getProperties().entrySet())) { + redundant.add(j); // b is a subset of a + } else if (b.getProperties().entrySet().containsAll(a.getProperties().entrySet())) { + redundant.add(i); // a is a subset of b + break; + } + } + } + if (!redundant.isEmpty()) { + List filtered = new ArrayList<>(); + for (int i = 0; i < allOf.size(); i++) { + if (!redundant.contains(i)) { + filtered.add(allOf.get(i)); + } + } + allOf.clear(); + allOf.addAll(filtered); + } + } + }); + + + // Fix polymorphic properties: replace inline oneOf with base type $ref + schemas.values().forEach(schema -> { + replaceInlineOneOfProperties(schema, schemas); + if (schema.getAllOf() != null) { + List allOf = schema.getAllOf(); + for (Schema allOfElement : allOf) { + replaceInlineOneOfProperties(allOfElement, schemas); + } + } + }); + + // Deduplicate allOf child schemas: remove properties that are already defined + // in the referenced parent schema to avoid duplication (e.g. EntityId children). + schemas.forEach((schemaName, schema) -> { + Set ownProps = schemaOwnProps.getOrDefault(schemaName, Set.of()); + deduplicateAllOfProperties(schema, schemas, ownProps); + }); + + // Reorder properties for all component schemas. This runs after all + // schemas are finalized so it covers schemas the ModelConverter only + // saw as a $ref (e.g. interface-based discriminator types like EntityId). + schemas.forEach((schemaName, schema) -> { + List propOrder = schemaPropertyOrders.getOrDefault(schemaName, List.of()); + reorderSchemaProperties(schema, propOrder); + }); + + // Synthesize a request-body example for every schema that uses a discriminator. + // Without this, Swagger UI shows only the discriminator-property field for + // polymorphic types (the parent schema doesn't know which oneOf branch to pick). + // We resolve the first declared subtype and inline its full property tree. + schemas.forEach((schemaName, schema) -> fillDiscriminatorExample(schema, schemas)); + + // Fix polymorphic request/response bodies: replace inline oneOf with base type $ref + paths.values().stream() + .flatMap(pathItem -> pathItem.readOperationsMap().values().stream()) + .forEach(operation -> { + // Request bodies + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + replaceInlineOneOfInContent(operation.getRequestBody().getContent(), schemas); + } + // Response bodies + if (operation.getResponses() != null) { + operation.getResponses().values().stream() + .filter(response -> response.getContent() != null) + .forEach(response -> replaceInlineOneOfInContent(response.getContent(), schemas)); + } + }); + } + + // Set JsonNode schema last so model scanning cannot overwrite it + openAPI.getComponents().addSchemas("JsonNode", new Schema<>() + .description("A value representing the any type (object or primitive)") + .example(JacksonUtil.newObjectNode())); + var sortedSchemas = new TreeMap<>(openAPI.getComponents().getSchemas()); openAPI.getComponents().setSchemas(new LinkedHashMap<>(sortedSchemas)); }; } private SecurityRequirement createSecurityRequirement(String schemeName) { - return new SecurityRequirement().addList(schemeName, Arrays.asList( - Authority.SYS_ADMIN.name(), - Authority.TENANT_ADMIN.name(), - Authority.CUSTOMER_USER.name() - )); + return new SecurityRequirement().addList(schemeName, List.of()); } private Tag extractTagFromPath(Map.Entry entry) { @@ -388,9 +587,134 @@ public class SwaggerConfiguration { return tagName != null ? tagFromTagItem(tagName) : null; } + private String findBaseTypeForOneOf(Map schemas, List oneOfSchemas) { + if (oneOfSchemas.isEmpty()) { + return null; + } + + for (Schema oneOfSchema : oneOfSchemas) { + String ref = oneOfSchema.get$ref(); + if (ref == null) { + continue; + } + String refName = ref.substring(ref.lastIndexOf('/') + 1); + + // Check if this entry is itself a base type with discriminator + Schema candidate = schemas.get(refName); + if (candidate != null && candidate.getDiscriminator() != null + && candidate.getDiscriminator().getMapping() != null) { + return refName; + } + + // Check if this subtype is in another schema's discriminator mapping + String baseType = schemas.entrySet().stream() + .filter(entry -> { + Schema schema = entry.getValue(); + if (schema.getDiscriminator() != null && schema.getDiscriminator().getMapping() != null) { + return schema.getDiscriminator().getMapping().values().stream() + .anyMatch(r -> r.endsWith("/" + refName)); + } + return false; + }) + .map(Map.Entry::getKey) + .findFirst() + .orElse(null); + + if (baseType != null) { + return baseType; + } + + // Check if other oneOf items extend this candidate via allOf (parent-child without discriminator) + if (candidate != null) { + boolean isParent = oneOfSchemas.stream() + .filter(s -> s.get$ref() != null && !s.get$ref().equals(ref)) + .anyMatch(s -> { + String otherName = s.get$ref().substring(s.get$ref().lastIndexOf('/') + 1); + Schema otherSchema = schemas.get(otherName); + return otherSchema != null && otherSchema.getAllOf() != null && + otherSchema.getAllOf().stream().anyMatch( + a -> a.get$ref() != null && a.get$ref().endsWith("/" + refName)); + }); + if (isParent) { + return refName; + } + } + } + return null; + } + + + private void replaceInlineOneOfInContent(Content content, Map schemas) { + content.values().forEach(mediaType -> { + Schema schema = mediaType.getSchema(); + if (schema != null && schema.getOneOf() != null && !schema.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(schemas, schema.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + mediaType.setSchema(refSchema); + log.debug("Replaced oneOf in content with $ref to {}", baseType); + } + } + }); + } + + @SuppressWarnings("unchecked") + private void replaceInlineOneOfProperties(Schema schema, Map allSchemas) { + if (schema == null || schema.getProperties() == null) { + return; + } + schema.getProperties().forEach((propName, propSchema) -> { + if (propSchema instanceof Schema) { + Schema prop = (Schema) propSchema; + + // Check if additionalProperties has oneOf (polymorphic map values) + if (prop.getAdditionalProperties() instanceof Schema) { + Schema additionalProps = (Schema) prop.getAdditionalProperties(); + if (additionalProps.getOneOf() != null && !additionalProps.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, additionalProps.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + prop.setAdditionalProperties(refSchema); + log.debug("Replaced oneOf in additionalProperties with $ref to {} in property {}", baseType, propName); + } + } + // Check if additionalProperties is an array whose items has oneOf (e.g. Map>) + if (additionalProps.getItems() != null && additionalProps.getItems().getOneOf() != null && !additionalProps.getItems().getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, additionalProps.getItems().getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + additionalProps.setItems(refSchema); + log.debug("Replaced oneOf in additionalProperties.items with $ref to {} in property {}", baseType, propName); + } + } + } + + // If property has oneOf, try to find the base discriminated type + if (prop.getOneOf() != null && !prop.getOneOf().isEmpty()) { + String baseType = findBaseTypeForOneOf(allSchemas, prop.getOneOf()); + if (baseType != null) { + Schema refSchema = new Schema<>(); + refSchema.set$ref("#/components/schemas/" + baseType); + if (prop.getDescription() != null) { + refSchema.setDescription(prop.getDescription()); + } + if (prop.getReadOnly() != null) { + refSchema.setReadOnly(prop.getReadOnly()); + } + schema.getProperties().put(propName, refSchema); + log.debug("Replaced oneOf with $ref to {} in property {}", baseType, propName); + } + } + } + }); + } + private String tagItemFromPathItem(PathItem item) { var operations = item.readOperationsMap().values(); - var operation = operations.stream().findAny(); + var operation = operations.stream().findFirst(); if (operation.isPresent()) { var tags = operation.get().getTags(); if (tags != null && !tags.isEmpty()) { @@ -480,28 +804,34 @@ public class SwaggerConfiguration { private static ApiResponses loginErrorResponses() { ApiResponses apiResponses = new ApiResponses(); - apiResponses.addApiResponse("401", errorResponse("Unauthorized", - Map.of( - "bad-credentials", errorExample("Bad credentials", - ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "token-expired", errorExample("JWT token expired", - ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED)), - "account-disabled", errorExample("Disabled account", - ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "account-locked", errorExample("Locked account", - ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)), - "authentication-failed", errorExample("General authentication error", - ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED)) - ) - )); - var credentialsExpiredSchema = new Schema().$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse"); - apiResponses.addApiResponse("401 ", errorResponse("Unauthorized (**Expired credentials**)", - Map.of( - "credentials-expired", errorExample("Expired credentials", - ThingsboardCredentialsExpiredResponse.of("User password expired!", StringUtils.randomAlphanumeric(30))) - ), - credentialsExpiredSchema + Map unauthorizedExamples = new LinkedHashMap<>(); + + unauthorizedExamples.put("bad-credentials", errorExample("Bad credentials", + ThingsboardErrorResponse.of("Invalid username or password", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("token-expired", errorExample("JWT token expired", + ThingsboardErrorResponse.of("Token has expired", ThingsboardErrorCode.JWT_TOKEN_EXPIRED, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("account-disabled", errorExample("Disabled account", + ThingsboardErrorResponse.of("User account is not active", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("account-locked", errorExample("Locked account", + ThingsboardErrorResponse.of("User account is locked due to security policy", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("authentication-failed", errorExample("General authentication error", + ThingsboardErrorResponse.of("Authentication failed", ThingsboardErrorCode.AUTHENTICATION, HttpStatus.UNAUTHORIZED))); + + unauthorizedExamples.put("credentials-expired", errorExample("Expired credentials", + ThingsboardCredentialsExpiredResponse.of("User password expired!", "udgDQOpS1Q4ZFEL8qHF9s8cSKQ7d1h"))); + + Schema unauthorizedSchema = new Schema<>(); + unauthorizedSchema.oneOf(List.of( + new Schema().$ref("#/components/schemas/ThingsboardErrorResponse"), + new Schema().$ref("#/components/schemas/ThingsboardCredentialsExpiredResponse") )); + + apiResponses.addApiResponse("401", errorResponse("Unauthorized", unauthorizedExamples, unauthorizedSchema)); + return apiResponses; } @@ -520,10 +850,389 @@ public class SwaggerConfiguration { return new ApiResponse().description(description).content(content); } + /** + * Recursively collects all property names reachable from {@code schemaName}, walking the + * ancestor chain through allOf $ref entries (to handle multi-level inheritance). + * {@code visited} prevents infinite loops in case of circular references. + */ + @SuppressWarnings("unchecked") + private void collectAllProperties(String schemaName, Map allSchemas, + Set result, Set visited) { + if (!visited.add(schemaName)) { + return; + } + Schema schema = allSchemas.get(schemaName); + if (schema == null) { + return; + } + if (schema.getProperties() != null) { + result.addAll(schema.getProperties().keySet()); + } + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + collectAllProperties(refName, allSchemas, result, visited); + } else if (allOfElement.getProperties() != null) { + result.addAll(allOfElement.getProperties().keySet()); + } + } + } + } + + private static final int MAX_EXAMPLE_DEPTH = 4; + + /** + * If {@code schema} has a discriminator, populate examples for the parent and every + * concrete subtype it maps to. Each subtype gets its own example with the discriminator + * field set to the mapping value that points at it, so fields typed as a specific + * subtype (e.g. {@code EntityView.id} → {@code EntityViewId}) resolve to a correct + * example without falling back to the parent's. + */ + @SuppressWarnings("unchecked") + private void fillDiscriminatorExample(Schema schema, Map allSchemas) { + var discriminator = schema.getDiscriminator(); + if (discriminator == null || discriminator.getMapping() == null || discriminator.getMapping().isEmpty()) { + return; + } + // 1. Populate an example on each mapped subtype. + for (var entry : discriminator.getMapping().entrySet()) { + String discriminatorValue = entry.getKey(); + String subtypeRef = entry.getValue(); + String subtypeName = subtypeRef.substring(subtypeRef.lastIndexOf('/') + 1); + Schema subtype = allSchemas.get(subtypeName); + if (subtype == null || subtype.getExample() != null) { + continue; + } + Map example = new LinkedHashMap<>(); + buildSchemaExample(subtypeName, allSchemas, example, new HashSet<>(), 0); + if (example.isEmpty()) { + continue; + } + example.put(discriminator.getPropertyName(), discriminatorValue); + subtype.setExample(example); + } + // 2. Mirror a subtype's example onto the parent so a field typed as the parent + // interface still gets a complete example. Prefer the subtype whose mapping key + // matches the example declared on the discriminator property itself + // (e.g. EntityId.getEntityType() has example = "DEVICE" → mirror DeviceId, not + // the alphabetically first AdminSettingsId). Fall back to the first mapping entry. + if (schema.getExample() == null) { + String preferredValue = null; + if (schema.getProperties() != null) { + Schema discProp = (Schema) schema.getProperties().get(discriminator.getPropertyName()); + if (discProp != null && discProp.getExample() != null) { + preferredValue = discProp.getExample().toString(); + } + } + String chosenRef = preferredValue != null ? discriminator.getMapping().get(preferredValue) : null; + if (chosenRef == null) { + chosenRef = discriminator.getMapping().values().iterator().next(); + } + String chosenSubtypeName = chosenRef.substring(chosenRef.lastIndexOf('/') + 1); + Schema chosenSubtype = allSchemas.get(chosenSubtypeName); + if (chosenSubtype != null && chosenSubtype.getExample() != null) { + schema.setExample(chosenSubtype.getExample()); + } + } + } + + @SuppressWarnings("unchecked") + private void buildSchemaExample(String schemaName, Map allSchemas, + Map result, Set visited, int depth) { + if (depth > MAX_EXAMPLE_DEPTH || !visited.add(schemaName)) { + return; + } + Schema schema = allSchemas.get(schemaName); + if (schema == null) { + return; + } + // Walk parents first so own properties (added later) override inherited entries. + if (schema.getAllOf() != null) { + String selfRef = "#/components/schemas/" + schemaName; + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + buildSchemaExample(refName, allSchemas, result, visited, depth); + // If the parent uses a discriminator, this schema is one of its mapping + // targets — override the discriminator field with the value that points + // back at us (e.g. EntityViewId → entityType: "ENTITY_VIEW", not "ADMIN_SETTINGS"). + Schema parentSchema = allSchemas.get(refName); + if (parentSchema != null && parentSchema.getDiscriminator() != null + && parentSchema.getDiscriminator().getMapping() != null) { + parentSchema.getDiscriminator().getMapping().entrySet().stream() + .filter(e -> selfRef.equals(e.getValue())) + .map(Map.Entry::getKey) + .findFirst() + .ifPresent(value -> result.put(parentSchema.getDiscriminator().getPropertyName(), value)); + } + } else if (allOfElement.getProperties() != null) { + allOfElement.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + } + if (schema.getProperties() != null) { + schema.getProperties().forEach((k, v) -> + result.put(k, sampleValue((Schema) v, allSchemas, visited, depth + 1))); + } + } + + @SuppressWarnings("unchecked") + private Object sampleValue(Schema propSchema, Map allSchemas, + Set visited, int depth) { + if (propSchema == null) { + return null; + } + if (propSchema.getExample() != null) { + return propSchema.getExample(); + } + String ref = propSchema.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + Schema refSchema = allSchemas.get(refName); + if (refSchema != null && refSchema.getExample() != null) { + return refSchema.getExample(); + } + if (depth >= MAX_EXAMPLE_DEPTH) { + return Map.of(); + } + Map nested = new LinkedHashMap<>(); + buildSchemaExample(refName, allSchemas, nested, new HashSet<>(visited), depth + 1); + return nested; + } + if (propSchema.getEnum() != null && !propSchema.getEnum().isEmpty()) { + return propSchema.getEnum().get(0); + } + String type = propSchema.getType(); + if (type == null) { + return null; + } + return switch (type) { + case "string" -> "string"; + case "integer", "number" -> 0; + case "boolean" -> false; + case "array" -> List.of(); + case "object" -> Map.of(); + default -> null; + }; + } + + @SuppressWarnings("unchecked") + private void deduplicateAllOfProperties(Schema schema, Map allSchemas, Set ownProps) { + if (schema.getAllOf() == null) { + return; + } + + // Collect properties defined in any $ref'd parent within the allOf, recursively + // walking the ancestor chain (each parent may itself use allOf to extend a grandparent). + Set parentProperties = new LinkedHashSet<>(); + for (Schema allOfElement : schema.getAllOf()) { + String ref = allOfElement.get$ref(); + if (ref != null) { + String refName = ref.substring(ref.lastIndexOf('/') + 1); + collectAllProperties(refName, allSchemas, parentProperties, new LinkedHashSet<>()); + } + } + + if (parentProperties.isEmpty()) { + return; + } + + // Properties to strip: in parent schema AND not declared as own-class fields. + // This removes inherited properties (from superclasses or pure interface getters) + // while keeping properties the class declares as its own fields. + Set toStrip = new LinkedHashSet<>(parentProperties); + toStrip.removeAll(ownProps); + + if (toStrip.isEmpty()) { + return; + } + + // Strip from inline (non-$ref) allOf elements + schema.getAllOf().removeIf(allOfElement -> { + if (allOfElement.get$ref() != null) { + return false; + } + if (allOfElement.getProperties() != null) { + Map filtered = new LinkedHashMap<>(allOfElement.getProperties()); + filtered.keySet().removeAll(toStrip); + allOfElement.setProperties(filtered.isEmpty() ? null : filtered); + } + return allOfElement.getProperties() == null + && allOfElement.getRequired() == null + && allOfElement.getType() == null; + }); + + // Remove stripped properties from the schema's required list + if (schema.getRequired() != null) { + List required = new ArrayList<>(schema.getRequired()); + required.removeAll(toStrip); + schema.setRequired(required.isEmpty() ? null : required); + } + } + + /** + * Computes the schema name that swagger-core will use for the given JavaType. + * For simple types, this is just the class simple name (e.g. {@code Device}). + * For parameterized types, type parameter names are appended + * (e.g. {@code PageData} becomes {@code PageDataDevice}). + * This matches the naming convention used by swagger-core's {@code TypeNameResolver}. + */ + private static String resolveSchemaName(JavaType javaType) { + Class cls = javaType.getRawClass(); + io.swagger.v3.oas.annotations.media.Schema schemaAnnotation = + cls.getAnnotation(io.swagger.v3.oas.annotations.media.Schema.class); + if (schemaAnnotation != null && !schemaAnnotation.name().isEmpty()) { + return schemaAnnotation.name(); + } + StringBuilder sb = new StringBuilder(cls.getSimpleName()); + if (javaType.hasGenericTypes()) { + for (int i = 0; i < javaType.containedTypeCount(); i++) { + JavaType param = javaType.containedType(i); + if (param != null) { + sb.append(param.getRawClass().getSimpleName()); + } + } + } + return sb.toString(); + } + + /** + * Returns the JSON property names that are backed by fields declared directly in {@code cls} + * (not inherited from a superclass). Used to distinguish "own" from "inherited" properties + * when deduplicating allOf inline elements. + */ + private static Set computeOwnPropNames(Class cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) { + Map allFieldToJson = new LinkedHashMap<>(); + for (var prop : beanDesc.findProperties()) { + if (prop.getField() != null && prop.couldSerialize()) { + allFieldToJson.put(prop.getField().getName(), prop.getName()); + } + } + Set own = new LinkedHashSet<>(); + for (Field f : cls.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + String jsonName = allFieldToJson.get(f.getName()); + if (jsonName != null) own.add(jsonName); + } + return own; + } + + @SuppressWarnings("unchecked") + private static void reorderSchemaProperties(Schema schema, List propOrder) { + if (schema.getProperties() != null && schema.getProperties().size() > 1) { + schema.setProperties(reorderProperties(schema.getProperties(), propOrder)); + } + if (schema.getAllOf() != null) { + for (Schema allOfElement : schema.getAllOf()) { + if (allOfElement.get$ref() != null) continue; + if (allOfElement.getProperties() != null && allOfElement.getProperties().size() > 1) { + allOfElement.setProperties(reorderProperties(allOfElement.getProperties(), propOrder)); + } + } + } + } + + private static LinkedHashMap reorderProperties(Map current, List propOrder) { + var reordered = new LinkedHashMap(); + for (String name : propOrder) { + Schema prop = current.get(name); + if (prop != null) reordered.put(name, prop); + } + // Any properties not covered by propOrder are appended + // alphabetically to guarantee a deterministic stable order. + new TreeMap<>(current).forEach((k, v) -> reordered.putIfAbsent(k, v)); + return reordered; + } + + /** + * Resolves the property ordering for a schema class. + * + *

Returns a list of JSON property names in the order they should appear in the + * OpenAPI schema. The caller uses this list to reorder the schema's property map; + * any properties not present in the returned list are appended alphabetically + * by the caller's {@code TreeMap} fallback, guaranteeing a stable, deterministic order. + * + *

Resolution strategy (first match wins): + *

    + *
  1. If {@code @JsonPropertyOrder} with an explicit {@code value()} is found on the + * class or any interface in its ancestry, that list is returned as-is. Note: if the + * annotation lists only a subset of fields, those fields are ordered first and the + * remaining properties fall through to the caller's alphabetical fallback — consistent + * with Jackson's own behaviour for partial {@code @JsonPropertyOrder}.
  2. + *
  3. Otherwise, field-backed properties are returned in declaration order (superclass + * fields first). Getter-only properties are intentionally excluded to avoid + * non-deterministic ordering across restarts.
  4. + *
+ */ + private static List resolvePropertyOrder(Class cls, com.fasterxml.jackson.databind.BeanDescription beanDesc) { + // If an explicit @JsonPropertyOrder is present on the class or any interface in its + // ancestry, honour it directly. Walk up the class hierarchy; for each class also walk + // the full interface hierarchy (including super-interfaces) via BFS. + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + JsonPropertyOrder propOrder = c.getAnnotation(JsonPropertyOrder.class); + if (propOrder != null && !propOrder.alphabetic() && propOrder.value().length > 0) { + return Arrays.asList(propOrder.value()); + } + Deque> ifaceQueue = new ArrayDeque<>(Arrays.asList(c.getInterfaces())); + Set> visitedIfaces = new LinkedHashSet<>(); + while (!ifaceQueue.isEmpty()) { + Class iface = ifaceQueue.poll(); + if (!visitedIfaces.add(iface)) continue; + propOrder = iface.getAnnotation(JsonPropertyOrder.class); + if (propOrder != null && !propOrder.alphabetic() && propOrder.value().length > 0) { + return Arrays.asList(propOrder.value()); + } + ifaceQueue.addAll(Arrays.asList(iface.getInterfaces())); + } + } + + // Map backing field names to their JSON property names (respects @JsonProperty) + Map fieldToJsonName = new LinkedHashMap<>(); + for (var prop : beanDesc.findProperties()) { + if (prop.couldSerialize()) { + if (prop.getField() != null) { + fieldToJsonName.put(prop.getField().getName(), prop.getName()); + } else { + // For transient fields, Jackson may not associate the field with the property. + // Fall back to using the property name as the field name key. + fieldToJsonName.putIfAbsent(prop.getName(), prop.getName()); + } + } + } + + // Walk class hierarchy (superclass first) to get field declaration order + List> hierarchy = new ArrayList<>(); + for (Class c = cls; c != null && c != Object.class; c = c.getSuperclass()) { + hierarchy.add(0, c); + } + List ordered = new ArrayList<>(); + for (Class c : hierarchy) { + for (Field f : c.getDeclaredFields()) { + if (Modifier.isStatic(f.getModifiers())) continue; + String jsonName = fieldToJsonName.get(f.getName()); + if (jsonName != null) ordered.add(jsonName); + } + } + + // Return only field-backed properties in declaration order. + // Getter-only properties (no backing field) are intentionally excluded: their set can vary + // between restarts (e.g. Optional-typed getters depend on Jackson module registration order), + // so including them here would make their position non-deterministic when some are in orderedNames + // and others are only in the schema map. The converter's TreeMap fallback handles ALL + // non-field-backed properties together in one alphabetical pass, guaranteeing stable order. + return ordered; + } + private static Example errorExample(String summary, ThingsboardErrorResponse example) { + var node = (ObjectNode) JacksonUtil.valueToTree(example); + node.put("timestamp", 1609459200000L); return new Example() .summary(summary) - .value(example); + .value(node); } -} +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java new file mode 100644 index 0000000000..a5ad4e8742 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/config/TbHttpClientSettingsComponent.java @@ -0,0 +1,51 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; +import org.thingsboard.server.queue.util.TbRuleEngineComponent; + +@TbRuleEngineComponent +@Component +public class TbHttpClientSettingsComponent implements TbHttpClientSettings { + + @Value("${actors.rule.external.http_client.max_parallel_requests:0}") + private int maxParallelRequests; + + @Value("${actors.rule.external.http_client.max_pending_requests:0}") + private int maxPendingRequests; + + @Value("${actors.rule.external.http_client.pool_max_connections:0}") + private int poolMaxConnections; + + @Override + public int getMaxParallelRequests() { + return maxParallelRequests; + } + + @Override + public int getMaxPendingRequests() { + return maxPendingRequests; + } + + @Override + public int getPoolMaxConnections() { + return poolMaxConnections; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java index 1f94afb398..f3efaf8d4e 100644 --- a/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/TbRuleEngineSecurityConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.config; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.context.annotation.Bean; @@ -33,11 +34,16 @@ import org.springframework.security.web.SecurityFilterChain; @ConditionalOnExpression("'${service.type:null}'=='tb-rule-engine'") public class TbRuleEngineSecurityConfiguration { + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers - .cacheControl(config -> {}) - .frameOptions(config -> {}).disable()) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .authorizeHttpRequests(config -> config diff --git a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java index cd5cecbef7..65bbe459c1 100644 --- a/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/ThingsboardSecurityConfiguration.java @@ -143,6 +143,9 @@ public class ThingsboardSecurityConfiguration { @Autowired private AuthExceptionHandler authExceptionHandler; + @Autowired + private HttpSecurityHeadersCustomizer httpSecurityHeadersCustomizer; + @Bean protected PayloadSizeFilter payloadSizeFilter() { return new PayloadSizeFilter(maxPayloadSizeConfig); @@ -231,9 +234,11 @@ public class ThingsboardSecurityConfiguration { http .securityMatchers(matchers -> matchers .requestMatchers("/*.js", "/*.css", "/*.ico", "/assets/**", "/static/**")) - .headers(header -> header - .defaultsDisabled() - .addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public"))) + .headers(headers -> { + headers.defaultsDisabled(); + headers.addHeaderWriter(new StaticHeadersWriter(HttpHeaders.CACHE_CONTROL, "max-age=0, public")); + httpSecurityHeadersCustomizer.customize(headers); + }) .authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll()) .requestCache(RequestCacheConfigurer::disable) .securityContext(AbstractHttpConfigurer::disable) @@ -243,8 +248,12 @@ public class ThingsboardSecurityConfiguration { @Bean SecurityFilterChain filterChain(HttpSecurity http) throws Exception { - http.headers(headers -> headers.defaultsDisabled() - .crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN))) + http.headers(headers -> { + headers.defaultsDisabled(); + headers.cacheControl(config -> {}); + headers.crossOriginOpenerPolicy(coop -> coop.policy(CrossOriginOpenerPolicy.SAME_ORIGIN)); + httpSecurityHeadersCustomizer.customize(headers); + }) .cors(cors -> {}) .csrf(AbstractHttpConfigurer::disable) .exceptionHandling(config -> {}) diff --git a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java index 3cd1e2a6e3..4ce7268232 100644 --- a/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java +++ b/application/src/main/java/org/thingsboard/server/config/WebSocketConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.config; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.web.socket.WebSocketHandler; @@ -40,11 +41,16 @@ public class WebSocketConfiguration implements WebSocketConfigurer { private final WebSocketHandler wsHandler; + @Value("${server.ws.max_text_message_buffer_size:32768}") + private int maxTextMessageBufferSize; + @Value("${server.ws.max_binary_message_buffer_size:32768}") + private int maxBinaryMessageBufferSize; + @Bean public ServletServerContainerFactoryBean createWebSocketContainer() { ServletServerContainerFactoryBean container = new ServletServerContainerFactoryBean(); - container.setMaxTextMessageBufferSize(32768); - container.setMaxBinaryMessageBufferSize(32768); + container.setMaxTextMessageBufferSize(maxTextMessageBufferSize); + container.setMaxBinaryMessageBufferSize(maxBinaryMessageBufferSize); return container; } @@ -54,7 +60,8 @@ public class WebSocketConfiguration implements WebSocketConfigurer { log.error("TbWebSocketHandler expected but [{}] provided", wsHandler); throw new RuntimeException("TbWebSocketHandler expected but " + wsHandler + " provided"); } - registry.addHandler(wsHandler, WS_API_MAPPING).setAllowedOriginPatterns("*"); + registry.addHandler(wsHandler, WS_API_MAPPING) + .setAllowedOriginPatterns("*"); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/AdminController.java b/application/src/main/java/org/thingsboard/server/controller/AdminController.java index 0b04a6939f..e4394bace3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AdminController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AdminController.java @@ -417,7 +417,7 @@ public class AdminController extends BaseController { "provider sends authorization code to specified redirect uri.)") @PreAuthorize("hasAuthority('SYS_ADMIN')") @GetMapping(value = "/mail/oauth2/authorize", produces = "application/text") - public String getAuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { + public String getMailOAuth2AuthorizationUrl(HttpServletRequest request, HttpServletResponse response) throws ThingsboardException { String state = StringUtils.generateSafeToken(); if (request.getParameter(PREV_URI_PATH_PARAMETER) != null) { CookieUtils.addCookie(response, PREV_URI_COOKIE_NAME, request.getParameter(PREV_URI_PATH_PARAMETER), 180); @@ -442,7 +442,7 @@ public class AdminController extends BaseController { } @GetMapping(value = "/mail/oauth2/code", params = {"code", "state"}) - public void codeProcessingUrl( + public void handleMailOAuth2Callback( @RequestParam(value = "code") String code, @RequestParam(value = "state") String state, HttpServletRequest request, HttpServletResponse response) throws ThingsboardException, IOException { Optional prevUrlOpt = CookieUtils.getCookie(request, PREV_URI_COOKIE_NAME); diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java index ae8305c070..8a113fb424 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmCommentController.java @@ -30,6 +30,7 @@ import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmCommentInfo; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; @@ -77,6 +78,7 @@ public class AlarmCommentController extends BaseController { AlarmId alarmId = new AlarmId(toUUID(strAlarmId)); Alarm alarm = checkAlarmInfoId(alarmId, Operation.WRITE); alarmComment.setAlarmId(alarmId); + alarmComment.setType(AlarmCommentType.OTHER); return tbAlarmCommentService.saveAlarmComment(alarm, alarmComment, getCurrentUser()); } diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java index 8f7c8bbbd1..0814e94700 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AlarmController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmController.java @@ -227,12 +227,12 @@ public class AlarmController extends BaseController { return tbAlarmService.unassign(alarm, System.currentTimeMillis(), getCurrentUser()); } - @ApiOperation(value = "Get Alarms (getAlarms)", + @ApiOperation(value = "Get Alarms (getAlarmsByEntity)", notes = "Returns a page of alarms for the selected entity. Specifying both parameters 'searchStatus' and 'status' at the same time will cause an error. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/alarm/{entityType}/{entityId}") - public PageData getAlarms( + public PageData getAlarmsByEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) diff --git a/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java new file mode 100644 index 0000000000..957d05d1f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/controller/AlarmRuleController.java @@ -0,0 +1,259 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EventInfo; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.event.EventType; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.EntityIdFactory; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.dao.event.EventService; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.permission.Operation; + +import java.util.EnumSet; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.ENTITY_TYPE_PARAM_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_END; +import static org.thingsboard.server.controller.ControllerConstants.MARKDOWN_CODE_BLOCK_START; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LINK; + +@RestController +@TbCoreComponent +@RequestMapping("/api") +@RequiredArgsConstructor +public class AlarmRuleController extends BaseController { + + private final TbCalculatedFieldService tbCalculatedFieldService; + private final EventService eventService; + + public static final String ALARM_RULE_ID = "alarmRuleId"; + + private static final String TEST_SCRIPT_EXPRESSION = + "Execute the alarm rule TBEL condition expression and return the result. " + + "Alarm rule expressions must return a boolean value. The format of request: \n\n" + + MARKDOWN_CODE_BLOCK_START + + "{\n" + + " \"expression\": \"return temperature > 50;\",\n" + + " \"arguments\": {\n" + + " \"temperature\": { \"type\": \"SINGLE_VALUE\", \"ts\": 1739776478057, \"value\": 55 }\n" + + " }\n" + + "}" + + MARKDOWN_CODE_BLOCK_END + + "\n\n Expected result JSON contains \"output\" and \"error\"."; + + @ApiOperation(value = "Create Or Update Alarm Rule (saveAlarmRule)", + notes = "Creates or Updates the Alarm Rule. When creating alarm rule, platform generates Alarm Rule Id as " + UUID_WIKI_LINK + + "The newly created Alarm Rule Id will be present in the response. " + + "Specify existing Alarm Rule Id to update the alarm rule. " + + "Referencing non-existing Alarm Rule Id will cause 'Not Found' error. " + + "Remove 'id', 'tenantId' from the request body example (below) to create new Alarm Rule entity. " + + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/alarm/rule") + public AlarmRuleDefinition saveAlarmRule(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the alarm rule.") + @RequestBody AlarmRuleDefinition alarmRuleDefinition) throws Exception { + alarmRuleDefinition.setTenantId(getTenantId()); + checkEntityId(alarmRuleDefinition.getEntityId(), Operation.WRITE_CALCULATED_FIELD); + if (alarmRuleDefinition.getId() != null) { + checkAlarmRule(alarmRuleDefinition.getId()); + } + CalculatedField calculatedField = alarmRuleDefinition.toCalculatedField(); + checkReferencedEntities(calculatedField.getConfiguration()); + CalculatedField saved = tbCalculatedFieldService.save(calculatedField, getCurrentUser()); + return AlarmRuleDefinition.fromCalculatedField(saved); + } + + @ApiOperation(value = "Get Alarm Rule (getAlarmRuleById)", + notes = "Fetch the Alarm Rule object based on the provided Alarm Rule Id." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/alarm/rule/{alarmRuleId}") + public AlarmRuleDefinition getAlarmRuleById(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException { + checkParameter(ALARM_RULE_ID, strAlarmRuleId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); + CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); + checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); + return AlarmRuleDefinition.fromCalculatedField(calculatedField); + } + + @ApiOperation(value = "Get Alarm Rules by Entity Id (getAlarmRulesByEntityId)", + notes = "Fetch the Alarm Rules based on the provided Entity Id." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/alarm/rules/{entityType}/{entityId}") + public PageData getAlarmRulesByEntityId( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + checkParameter("entityId", entityIdStr); + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); + checkEntityId(entityId, Operation.READ_CALCULATED_FIELD); + PageData result = checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, CalculatedFieldType.ALARM, pageLink)); + return result.mapData(AlarmRuleDefinition::fromCalculatedField); + } + + @ApiOperation(value = "Get alarm rules (getAlarmRules)", + notes = "Fetch tenant alarm rules based on the filter." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/alarm/rules") + public PageData getAlarmRules(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Entity type filter. If not specified, alarm rules for all supported entity types will be returned.") + @RequestParam(required = false) EntityType entityType, + @Parameter(description = "Entities filter. If not specified, alarm rules for entity type filter will be returned.") + @RequestParam(required = false) Set entities, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + SecurityUser user = getCurrentUser(); + + Set entityTypes; + if (entityType == null) { + entityTypes = CalculatedField.SUPPORTED_ENTITIES.entrySet().stream() + .filter(entry -> entry.getValue().contains(CalculatedFieldType.ALARM)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + } else { + entityTypes = EnumSet.of(entityType); + } + + CalculatedFieldFilter filter = CalculatedFieldFilter.builder() + .types(EnumSet.of(CalculatedFieldType.ALARM)) + .entityTypes(entityTypes) + .entityIds(entities) + .build(); + PageData result = calculatedFieldService.findCalculatedFieldsByTenantIdAndFilter(user.getTenantId(), filter, pageLink); + return result.mapData(AlarmRuleDefinitionInfo::fromCalculatedFieldInfo); + } + + @ApiOperation(value = "Get alarm rule names (getAlarmRuleNames)", + notes = "Fetch the list of alarm rule names." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/alarm/rules/names") + public PageData getAlarmRuleNames(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { + PageLink pageLink = createPageLink(pageSize, page, textSearch, "name", sortOrder); + return calculatedFieldService.findCalculatedFieldNamesByTenantIdAndType(getTenantId(), CalculatedFieldType.ALARM, pageLink); + } + + @ApiOperation(value = "Delete Alarm Rule (deleteAlarmRule)", + notes = "Deletes the alarm rule. Referencing non-existing Alarm Rule Id will cause an error." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @DeleteMapping("/alarm/rule/{alarmRuleId}") + @ResponseStatus(HttpStatus.OK) + public void deleteAlarmRule(@PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws Exception { + checkParameter(ALARM_RULE_ID, strAlarmRuleId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); + CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); + checkEntityId(calculatedField.getEntityId(), Operation.WRITE_CALCULATED_FIELD); + tbCalculatedFieldService.delete(calculatedField, getCurrentUser()); + } + + @ApiOperation(value = "Get latest alarm rule debug event (getLatestAlarmRuleDebugEvent)", + notes = "Gets latest alarm rule debug event for specified alarm rule id. " + + "Referencing non-existing alarm rule id will cause an error. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping("/alarm/rule/{alarmRuleId}/debug") + public JsonNode getLatestAlarmRuleDebugEvent(@Parameter @PathVariable(ALARM_RULE_ID) String strAlarmRuleId) throws ThingsboardException { + checkParameter(ALARM_RULE_ID, strAlarmRuleId); + CalculatedFieldId calculatedFieldId = new CalculatedFieldId(toUUID(strAlarmRuleId)); + CalculatedField calculatedField = checkAlarmRule(calculatedFieldId); + checkEntityId(calculatedField.getEntityId(), Operation.READ_CALCULATED_FIELD); + TenantId tenantId = getCurrentUser().getTenantId(); + return Optional.ofNullable(eventService.findLatestEvents(tenantId, calculatedFieldId, EventType.DEBUG_CALCULATED_FIELD, 1)) + .flatMap(events -> events.stream().map(EventInfo::getBody).findFirst()) + .orElse(null); + } + + @ApiOperation(value = "Test alarm rule TBEL expression (testAlarmRuleScript)", + notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @PostMapping("/alarm/rule/testScript") + public JsonNode testAlarmRuleScript( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test alarm rule TBEL condition expression. The expression must return a boolean value.") + @RequestBody JsonNode inputParams) throws ThingsboardException { + checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null); + return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams); + } + + private CalculatedField checkAlarmRule(CalculatedFieldId calculatedFieldId) throws ThingsboardException { + CalculatedField calculatedField = tbCalculatedFieldService.findById(calculatedFieldId, getCurrentUser()); + checkNotNull(calculatedField); + if (calculatedField.getType() != CalculatedFieldType.ALARM) { + throw new ThingsboardException("Alarm rule not found", ThingsboardErrorCode.ITEM_NOT_FOUND); + } + return calculatedField; + } + +} diff --git a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java index 2c6ae69ca5..ae3ea1c60d 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ApiKeyController.java @@ -68,7 +68,7 @@ public class ApiKeyController extends BaseController { private final ApiKeyService apiKeyService; @ApiOperation(value = "Save API key for user (saveApiKey)", - notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey '." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + notes = "Creates an API key for the given user and returns the token ONCE as 'ApiKey {value}'." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/apiKey") public ApiKey saveApiKey( @@ -77,7 +77,11 @@ public class ApiKeyController extends BaseController { User user = checkUserId(apiKeyInfo.getUserId(), Operation.WRITE); apiKeyInfo.setTenantId(user.getTenantId()); checkEntity(apiKeyInfo.getId(), apiKeyInfo, Resource.API_KEY); - return checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo)); + ApiKey savedApiKey = checkNotNull(apiKeyService.saveApiKey(apiKeyInfo.getTenantId(), apiKeyInfo)); + if (apiKeyInfo.getId() != null) { + savedApiKey.setValue(null); + } + return savedApiKey; } @ApiOperation(value = "Get User Api Keys (getUserApiKeys)", @@ -121,7 +125,7 @@ public class ApiKeyController extends BaseController { ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setDescription(description.orElse(null)); - return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + return new ApiKeyInfo(apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey)); } @ApiOperation(value = "Enable or disable API key (enableApiKey)", @@ -137,7 +141,7 @@ public class ApiKeyController extends BaseController { ApiKey apiKey = checkApiKeyId(apiKeyId, Operation.WRITE); checkUserId(apiKey.getUserId(), Operation.WRITE); apiKey.setEnabled(enabledValue); - return apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey); + return new ApiKeyInfo(apiKeyService.saveApiKey(apiKey.getTenantId(), apiKey)); } @ApiOperation(value = "Delete API key by ID (deleteApiKey)", diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetController.java b/application/src/main/java/org/thingsboard/server/controller/AssetController.java index e4158d014f..4a20e2fcd2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,6 +24,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -217,8 +219,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/assets") public PageData getTenantAssets( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, @@ -245,8 +246,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets info objects owned by tenant. " + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/assetInfos") public PageData getTenantAssetInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, @@ -274,25 +274,30 @@ public class AssetController extends BaseController { } } - @ApiOperation(value = "Get Tenant Asset (getTenantAsset)", + @Hidden + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/assets", params = {"assetName"}) + public Asset getTenantAsset(@RequestParam String assetName) throws ThingsboardException { + TenantId tenantId = getCurrentUser().getTenantId(); + return checkNotNull(assetService.findAssetByTenantIdAndName(tenantId, assetName)); + } + + @ApiOperation(value = "Get Tenant Asset (getTenantAssetByName)", notes = "Requested asset must be owned by tenant that the user belongs to. " + "Asset name is an unique property of asset. So it can be used to identify the asset." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/assets", params = {"assetName"}, method = RequestMethod.GET) - @ResponseBody - public Asset getTenantAsset( + @GetMapping(value = "/tenant/asset") + public Asset getTenantAssetByName( @Parameter(description = ASSET_NAME_DESCRIPTION) @RequestParam String assetName) throws ThingsboardException { - TenantId tenantId = getCurrentUser().getTenantId(); - return checkNotNull(assetService.findAssetByTenantIdAndName(tenantId, assetName)); + return getTenantAsset(assetName); } @ApiOperation(value = "Get Customer Assets (getCustomerAssets)", notes = "Returns a page of assets objects assigned to customer. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/assets") public PageData getCustomerAssets( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -324,8 +329,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets info objects assigned to customer. " + PAGE_DATA_PARAMETERS + ASSET_INFO_DESCRIPTION) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/assetInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/assetInfos") public PageData getCustomerAssetInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -361,8 +365,7 @@ public class AssetController extends BaseController { @ApiOperation(value = "Get Assets By Ids (getAssetsByIds)", notes = "Requested assets must be owned by tenant or assigned to customer which user is performing the request. ") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/assets", params = {"assetIds"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assets") public List getAssetsByIds( @Parameter(description = "A list of assets ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("assetIds") String[] strAssetIds) throws ThingsboardException, ExecutionException, InterruptedException { @@ -383,14 +386,14 @@ public class AssetController extends BaseController { return checkNotNull(assets.get()); } - @ApiOperation(value = "Find related assets (findByQuery)", + @ApiOperation(value = "Find related assets (findAssetsByQuery)", notes = "Returns all assets that are related to the specific entity. " + "The entity id, relation type, asset types, depth of the search, and other query parameters defined using complex 'AssetSearchQuery' object. " + "See 'Model' tab of the Parameters for more info.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/assets", method = RequestMethod.POST) @ResponseBody - public List findByQuery(@RequestBody AssetSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findAssetsByQuery(@RequestBody AssetSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); checkNotNull(query.getParameters()); checkNotNull(query.getAssetTypes()); @@ -469,8 +472,7 @@ public class AssetController extends BaseController { notes = "Returns a page of assets assigned to edge. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/assets", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/assets") public PageData getEdgeAssets( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION) @PathVariable(EDGE_ID) String strEdgeId, @@ -516,11 +518,11 @@ public class AssetController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Import the bulk of assets (processAssetsBulkImport)", + @ApiOperation(value = "Import the bulk of assets (processAssetBulkImport)", notes = "There's an ability to import the bulk of assets using the only .csv file.") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @PostMapping("/asset/bulk_import") - public BulkImportResult processAssetsBulkImport(@RequestBody BulkImportRequest request) throws Exception { + public BulkImportResult processAssetBulkImport(@RequestBody BulkImportRequest request) throws Exception { SecurityUser user = getCurrentUser(); return assetBulkImportService.processBulkImport(request, user); } diff --git a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java index 65e324a715..86483413df 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AssetProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -175,8 +176,7 @@ public class AssetProfileController extends BaseController { notes = "Returns a page of asset profile objects owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/assetProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assetProfiles") public PageData getAssetProfiles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -196,8 +196,7 @@ public class AssetProfileController extends BaseController { notes = "Returns a page of asset profile info objects owned by tenant. " + PAGE_DATA_PARAMETERS + ASSET_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/assetProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/assetProfileInfos") public PageData getAssetProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -227,12 +226,10 @@ public class AssetProfileController extends BaseController { return checkNotNull(assetProfileService.findAssetProfileNamesByTenantId(tenantId, activeOnly)); } - @ApiOperation(value = "Get Asset Profiles By Ids (getAssetProfilesByIds)", - notes = "Requested asset profiles must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/assetProfileInfos", params = {"assetProfileIds"}) - public List getAssetProfilesByIds( + public List getAssetProfilesByIdsV1( @Parameter(description = "A list of asset profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("assetProfileIds") Set assetProfileUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -243,4 +240,15 @@ public class AssetProfileController extends BaseController { return assetProfileService.findAssetProfilesByIds(tenantId, assetProfileIds); } + @ApiOperation(value = "Get Asset Profiles By Ids (getAssetProfilesByIds)", + notes = "Requested asset profiles must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/assetProfileInfos/list") + public List getAssetProfilesByIds( + @Parameter(description = "A list of asset profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("assetProfileIds") Set assetProfileUUIDs) throws ThingsboardException { + return getAssetProfilesByIdsV1(assetProfileUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java index e22c0aa156..6a42f034ba 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuditLogController.java @@ -18,11 +18,10 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.audit.ActionType; @@ -50,6 +49,7 @@ import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PA import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.USER_ID_PARAM_DESCRIPTION; @@ -73,8 +73,7 @@ public class AuditLogController extends BaseController { "and users actions (login, logout, etc.) that belong to this customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/customer/{customerId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/audit/logs/customer/{customerId}") public PageData getAuditLogsByCustomerId( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -106,8 +105,7 @@ public class AuditLogController extends BaseController { "For example, RPC call to a particular device, or alarm acknowledgment for a specific device, etc. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/user/{userId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/audit/logs/user/{userId}") public PageData getAuditLogsByUserId( @Parameter(description = USER_ID_PARAM_DESCRIPTION) @PathVariable("userId") String strUserId, @@ -138,10 +136,9 @@ public class AuditLogController extends BaseController { notes = "Returns a page of audit logs related to the actions on the targeted entity. " + "Basically, this API call is used to get the full lifecycle of some specific entity. " + "For example to see when a device was created, updated, assigned to some customer, or even deleted from the system. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/entity/{entityType}/{entityId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/audit/logs/entity/{entityType}/{entityId}") public PageData getAuditLogsByEntityId( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String strEntityType, @@ -173,10 +170,9 @@ public class AuditLogController extends BaseController { @ApiOperation(value = "Get all audit logs (getAuditLogs)", notes = "Returns a page of audit logs related to all entities in the scope of the current user's Tenant. " + - PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) - @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/audit/logs") public PageData getAuditLogs( @Parameter(description = PAGE_SIZE_DESCRIPTION) @RequestParam int pageSize, diff --git a/application/src/main/java/org/thingsboard/server/controller/AuthController.java b/application/src/main/java/org/thingsboard/server/controller/AuthController.java index 8bda29e98b..af3a619906 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AuthController.java @@ -136,7 +136,7 @@ public class AuthController extends BaseController { "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Create Password' page and same 'activateToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + "If token is expired, redirects to error page.") - @GetMapping(value = "/noauth/activate", params = {"activateToken"}) + @GetMapping(value = "/noauth/activate") public ResponseEntity checkActivateToken( @Parameter(description = "The activate token string.") @RequestParam(value = "activateToken") String activateToken) { @@ -176,7 +176,7 @@ public class AuthController extends BaseController { "If token is valid, returns '303 See Other' (redirect) response code with the correct address of 'Reset Password' page and same 'resetToken' specified in the URL parameters. " + "If token is not valid, returns '409 Conflict'. " + "If token is expired, redirects to error page.") - @GetMapping(value = "/noauth/resetPassword", params = {"resetToken"}) + @GetMapping(value = "/noauth/resetPassword") public ResponseEntity checkResetToken( @Parameter(description = "The reset token string.") @RequestParam(value = "resetToken") String resetToken) { diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index bd2a160d5b..56f1cf6a4b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -71,6 +71,7 @@ import org.thingsboard.server.common.data.asset.AssetInfo; import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeInfo; @@ -681,6 +682,17 @@ public abstract class BaseController { return entity; } + protected void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { + for (EntityId referencedEntityId : calculatedFieldConfig.getReferencedEntities()) { + EntityType refEntityType = referencedEntityId.getEntityType(); + switch (refEntityType) { + case TENANT -> {} + case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); + default -> throw new IllegalArgumentException("Unsupported referenced entity type: '" + refEntityType + "'."); + } + } + } + Device checkDeviceId(DeviceId deviceId, Operation operation) throws ThingsboardException { return checkEntityId(deviceId, deviceService::findDeviceById, operation); } @@ -881,10 +893,8 @@ public abstract class BaseController { protected > void logEntityAction(SecurityUser user, EntityType entityType, E entity, E savedEntity, ActionType actionType, Exception e) { EntityId entityId = savedEntity != null ? savedEntity.getId() : emptyId(entityType); - if (!user.isSystemAdmin()) { - entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, - user.getCustomerId(), actionType, e); - } + entityActionService.logEntityAction(user, entityId, savedEntity != null ? savedEntity : entity, + user.getCustomerId(), actionType, e); } protected > E doSaveAndLog(EntityType entityType, E entity, BiFunction savingFunction) throws Exception { diff --git a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java index 8ef957066b..84ff4b49a4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CalculatedFieldController.java @@ -15,15 +15,15 @@ */ package org.thingsboard.server.controller; -import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections4.CollectionUtils; -import org.apache.commons.lang3.ObjectUtils; -import org.apache.commons.lang3.exception.ExceptionUtils; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.util.MultiValueMap; @@ -36,13 +36,6 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.script.api.tbel.TbelCfArg; -import org.thingsboard.script.api.tbel.TbelCfCtx; -import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; -import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; -import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; -import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -61,21 +54,15 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.config.annotations.ApiOperation; import org.thingsboard.server.dao.event.EventService; import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.cf.TbCalculatedFieldService; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.permission.Operation; -import java.util.ArrayList; -import java.util.Collections; import java.util.EnumSet; import java.util.HashSet; -import java.util.Map; -import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.UUID; -import java.util.concurrent.TimeUnit; import static org.thingsboard.server.controller.ControllerConstants.CF_TEXT_SEARCH_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_ID_PARAM_DESCRIPTION; @@ -94,17 +81,13 @@ import static org.thingsboard.server.controller.ControllerConstants.UUID_WIKI_LI @TbCoreComponent @RequestMapping("/api") @RequiredArgsConstructor -@Slf4j public class CalculatedFieldController extends BaseController { private final TbCalculatedFieldService tbCalculatedFieldService; private final EventService eventService; - private final TbelInvokeService tbelInvokeService; public static final String CALCULATED_FIELD_ID = "calculatedFieldId"; - public static final int TIMEOUT = 20; - private static final String TEST_SCRIPT_EXPRESSION = "Execute the Script expression and return the result. The format of request: \n\n" + MARKDOWN_CODE_BLOCK_START @@ -163,27 +146,17 @@ public class CalculatedFieldController extends BaseController { return calculatedField; } - @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)", - notes = "Fetch the Calculated Fields based on the provided Entity Id." - ) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/{entityType}/{entityId}/calculatedFields", params = {"pageSize", "page"}) - public PageData getCalculatedFieldsByEntityId(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) - @PathVariable("entityType") String entityType, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) - @PathVariable("entityId") String entityIdStr, - @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = "Calculated field type. If not specified, all types will be returned.") - @RequestParam(required = false) CalculatedFieldType type, - @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getCalculatedFieldsByEntityIdV1(@PathVariable("entityType") String entityType, + @PathVariable("entityId") String entityIdStr, + @RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) CalculatedFieldType type, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); checkParameter("entityId", entityIdStr); EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, entityIdStr); @@ -191,8 +164,29 @@ public class CalculatedFieldController extends BaseController { return checkNotNull(tbCalculatedFieldService.findByTenantIdAndEntityId(getTenantId(), entityId, type, pageLink)); } + @ApiOperation(value = "Get Calculated Fields by Entity Id (getCalculatedFieldsByEntityId)", + notes = "Fetch the Calculated Fields based on the provided Entity Id." + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/calculatedField/{entityType}/{entityId}") + public PageData getCalculatedFieldsByEntityId( + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) @RequestParam int page, + @Parameter(description = "Calculated field type. If not specified, all types will be returned.") + @RequestParam(required = false) CalculatedFieldType type, + @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder) throws ThingsboardException { + return getCalculatedFieldsByEntityIdV1(entityType, entityIdStr, pageSize, page, type, textSearch, sortProperty, sortOrder); + } + @ApiOperation(value = "Get calculated fields (getCalculatedFields)", notes = "Fetch tenant calculated fields based on the filter.") + @Parameters({ + @Parameter(name = "name", description = "Repeatable name query parameter", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/calculatedFields") public PageData getCalculatedFields(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -205,14 +199,13 @@ public class CalculatedFieldController extends BaseController { @RequestParam(required = false) EntityType entityType, @Parameter(description = "Entities filter. If not specified, calculated fields for entity type filter will be returned.") @RequestParam(required = false) Set entities, - @Parameter(description = "Name filter. To specify multiple names, duplicate 'name' parameter for each name, for example '?name=name1&name=name2") - @RequestParam(required = false) String name, // for Swagger only, retrieved from MultiValueMap params (due to issues when name contains comma) @Parameter(description = CF_TEXT_SEARCH_DESCRIPTION) @RequestParam(required = false) String textSearch, @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name"})) @RequestParam(required = false) String sortProperty, @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) @RequestParam(required = false) String sortOrder, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); SecurityUser user = getCurrentUser(); @@ -289,88 +282,11 @@ public class CalculatedFieldController extends BaseController { notes = TEST_SCRIPT_EXPRESSION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/calculatedField/testScript") - public JsonNode testScript( + public JsonNode testCalculatedFieldScript( @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test calculated field TBEL expression.") - @RequestBody JsonNode inputParams) { - String expression = inputParams.get("expression").asText(); - Map arguments = Objects.requireNonNullElse( - JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), - Collections.emptyMap() - ); - - ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); - ctxAndArgNames.add("ctx"); - ctxAndArgNames.addAll(arguments.keySet()); - - String output = ""; - String errorText = ""; - - CalculatedFieldTbelScriptEngine engine = null; - try { - if (tbelInvokeService == null) { - throw new IllegalArgumentException("TBEL script engine is disabled!"); - } - - engine = new CalculatedFieldTbelScriptEngine( - getTenantId(), - tbelInvokeService, - expression, - ctxAndArgNames.toArray(String[]::new) - ); - - Object[] args = new Object[ctxAndArgNames.size()]; - args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments)); - for (int i = 1; i < ctxAndArgNames.size(); i++) { - var arg = arguments.get(ctxAndArgNames.get(i)); - if (arg instanceof TbelCfSingleValueArg svArg) { - args[i] = svArg.getValue(); - } else { - args[i] = arg; - } - } - - JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); - output = JacksonUtil.toString(json); - } catch (Exception e) { - log.error("Error evaluating expression", e); - Throwable rootCause = ExceptionUtils.getRootCause(e); - errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getMessage(), e.getClass().getSimpleName()); - } finally { - if (engine != null) { - engine.destroy(); - } - } - return JacksonUtil.newObjectNode() - .put("output", output) - .put("error", errorText); - } - - private long getLatestTimestamp(Map arguments) { - long lastUpdateTimestamp = -1; - for (TbelCfArg entry : arguments.values()) { - if (entry instanceof TbelCfSingleValueArg singleValueArg) { - long ts = singleValueArg.getTs(); - lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts); - } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) { - long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1); - lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs); - } - } - return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; - } - - private void checkReferencedEntities(CalculatedFieldConfiguration calculatedFieldConfig) throws ThingsboardException { - Set referencedEntityIds = calculatedFieldConfig.getReferencedEntities(); - for (EntityId referencedEntityId : referencedEntityIds) { - EntityType entityType = referencedEntityId.getEntityType(); - switch (entityType) { - case TENANT -> { - return; - } - case CUSTOMER, ASSET, DEVICE -> checkEntityId(referencedEntityId, Operation.READ); - default -> throw new IllegalArgumentException("Calculated fields do not support '" + entityType + "' for referenced entities."); - } - } + @RequestBody JsonNode inputParams) throws ThingsboardException { + checkParameter("expression", inputParams.has("expression") ? inputParams.get("expression").asText() : null); + return tbCalculatedFieldService.executeTestScript(getTenantId(), inputParams); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java index 55dbb8fe04..91569261b7 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java +++ b/application/src/main/java/org/thingsboard/server/controller/ComponentDescriptorController.java @@ -19,6 +19,7 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -81,8 +82,7 @@ public class ComponentDescriptorController extends BaseController { notes = "Gets the Component Descriptors using coma separated list of rule node types and optional rule chain type request parameters. " + COMPONENT_DESCRIPTOR_DEFINITION + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN')") - @RequestMapping(value = "/components", params = {"componentTypes"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/components") public List getComponentDescriptorsByTypes( @Parameter(description = "List of types of the Rule Nodes, (ENRICHMENT, FILTER, TRANSFORMATION, ACTION or EXTERNAL)", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("componentTypes") String[] strComponentTypes, diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index ab5c1c0ae4..04afb1330e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -444,106 +444,6 @@ public class ControllerConstants { " * 'BOOLEAN' - used for boolean values. Operations: EQUAL, NOT_EQUAL;\n" + " * 'DATE_TIME' - similar to numeric, transforms value to milliseconds since epoch. Operations: EQUAL, NOT_EQUAL, GREATER, LESS, GREATER_OR_EQUAL, LESS_OR_EQUAL; \n"; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"schedule\":{\n" + - " \"type\":\"SPECIFIC_TIME\",\n" + - " \"endsOn\":64800000,\n" + - " \"startsOn\":43200000,\n" + - " \"timezone\":\"Europe/Kiev\",\n" + - " \"daysOfWeek\":[\n" + - " 1,\n" + - " 3,\n" + - " 5\n" + - " ]\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"schedule\":{\n" + - " \"type\":\"CUSTOM\",\n" + - " \"items\":[\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":1\n" + - " },\n" + - " {\n" + - " \"endsOn\":64800000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":43200000,\n" + - " \"dayOfWeek\":2\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":3\n" + - " },\n" + - " {\n" + - " \"endsOn\":57600000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":36000000,\n" + - " \"dayOfWeek\":4\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":5\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":6\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":7\n" + - " }\n" + - " ],\n" + - " \"timezone\":\"Europe/Kiev\"\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "\"schedule\": null" + MARKDOWN_CODE_BLOCK_END; - - protected static final String DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"spec\":{\n" + - " \"type\":\"REPEATING\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":5,\n" + - " \"dynamicValue\":{\n" + - " \"inherit\":true,\n" + - " \"sourceType\":\"CURRENT_DEVICE\",\n" + - " \"sourceAttribute\":\"tempAttr\"\n" + - " }\n" + - " }\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - - protected static final String DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE = MARKDOWN_CODE_BLOCK_START + - "{\n" + - " \"spec\":{\n" + - " \"type\":\"DURATION\",\n" + - " \"unit\":\"MINUTES\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " }\n" + - "}" + - MARKDOWN_CODE_BLOCK_END; - protected static final String RELATION_TYPE_PARAM_DESCRIPTION = "A string value representing relation type between entities. For example, 'Contains', 'Manages'. It can be any string value."; protected static final String RELATION_TYPE_GROUP_PARAM_DESCRIPTION = "A string value representing relation type group. For example, 'COMMON'"; @@ -1328,8 +1228,6 @@ public class ControllerConstants { ALARM_FILTER_KEY + FILTER_VALUE_TYPE + NEW_LINE + DEVICE_PROFILE_FILTER_PREDICATE + NEW_LINE; protected static final String DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + - " \"alarms\":[\n" + - " ],\n" + " \"configuration\":{\n" + " \"type\":\"DEFAULT\"\n" + " },\n" + @@ -1343,219 +1241,6 @@ public class ControllerConstants { "}" + MARKDOWN_CODE_BLOCK_END; protected static final String CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE = MARKDOWN_CODE_BLOCK_START + "{\n" + - " \"alarms\":[\n" + - " {\n" + - " \"id\":\"2492b935-1226-59e9-8615-17d8978a4f93\",\n" + - " \"alarmType\":\"Temperature Alarm\",\n" + - " \"clearRule\":{\n" + - " \"schedule\":null,\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"SIMPLE\"\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"LESS\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"propagate\":false,\n" + - " \"createRules\":{\n" + - " \"MAJOR\":{\n" + - " \"schedule\":{\n" + - " \"type\":\"SPECIFIC_TIME\",\n" + - " \"endsOn\":64800000,\n" + - " \"startsOn\":43200000,\n" + - " \"timezone\":\"Europe/Kiev\",\n" + - " \"daysOfWeek\":[\n" + - " 1,\n" + - " 3,\n" + - " 5\n" + - " ]\n" + - " },\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"DURATION\",\n" + - " \"unit\":\"MINUTES\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"COMPLEX\",\n" + - " \"operation\":\"OR\",\n" + - " \"predicates\":[\n" + - " {\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":50.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"LESS_OR_EQUAL\"\n" + - " },\n" + - " {\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":30.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"GREATER\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"WARNING\":{\n" + - " \"schedule\":{\n" + - " \"type\":\"CUSTOM\",\n" + - " \"items\":[\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":1\n" + - " },\n" + - " {\n" + - " \"endsOn\":64800000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":43200000,\n" + - " \"dayOfWeek\":2\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":3\n" + - " },\n" + - " {\n" + - " \"endsOn\":57600000,\n" + - " \"enabled\":true,\n" + - " \"startsOn\":36000000,\n" + - " \"dayOfWeek\":4\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":5\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":6\n" + - " },\n" + - " {\n" + - " \"endsOn\":0,\n" + - " \"enabled\":false,\n" + - " \"startsOn\":0,\n" + - " \"dayOfWeek\":7\n" + - " }\n" + - " ],\n" + - " \"timezone\":\"Europe/Kiev\"\n" + - " },\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"REPEATING\",\n" + - " \"predicate\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":5,\n" + - " \"dynamicValue\":null\n" + - " }\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"tempConstant\",\n" + - " \"type\":\"CONSTANT\"\n" + - " },\n" + - " \"value\":30,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":0.0,\n" + - " \"dynamicValue\":{\n" + - " \"inherit\":false,\n" + - " \"sourceType\":\"CURRENT_DEVICE\",\n" + - " \"sourceAttribute\":\"tempThreshold\"\n" + - " }\n" + - " },\n" + - " \"operation\":\"EQUAL\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " },\n" + - " \"CRITICAL\":{\n" + - " \"schedule\":null,\n" + - " \"condition\":{\n" + - " \"spec\":{\n" + - " \"type\":\"SIMPLE\"\n" + - " },\n" + - " \"condition\":[\n" + - " {\n" + - " \"key\":{\n" + - " \"key\":\"temperature\",\n" + - " \"type\":\"TIME_SERIES\"\n" + - " },\n" + - " \"value\":null,\n" + - " \"predicate\":{\n" + - " \"type\":\"NUMERIC\",\n" + - " \"value\":{\n" + - " \"userValue\":null,\n" + - " \"defaultValue\":50.0,\n" + - " \"dynamicValue\":null\n" + - " },\n" + - " \"operation\":\"GREATER\"\n" + - " },\n" + - " \"valueType\":\"NUMERIC\"\n" + - " }\n" + - " ]\n" + - " },\n" + - " \"dashboardId\":null,\n" + - " \"alarmDetails\":null\n" + - " }\n" + - " },\n" + - " \"propagateRelationTypes\":null\n" + - " }\n" + - " ],\n" + " \"configuration\":{\n" + " \"type\":\"DEFAULT\"\n" + " },\n" + @@ -1577,40 +1262,11 @@ public class ControllerConstants { " }\n" + "}" + MARKDOWN_CODE_BLOCK_END; protected static final String DEVICE_PROFILE_DATA_DEFINITION = NEW_LINE + "# Device profile data definition" + NEW_LINE + - "Device profile data object contains alarm rules configuration, device provision strategy and transport type configuration for device connectivity. Let's review some examples. " + + "Device profile data object contains device provision strategy and transport type configuration for device connectivity. Let's review some examples. " + "First one is the default device profile data configuration and second one - the custom one. " + NEW_LINE + DEFAULT_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + CUSTOM_DEVICE_PROFILE_DATA_EXAMPLE + NEW_LINE + "Let's review some specific objects examples related to the device profile configuration:"; - protected static final String ALARM_SCHEDULE = NEW_LINE + "# Alarm Schedule" + NEW_LINE + - "Alarm Schedule JSON object represents the time interval during which the alarm rule is active. Note, " + - NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_ALWAYS_EXAMPLE + NEW_LINE + "means alarm rule is active all the time. " + - "**'daysOfWeek'** field represents Monday as 1, Tuesday as 2 and so on. **'startsOn'** and **'endsOn'** fields represent hours in millis (e.g. 64800000 = 18:00 or 6pm). " + - "**'enabled'** flag specifies if item in a custom rule is active for specific day of the week:" + NEW_LINE + - "## Specific Time Schedule" + NEW_LINE + - DEVICE_PROFILE_ALARM_SCHEDULE_SPECIFIC_TIME_EXAMPLE + NEW_LINE + - "## Custom Schedule" + - NEW_LINE + DEVICE_PROFILE_ALARM_SCHEDULE_CUSTOM_EXAMPLE + NEW_LINE; - - protected static final String ALARM_CONDITION_TYPE = "# Alarm condition type (**'spec'**)" + NEW_LINE + - "Alarm condition type can be either simple, duration, or repeating. For example, 5 times in a row or during 5 minutes." + NEW_LINE + - "Note, **'userValue'** field is not used and reserved for future usage, **'dynamicValue'** is used for condition appliance by using the value of the **'sourceAttribute'** " + - "or else **'defaultValue'** is used (if **'sourceAttribute'** is absent).\n" + - "\n**'sourceType'** of the **'sourceAttribute'** can be: \n" + - " * 'CURRENT_DEVICE';\n" + - " * 'CURRENT_CUSTOMER';\n" + - " * 'CURRENT_TENANT'." + NEW_LINE + - "**'sourceAttribute'** can be inherited from the owner if **'inherit'** is set to true (for CURRENT_DEVICE and CURRENT_CUSTOMER)." + NEW_LINE + - "## Repeating alarm condition" + NEW_LINE + - DEVICE_PROFILE_ALARM_CONDITION_REPEATING_EXAMPLE + NEW_LINE + - "## Duration alarm condition" + NEW_LINE + - DEVICE_PROFILE_ALARM_CONDITION_DURATION_EXAMPLE + NEW_LINE + - "**'unit'** can be: \n" + - " * 'SECONDS';\n" + - " * 'MINUTES';\n" + - " * 'HOURS';\n" + - " * 'DAYS'." + NEW_LINE; - protected static final String PROVISION_CONFIGURATION = "# Provision Configuration" + NEW_LINE + "There are 3 types of device provision configuration for the device profile: \n" + " * 'DISABLED';\n" + @@ -1618,8 +1274,8 @@ public class ControllerConstants { " * 'CHECK_PRE_PROVISIONED_DEVICES'." + NEW_LINE + "Please refer to the [docs](https://thingsboard.io/docs/user-guide/device-provisioning/) for more details." + NEW_LINE; - protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + ALARM_SCHEDULE + ALARM_CONDITION_TYPE + - KEY_FILTERS_DESCRIPTION + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION; + protected static final String DEVICE_PROFILE_DATA = DEVICE_PROFILE_DATA_DEFINITION + + PROVISION_CONFIGURATION + TRANSPORT_CONFIGURATION; protected static final String DEVICE_PROFILE_ID = "deviceProfileId"; diff --git a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java index 5ae949d92e..5acf09a779 100644 --- a/application/src/main/java/org/thingsboard/server/controller/CustomerController.java +++ b/application/src/main/java/org/thingsboard/server/controller/CustomerController.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -172,8 +173,7 @@ public class CustomerController extends BaseController { notes = "Returns a page of customers owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/customers", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customers") public PageData getCustomers( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -193,8 +193,7 @@ public class CustomerController extends BaseController { @ApiOperation(value = "Get Tenant Customer by Customer title (getTenantCustomer)", notes = "Get the Customer using Customer Title. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/customers", params = {"customerTitle"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/customers") public Customer getTenantCustomer( @Parameter(description = "A string value representing the Customer title.") @RequestParam String customerTitle) throws ThingsboardException { @@ -202,12 +201,10 @@ public class CustomerController extends BaseController { return checkNotNull(customerService.findCustomerByTenantIdAndTitle(tenantId, customerTitle), "Customer with title [" + customerTitle + "] is not found"); } - @ApiOperation(value = "Get customers by Customer Ids (getCustomersByIds)", - notes = "Returns a list of Customer objects based on the provided ids." + - TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @GetMapping(value = "/customers", params = {"customerIds"}) - public List getCustomersByIds( + public List getCustomersByIdsV1( @Parameter(description = "A list of customer ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("customerIds") Set customerUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -218,4 +215,15 @@ public class CustomerController extends BaseController { return customerService.findCustomersByTenantIdAndIds(tenantId, customerIds); } + @ApiOperation(value = "Get customers by Customer Ids (getCustomersByIds)", + notes = "Returns a list of Customer objects based on the provided ids." + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") + @GetMapping(value = "/customers/list") + public List getCustomersByIds( + @Parameter(description = "A list of customer ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("customerIds") Set customerUUIDs) throws ThingsboardException { + return getCustomersByIdsV1(customerUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java index b16161437d..9f59044356 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DashboardController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DashboardController.java @@ -17,10 +17,10 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletResponse; @@ -119,7 +119,7 @@ public class DashboardController extends BaseController { "Used to adjust view of the dashboards according to the difference between browser and server time.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/serverTime") - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "1636023857137"))) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "integer", format = "int64", example = "1636023857137"))) public long getServerTime() { return System.currentTimeMillis(); } @@ -131,7 +131,7 @@ public class DashboardController extends BaseController { "The actual value of the limit is configurable in the system configuration file.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/maxDatapointsLimit") - @ApiResponse(responseCode = "200", description = "OK", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = "5000"))) + @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "integer", format = "int64", example = "5000"))) public long getMaxDatapointsLimit() { return maxDatapointsLimit; } @@ -151,6 +151,8 @@ public class DashboardController extends BaseController { @ApiOperation(value = "Get Dashboard (getDashboardById)", notes = "Get the dashboard based on 'dashboardId' parameter. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH ) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = Dashboard.class))) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/{dashboardId}") public void getDashboardById(@Parameter(description = DASHBOARD_ID_PARAM_DESCRIPTION) @@ -176,6 +178,8 @@ public class DashboardController extends BaseController { "Referencing non-existing dashboard Id will cause 'Not Found' error. " + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Dashboard entity. " + TENANT_AUTHORITY_PARAGRAPH) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = Dashboard.class))) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping(value = "/dashboard") public void saveDashboard(@io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON value representing the dashboard.") @@ -325,12 +329,12 @@ public class DashboardController extends BaseController { return tbDashboardService.unassignDashboardFromPublicCustomer(dashboard, getCurrentUser()); } - @ApiOperation(value = "Get Tenant Dashboards by System Administrator (getTenantDashboards)", + @ApiOperation(value = "Get Tenant Dashboards by System Administrator (getTenantDashboardsByTenantId)", notes = "Returns a page of dashboard info objects owned by tenant. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenant/{tenantId}/dashboards", params = {"pageSize", "page"}) - public PageData getTenantDashboards( + @GetMapping(value = "/tenant/{tenantId}/dashboards") + public PageData getTenantDashboardsByTenantId( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -353,7 +357,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the tenant of a current user. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/dashboards") public PageData getTenantDashboards( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -380,7 +384,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects owned by the specified customer. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/dashboards") public PageData getCustomerDashboards( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -413,6 +417,8 @@ public class DashboardController extends BaseController { "If 'homeDashboardId' parameter is not set on the User level and the User has authority 'CUSTOMER_USER', check the same parameter for the corresponding Customer. " + "If 'homeDashboardId' parameter is not set on the User and Customer levels then checks the same parameter for the Tenant that owns the user. " + DASHBOARD_DEFINITION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiResponse(responseCode = "200", description = "OK", + content = @Content(schema = @Schema(implementation = HomeDashboard.class))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboard/home") public void getHomeDashboard(@RequestHeader(name = HttpHeaders.ACCEPT_ENCODING, required = false) String acceptEncodingHeader, @@ -574,7 +580,7 @@ public class DashboardController extends BaseController { notes = "Returns a page of dashboard info objects assigned to the specified edge. " + DASHBOARD_INFO_DEFINITION + " " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/edge/{edgeId}/dashboards", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/dashboards") public PageData getEdgeDashboards( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -602,13 +608,10 @@ public class DashboardController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Get dashboards by Dashboard Ids (getDashboardsByIds)", - notes = "Returns a list of DashboardInfo objects based on the provided ids. " + - TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/dashboards", params = {"dashboardIds"}) - public List getDashboardsByIds(@Parameter(description = "A list of dashboard ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { + public List getDashboardsByIdsV1(@RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List dashboardIds = new ArrayList<>(); for (UUID dashboardUUID : dashboardUUIDs) { @@ -618,6 +621,16 @@ public class DashboardController extends BaseController { return filterDashboardsByReadPermission(dashboards); } + @ApiOperation(value = "Get dashboards by Dashboard Ids (getDashboardsByIds)", + notes = "Returns a list of DashboardInfo objects based on the provided ids. " + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/dashboards/list") + public List getDashboardsByIds(@Parameter(description = "A list of dashboard ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("dashboardIds") Set dashboardUUIDs) throws ThingsboardException { + return getDashboardsByIdsV1(dashboardUUIDs); + } + private Set customerIdFromStr(String[] strCustomerIds) { Set customerIds = new HashSet<>(); if (strCustomerIds != null) { diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java index 8b899ded41..b7e94d2212 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceConnectivityController.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; @@ -75,6 +76,7 @@ public class DeviceConnectivityController extends BaseController { description = "OK", content = @Content( mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = JsonNode.class), examples = { @ExampleObject( name = "http", @@ -105,7 +107,18 @@ public class DeviceConnectivityController extends BaseController { return deviceConnectivityService.findDevicePublishTelemetryCommands(baseUrl, device); } - @ApiOperation(value = "Download server certificate using file path defined in device.connectivity properties (downloadServerCertificate)", notes = "Download server certificate.") + @ApiOperation(value = "Download server certificate using file path defined in device.connectivity properties (downloadServerCertificate)", + notes = "Download server certificate.", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + }) @RequestMapping(value = "/device-connectivity/{protocol}/certificate/download", method = RequestMethod.GET) @ResponseBody public ResponseEntity downloadServerCertificate(@Parameter(description = PROTOCOL_PARAM_DESCRIPTION) @@ -122,7 +135,18 @@ public class DeviceConnectivityController extends BaseController { .body(pemCert); } - @ApiOperation(value = "Download generated docker-compose.yml file for gateway (downloadGatewayDockerCompose)", notes = "Download generated docker-compose.yml for gateway.") + @ApiOperation(value = "Download generated docker-compose.yml file for gateway (downloadGatewayDockerCompose)", + notes = "Download generated docker-compose.yml for gateway.", + responses = { + @ApiResponse( + responseCode = "200", + description = "OK", + content = @Content( + mediaType = MediaType.APPLICATION_OCTET_STREAM_VALUE, + schema = @Schema(type = "string", format = "binary") + ) + ) + }) @RequestMapping(value = "/device-connectivity/gateway-launch/{deviceId}/docker-compose/download", method = RequestMethod.GET) @ResponseBody public ResponseEntity downloadGatewayDockerCompose(@Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java index 78b4cc9880..2f43c39f76 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceController.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -29,6 +30,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -204,16 +206,16 @@ public class DeviceController extends BaseController { notes = "Create or update the Device. When creating device, platform generates Device Id as " + UUID_WIKI_LINK + "Requires to provide the Device Credentials object as well as an existing device profile ID or use \"default\".\n" + "You may find the example of device with different type of credentials below: \n\n" + - "- Credentials type: \"Access token\" with device profile ID below: \n\n" + + "- Credentials type: **\"Access token\"** with **device profile ID** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_ACCESS_TOKEN_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"Access token\" with device profile default below: \n\n" + + "- Credentials type: **\"Access token\"** with **device profile default** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_ACCESS_TOKEN_DEFAULT_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"X509\" with device profile ID below: \n\n" + - "Note: credentialsId - format Sha3Hash, certificateValue - format PEM (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + + "- Credentials type: **\"X509\"** with **device profile ID** below: \n\n" + + "Note: **credentialsId** - format **Sha3Hash**, **certificateValue** - format **PEM** (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_X509_CERTIFICATE_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"MQTT_BASIC\" with device profile ID below: \n\n" + + "- Credentials type: **\"MQTT_BASIC\"** with **device profile ID** below: \n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_MQTT_BASIC_DESCRIPTION_MARKDOWN + "\n\n" + - "- You may find the example of LwM2M device and RPK credentials below: \n\n" + + "- You may find the example of **LwM2M** device and **RPK** credentials below: \n\n" + "Note: LwM2M device - only existing device profile ID (Transport configuration -> Transport type: \"LWM2M\".\n\n" + DEVICE_WITH_DEVICE_CREDENTIALS_PARAM_LVM2M_RPK_DESCRIPTION_MARKDOWN + "\n\n" + "Remove 'id', 'tenantId' and optionally 'customerId' from the request body example (below) to create new Device entity. " + @@ -320,14 +322,14 @@ public class DeviceController extends BaseController { "Then use current method to update the credentials type and value. It is not possible to create multiple device credentials for the same device.\n" + "The structure of device credentials id and value is simple for the 'ACCESS_TOKEN' but is much more complex for the 'MQTT_BASIC' or 'LWM2M_CREDENTIALS'.\n" + "You may find the example of device with different type of credentials below: \n\n" + - "- Credentials type: \"Access token\" with device ID and with device ID below: \n\n" + + "- Credentials type: **\"Access token\"** with **device ID** and with **device ID** below: \n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_ACCESS_TOKEN_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"X509\" with device profile ID below: \n\n" + - "Note: credentialsId - format Sha3Hash, certificateValue - format PEM (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + + "- Credentials type: **\"X509\"** with **device profile ID** below: \n\n" + + "Note: **credentialsId** - format **Sha3Hash**, **certificateValue** - format **PEM** (with \"--BEGIN CERTIFICATE----\" and -\"----END CERTIFICATE-\").\n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_X509_CERTIFICATE_DESCRIPTION_MARKDOWN + "\n\n" + - "- Credentials type: \"MQTT_BASIC\" with device profile ID below: \n\n" + + "- Credentials type: **\"MQTT_BASIC\"** with **device profile ID** below: \n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_MQTT_BASIC_DESCRIPTION_MARKDOWN + "\n\n" + - "- You may find the example of LwM2M device and RPK credentials below: \n\n" + + "- You may find the example of **LwM2M** device and **RPK** credentials below: \n\n" + "Note: LwM2M device - only existing device profile ID (Transport configuration -> Transport type: \"LWM2M\".\n\n" + DEVICE_UPDATE_CREDENTIALS_PARAM_LVM2M_RPK_DESCRIPTION_MARKDOWN + "\n\n" + "Update to real value:\n" + @@ -350,8 +352,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/devices") public PageData getTenantDevices( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -378,8 +379,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices info objects owned by tenant. " + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/deviceInfos") public PageData getTenantDeviceInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -411,25 +411,31 @@ public class DeviceController extends BaseController { return checkNotNull(deviceService.findDeviceInfosByFilter(filter.build(), pageLink)); } - @ApiOperation(value = "Get Tenant Device (getTenantDevice)", - notes = "Requested device must be owned by tenant that the user belongs to. " + - "Device name is an unique property of device. So it can be used to identify the device." + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/tenant/devices", params = {"deviceName"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/tenant/devices", params = {"deviceName"}) public Device getTenantDevice( - @Parameter(description = DEVICE_NAME_DESCRIPTION) @RequestParam String deviceName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(deviceService.findDeviceByTenantIdAndName(tenantId, deviceName)); } + @ApiOperation(value = "Get Tenant Device (getTenantDeviceByName)", + notes = "Requested device must be owned by tenant that the user belongs to. " + + "Device name is an unique property of device. So it can be used to identify the device." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/device") + public Device getTenantDeviceByName( + @Parameter(description = DEVICE_NAME_DESCRIPTION) + @RequestParam String deviceName) throws ThingsboardException { + return getTenantDevice(deviceName); + } + @ApiOperation(value = "Get Customer Devices (getCustomerDevices)", notes = "Returns a page of devices objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/devices") public PageData getCustomerDevices( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -461,8 +467,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices info objects assigned to customer. " + PAGE_DATA_PARAMETERS + DEVICE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/customer/{customerId}/deviceInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/customer/{customerId}/deviceInfos") public PageData getCustomerDeviceInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable("customerId") String strCustomerId, @@ -502,8 +507,7 @@ public class DeviceController extends BaseController { @ApiOperation(value = "Get Devices By Ids (getDevicesByIds)", notes = "Requested devices must be owned by tenant or assigned to customer which user is performing the request. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/devices", params = {"deviceIds"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/devices") public List getDevicesByIds( @Parameter(description = "A list of devices ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("deviceIds") String[] strDeviceIds) throws ThingsboardException, ExecutionException, InterruptedException { @@ -524,14 +528,14 @@ public class DeviceController extends BaseController { return checkNotNull(devices.get()); } - @ApiOperation(value = "Find related devices (findByQuery)", + @ApiOperation(value = "Find related devices (findDevicesByQuery)", notes = "Returns all devices that are related to the specific entity. " + "The entity id, relation type, device types, depth of the search, and other query parameters defined using complex 'DeviceSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/devices", method = RequestMethod.POST) @ResponseBody - public List findByQuery( + public List findDevicesByQuery( @Parameter(description = "The device search query JSON") @RequestBody DeviceSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); @@ -730,8 +734,7 @@ public class DeviceController extends BaseController { notes = "Returns a page of devices assigned to edge. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/edge/{edgeId}/devices", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/edge/{edgeId}/devices") public PageData getEdgeDevices( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, diff --git a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java index 343703cb7d..f70583f721 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DeviceProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -129,7 +130,7 @@ public class DeviceProfileController extends BaseController { return checkNotNull(deviceProfileService.findDefaultDeviceProfileInfo(getTenantId())); } - @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", + @ApiOperation(value = "Get time series keys (getDeviceProfileTimeseriesKeys)", notes = "Get a set of unique time series keys used by devices that belong to specified profile. " + "If profile is not set returns a list of unique keys among all profiles. " + "The call is used for auto-complete in the UI forms. " + @@ -138,7 +139,7 @@ public class DeviceProfileController extends BaseController { @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") @RequestMapping(value = "/deviceProfile/devices/keys/timeseries", method = RequestMethod.GET) @ResponseBody - public List getTimeseriesKeys( + public List getDeviceProfileTimeseriesKeys( @Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) @RequestParam(name = DEVICE_PROFILE_ID, required = false) String deviceProfileIdStr) throws ThingsboardException { DeviceProfileId deviceProfileId; @@ -228,8 +229,7 @@ public class DeviceProfileController extends BaseController { notes = "Returns a page of devices profile objects owned by tenant. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @RequestMapping(value = "/deviceProfiles", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/deviceProfiles") public PageData getDeviceProfiles( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -249,8 +249,7 @@ public class DeviceProfileController extends BaseController { notes = "Returns a page of devices profile info objects owned by tenant. " + PAGE_DATA_PARAMETERS + DEVICE_PROFILE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @RequestMapping(value = "/deviceProfileInfos", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/deviceProfileInfos") public PageData getDeviceProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -282,14 +281,10 @@ public class DeviceProfileController extends BaseController { return checkNotNull(deviceProfileService.findDeviceProfileNamesByTenantId(tenantId, activeOnly)); } - @ApiOperation(value = "Get Device Profile Infos By Ids (getDeviceProfilesByIds)", - notes = "Requested device profiles must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/deviceProfileInfos", params = {"deviceProfileIds"}) - public List getDeviceProfileInfosByIds( - @Parameter(description = "A list of device profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { + public List getDeviceProfileInfosByIdsV1(@RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List deviceProfileIds = new ArrayList<>(); for (UUID deviceProfileUUID : deviceProfileUUIDs) { @@ -298,4 +293,15 @@ public class DeviceProfileController extends BaseController { return deviceProfileService.findDeviceProfilesByIds(tenantId, deviceProfileIds); } + @ApiOperation(value = "Get Device Profile Infos By Ids (getDeviceProfileInfosByIds)", + notes = "Requested device profiles must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/deviceProfileInfos/list") + public List getDeviceProfileInfosByIds( + @Parameter(description = "A list of device profile ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("deviceProfileIds") Set deviceProfileUUIDs) throws ThingsboardException { + return getDeviceProfileInfosByIdsV1(deviceProfileUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/DomainController.java b/application/src/main/java/org/thingsboard/server/controller/DomainController.java index 0afffda159..c34bbe6ac9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java @@ -81,31 +81,31 @@ public class DomainController extends BaseController { return tbDomainService.save(domain, getOAuth2ClientIds(ids), getCurrentUser()); } - @ApiOperation(value = "Update oauth2 clients (updateOauth2Clients)", + @ApiOperation(value = "Update oauth2 clients (updateDomainOauth2Clients)", notes = "Update oauth2 clients for the specified domain. ") @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PutMapping(value = "/domain/{id}/oauth2Clients") - public void updateOauth2Clients(@PathVariable UUID id, - @RequestBody UUID[] clientIds) throws ThingsboardException { + public void updateDomainOauth2Clients(@PathVariable UUID id, + @RequestBody UUID[] clientIds) throws ThingsboardException { DomainId domainId = new DomainId(id); Domain domain = checkDomainId(domainId, Operation.WRITE); List oAuth2ClientIds = getOAuth2ClientIds(clientIds); tbDomainService.updateOauth2Clients(domain, oAuth2ClientIds, getCurrentUser()); } - @ApiOperation(value = "Get Domain infos (getTenantDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get Domain infos (getDomainInfos)", notes = SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @GetMapping(value = "/domain/infos") - public PageData getTenantDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = "Case-insensitive 'substring' filter based on domain's name") - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getDomainInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Case-insensitive 'substring' filter based on domain's name") + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.DOMAIN, Operation.READ); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); return domainService.findDomainInfosByTenantId(getTenantId(), pageLink); diff --git a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java index f0d9e39d6a..361104d9a2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EdgeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EdgeController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -277,7 +278,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects owned by tenant. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/edgeInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/edgeInfos") public PageData getTenantEdgeInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -300,17 +301,24 @@ public class EdgeController extends BaseController { } } - @ApiOperation(value = "Get Tenant Edge (getTenantEdge)", - notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + - "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/tenant/edges", params = {"edgeName"}) - public Edge getTenantEdge(@Parameter(description = "Unique name of the edge", required = true) - @RequestParam String edgeName) throws ThingsboardException { + public Edge getTenantEdge(@RequestParam String edgeName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(edgeService.findEdgeByTenantIdAndName(tenantId, edgeName)); } + @ApiOperation(value = "Get Tenant Edge by name (getTenantEdgeByName)", + notes = "Requested edge must be owned by tenant or customer that the user belongs to. " + + "Edge name is an unique property of edge. So it can be used to identify the edge." + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/edge") + public Edge getTenantEdgeByName(@Parameter(description = "Unique name of the edge", required = true) + @RequestParam String edgeName) throws ThingsboardException { + return getTenantEdge(edgeName); + } + @ApiOperation(value = "Set root rule chain for provided edge (setEdgeRootRuleChain)", notes = "Change root rule chain of the edge to the new provided rule chain. \n" + "This operation will send a notification to update root rule chain on remote edge service." + TENANT_AUTHORITY_PARAGRAPH) @@ -334,7 +342,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/edges", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/edges") public PageData getCustomerEdges( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -369,7 +377,7 @@ public class EdgeController extends BaseController { notes = "Returns a page of edges info objects assigned to customer. " + PAGE_DATA_PARAMETERS + EDGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/edgeInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/edgeInfos") public PageData getCustomerEdgeInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION) @PathVariable("customerId") String strCustomerId, @@ -400,12 +408,10 @@ public class EdgeController extends BaseController { return checkNotNull(result); } - @ApiOperation(value = "Get Edges By Ids (getEdgesByIds)", - notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/edges", params = {"edgeIds"}) public List getEdgesByIds( - @Parameter(description = "A list of edges ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException, ExecutionException, InterruptedException { checkArrayParameter("edgeIds", strEdgeIds); SecurityUser user = getCurrentUser(); @@ -425,13 +431,23 @@ public class EdgeController extends BaseController { return checkNotNull(edges); } - @ApiOperation(value = "Find related edges (findByQuery)", + @ApiOperation(value = "Get Edges By Ids (getEdgeList)", + notes = "Requested edges must be owned by tenant or assigned to customer which user is performing the request." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/edges/list") + public List getEdgeList( + @Parameter(description = "A list of edges ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("edgeIds") String[] strEdgeIds) throws ThingsboardException, ExecutionException, InterruptedException { + return getEdgesByIds(strEdgeIds); + } + + @ApiOperation(value = "Find related edges (findEdgesByQuery)", notes = "Returns all edges that are related to the specific entity. " + "The entity id, relation type, edge types, depth of the search, and other query parameters defined using complex 'EdgeSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/edges") - public List findByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEdgesByQuery(@RequestBody EdgeSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); checkNotNull(query.getParameters()); checkNotNull(query.getEdgeTypes()); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java index 10f4cd2f51..8dd607bb6f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntitiesVersionControlController.java @@ -19,6 +19,7 @@ import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -213,19 +214,19 @@ public class EntitiesVersionControlController extends BaseController { " \"timestamp\": 1655198593000,\n" + " \"id\": \"fd82625bdd7d6131cf8027b44ee967012ecaf990\",\n" + " \"name\": \"Devices and assets - v2.0\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " },\n" + " {\n" + " \"timestamp\": 1655198528000,\n" + " \"id\": \"682adcffa9c8a2f863af6f00c4850323acbd4219\",\n" + " \"name\": \"Update my device\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " },\n" + " {\n" + " \"timestamp\": 1655198280000,\n" + " \"id\": \"d2a6087c2b30e18cc55e7cdda345a8d0dfb959a4\",\n" + " \"name\": \"Devices and assets - v1.0\",\n" + - " \"author\": \"John Doe \"\n" + + " \"author\": \"John Doe (johndoe@gmail.com)\"\n" + " }\n" + " ],\n" + " \"totalPages\": 1,\n" + @@ -234,7 +235,7 @@ public class EntitiesVersionControlController extends BaseController { "}" + MARKDOWN_CODE_BLOCK_END + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version/{entityType}/{externalEntityUuid}", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version/{entityType}/{externalEntityUuid}") public DeferredResult> listEntityVersions(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = "A string value representing external entity id. This is `externalId` property of an entity, or otherwise if not set - simply id of this entity.") @@ -263,7 +264,7 @@ public class EntitiesVersionControlController extends BaseController { "If specified branch does not exist - empty page data will be returned. " + "The response structure is the same as for `listEntityVersions` API method." + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version/{entityType}", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version/{entityType}") public DeferredResult> listEntityTypeVersions(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = BRANCH_PARAM_DESCRIPTION, required = true) @@ -288,7 +289,7 @@ public class EntitiesVersionControlController extends BaseController { "If specified branch does not exist - empty page data will be returned. " + "The response format is the same as for `listEntityVersions` API method." + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/version", params = {"branch", "pageSize", "page"}) + @GetMapping(value = "/version") public DeferredResult> listVersions(@Parameter(description = BRANCH_PARAM_DESCRIPTION, required = true) @RequestParam String branch, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -355,7 +356,7 @@ public class EntitiesVersionControlController extends BaseController { "Returns an object with current entity data and the one at a specific version. " + "Entity data structure is the same as stored in a repository. " + TENANT_AUTHORITY_PARAGRAPH) - @GetMapping(value = "/diff/{entityType}/{internalEntityUuid}", params = {"versionId"}) + @GetMapping(value = "/diff/{entityType}/{internalEntityUuid}") public DeferredResult compareEntityDataToVersion(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable EntityType entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java index bb025a5fcf..cde3129d53 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityQueryController.java @@ -36,6 +36,7 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataPageLink; @@ -47,6 +48,8 @@ import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.query.EntityQueryService; import org.thingsboard.server.service.security.permission.Operation; +import java.util.Set; + import static org.thingsboard.server.controller.ControllerConstants.ALARM_DATA_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_COUNT_QUERY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.ENTITY_DATA_QUERY_DESCRIPTION; @@ -115,9 +118,11 @@ public class EntityQueryController extends BaseController { return entityQueryService.countAlarmsByQuery(getCurrentUser(), query); } + @Deprecated(forRemoval = true) @ApiOperation( - value = "Find Available Entity Keys by Query", + value = "Find Available Entity Keys by Query (deprecated)", notes = """ + **Deprecated.** Use the V2 endpoint (`POST /api/v2/entitiesQuery/find/keys`) instead.\n Returns unique time series and/or attribute key names from entities matching the query.\n Executes the Entity Data Query to find up to 100 entities, then fetches and aggregates all distinct key names.\n Primarily used for UI features like autocomplete suggestions.""" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH @@ -128,9 +133,6 @@ public class EntityQueryController extends BaseController { @Parameter(description = "Entity data query to find entities. Page size is capped at 100.") @RequestBody EntityDataQuery query, - // fixme: combination of timeseries = false and attributes = false is allowed, but always results in empty response, therefore does not make any sense - // such combinations should NOT be allowed, but changing this will break clients - @Parameter(description = """ When true, includes unique time series key names in the response. When false, the 'timeseries' list will be empty.""") @@ -155,6 +157,54 @@ public class EntityQueryController extends BaseController { return wrapFuture(entityQueryService.getKeysByQuery(getCurrentUser(), getTenantId(), query, includeTimeseries, includeAttributes, scope)); } + @ApiOperation( + value = "Find Available Entity Keys By Query", + notes = """ + Discovers unique time series and/or attribute key names available on entities that match the given query. + Works in two steps: first, the request body (an Entity Data Query) is executed to find matching entities + (page size is capped at 100); then, all distinct key names are collected from those entities.\n + Optionally, each key can include a sample — the most recent value (by timestamp) for that key + across all matched entities.""" + + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @PostMapping("/v2/entitiesQuery/find/keys") + public DeferredResult findAvailableEntityKeysByQueryV2( + @Parameter(description = "Entity data query to find entities. Page size is capped at 100.") + @RequestBody EntityDataQuery query, + + @Parameter(description = """ + When true, includes unique time series keys in the response. + When false, the 'timeseries' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""") + @RequestParam(defaultValue = "true") boolean includeTimeseries, + + @Parameter(description = """ + When true, includes unique attribute keys in the response. + When false, the 'attributes' field is omitted. At least one of 'includeTimeseries' or 'includeAttributes' must be true.""") + @RequestParam(defaultValue = "true") boolean includeAttributes, + + @Parameter(description = """ + Filters attribute keys by scope. Only applies when 'includeAttributes' is true. + When not specified, scopes are auto-determined: all three scopes (server, client, shared) for device entities, + server scope only for other entity types.""", + schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) + @RequestParam(required = false) Set scopes, + + @Parameter(description = """ + When true, each key entry includes a 'sample' object with the most recent value and timestamp. + When false, only key names are returned (sample is omitted from JSON).""") + @RequestParam(defaultValue = "false") boolean includeSamples + ) throws ThingsboardException { + resolveQuery(query); + EntityDataPageLink pageLink = query.getPageLink(); + if (pageLink.getPageSize() > MAX_PAGE_SIZE) { + pageLink.setPageSize(MAX_PAGE_SIZE); + } + return wrapFuture(entityQueryService.findAvailableEntityKeysByQuery( + getCurrentUser(), query, + includeTimeseries, includeAttributes, scopes, includeSamples)); + } + @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") @PostMapping("/edqs/system/request") public void processSystemEdqsRequest(@RequestBody ToCoreEdqsRequest request) { diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java index 9314f584ab..a5c82f11f4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityRelationController.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -71,25 +73,22 @@ public class EntityRelationController extends BaseController { "If the user has the authority of 'Tenant Administrator', the server checks that the entity is owned by the same tenant. " + "If the user has the authority of 'Customer User', the server checks that the entity is assigned to the same customer."; - @ApiOperation(value = "Create Relation (saveRelation)", - notes = "Creates or updates a relation between two entities in the platform. " + - "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + - SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @PostMapping("/relation") - public void saveRelation(@Parameter(description = "A JSON value representing the relation.", required = true) + @PostMapping(value = "/relation") + public void saveRelationV1(@Parameter(description = "A JSON value representing the relation.", required = true) @RequestBody EntityRelation relation) throws ThingsboardException { doSave(relation); } - @ApiOperation(value = "Create Relation (saveRelationV2)", + @ApiOperation(value = "Create Relation (saveRelation)", notes = "Creates or updates a relation between two entities in the platform. " + "Relations unique key is a combination of from/to entity id and relation type group and relation type. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @PostMapping("/v2/relation") - public EntityRelation saveRelationV2(@Parameter(description = "A JSON value representing the relation.", required = true) - @RequestBody EntityRelation relation) throws ThingsboardException { + @PostMapping(value = "/v2/relation") + public EntityRelation saveRelation(@Parameter(description = "A JSON value representing the relation.", required = true) + @RequestBody EntityRelation relation) throws ThingsboardException { return doSave(relation); } @@ -103,11 +102,10 @@ public class EntityRelationController extends BaseController { return tbEntityRelationService.save(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } - @ApiOperation(value = "Delete Relation (deleteRelation)", - notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) - public void deleteRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @DeleteMapping(value = "/relation") + public void deleteRelationV1(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, @@ -116,16 +114,16 @@ public class EntityRelationController extends BaseController { doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); } - @ApiOperation(value = "Delete Relation (deleteRelationV2)", + @ApiOperation(value = "Delete Relation (deleteRelation)", notes = "Deletes a relation between two entities in the platform. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/v2/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) - public EntityRelation deleteRelationV2(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, - @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { + @DeleteMapping(value = "/v2/relation") + public EntityRelation deleteRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, + @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType) throws ThingsboardException { return doDelete(strFromId, strFromType, strRelationType, strRelationTypeGroup, strToId, strToType); } @@ -144,11 +142,11 @@ public class EntityRelationController extends BaseController { return tbEntityRelationService.delete(getTenantId(), getCurrentUser().getCustomerId(), relation, getCurrentUser()); } - @ApiOperation(value = "Delete common relations (deleteCommonRelations)", + @ApiOperation(value = "Delete common relations (deleteRelations)", notes = "Deletes all the relations ('from' and 'to' direction) for the specified entity and relation type group: 'COMMON'. " + SECURITY_CHECKS_ENTITY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN','TENANT_ADMIN', 'CUSTOMER_USER')") - @DeleteMapping(value = "/relations", params = {"entityId", "entityType"}) + @DeleteMapping(value = "/relations") public void deleteRelations(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam("entityId") String strId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam("entityType") String strType) throws ThingsboardException { checkParameter("entityId", strId); @@ -161,7 +159,7 @@ public class EntityRelationController extends BaseController { @ApiOperation(value = "Get Relation (getRelation)", notes = "Returns relation object between two specified entities if present. Otherwise throws exception. " + SECURITY_CHECKS_ENTITIES_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/relation", params = {FROM_ID, FROM_TYPE, RELATION_TYPE, TO_ID, TO_TYPE}) + @GetMapping(value = "/relation") public EntityRelation getRelation(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, @@ -180,14 +178,11 @@ public class EntityRelationController extends BaseController { return checkNotNull(relationService.getRelation(getTenantId(), fromId, toId, strRelationType, typeGroup)); } - @ApiOperation(value = "Get List of Relations (findByFrom)", - notes = "Returns list of relation objects for the specified entity by the 'from' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {FROM_ID, FROM_TYPE}) - public List findByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + public List findByFrom(@RequestParam(FROM_ID) String strFromId, + @RequestParam(FROM_TYPE) String strFromType, @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { checkParameter(FROM_ID, strFromId); checkParameter(FROM_TYPE, strFromType); @@ -197,9 +192,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByFrom(getTenantId(), entityId, typeGroup))); } - @ApiOperation(value = "Get List of Relation Infos (findInfoByFrom)", - notes = "Returns list of relation info objects for the specified entity by the 'from' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @ApiOperation(value = "Get List of Relations (findEntityRelationsByFrom)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/from/{fromType}/{fromId}") + public List findEntityRelationsByFrom(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByFrom(strFromId, strFromType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations/info", params = {FROM_ID, FROM_TYPE}) public List findInfoByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, @@ -214,15 +219,24 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByFrom(getTenantId(), entityId, typeGroup).get())); } - @ApiOperation(value = "Get List of Relations (findByFrom)", - notes = "Returns list of relation objects for the specified entity by the 'from' direction and relation type. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @ApiOperation(value = "Get List of Relation Infos (findEntityRelationInfosByFrom)", + notes = "Returns list of relation info objects for the specified entity by the 'from' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/info/from/{fromType}/{fromId}") + public List findEntityRelationInfosByFrom(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException, ExecutionException, InterruptedException { + return findInfoByFrom(strFromId, strFromType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {FROM_ID, FROM_TYPE, RELATION_TYPE}) - public List findByFrom(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_ID) String strFromId, - @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(FROM_TYPE) String strFromType, - @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(RELATION_TYPE) String strRelationType, - @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + public List findByFrom(@RequestParam(FROM_ID) String strFromId, + @RequestParam(FROM_TYPE) String strFromType, + @RequestParam(RELATION_TYPE) String strRelationType, @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { checkParameter(FROM_ID, strFromId); checkParameter(FROM_TYPE, strFromType); @@ -233,10 +247,21 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByFromAndType(getTenantId(), entityId, strRelationType, typeGroup))); } - @ApiOperation(value = "Get List of Relations (findByTo)", - notes = "Returns list of relation objects for the specified entity by the 'to' direction. " + + @ApiOperation(value = "Get List of Relations (findEntityRelationsByFromAndRelationType)", + notes = "Returns list of relation objects for the specified entity by the 'from' direction and relation type. " + SECURITY_CHECKS_ENTITY_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/from/{fromType}/{fromId}/{relationType}") + public List findEntityRelationsByFromAndRelationType(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_TYPE) String strFromType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(FROM_ID) String strFromId, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByFrom(strFromId, strFromType, strRelationType, strRelationTypeGroup); + } + + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {TO_ID, TO_TYPE}) public List findByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @RequestParam(TO_TYPE) String strToType, @@ -250,9 +275,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByTo(getTenantId(), entityId, typeGroup))); } - @ApiOperation(value = "Get List of Relation Infos (findInfoByTo)", - notes = "Returns list of relation info objects for the specified entity by the 'to' direction. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @ApiOperation(value = "Get List of Relations (findEntityRelationsByTo)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/to/{toType}/{toId}") + public List findEntityRelationsByTo(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByTo(strToId, strToType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations/info", params = {TO_ID, TO_TYPE}) public List findInfoByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @@ -267,9 +302,19 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findInfoByTo(getTenantId(), entityId, typeGroup).get())); } - @ApiOperation(value = "Get List of Relations (findByTo)", - notes = "Returns list of relation objects for the specified entity by the 'to' direction and relation type. " + - SECURITY_CHECKS_ENTITY_DESCRIPTION) + @ApiOperation(value = "Get List of Relation Infos (findEntityRelationInfosByTo)", + notes = "Returns list of relation info objects for the specified entity by the 'to' direction. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION + " " + RELATION_INFO_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/info/to/{toType}/{toId}") + public List findEntityRelationInfosByTo(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException, ExecutionException, InterruptedException { + return findInfoByTo(strToId, strToType, strRelationTypeGroup); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/relations", params = {TO_ID, TO_TYPE, RELATION_TYPE}) public List findByTo(@Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @RequestParam(TO_ID) String strToId, @@ -286,28 +331,41 @@ public class EntityRelationController extends BaseController { return checkNotNull(filterRelationsByReadPermission(relationService.findByToAndType(getTenantId(), entityId, strRelationType, typeGroup))); } - @ApiOperation(value = "Find related entities (findByQuery)", + @ApiOperation(value = "Get List of Relations (findEntityRelationsByToAndRelationType)", + notes = "Returns list of relation objects for the specified entity by the 'to' direction and relation type. " + + SECURITY_CHECKS_ENTITY_DESCRIPTION) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/relations/to/{toType}/{toId}/{relationType}") + public List findEntityRelationsByToAndRelationType(@Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(TO_TYPE) String strToType, + @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TO_ID) String strToId, + @Parameter(description = RELATION_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(RELATION_TYPE) String strRelationType, + @Parameter(description = RELATION_TYPE_GROUP_PARAM_DESCRIPTION) + @RequestParam(value = "relationTypeGroup", required = false) String strRelationTypeGroup) throws ThingsboardException { + return findByTo(strToId, strToType, strRelationType, strRelationTypeGroup); + } + + @ApiOperation(value = "Find related entities (findEntityRelationsByQuery)", notes = "Returns all entities that are related to the specific entity. " + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + "See 'Model' tab of the Parameters for more info.") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping("/relations") - public List findByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) - @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEntityRelationsByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query.getParameters()); checkNotNull(query.getFilters()); checkEntityId(query.getParameters().getEntityId(), Operation.READ); return checkNotNull(filterRelationsByReadPermission(relationService.findByQuery(getTenantId(), query).get())); } - @ApiOperation(value = "Find related entity infos (findInfoByQuery)", + @ApiOperation(value = "Find related entity infos (findEntityRelationInfosByQuery)", notes = "Returns all entity infos that are related to the specific entity. " + "The entity id, relation type, entity types, depth of the search, and other query parameters defined using complex 'EntityRelationsQuery' object. " + "See 'Model' tab of the Parameters for more info. " + RELATION_INFO_DESCRIPTION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping("/relations/info") - public List findInfoByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) - @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { + public List findEntityRelationInfosByQuery(@Parameter(description = "A JSON value representing the entity relations query object.", required = true) + @RequestBody EntityRelationsQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query.getParameters()); checkNotNull(query.getFilters()); checkEntityId(query.getParameters().getEntityId(), Operation.READ); diff --git a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java index e061c691ac..821a3c77a1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EntityViewController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.ListenableFuture; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -23,7 +24,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -79,7 +79,6 @@ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_ import static org.thingsboard.server.controller.ControllerConstants.ENTITY_VIEW_TYPE; import static org.thingsboard.server.controller.ControllerConstants.MODEL_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.NAME_CONFLICT_POLICY_DESC; -import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; @@ -87,6 +86,7 @@ import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_D import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; +import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_SEPARATOR_DESC; import static org.thingsboard.server.controller.ControllerConstants.UNIQUIFY_STRATEGY_DESC; import static org.thingsboard.server.controller.EdgeController.EDGE_ID; @@ -167,17 +167,25 @@ public class EntityViewController extends BaseController { tbEntityViewService.delete(entityView, getCurrentUser()); } - @ApiOperation(value = "Get Entity View by name (getTenantEntityView)", - notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/tenant/entityViews", params = {"entityViewName"}) public EntityView getTenantEntityView( - @Parameter(description = "Entity View name") @RequestParam String entityViewName) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); return checkNotNull(entityViewService.findEntityViewByTenantIdAndName(tenantId, entityViewName)); } + @ApiOperation(value = "Get Entity View by name (getTenantEntityViewByName)", + notes = "Fetch the Entity View object based on the tenant id and entity view name. " + TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/tenant/entityView") + public EntityView getTenantEntityViewByName( + @Parameter(description = "Entity View name") + @RequestParam String entityViewName) throws ThingsboardException { + return getTenantEntityView(entityViewName); + } + @ApiOperation(value = "Assign Entity View to customer (assignEntityViewToCustomer)", notes = "Creates assignment of the Entity View to customer. Customer will be able to query Entity View afterwards." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @@ -222,7 +230,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View objects assigned to customer. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/entityViews") public PageData getCustomerEntityViews( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -254,7 +262,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of Entity View info objects assigned to customer. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/customer/{customerId}/entityViewInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/entityViewInfos") public PageData getCustomerEntityViewInfos( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -286,7 +294,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/entityViews") public PageData getTenantEntityViews( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -314,7 +322,7 @@ public class EntityViewController extends BaseController { notes = "Returns a page of entity views info owned by tenant. " + ENTITY_VIEW_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/tenant/entityViewInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/entityViewInfos") public PageData getTenantEntityViewInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -337,13 +345,13 @@ public class EntityViewController extends BaseController { } } - @ApiOperation(value = "Find related entity views (findByQuery)", + @ApiOperation(value = "Find related entity views (findEntityViewsByQuery)", notes = "Returns all entity views that are related to the specific entity. " + "The entity id, relation type, entity view types, depth of the search, and other query parameters defined using complex 'EntityViewSearchQuery' object. " + "See 'Model' tab of the Parameters for more info." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @PostMapping(value = "/entityViews") - public List findByQuery( + public List findEntityViewsByQuery( @Parameter(description = "The entity view search query JSON") @RequestBody EntityViewSearchQuery query) throws ThingsboardException, ExecutionException, InterruptedException { checkNotNull(query); @@ -429,7 +437,7 @@ public class EntityViewController extends BaseController { } @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/edge/{edgeId}/entityViews", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/entityViews") public PageData getEdgeEntityViews( @PathVariable(EDGE_ID) String strEdgeId, @RequestParam int pageSize, @@ -459,11 +467,10 @@ public class EntityViewController extends BaseController { return checkNotNull(filteredResult); } - @ApiOperation(value = "Get Entity Views By Ids (getEntityViewsByIds)", - notes = "Requested entity views must be owned by tenant or assigned to customer which user is performing the request. ") + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/entityViews", params = {"entityViewIds"}) - public List getEntityViewsByIds(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getEntityViewsByIdsV1(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("entityViewIds") Set entityViewUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List entityViewIds = new ArrayList<>(); @@ -474,6 +481,15 @@ public class EntityViewController extends BaseController { return filterEntityViewsByReadPermission(entityViews); } + @ApiOperation(value = "Get Entity Views By Ids (getEntityViewsByIds)", + notes = "Requested entity views must be owned by tenant or assigned to customer which user is performing the request. ") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/entityViews/list") + public List getEntityViewsByIds(@Parameter(description = "A list of entity view ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("entityViewIds") Set entityViewUUIDs) throws ThingsboardException { + return getEntityViewsByIdsV1(entityViewUUIDs); + } + private List filterEntityViewsByReadPermission(List entityViews) { return entityViews.stream().filter(entityView -> { try { diff --git a/application/src/main/java/org/thingsboard/server/controller/EventController.java b/application/src/main/java/org/thingsboard/server/controller/EventController.java index 7bb2d33366..6e8c17c674 100644 --- a/application/src/main/java/org/thingsboard/server/controller/EventController.java +++ b/application/src/main/java/org/thingsboard/server/controller/EventController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import org.springframework.beans.factory.annotation.Autowired; @@ -113,13 +114,13 @@ public class EventController extends BaseController { @Autowired private EventService eventService; - @ApiOperation(value = "Get Events by type (getEvents)", + @ApiOperation(value = "Get Events by type (getEventsByType)", notes = "Returns a page of events for specified entity by specifying event type. " + PAGE_DATA_PARAMETERS) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}/{eventType}", method = RequestMethod.GET) @ResponseBody - public PageData getEvents( + public PageData getEventsByType( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -152,16 +153,12 @@ public class EventController extends BaseController { return checkNotNull(eventService.findEvents(tenantId, entityId, resolveEventType(eventType), pageLink)); } - @ApiOperation(value = "Get Events (Deprecated)", - notes = "Returns a page of events for specified entity. Deprecated and will be removed in next minor release. " + - "The call was deprecated to improve the performance of the system. " + - "Current implementation will return 'Lifecycle' events only. " + - "Use 'Get events by type' or 'Get events by filter' instead. " + - PAGE_DATA_PARAMETERS) + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.GET) @ResponseBody - public PageData getEvents( + public PageData getEventsDeprecated( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -194,14 +191,14 @@ public class EventController extends BaseController { return checkNotNull(eventService.findEvents(tenantId, entityId, EventType.LC_EVENT, pageLink)); } - @ApiOperation(value = "Get Events by event filter (getEvents)", + @ApiOperation(value = "Get Events by event filter (getEventsByFilter)", notes = "Returns a page of events for the chosen entity by specifying the event filter. " + PAGE_DATA_PARAMETERS + NEW_LINE + EVENT_FILTER_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/events/{entityType}/{entityId}", method = RequestMethod.POST) @ResponseBody - public PageData getEvents( + public PageData getEventsByFilter( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable(ENTITY_TYPE) String strEntityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) diff --git a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java index 5e3f3be442..5566c11a4a 100644 --- a/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java +++ b/application/src/main/java/org/thingsboard/server/controller/Lwm2mController.java @@ -69,11 +69,11 @@ public class Lwm2mController extends BaseController { return lwM2MService.getServerSecurityInfo(bootstrapServer); } - @ApiOperation(hidden = true, value = "Save device with credentials (Deprecated)") + @ApiOperation(hidden = true, value = "Save LwM2M device with credentials (saveLwm2mDeviceWithCredentials) (Deprecated)") @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/lwm2m/device-credentials", method = RequestMethod.POST) @ResponseBody - public Device saveDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { + public Device saveLwm2mDeviceWithCredentials(@RequestBody Map, Object> deviceWithDeviceCredentials) throws ThingsboardException { Device device = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(Device.class), Device.class)); DeviceCredentials credentials = checkNotNull(JacksonUtil.convertValue(deviceWithDeviceCredentials.get(DeviceCredentials.class), DeviceCredentials.class)); return deviceController.saveDeviceWithCredentials(new SaveDeviceWithCredentialsRequest(device, credentials), DEFAULT.policy(), DEFAULT.separator(), DEFAULT.uniquifyStrategy()); diff --git a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java index 70ac96db67..f8a3f8ad0b 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MailConfigTemplateController.java @@ -43,12 +43,12 @@ public class MailConfigTemplateController extends BaseController { private static final String MAIL_CONFIG_TEMPLATE_DEFINITION = "Mail configuration template is set of default smtp settings for mail server that specific provider supports"; private final TbMailConfigTemplateService mailConfigTemplateService; - @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getMailConfigTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, notes = MAIL_CONFIG_TEMPLATE_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(method = RequestMethod.GET, produces = "application/json") @ResponseBody - public JsonNode getClientRegistrationTemplates() throws ThingsboardException, IOException { + public JsonNode getMailConfigTemplates() throws ThingsboardException, IOException { accessControlService.checkPermission(getCurrentUser(), Resource.ADMIN_SETTINGS, Operation.READ); return mailConfigTemplateService.findAllMailConfigTemplates(); } diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java index 304805a820..feb4aed0e3 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileAppBundleController.java @@ -81,12 +81,12 @@ public class MobileAppBundleController extends BaseController { return tbMobileAppBundleService.save(mobileAppBundle, getOAuth2ClientIds(ids), getCurrentUser()); } - @ApiOperation(value = "Update oauth2 clients (updateOauth2Clients)", + @ApiOperation(value = "Update oauth2 clients (updateMobileAppBundleOauth2Clients)", notes = "Update oauth2 clients of the specified mobile app bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @PutMapping(value = "/mobile/bundle/{id}/oauth2Clients") - public void updateOauth2Clients(@PathVariable UUID id, - @RequestBody UUID[] clientIds) throws ThingsboardException { + public void updateMobileAppBundleOauth2Clients(@PathVariable UUID id, + @RequestBody UUID[] clientIds) throws ThingsboardException { MobileAppBundleId mobileAppBundleId = new MobileAppBundleId(id); MobileAppBundle mobileAppBundle = checkMobileAppBundleId(mobileAppBundleId, Operation.WRITE); List oAuth2ClientIds = getOAuth2ClientIds(clientIds); diff --git a/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java b/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java index 7c77a26e42..3ca23b2ddc 100644 --- a/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java +++ b/application/src/main/java/org/thingsboard/server/controller/MobileAppController.java @@ -123,7 +123,7 @@ public class MobileAppController extends BaseController { return tbMobileAppService.save(mobileApp, getCurrentUser()); } - @ApiOperation(value = "Get mobile app infos (getTenantMobileAppInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get mobile app infos (getTenantMobileApps)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/mobile/app") public PageData getTenantMobileApps(@Parameter(description = "Platform type: ANDROID or IOS") @@ -142,7 +142,7 @@ public class MobileAppController extends BaseController { return mobileAppService.findMobileAppsByTenantId(getTenantId(), platformType, pageLink); } - @ApiOperation(value = "Get mobile info by id (getMobileAppInfoById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get mobile info by id (getMobileAppById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/mobile/app/{id}") public MobileApp getMobileAppById(@PathVariable UUID id) throws ThingsboardException { diff --git a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java index 72c7f15a3b..17bb87233e 100644 --- a/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java +++ b/application/src/main/java/org/thingsboard/server/controller/NotificationTargetController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -153,12 +154,10 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findRecipientsForNotificationTargetConfig(user.getTenantId(), (PlatformUsersNotificationTargetConfig) notificationTarget.getConfiguration(), pageLink); } - @ApiOperation(value = "Get notification targets by ids (getNotificationTargetsByIds)", - notes = "Returns the list of notification targets found by provided ids." + - SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @GetMapping(value = "/targets", params = {"ids"}) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public List getNotificationTargetsByIds(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getNotificationTargetsByIdsV1(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("ids") UUID[] ids, @AuthenticationPrincipal SecurityUser user) { // PE: generic permission @@ -166,6 +165,17 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantIdAndIds(user.getTenantId(), targetsIds); } + @ApiOperation(value = "Get notification targets by ids (getNotificationTargetsByIds)", + notes = "Returns the list of notification targets found by provided ids." + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/targets/list") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public List getNotificationTargetsByIds(@Parameter(description = "Comma-separated list of uuids representing targets ids", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("ids") UUID[] ids, + @AuthenticationPrincipal SecurityUser user) { + return getNotificationTargetsByIdsV1(ids, user); + } + @ApiOperation(value = "Get notification targets (getNotificationTargets)", notes = "Returns the page of notification targets owned by sysadmin or tenant." + NEW_LINE + PAGE_DATA_PARAMETERS + @@ -188,13 +198,10 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantId(user.getTenantId(), pageLink); } - @ApiOperation(value = "Get notification targets by supported notification type (getNotificationTargetsBySupportedNotificationType)", - notes = "Returns the page of notification targets filtered by notification type that they can be used for." + NEW_LINE + - PAGE_DATA_PARAMETERS + - SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @GetMapping(value = "/targets", params = "notificationType") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - public PageData getNotificationTargetsBySupportedNotificationType(@RequestParam int pageSize, + public PageData getNotificationTargetsBySupportedNotificationTypeV1(@RequestParam int pageSize, @RequestParam int page, @RequestParam(required = false) String textSearch, @RequestParam(required = false) String sortProperty, @@ -206,6 +213,22 @@ public class NotificationTargetController extends BaseController { return notificationTargetService.findNotificationTargetsByTenantIdAndSupportedNotificationType(user.getTenantId(), notificationType, pageLink); } + @ApiOperation(value = "Get notification targets by supported notification type (getNotificationTargetsBySupportedNotificationType)", + notes = "Returns the page of notification targets filtered by notification type that they can be used for." + NEW_LINE + + PAGE_DATA_PARAMETERS + + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @GetMapping(value = "/targets/notificationType/{notificationType}") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + public PageData getNotificationTargetsBySupportedNotificationType(@PathVariable NotificationType notificationType, + @RequestParam int pageSize, + @RequestParam int page, + @RequestParam(required = false) String textSearch, + @RequestParam(required = false) String sortProperty, + @RequestParam(required = false) String sortOrder, + @AuthenticationPrincipal SecurityUser user) throws ThingsboardException { + return getNotificationTargetsBySupportedNotificationTypeV1(pageSize, page, textSearch, sortProperty, sortOrder, notificationType, user); + } + @ApiOperation(value = "Delete notification target by id (deleteNotificationTargetById)", notes = "Deletes notification target by its id." + NEW_LINE + "This target cannot be referenced by existing scheduled notification requests or any notification rules." + diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java index 8891fcc64e..6c773c9509 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2ConfigTemplateController.java @@ -71,12 +71,12 @@ public class OAuth2ConfigTemplateController extends BaseController { oAuth2ConfigTemplateService.deleteClientRegistrationTemplateById(clientRegistrationTemplateId); } - @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, + @ApiOperation(value = "Get the list of all OAuth2 client registration templates (getOAuth2ClientRegistrationTemplates)" + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH, notes = OAUTH2_CLIENT_REGISTRATION_TEMPLATE_DEFINITION) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @RequestMapping(method = RequestMethod.GET, produces = "application/json") @ResponseBody - public List getClientRegistrationTemplates() throws ThingsboardException { + public List getOAuth2ClientRegistrationTemplates() throws ThingsboardException { accessControlService.checkPermission(getCurrentUser(), Resource.OAUTH2_CONFIGURATION_TEMPLATE, Operation.READ); return oAuth2ConfigTemplateService.findAllClientRegistrationTemplates(); } diff --git a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java index 71b8ac27a9..5e51e0bbb6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/OAuth2Controller.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -115,32 +116,40 @@ public class OAuth2Controller extends BaseController { return tbOauth2ClientService.save(oAuth2Client, getCurrentUser()); } - @ApiOperation(value = "Get OAuth2 Client infos (findTenantOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get OAuth2 Client infos (findOAuth2ClientInfos)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/oauth2/client/infos") - public PageData findTenantOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = "Case-insensitive 'substring' filter based on client's title") - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData findOAuth2ClientInfos(@Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = "Case-insensitive 'substring' filter based on client's title") + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); return oAuth2ClientService.findOAuth2ClientInfosByTenantId(getTenantId(), pageLink); } + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/oauth2/client/infos", params = {"clientIds"}) + public List findTenantOAuth2ClientInfosByIdsV1( + @RequestParam("clientIds") UUID[] clientIds) throws ThingsboardException { + List oAuth2ClientIds = getOAuth2ClientIds(clientIds); + return oAuth2ClientService.findOAuth2ClientInfosByIds(getTenantId(), oAuth2ClientIds); + } + @ApiOperation(value = "Get OAuth2 Client infos By Ids (findTenantOAuth2ClientInfosByIds)", notes = "Fetch OAuth2 Client info objects based on the provided ids. " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @GetMapping(value = "/oauth2/client/infos", params = {"clientIds"}) + @GetMapping(value = "/oauth2/client/list") public List findTenantOAuth2ClientInfosByIds( @Parameter(description = "A list of oauth2 ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("clientIds") UUID[] clientIds) throws ThingsboardException { - List oAuth2ClientIds = getOAuth2ClientIds(clientIds); - return oAuth2ClientService.findOAuth2ClientInfosByIds(getTenantId(), oAuth2ClientIds); + return findTenantOAuth2ClientInfosByIdsV1(clientIds); } @ApiOperation(value = "Get OAuth2 Client by id (getOAuth2ClientById)", notes = SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) diff --git a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java index 4967023ec0..ab93a29db5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java +++ b/application/src/main/java/org/thingsboard/server/controller/OtaPackageController.java @@ -181,25 +181,25 @@ public class OtaPackageController extends BaseController { return checkNotNull(otaPackageService.findTenantOtaPackagesByTenantId(getTenantId(), pageLink)); } - @ApiOperation(value = "Get OTA Package Infos (getOtaPackages)", + @ApiOperation(value = "Get OTA Package Infos by device profile and type (getOtaPackagesByDeviceProfileAndType)", notes = "Returns a page of OTA Package Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + OTA_PACKAGE_INFO_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/otaPackages/{deviceProfileId}/{type}") - public PageData getOtaPackages(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) - @PathVariable("deviceProfileId") String strDeviceProfileId, - @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) - @PathVariable("type") String strType, - @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) - @RequestParam int pageSize, - @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) - @RequestParam int page, - @Parameter(description = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) - @RequestParam(required = false) String textSearch, - @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "type", "title", "version", "tag", "url", "fileName", "dataSize", "checksum"})) - @RequestParam(required = false) String sortProperty, - @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) - @RequestParam(required = false) String sortOrder) throws ThingsboardException { + public PageData getOtaPackagesByDeviceProfileAndType(@Parameter(description = DEVICE_PROFILE_ID_PARAM_DESCRIPTION) + @PathVariable("deviceProfileId") String strDeviceProfileId, + @Parameter(description = "OTA Package type.", schema = @Schema(allowableValues = {"FIRMWARE", "SOFTWARE"})) + @PathVariable("type") String strType, + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = OTA_PACKAGE_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "type", "title", "version", "tag", "url", "fileName", "dataSize", "checksum"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder) throws ThingsboardException { checkParameter("deviceProfileId", strDeviceProfileId); checkParameter("type", strType); PageLink pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); diff --git a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java index d266b684d8..6b90fb142f 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QrCodeSettingsController.java @@ -129,7 +129,7 @@ public class QrCodeSettingsController extends BaseController { return qrCodeSettingService.saveQrCodeSettings(currentUser.getTenantId(), qrCodeSettings); } - @ApiOperation(value = "Get Mobile application settings (getMobileAppSettings)", + @ApiOperation(value = "Get Mobile application settings (getQrCodeSettings)", notes = "The response payload contains configuration for android/iOS applications and platform qr code widget settings." + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/api/mobile/qr/settings") diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueController.java b/application/src/main/java/org/thingsboard/server/controller/QueueController.java index 2d584a6e27..dd2cc19fd6 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QueueController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QueueController.java @@ -19,7 +19,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; @@ -65,8 +67,7 @@ public class QueueController extends BaseController { notes = "Returns a page of queues registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/queues", params = {"serviceType", "pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @GetMapping(value = "/queues") public PageData getTenantQueuesByServiceType(@Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) @RequestParam String serviceType, @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @@ -122,8 +123,7 @@ public class QueueController extends BaseController { "Remove 'id', 'tenantId' from the request body example (below) to create new Queue entity. " + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN')") - @RequestMapping(value = "/queues", params = {"serviceType"}, method = RequestMethod.POST) - @ResponseBody + @PostMapping(value = "/queues") public Queue saveQueue(@Parameter(description = "A JSON value representing the queue.") @RequestBody Queue queue, @Parameter(description = QUEUE_SERVICE_TYPE_DESCRIPTION, schema = @Schema(allowableValues = {"TB-RULE-ENGINE", "TB-CORE", "TB-TRANSPORT", "JS-EXECUTOR"}, requiredMode = Schema.RequiredMode.REQUIRED)) diff --git a/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java b/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java index 1ccc35b875..57ec9a43c9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/QueueStatsController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -85,12 +86,10 @@ public class QueueStatsController extends BaseController { return checkNotNull(queueStatsService.findQueueStatsById(getTenantId(), queueStatsId)); } - @ApiOperation(value = "Get QueueStats By Ids (getQueueStatsByIds)", - notes = "Fetch the Queue stats objects based on the provided ids. ") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/queueStats", params = {"queueStatsIds"}) - public List getQueueStatsByIds( - @Parameter(description = "A list of queue stats ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getQueueStatsByIdsV1( @RequestParam("queueStatsIds") String[] strQueueStatsIds) throws ThingsboardException { checkArrayParameter("queueStatsIds", strQueueStatsIds); List queueStatsIds = new ArrayList<>(); @@ -99,4 +98,14 @@ public class QueueStatsController extends BaseController { } return queueStatsService.findQueueStatsByIds(getTenantId(), queueStatsIds); } + + @ApiOperation(value = "Get QueueStats By Ids (getQueueStatsByIds)", + notes = "Fetch the Queue stats objects based on the provided ids. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/queueStats/list") + public List getQueueStatsByIds( + @Parameter(description = "A list of queue stats ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("queueStatsIds") String[] strQueueStatsIds) throws ThingsboardException { + return getQueueStatsByIdsV1(strQueueStatsIds); + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java index dc17136d63..5e555cbcbe 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV1Controller.java @@ -16,6 +16,8 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; @@ -43,26 +45,28 @@ import static org.thingsboard.server.controller.ControllerConstants.TENANT_OR_CU @Slf4j public class RpcV1Controller extends AbstractRpcController { - @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequestV1)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleOneWayDeviceRPCRequest( + public DeferredResult handleOneWayDeviceRPCRequestV1( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); } - @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequest)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequestV1)", notes = "Deprecated. See 'Rpc V 2 Controller' instead." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleTwoWayDeviceRPCRequest( + public DeferredResult handleTwoWayDeviceRPCRequestV1( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.REQUEST_TIMEOUT, HttpStatus.CONFLICT); } diff --git a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java index 412e775327..4366134927 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java +++ b/application/src/main/java/org/thingsboard/server/controller/RpcV2Controller.java @@ -17,6 +17,7 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.FutureCallback; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -113,7 +114,7 @@ public class RpcV2Controller extends AbstractRpcController { private static final String TWO_WAY_RPC_REQUEST_DESCRIPTION = "Sends the two-way remote-procedure call (RPC) request to device. " + RPC_REQUEST_DESCRIPTION + TWO_WAY_RPC_RESULT + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH; - @ApiOperation(value = "Send one-way RPC request", notes = ONE_WAY_RPC_REQUEST_DESCRIPTION) + @ApiOperation(value = "Send one-way RPC request (handleOneWayDeviceRPCRequestV2)", notes = ONE_WAY_RPC_REQUEST_DESCRIPTION) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC request was sent to the device."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @@ -124,15 +125,16 @@ public class RpcV2Controller extends AbstractRpcController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/oneway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleOneWayDeviceRPCRequest( + public DeferredResult handleOneWayDeviceRPCRequestV2( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable("deviceId") String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(true, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } - @ApiOperation(value = "Send two-way RPC request", notes = TWO_WAY_RPC_REQUEST_DESCRIPTION) + @ApiOperation(value = "Send two-way RPC request (handleTwoWayDeviceRPCRequestV2)", notes = TWO_WAY_RPC_REQUEST_DESCRIPTION) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Persistent RPC request was saved to the database or lightweight RPC response received."), @ApiResponse(responseCode = "400", description = "Invalid structure of the request."), @@ -143,10 +145,11 @@ public class RpcV2Controller extends AbstractRpcController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/twoway/{deviceId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleTwoWayDeviceRPCRequest( + public DeferredResult handleTwoWayDeviceRPCRequestV2( @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION) @PathVariable(DEVICE_ID) String deviceIdStr, - @Parameter(description = "A JSON value representing the RPC request.") + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the RPC request.", + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { return handleDeviceRPCRequest(false, new DeviceId(UUID.fromString(deviceIdStr)), requestBody, HttpStatus.GATEWAY_TIMEOUT, HttpStatus.GATEWAY_TIMEOUT); } diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java index accec11afe..0ac6fcdac1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleChainController.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -229,12 +230,12 @@ public class RuleChainController extends BaseController { return tbRuleChainService.save(ruleChain, getCurrentUser()); } - @ApiOperation(value = "Create Default Rule Chain", + @ApiOperation(value = "Create Default Rule Chain (setDeviceDefaultRuleChain)", notes = "Create rule chain from template, based on the specified name in the request. " + "Creates the rule chain based on the template that is used to create root rule chain. " + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/ruleChain/device/default") - public RuleChain saveRuleChain( + public RuleChain setDeviceDefaultRuleChain( @Parameter(description = "A JSON value representing the request.") @RequestBody DefaultRuleChainCreateRequest request) throws Exception { checkNotNull(request); @@ -281,7 +282,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Rule Chains (getRuleChains)", notes = "Returns a page of Rule Chains owned by tenant. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/ruleChains", params = {"pageSize", "page"}) + @GetMapping(value = "/ruleChains") public PageData getRuleChains( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -347,7 +348,7 @@ public class RuleChainController extends BaseController { notes = TEST_SCRIPT_FUNCTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping("/ruleChain/testScript") - public JsonNode testScript( + public JsonNode testRuleChainScript( @Parameter(description = "Script language: JS or TBEL") @RequestParam(required = false) ScriptLanguage scriptLang, @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "Test JS request. See API call description above.") @@ -409,7 +410,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Export Rule Chains", notes = "Exports all tenant rule chains as one JSON." + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/ruleChains/export", params = {"limit"}) + @GetMapping(value = "/ruleChains/export") public RuleChainData exportRuleChains( @Parameter(description = "A limit of rule chains to export.", required = true) @RequestParam("limit") int limit) throws ThingsboardException { @@ -505,7 +506,7 @@ public class RuleChainController extends BaseController { @ApiOperation(value = "Get Edge Rule Chains (getEdgeRuleChains)", notes = "Returns a page of Rule Chains assigned to the specified edge. " + RULE_CHAIN_DESCRIPTION + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/edge/{edgeId}/ruleChains", params = {"pageSize", "page"}) + @GetMapping(value = "/edge/{edgeId}/ruleChains") public PageData getEdgeRuleChains( @Parameter(description = EDGE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(EDGE_ID) String strEdgeId, @@ -582,14 +583,10 @@ public class RuleChainController extends BaseController { return checkNotNull(result); } - @ApiOperation(value = "Get Rule Chains By Ids (getRuleChainsByIds)", - notes = "Requested rule chains must be owned by tenant which is performing the request. " + - NEW_LINE) + @Hidden @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping(value = "/ruleChains", params = {"ruleChainIds"}) - public List getRuleChainsByIds( - @Parameter(description = "A list of rule chain ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) - @RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { + public List getRuleChainsByIdsV1(@RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { TenantId tenantId = getCurrentUser().getTenantId(); List ruleChainIds = new ArrayList<>(); for (UUID ruleChainUUID : ruleChainUUIDs) { @@ -598,4 +595,15 @@ public class RuleChainController extends BaseController { return ruleChainService.findRuleChainsByIds(tenantId, ruleChainIds); } + @ApiOperation(value = "Get Rule Chains By Ids (getRuleChainsByIds)", + notes = "Requested rule chains must be owned by tenant which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") + @GetMapping(value = "/ruleChains/list") + public List getRuleChainsByIds( + @Parameter(description = "A list of rule chain ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("ruleChainIds") Set ruleChainUUIDs) throws Exception { + return getRuleChainsByIdsV1(ruleChainUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java index 2b4489e387..d63e0f5d19 100644 --- a/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java +++ b/application/src/main/java/org/thingsboard/server/controller/RuleEngineController.java @@ -17,6 +17,8 @@ package org.thingsboard.server.controller; import com.google.common.util.concurrent.FutureCallback; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.annotation.Nullable; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -77,7 +79,7 @@ public class RuleEngineController extends BaseController { @Autowired private AccessValidator accessValidator; - @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push user message to the rule engine (handleRuleEngineRequestForUser)", notes = MSG_DESCRIPTION_PREFIX + "Uses current User Id ( the one which credentials is used to perform the request) as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -86,13 +88,14 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( - @Parameter(description = "A JSON value representing the message.", required = true) + public DeferredResult handleRuleEngineRequestForUser( + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { - return handleRuleEngineRequest(null, null, null, defaultResponseTimeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(null, null, null, defaultResponseTimeout, requestBody); } - @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message to the rule engine (handleRuleEngineRequestForEntity)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -101,17 +104,18 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntity( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { - return handleRuleEngineRequest(entityType, entityIdStr, null, defaultResponseTimeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, defaultResponseTimeout, requestBody); } - @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message with timeout to the rule engine (handleRuleEngineRequestForEntityWithTimeout)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -120,19 +124,20 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/{timeout}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntityWithTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = "Timeout to process the request in milliseconds", required = true) @PathVariable("timeout") int timeout, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { - return handleRuleEngineRequest(entityType, entityIdStr, null, timeout, requestBody); + return handleRuleEngineRequestForEntityWithQueueAndTimeout(entityType, entityIdStr, null, timeout, requestBody); } - @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequest)", + @ApiOperation(value = "Push entity message with timeout and specified queue to the rule engine (handleRuleEngineRequestForEntityWithQueueAndTimeout)", notes = MSG_DESCRIPTION_PREFIX + "Uses specified Entity Id as the Rule Engine message originator. " + MSG_DESCRIPTION + @@ -142,7 +147,7 @@ public class RuleEngineController extends BaseController { @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @RequestMapping(value = "/{entityType}/{entityId}/{queueName}/{timeout}", method = RequestMethod.POST) @ResponseBody - public DeferredResult handleRuleEngineRequest( + public DeferredResult handleRuleEngineRequestForEntityWithQueueAndTimeout( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @@ -151,7 +156,8 @@ public class RuleEngineController extends BaseController { @PathVariable("queueName") String queueName, @Parameter(description = "Timeout to process the request in milliseconds", required = true) @PathVariable("timeout") int timeout, - @Parameter(description = "A JSON value representing the message.", required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = "A JSON object representing the message.", required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { try { SecurityUser currentUser = getCurrentUser(); @@ -244,5 +250,7 @@ public class RuleEngineController extends BaseController { response != null ? response.getData() : ""); } - private record LocalRequestMetaData(TbMsg request, SecurityUser user, DeferredResult responseWriter) {} + private record LocalRequestMetaData(TbMsg request, SecurityUser user, + DeferredResult responseWriter) { + } } diff --git a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java index ee2e871d6e..9bcd756de0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java +++ b/application/src/main/java/org/thingsboard/server/controller/SystemInfoController.java @@ -76,6 +76,9 @@ public class SystemInfoController extends BaseController { @Value("${debug.settings.default_duration:15}") private int defaultDebugDurationMinutes; + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired(required = false) private BuildProperties buildProperties; @@ -173,6 +176,7 @@ public class SystemInfoController extends BaseController { systemParams.setMobileQrEnabled(Optional.ofNullable(qrCodeSettingService.findQrCodeSettings(TenantId.SYS_TENANT_ID)) .map(QrCodeSettings::getQrCodeConfig).map(QRCodeConfig::isShowOnHomePage) .orElse(false)); + systemParams.setAllowKeyFiltersOrConditions(keyFiltersOrConditionsEnabled); return systemParams; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java index a7aaf37ffd..429f9b9cf2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TbResourceController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -118,7 +119,7 @@ public class TbResourceController extends BaseController { .body(resource); } - @ApiOperation(value = "Download resource (downloadResource)", + @ApiOperation(value = "Download resource (downloadResourceIfChanged)", notes = "Download resource with a given type and key for the given scope" + AVAILABLE_FOR_ANY_AUTHORIZED_USER) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/resource/{resourceType}/{scope}/{key}") @@ -336,11 +337,10 @@ public class TbResourceController extends BaseController { } } - @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/resource", params = {"resourceIds"}) - public List getSystemOrTenantResourcesByIds( - @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + public List getSystemOrTenantResourcesByIdsV1( @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { SecurityUser user = getCurrentUser(); List resourceIds = new ArrayList<>(); @@ -350,7 +350,16 @@ public class TbResourceController extends BaseController { return resourceService.findSystemOrTenantResourcesByIds(user.getTenantId(), resourceIds); } - @ApiOperation(value = "Get All Resource Infos (getAllResources)", + @ApiOperation(value = "Get Resource Infos by ids (getSystemOrTenantResourcesByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/resource/list") + public List getSystemOrTenantResourcesByIds( + @Parameter(description = "A list of resource ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("resourceIds") Set resourceUuids) throws ThingsboardException { + return getSystemOrTenantResourcesByIdsV1(resourceUuids); + } + + @ApiOperation(value = "Get All Resource Infos (getTenantResources)", notes = "Returns a page of Resource Info objects owned by tenant. " + PAGE_DATA_PARAMETERS + RESOURCE_INFO_DESCRIPTION + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN')") diff --git a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java index b0f5f41c9a..754c9253d9 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TelemetryController.java @@ -24,7 +24,12 @@ import com.google.common.util.concurrent.MoreExecutors; import com.google.gson.JsonElement; import com.google.gson.JsonParseException; import com.google.gson.JsonParser; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -166,7 +171,8 @@ public class TelemetryController extends BaseController { "\n\n * SERVER_SCOPE - supported for all entity types;" + "\n * CLIENT_SCOPE - supported for devices;" + "\n * SHARED_SCOPE - supported for devices. " - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/attributes") public DeferredResult getAttributeKeys( @@ -180,7 +186,8 @@ public class TelemetryController extends BaseController { "\n\n * SERVER_SCOPE - supported for all entity types;" + "\n * CLIENT_SCOPE - supported for devices;" + "\n * SHARED_SCOPE - supported for devices. " - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/attributes/{scope}") public DeferredResult getAttributeKeysByScope( @@ -197,13 +204,18 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + ATTRIBUTE_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(implementation = AttributeData.class))))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/attributes") public DeferredResult getAttributes( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -220,7 +232,11 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + ATTRIBUTE_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(content = @Content(mediaType = "application/json", array = @ArraySchema(schema = @Schema(implementation = AttributeData.class))))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/attributes/{scope}") public DeferredResult getAttributesByScope( @@ -228,6 +244,7 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -237,7 +254,8 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Get time series keys (getTimeseriesKeys)", notes = "Returns a set of unique time series key names for the selected entity. " + - "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(array = @ArraySchema(schema = @Schema(type = "string"))))) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/keys/timeseries") public DeferredResult getTimeseriesKeys( @@ -258,7 +276,11 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + LATEST_TS_STRICT_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "object", additionalPropertiesSchema = TsData[].class)))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/{entityType}/{entityId}/values/timeseries") public DeferredResult getLatestTimeseries( @@ -267,6 +289,7 @@ public class TelemetryController extends BaseController { @Parameter(description = TELEMETRY_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); SecurityUser user = getCurrentUser(); @@ -274,7 +297,32 @@ public class TelemetryController extends BaseController { (result, tenantId, entityId) -> getLatestTimeseriesValuesCallback(result, user, entityId, keys, useStrictDataTypes)); } - @ApiOperation(value = "Get time series data (getTimeseries)", + @Hidden + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"startTs", "endTs"}) + public DeferredResult getTimeseries( + @PathVariable("entityType") String entityType, + @PathVariable("entityId") String entityIdStr, + @RequestParam(name = "keys", required = false) String keysStr, + @RequestParam(name = "startTs") Long startTs, + @RequestParam(name = "endTs") Long endTs, + @RequestParam(name = "intervalType", required = false) IntervalType intervalType, + @RequestParam(name = "interval", defaultValue = "0") Long interval, + @RequestParam(name = "timeZone", required = false) String timeZone, + @RequestParam(name = "limit", defaultValue = "100") Integer limit, + @RequestParam(name = "agg", defaultValue = "NONE") String aggStr, + @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, + @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @RequestParam MultiValueMap params) throws ThingsboardException { + List keys = getKeys(keysStr, params); + DeferredResult response = new DeferredResult<>(); + Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), keys, startTs, endTs, + intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), + getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); + return response; + } + + @ApiOperation(value = "Get time series data (getTimeseriesHistory)", notes = "Returns a range of time series values for specified entity. " + "Returns not aggregated data by default. " + "Use aggregation function ('agg') and aggregation interval ('interval') to enable aggregation of the results on the database / server side. " + @@ -282,10 +330,14 @@ public class TelemetryController extends BaseController { + MARKDOWN_CODE_BLOCK_START + TS_STRICT_DATA_EXAMPLE + MARKDOWN_CODE_BLOCK_END - + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + + "\n\n" + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH, + responses = @ApiResponse(responseCode = "200", description = "OK", content = @Content(schema = @Schema(type = "object", additionalPropertiesSchema = TsData[].class)))) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/{entityType}/{entityId}/values/timeseries", params = {"keys", "startTs", "endTs"}) - public DeferredResult getTimeseries( + @GetMapping(value = "/{entityType}/{entityId}/values/timeseries/history") + public DeferredResult getTimeseriesHistory( @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = TELEMETRY_KEYS_BASE_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, @@ -310,13 +362,9 @@ public class TelemetryController extends BaseController { @RequestParam(name = "orderBy", defaultValue = "DESC") String orderBy, @Parameter(description = STRICT_DATA_TYPES_DESCRIPTION) @RequestParam(name = "useStrictDataTypes", required = false, defaultValue = "false") Boolean useStrictDataTypes, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { - List keys = getKeys(keysStr, params); - DeferredResult response = new DeferredResult<>(); - Futures.addCallback(tbTelemetryService.getTimeseries(EntityIdFactory.getByTypeAndId(entityType, entityIdStr), keys, startTs, endTs, - intervalType, interval, timeZone, limit, Aggregation.valueOf(aggStr), orderBy, useStrictDataTypes, getCurrentUser()), - getTsKvListCallback(response, useStrictDataTypes), MoreExecutors.directExecutor()); - return response; + return getTimeseries(entityType, entityIdStr, keysStr, startTs, endTs, intervalType, interval, timeZone, limit, aggStr, orderBy, useStrictDataTypes, params); } @ApiOperation(value = "Save device attributes (saveDeviceAttributes)", @@ -338,7 +386,8 @@ public class TelemetryController extends BaseController { @PathVariable("deviceId") String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -363,7 +412,8 @@ public class TelemetryController extends BaseController { @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"})) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -388,7 +438,8 @@ public class TelemetryController extends BaseController { @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true) + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = ATTRIBUTES_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String request) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveAttributes(getTenantId(), entityId, scope, request); @@ -412,7 +463,8 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_TYPE_PARAM_DESCRIPTION, required = true, schema = @Schema(defaultValue = "DEVICE")) @PathVariable("entityType") String entityType, @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveTelemetry(getTenantId(), entityId, requestBody, 0L); } @@ -436,7 +488,8 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = TELEMETRY_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = "ANY")) @PathVariable("scope") String scope, @Parameter(description = "A long value representing TTL (Time to Live) parameter.", required = true) @PathVariable("ttl") Long ttl, - @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true) @RequestBody String requestBody) throws ThingsboardException { + @io.swagger.v3.oas.annotations.parameters.RequestBody(description = TELEMETRY_JSON_REQUEST_DESCRIPTION, required = true, + content = @Content(mediaType = "text/plain", schema = @Schema(type = "string"))) @RequestBody String requestBody) throws ThingsboardException { EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); return saveTelemetry(getTenantId(), entityId, requestBody, ttl); } @@ -449,6 +502,9 @@ public class TelemetryController extends BaseController { " Use 'rewriteLatestIfDeleted' to rewrite latest value (stored in separate table for performance) if the value's timestamp matches the time-range and 'deleteLatest' param is true." + " The replacement value will be fetched from the 'time series' table, and its timestamp will be the most recent one before the defined time-range. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Time series for the selected keys in the request was removed. " + "Platform creates an audit log event about entity time series removal with action type 'TIMESERIES_DELETED'."), @@ -473,6 +529,7 @@ public class TelemetryController extends BaseController { @RequestParam(name = "deleteLatest", required = false, defaultValue = "true") boolean deleteLatest, @Parameter(description = "If the parameter is set to true, the latest telemetry will be rewritten in case that current latest value was removed, otherwise, in case that parameter is set to false the new latest value will not set.") @RequestParam(name = "rewriteLatestIfDeleted", defaultValue = "false") boolean rewriteLatestIfDeleted, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); @@ -530,6 +587,9 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Delete device attributes (deleteDeviceAttributes)", notes = "Delete device attributes using provided Device Id, scope and a list of keys. " + "Referencing a non-existing Device Id will cause an error" + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "Device attributes was removed for the selected keys in the request. " + "Platform creates an audit log event about device attributes removal with action type 'ATTRIBUTES_DELETED'."), @@ -544,6 +604,7 @@ public class TelemetryController extends BaseController { @Parameter(description = DEVICE_ID_PARAM_DESCRIPTION, required = true) @PathVariable(DEVICE_ID) String deviceIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"}, requiredMode = Schema.RequiredMode.REQUIRED)) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndUuid(EntityType.DEVICE, deviceIdStr); @@ -552,9 +613,13 @@ public class TelemetryController extends BaseController { @ApiOperation(value = "Delete entity attributes (deleteEntityAttributes)", notes = "Delete entity attributes using provided Entity Id, scope and a list of keys. " + + "This operation is idempotent: keys that do not exist are silently ignored and the response is still 200 OK. " + INVALID_ENTITY_ID_OR_ENTITY_TYPE_DESCRIPTION + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Parameters({ + @Parameter(name = "key", description = "Repeatable key query parameter (alternative to comma-separated 'keys')", in = ParameterIn.QUERY, required = false, array = @ArraySchema(schema = @Schema(type = "string"))) + }) @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "Entity attributes was removed for the selected keys in the request. " + + @ApiResponse(responseCode = "200", description = "Entity attributes were removed for the selected keys in the request (keys that did not exist are silently ignored). " + "Platform creates an audit log event about entity attributes removal with action type 'ATTRIBUTES_DELETED'."), @ApiResponse(responseCode = "400", description = "Platform returns a bad request in case if keys or scope are not specified."), @ApiResponse(responseCode = "401", description = "User is not authorized to delete entity attributes for selected entity. Most likely, User belongs to different Customer or Tenant."), @@ -568,6 +633,7 @@ public class TelemetryController extends BaseController { @Parameter(description = ENTITY_ID_PARAM_DESCRIPTION, required = true) @PathVariable("entityId") String entityIdStr, @Parameter(description = ATTRIBUTES_SCOPE_DESCRIPTION, required = true, schema = @Schema(allowableValues = {"SERVER_SCOPE", "SHARED_SCOPE", "CLIENT_SCOPE"})) @PathVariable("scope") AttributeScope scope, @Parameter(description = ATTRIBUTES_KEYS_DESCRIPTION) @RequestParam(name = "keys", required = false) String keysStr, + @Parameter(hidden = true) @RequestParam MultiValueMap params) throws ThingsboardException { List keys = getKeys(keysStr, params); EntityId entityId = EntityIdFactory.getByTypeAndId(entityType, entityIdStr); diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantController.java b/application/src/main/java/org/thingsboard/server/controller/TenantController.java index efa1d36cff..dcd53f7762 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -22,7 +23,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -133,7 +133,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants (getTenants)", notes = "Returns a page of tenants registered in the platform. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenants", params = {"pageSize", "page"}) + @GetMapping(value = "/tenants") public PageData getTenants( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -152,7 +152,7 @@ public class TenantController extends BaseController { @ApiOperation(value = "Get Tenants Info (getTenants)", notes = "Returns a page of tenant info objects registered in the platform. " + TENANT_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenantInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenantInfos") public PageData getTenantInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -169,9 +169,10 @@ public class TenantController extends BaseController { return checkNotNull(tenantService.findTenantInfos(pageLink)); } + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/tenants", params = {"tenantIds"}) - public List getTenantsByIds( + public List getTenantsByIdsV1( @Parameter(description = "A list of tenant ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) @RequestParam("tenantIds") Set tenantUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); @@ -183,6 +184,15 @@ public class TenantController extends BaseController { return filterTenantsByReadPermission(tenants); } + @ApiOperation(value = "Get Tenants list (getTenantsByIds)") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") + @GetMapping(value = "/tenants/list") + public List getTenantsByIds( + @Parameter(description = "A list of tenant ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("tenantIds") Set tenantUUIDs) throws ThingsboardException { + return getTenantsByIdsV1(tenantUUIDs); + } + private List filterTenantsByReadPermission(List tenants) { return tenants.stream().filter(tenant -> { try { diff --git a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java index c0f7b9eb40..a47768e91c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TenantProfileController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -191,7 +192,7 @@ public class TenantProfileController extends BaseController { oldProfile = checkTenantProfileId(tenantProfile.getId(), Operation.WRITE); } - return tbTenantProfileService.save(getTenantId(), tenantProfile, oldProfile); + return tbTenantProfileService.save(getTenantId(), tenantProfile, oldProfile, getCurrentUser()); } @ApiOperation(value = "Delete Tenant Profile (deleteTenantProfile)", @@ -204,7 +205,7 @@ public class TenantProfileController extends BaseController { checkParameter("tenantProfileId", strTenantProfileId); TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); TenantProfile profile = checkTenantProfileId(tenantProfileId, Operation.DELETE); - tbTenantProfileService.delete(getTenantId(), profile); + tbTenantProfileService.delete(getTenantId(), profile, getCurrentUser()); } @ApiOperation(value = "Make tenant profile default (setDefaultTenantProfile)", @@ -217,7 +218,7 @@ public class TenantProfileController extends BaseController { checkParameter("tenantProfileId", strTenantProfileId); TenantProfileId tenantProfileId = new TenantProfileId(toUUID(strTenantProfileId)); TenantProfile tenantProfile = checkTenantProfileId(tenantProfileId, Operation.WRITE); - tenantProfileService.setDefaultTenantProfile(getTenantId(), tenantProfileId); + tenantProfile = tbTenantProfileService.setDefaultTenantProfile(getTenantId(), tenantProfile, getCurrentUser()); return tenantProfile; } @@ -242,7 +243,7 @@ public class TenantProfileController extends BaseController { @ApiOperation(value = "Get Tenant Profiles Info (getTenantProfileInfos)", notes = "Returns a page of tenant profile info objects registered in the platform. " + TENANT_PROFILE_INFO_DESCRIPTION + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenantProfileInfos", params = {"pageSize", "page"}) + @GetMapping(value = "/tenantProfileInfos") public PageData getTenantProfileInfos( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -258,11 +259,19 @@ public class TenantProfileController extends BaseController { return checkNotNull(tenantProfileService.findTenantProfileInfos(getTenantId(), pageLink)); } + @Hidden @GetMapping(value = "/tenantProfiles", params = {"ids"}) @PreAuthorize("hasAuthority('SYS_ADMIN')") - public List getTenantProfilesByIds(@Parameter(description = "Comma-separated list of tenant profile ids", array = @ArraySchema(schema = @Schema(type = "string"))) - @RequestParam("ids") UUID[] ids) { + public List getTenantProfilesByIds(@RequestParam("ids") UUID[] ids) { return tenantProfileService.findTenantProfilesByIds(TenantId.SYS_TENANT_ID, ids); } + @ApiOperation(value = "Get Tenant Profile list (getTenantProfileList)") + @GetMapping(value = "/tenantProfiles/list") + @PreAuthorize("hasAuthority('SYS_ADMIN')") + public List getTenantProfileList(@Parameter(description = "Comma-separated list of tenant profile ids", array = @ArraySchema(schema = @Schema(type = "string"))) + @RequestParam("ids") UUID[] ids) { + return getTenantProfilesByIds(ids); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java index fddeabfeaa..3b15085651 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthConfigController.java @@ -16,7 +16,11 @@ package org.thingsboard.server.controller; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; import jakarta.validation.Valid; import lombok.Data; import lombok.RequiredArgsConstructor; @@ -187,7 +191,7 @@ public class TwoFactorAuthConfigController extends BaseController { return twoFaConfigManager.deleteTwoFaAccountConfig(user.getTenantId(), user, providerType); } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviderTypes)", notes = "Get the list of provider types available for user to use (the ones configured by tenant or sysadmin).\n" + "Example of response:\n" + "```\n[\n \"TOTP\",\n \"EMAIL\",\n \"SMS\"\n]\n```" + @@ -195,7 +199,7 @@ public class TwoFactorAuthConfigController extends BaseController { ) @GetMapping("/providers") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER', 'MFA_CONFIGURATION_TOKEN')") - public List getAvailableTwoFaProviders() throws ThingsboardException { + public List getAvailableTwoFaProviderTypes() throws ThingsboardException { return twoFaConfigManager.getPlatformTwoFaSettings(getTenantId(), true) .map(PlatformTwoFaSettings::getProviders).orElse(Collections.emptyList()).stream() .map(TwoFaProviderConfig::getProviderType) @@ -261,6 +265,7 @@ public class TwoFactorAuthConfigController extends BaseController { } @Data + @Schema public static class TwoFaAccountConfigUpdateRequest { private boolean useByDefault; } diff --git a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java index e26932ff22..8007a77cb1 100644 --- a/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java +++ b/application/src/main/java/org/thingsboard/server/controller/TwoFactorAuthController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import com.fasterxml.jackson.annotation.JsonProperty; import jakarta.servlet.http.HttpServletRequest; import lombok.AllArgsConstructor; import lombok.Builder; @@ -99,7 +100,7 @@ public class TwoFactorAuthController extends BaseController { } } - @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviders)", notes = + @ApiOperation(value = "Get available 2FA providers (getAvailableTwoFaProviderInfos)", notes = "Get the list of 2FA provider infos available for user to use. Example:\n" + "```\n[\n" + " {\n \"type\": \"EMAIL\",\n \"default\": true,\n \"contact\": \"ab*****ko@gmail.com\"\n },\n" + @@ -108,7 +109,7 @@ public class TwoFactorAuthController extends BaseController { "]\n```") @GetMapping("/providers") @PreAuthorize("hasAuthority('PRE_VERIFICATION_TOKEN')") - public List getAvailableTwoFaProviders() throws ThingsboardException { + public List getAvailableTwoFaProviderInfos() throws ThingsboardException { SecurityUser user = getCurrentUser(); Optional platformTwoFaSettings = twoFaConfigManager.getPlatformTwoFaSettings(user.getTenantId(), true); return twoFaConfigManager.getAccountTwoFaSettings(user.getTenantId(), user) @@ -166,6 +167,7 @@ public class TwoFactorAuthController extends BaseController { @Builder public static class TwoFaProviderInfo { private TwoFaProviderType type; + @JsonProperty("default") private boolean isDefault; private String contact; private Integer minVerificationCodeSendPeriod; diff --git a/application/src/main/java/org/thingsboard/server/controller/UserController.java b/application/src/main/java/org/thingsboard/server/controller/UserController.java index 878a3287bc..b919b664e5 100644 --- a/application/src/main/java/org/thingsboard/server/controller/UserController.java +++ b/application/src/main/java/org/thingsboard/server/controller/UserController.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -273,7 +274,7 @@ public class UserController extends BaseController { notes = "Returns a page of users owned by tenant or customer. The scope depends on authority of the user that performs the request." + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/users", params = {"pageSize", "page"}) + @GetMapping(value = "/users") public PageData getUsers( @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) @RequestParam int pageSize, @@ -334,7 +335,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Tenant Users (getTenantAdmins)", notes = "Returns a page of users owned by tenant. " + PAGE_DATA_PARAMETERS + SYSTEM_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('SYS_ADMIN')") - @GetMapping(value = "/tenant/{tenantId}/users", params = {"pageSize", "page"}) + @GetMapping(value = "/tenant/{tenantId}/users") public PageData getTenantAdmins( @Parameter(description = TENANT_ID_PARAM_DESCRIPTION, required = true) @PathVariable(TENANT_ID) String strTenantId, @@ -357,7 +358,7 @@ public class UserController extends BaseController { @ApiOperation(value = "Get Customer Users (getCustomerUsers)", notes = "Returns a page of users owned by customer. " + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAuthority('TENANT_ADMIN')") - @GetMapping(value = "/customer/{customerId}/users", params = {"pageSize", "page"}) + @GetMapping(value = "/customer/{customerId}/users") public PageData getCustomerUsers( @Parameter(description = CUSTOMER_ID_PARAM_DESCRIPTION, required = true) @PathVariable(CUSTOMER_ID) String strCustomerId, @@ -404,7 +405,7 @@ public class UserController extends BaseController { "Search is been executed by email, firstName and lastName fields. " + PAGE_DATA_PARAMETERS + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") - @GetMapping(value = "/users/assign/{alarmId}", params = {"pageSize", "page"}) + @GetMapping(value = "/users/assign/{alarmId}") public PageData getUsersForAssign( @Parameter(description = ALARM_ID_PARAM_DESCRIPTION, required = true) @PathVariable("alarmId") String strAlarmId, @@ -456,10 +457,7 @@ public class UserController extends BaseController { return userSettingsService.saveUserSettings(currentUser.getTenantId(), userSettings).getSettings(); } - @ApiOperation(value = "Update user settings (saveUserSettings)", - notes = "Update user settings for authorized user. Only specified json elements will be updated." + - "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + - "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @PutMapping(value = "/user/settings") public void putUserSettings(@RequestBody JsonNode settings) throws ThingsboardException { @@ -467,8 +465,17 @@ public class UserController extends BaseController { userSettingsService.updateUserSettings(currentUser.getTenantId(), currentUser.getId(), UserSettingsType.GENERAL, settings); } - @ApiOperation(value = "Get user settings (getUserSettings)", - notes = "Fetch the User settings based on authorized user. ") + @ApiOperation(value = "Update user settings (putGeneralUserSettings)", + notes = "Update user settings for authorized user. Only specified json elements will be updated." + + "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + + "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @PutMapping(value = "/user/settings/general") + public void putGeneralUserSettings(@RequestBody JsonNode settings) throws ThingsboardException { + putUserSettings(settings); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/user/settings") public JsonNode getUserSettings() throws ThingsboardException { @@ -478,20 +485,28 @@ public class UserController extends BaseController { return userSettings == null ? JacksonUtil.newObjectNode() : userSettings.getSettings(); } - @ApiOperation(value = "Delete user settings (deleteUserSettings)", + @ApiOperation(value = "Get user settings (getGeneralUserSettings)", + notes = "Fetch the User settings based on authorized user. ") + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/user/settings/general") + public JsonNode getGeneralUserSettings() throws ThingsboardException { + return getUserSettings(); + } + + @ApiOperation(value = "Delete user settings (deleteGeneralUserSettings)", notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @DeleteMapping(value = "/user/settings/{paths}") - public void deleteUserSettings(@Parameter(description = PATHS) - @PathVariable(PATHS) String paths) throws ThingsboardException { + public void deleteGeneralUserSettings(@Parameter(description = PATHS) + @PathVariable(PATHS) String paths) throws ThingsboardException { checkParameter(USER_ID, paths); SecurityUser currentUser = getCurrentUser(); userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), UserSettingsType.GENERAL, Arrays.asList(paths.split(","))); } - @ApiOperation(value = "Update user settings (saveUserSettings)", + @ApiOperation(value = "Update user settings (putUserSettings)", notes = "Update user settings for authorized user. Only specified json elements will be updated." + "Example: you have such settings: {A:5, B:{C:10, D:20}}. Updating it with {B:{C:10, D:30}} will result in" + "{A:5, B:{C:10, D:30}}. The same could be achieved by putting {B.D:30}") @@ -518,15 +533,15 @@ public class UserController extends BaseController { return userSettings == null ? JacksonUtil.newObjectNode() : userSettings.getSettings(); } - @ApiOperation(value = "Delete user settings (deleteUserSettings)", + @ApiOperation(value = "Delete user settings by type (deleteUserSettingsByType)", notes = "Delete user settings by specifying list of json element xpaths. \n " + "Example: to delete B and C element in { \"A\": {\"B\": 5}, \"C\": 15} send A.B,C in jsonPaths request parameter") @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @DeleteMapping(value = "/user/settings/{type}/{paths}") - public void deleteUserSettings(@Parameter(description = PATHS) - @PathVariable(PATHS) String paths, - @Parameter(description = "Settings type, case insensitive, one of: \"general\", \"quick_links\", \"doc_links\" or \"dashboards\".") - @PathVariable("type") String strType) throws ThingsboardException { + public void deleteUserSettingsByType(@Parameter(description = PATHS) + @PathVariable(PATHS) String paths, + @Parameter(description = "Settings type, case insensitive, one of: \"general\", \"quick_links\", \"doc_links\" or \"dashboards\".") + @PathVariable("type") String strType) throws ThingsboardException { checkParameter(USER_ID, paths); UserSettingsType type = checkEnumParameter("Settings type", strType, UserSettingsType::valueOf); checkNotReserved(strType, type); @@ -534,8 +549,7 @@ public class UserController extends BaseController { userSettingsService.deleteUserSettings(currentUser.getTenantId(), currentUser.getId(), type, Arrays.asList(paths.split(","))); } - @ApiOperation(value = "Get information about last visited and starred dashboards (getLastVisitedDashboards)", - notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/user/dashboards") public UserDashboardsInfo getUserDashboardsInfo() throws ThingsboardException { @@ -543,6 +557,14 @@ public class UserController extends BaseController { return userSettingsService.findUserDashboardsInfo(currentUser.getTenantId(), currentUser.getId()); } + @ApiOperation(value = "Get information about last visited and starred dashboards (getLastVisitedDashboards)", + notes = "Fetch the list of last visited and starred dashboards. Both lists are limited to 10 items." + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/user/lastVisitedDashboards") + public UserDashboardsInfo getLastVisitedDashboards() throws ThingsboardException { + return getUserDashboardsInfo(); + } + @ApiOperation(value = "Report action of User over the dashboard (reportUserDashboardAction)", notes = "Report action of User over the dashboard. " + TENANT_OR_CUSTOMER_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @@ -583,12 +605,10 @@ public class UserController extends BaseController { userService.removeMobileSession(user.getTenantId(), mobileToken); } - @ApiOperation(value = "Get Users By Ids (getUsersByIds)", - notes = "Requested users must be owned by tenant or assigned to customer which user is performing the request. ") + @Hidden @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/users", params = {"userIds"}) - public List getUsersByIds( - @Parameter(description = "A list of user ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + public List getUsersByIdsV1( @RequestParam("userIds") Set userUUIDs) throws ThingsboardException { TenantId tenantId = getCurrentUser().getTenantId(); List userIds = new ArrayList<>(); @@ -599,6 +619,16 @@ public class UserController extends BaseController { return filterUsersByReadPermission(users); } + @ApiOperation(value = "Get Users By Ids (getUsersByIds)", + notes = "Requested users must be owned by tenant or assigned to customer which user is performing the request. ") + @PreAuthorize("hasAnyAuthority('TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/users/list") + public List getUsersByIds( + @Parameter(description = "A list of user ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("userIds") Set userUUIDs) throws ThingsboardException { + return getUsersByIdsV1(userUUIDs); + } + private List filterUsersByReadPermission(List users) { return users.stream().filter(user -> { try { diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java index a519d92e3a..a89d870bf0 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetTypeController.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -209,8 +210,7 @@ public class WidgetTypeController extends AutoCommitController { } } - @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypesByBundleAlias) (Deprecated)", - notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/widgetTypes", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -229,19 +229,27 @@ public class WidgetTypeController extends AutoCommitController { return checkNotNull(widgetTypeService.findWidgetTypesByWidgetsBundleId(getTenantId(), widgetsBundle.getId())); } - @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)", - notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetTypes", params = {"widgetsBundleId"}) - public List getBundleWidgetTypes( + public List getBundleWidgetTypesV1( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { WidgetsBundleId widgetsBundleId = new WidgetsBundleId(toUUID(strWidgetsBundleId)); return checkNotNull(widgetTypeService.findWidgetTypesByWidgetsBundleId(getTenantId(), widgetsBundleId)); } - @ApiOperation(value = "Get all Widget types details for specified Bundle (getBundleWidgetTypesDetailsByBundleAlias) (Deprecated)", - notes = "Returns an array of Widget Type Details objects that belong to specified Widget Bundle." + WIDGET_TYPE_DETAILS_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @ApiOperation(value = "Get all Widget types for specified Bundle (getBundleWidgetTypes)", + notes = "Returns an array of Widget Type objects that belong to specified Widget Bundle." + WIDGET_TYPE_DESCRIPTION + " " + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/widgetsBundle/{widgetsBundleId}/widgetTypes") + public List getBundleWidgetTypes( + @Parameter(description = "Widget Bundle Id", required = true) + @PathVariable("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { + return getBundleWidgetTypesV1(strWidgetsBundleId); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") @GetMapping(value = "/widgetTypesDetails", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -284,7 +292,7 @@ public class WidgetTypeController extends AutoCommitController { @ApiOperation(value = "Get all Widget type fqns for specified Bundle (getBundleWidgetTypeFqns)", notes = "Returns an array of Widget Type fqns that belong to specified Widget Bundle." + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @GetMapping(value = "/widgetTypeFqns", params = {"widgetsBundleId"}) + @GetMapping(value = "/widgetTypeFqns") public List getBundleWidgetTypeFqns( @Parameter(description = "Widget Bundle Id", required = true) @RequestParam("widgetsBundleId") String strWidgetsBundleId) throws ThingsboardException { @@ -292,8 +300,7 @@ public class WidgetTypeController extends AutoCommitController { return checkNotNull(widgetTypeService.findWidgetFqnsByWidgetsBundleId(getTenantId(), widgetsBundleId)); } - @ApiOperation(value = "Get Widget Type Info objects (getBundleWidgetTypesInfosByBundleAlias) (Deprecated)", - notes = "Get the Widget Type Info objects based on the provided parameters. " + WIDGET_TYPE_INFO_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetTypesInfos", params = {"isSystem", "bundleAlias"}) @Deprecated @@ -344,8 +351,7 @@ public class WidgetTypeController extends AutoCommitController { widgetTypeDeprecatedFilter, widgetTypes, pageLink)); } - @ApiOperation(value = "Get Widget Type (getWidgetTypeByBundleAliasAndTypeAlias) (Deprecated)", - notes = "Get the Widget Type based on the provided parameters. " + WIDGET_TYPE_DESCRIPTION + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetType", params = {"isSystem", "bundleAlias", "alias"}) @Deprecated diff --git a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java index c2d612f014..af087c5fd2 100644 --- a/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java +++ b/application/src/main/java/org/thingsboard/server/controller/WidgetsBundleController.java @@ -15,13 +15,13 @@ */ package org.thingsboard.server.controller; +import io.swagger.v3.oas.annotations.Hidden; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -222,11 +222,10 @@ public class WidgetsBundleController extends BaseController { } } - @ApiOperation(value = "Get all Widget Bundles (getWidgetsBundles)", - notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetsBundles") - public List getWidgetsBundles() throws ThingsboardException { + public List getWidgetsBundlesV1() throws ThingsboardException { if (Authority.SYS_ADMIN.equals(getCurrentUser().getAuthority())) { return checkNotNull(widgetsBundleService.findSystemWidgetsBundles(getTenantId())); } else { @@ -235,13 +234,18 @@ public class WidgetsBundleController extends BaseController { } } - @ApiOperation(value = "Get Widgets Bundles By Ids (getWidgetsBundlesByIds)", - notes = "Requested widgets bundles must be system level or owned by tenant of the user which is performing the request. " + - NEW_LINE) + @ApiOperation(value = "Get all Widget Bundles (getAllWidgetsBundles)", + notes = "Returns an array of Widget Bundle objects that are available for current user." + WIDGET_BUNDLE_DESCRIPTION + " " + AVAILABLE_FOR_ANY_AUTHORIZED_USER) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/widgetsBundles/all") + public List getAllWidgetsBundles() throws ThingsboardException { + return getWidgetsBundlesV1(); + } + + @Hidden @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") @GetMapping(value = "/widgetsBundles", params = {"widgetsBundleIds"}) public List getWidgetsBundlesByIds( - @Parameter(description = "A list of widgets bundle ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) @RequestParam("widgetsBundleIds") Set widgetsBundleUUIDs) throws ThingsboardException { List widgetsBundleIds = new ArrayList<>(); for (UUID widgetsBundleUUID : widgetsBundleUUIDs) { @@ -250,4 +254,15 @@ public class WidgetsBundleController extends BaseController { return widgetsBundleService.findSystemOrTenantWidgetsBundlesByIds(getTenantId(), widgetsBundleIds); } + @ApiOperation(value = "Get Widgets Bundles By Ids (getWidgetsBundlesList)", + notes = "Requested widgets bundles must be system level or owned by tenant of the user which is performing the request. " + + NEW_LINE) + @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN', 'CUSTOMER_USER')") + @GetMapping(value = "/widgetsBundles/list", params = {"widgetsBundleIds"}) + public List getWidgetsBundlesList( + @Parameter(description = "A list of widgets bundle ids, separated by comma ','", array = @ArraySchema(schema = @Schema(type = "string")), required = true) + @RequestParam("widgetsBundleIds") Set widgetsBundleUUIDs) throws ThingsboardException { + return getWidgetsBundlesByIds(widgetsBundleUUIDs); + } + } diff --git a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java index 73315be73f..cf56d051ca 100644 --- a/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java +++ b/application/src/main/java/org/thingsboard/server/controller/plugin/TbWebSocketHandler.java @@ -52,6 +52,7 @@ import org.thingsboard.server.config.WebSocketConfiguration; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.model.SecurityUser; import org.thingsboard.server.service.security.model.UserPrincipal; @@ -100,6 +101,8 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private RateLimitService rateLimitService; @Autowired private JwtAuthenticationProvider authenticationProvider; + @Autowired + private ApiKeyAuthenticationProvider apiKeyAuthenticationProvider; @Value("${server.ws.send_timeout:5000}") private long sendTimeout; @@ -115,7 +118,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke private final ConcurrentMap> tenantSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSessionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSessionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSessionsMap = new ConcurrentHashMap<>(); private Cache pendingSessions; @@ -194,7 +197,11 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke log.trace("{} Authenticating session", sessionRef); SecurityUser securityCtx; try { - securityCtx = authenticationProvider.authenticate(authCmd.getToken()); + if (StringUtils.isNotEmpty(authCmd.getApiKey())) { + securityCtx = apiKeyAuthenticationProvider.authenticate(authCmd.getApiKey()); + } else { + securityCtx = authenticationProvider.authenticate(authCmd.getToken()); + } } catch (Exception e) { close(sessionRef, CloseStatus.BAD_DATA.withReason(e.getMessage())); return; @@ -328,9 +335,12 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } SecurityUser securityCtx = null; - String token = StringUtils.substringAfter(session.getUri().getQuery(), "token="); - if (StringUtils.isNotEmpty(token)) { - securityCtx = authenticationProvider.authenticate(token); + String query = session.getUri().getQuery(); + if (query != null) { + String token = extractQueryParam(query, "token"); + if (StringUtils.isNotEmpty(token)) { + securityCtx = authenticationProvider.authenticate(token); + } } return WebSocketSessionRef.builder() .sessionId(UUID.randomUUID().toString()) @@ -341,6 +351,15 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke .build(); } + private String extractQueryParam(String query, String paramName) { + for (String param : query.split("&")) { + if (param.startsWith(paramName + "=")) { + return param.substring(paramName.length() + 1); + } + } + return null; + } + private SessionMetaData getSessionMd(String internalSessionId) { SessionMetaData sessionMd = internalSessionMap.get(internalSessionId); if (sessionMd == null) { @@ -482,6 +501,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } } + } @Override @@ -611,7 +631,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { limitAllowed = publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSessionsPerPublicUser(); if (limitAllowed) { @@ -655,7 +675,7 @@ public class TbWebSocketHandler extends TextWebSocketHandler implements WebSocke } } if (tenantProfileConfiguration.getMaxWsSessionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSessionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.remove(sessionId); } diff --git a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java index ce7ea1efdd..58b1e8d422 100644 --- a/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java +++ b/application/src/main/java/org/thingsboard/server/exception/ThingsboardErrorResponseHandler.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.exception; +import jakarta.persistence.PersistenceException; import jakarta.servlet.RequestDispatcher; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -52,6 +53,7 @@ import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.msg.tools.MaxPayloadSizeExceededException; import org.thingsboard.server.common.msg.tools.TbRateLimitsException; +import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.service.security.exception.AuthMethodNotSupportedException; import org.thingsboard.server.service.security.exception.JwtExpiredTokenException; import org.thingsboard.server.service.security.exception.UserPasswordExpiredException; @@ -162,8 +164,8 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand handleAuthenticationException(authenticationException, response); } else if (exception instanceof MaxPayloadSizeExceededException maxPayloadSizeExceededException) { handleMaxPayloadSizeExceededException(response, maxPayloadSizeExceededException); - } else if (exception instanceof DataAccessException e) { - handleDatabaseException(e, response); + } else if (exception instanceof DataAccessException || exception instanceof PersistenceException) { + handleDatabaseException(exception, response); } else { response.setStatus(HttpStatus.INTERNAL_SERVER_ERROR.value()); JacksonUtil.writeValue(response.getWriter(), ThingsboardErrorResponse.of(exception.getMessage(), @@ -217,8 +219,17 @@ public class ThingsboardErrorResponseHandler extends ResponseEntityExceptionHand private void handleDatabaseException(Throwable databaseException, HttpServletResponse response) throws IOException { ThingsboardErrorResponse errorResponse; - if (databaseException instanceof ConstraintViolationException) { - errorResponse = ThingsboardErrorResponse.of(ExceptionUtils.getRootCause(databaseException).getMessage(), ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST); + ConstraintViolationException constraintViolationException = DaoUtil.extractConstraintViolation(databaseException); + if (constraintViolationException != null) { + log.debug("Constraint violation: {}", ExceptionUtils.getRootCauseMessage(databaseException)); + String constraintName = constraintViolationException.getConstraintName(); + String userMessage; + if (constraintName != null && !constraintName.isEmpty()) { + userMessage = "Constraint violation: " + constraintName; + } else { + userMessage = "Constraint violation"; + } + errorResponse = ThingsboardErrorResponse.of(userMessage, ThingsboardErrorCode.BAD_REQUEST_PARAMS, HttpStatus.BAD_REQUEST); } else { log.warn("Database error: {} - {}", databaseException.getClass().getSimpleName(), ExceptionUtils.getRootCauseMessage(databaseException)); errorResponse = ThingsboardErrorResponse.of("Database error", ThingsboardErrorCode.DATABASE, HttpStatus.INTERNAL_SERVER_ERROR); diff --git a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java index 8ea989fc01..66695c396f 100644 --- a/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java +++ b/application/src/main/java/org/thingsboard/server/install/ThingsboardInstallService.java @@ -116,7 +116,6 @@ public class ThingsboardInstallService { entityDatabaseSchemaService.createDatabaseIndexes(); // TODO: cleanup update code after each release - systemDataLoaderService.updateDefaultNotificationConfigs(false); // Runs upgrade scripts that are not possible in plain SQL. dataUpdateService.updateData(); diff --git a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java index 774396a704..3b72ac662f 100644 --- a/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/action/EntityActionService.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.action; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -235,7 +236,7 @@ public class EntityActionService { } } - public void logEntityAction(User user, I entityId, E entity, CustomerId customerId, + public void logEntityAction(User user, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, Exception e, Object... additionalInfo) { if (customerId == null || customerId.isNullUid()) { customerId = user.getCustomerId(); diff --git a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java index 212d363280..15be6f3734 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/AiChatModelServiceImpl.java @@ -17,6 +17,7 @@ package org.thingsboard.server.service.ai; import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.Futures; import dev.langchain4j.data.message.ChatMessage; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.TextContent; @@ -42,7 +43,12 @@ class AiChatModelServiceImpl implements AiChatModelService { @Override public > FluentFuture sendChatRequestAsync(AiChatModelConfig chatModelConfig, ChatRequest chatRequest) { - ChatModel langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + ChatModel langChainChatModel; + try { + langChainChatModel = chatModelConfig.configure(chatModelConfigurer); + } catch (Throwable t) { + return FluentFuture.from(Futures.immediateFailedFuture(t)); + } if (langChainChatModel.provider() == ModelProvider.GITHUB_MODELS) { chatRequest = prepareGithubChatRequest(chatRequest); } diff --git a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java index 28d696468c..8b569f052c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java +++ b/application/src/main/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImpl.java @@ -37,6 +37,7 @@ import dev.langchain4j.model.openai.OpenAiChatModel; import dev.langchain4j.model.vertexai.gemini.VertexAiGeminiChatModel; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; @@ -58,6 +59,7 @@ import software.amazon.awssdk.services.bedrockruntime.BedrockRuntimeClient; import java.io.ByteArrayInputStream; import java.io.IOException; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Base64; @@ -69,6 +71,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OpenAiChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); return OpenAiChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .apiKey(chatModelConfig.providerConfig().apiKey()) @@ -86,6 +89,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(AzureOpenAiChatModelConfig chatModelConfig) { AzureOpenAiProviderConfig providerConfig = chatModelConfig.providerConfig(); + validateBaseUrl(providerConfig.endpoint()); return AzureOpenAiChatModel.builder() .endpoint(providerConfig.endpoint()) .serviceVersion(providerConfig.serviceVersion()) @@ -177,8 +181,8 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur if (chatModelConfig.frequencyPenalty() != null) { generationConfigBuilder.setFrequencyPenalty(chatModelConfig.frequencyPenalty().floatValue()); } - if (chatModelConfig.frequencyPenalty() != null) { - generationConfigBuilder.setPresencePenalty(chatModelConfig.frequencyPenalty().floatValue()); + if (chatModelConfig.presencePenalty() != null) { + generationConfigBuilder.setPresencePenalty(chatModelConfig.presencePenalty().floatValue()); } if (chatModelConfig.maxOutputTokens() != null) { generationConfigBuilder.setMaxOutputTokens(chatModelConfig.maxOutputTokens()); @@ -273,6 +277,7 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur @Override public ChatModel configureChatModel(OllamaChatModelConfig chatModelConfig) { + validateBaseUrl(chatModelConfig.providerConfig().baseUrl()); var builder = OllamaChatModel.builder() .baseUrl(chatModelConfig.providerConfig().baseUrl()) .modelName(chatModelConfig.modelId()) @@ -300,6 +305,10 @@ class Langchain4jChatModelConfigurerImpl implements Langchain4jChatModelConfigur return builder.build(); } + private static void validateBaseUrl(String url) { + SsrfProtectionValidator.validateUri(URI.create(url)); + } + private static Duration toDuration(Integer timeoutSeconds) { return timeoutSeconds != null ? Duration.ofSeconds(timeoutSeconds) : null; } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java index 75da5c5d7a..6e9da33d0b 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java @@ -41,7 +41,7 @@ public interface CalculatedFieldCache { List getCalculatedFieldCtxsByEntityId(EntityId entityId); - Stream getCalculatedFieldCtxsByType(CalculatedFieldType cfType); + Stream getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType); boolean hasCalculatedFields(TenantId tenantId, EntityId entityId, Predicate filter); @@ -61,7 +61,7 @@ public interface CalculatedFieldCache { void addOwnerEntity(TenantId tenantId, EntityId entityId); - void evictEntity(EntityId entityId); + void evictOwnerEntity(EntityId entityId); void evictOwner(EntityId owner); diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java index ea55c2b60d..3a989aaa60 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCache.java @@ -20,6 +20,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.springframework.util.ConcurrentReferenceHashMap; import org.thingsboard.server.actors.ActorSystemContext; @@ -37,6 +38,8 @@ import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageDataIterable; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.queue.util.AfterStartUp; @@ -45,7 +48,9 @@ import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import java.util.Collections; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; @@ -53,6 +58,7 @@ import java.util.concurrent.CopyOnWriteArrayList; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.Predicate; +import java.util.stream.Collectors; import java.util.stream.Stream; @Service @@ -152,9 +158,9 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { } @Override - public Stream getCalculatedFieldCtxsByType(CalculatedFieldType cfType) { + public Stream getCalculatedFieldCtxsByType(TenantId tenantId, CalculatedFieldType cfType) { return calculatedFields.values().stream() - .filter(cf -> cfType.equals(cf.getType())) + .filter(cf -> cf.getTenantId().equals(tenantId) && cfType.equals(cf.getType())) .map(cf -> getCalculatedFieldCtx(cf.getId())); } @@ -268,18 +274,25 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { @Override public void updateOwnerEntity(TenantId tenantId, EntityId entityId) { - evictEntity(entityId); + evictOwnerEntity(entityId); addOwnerEntity(tenantId, entityId); } @Override - public void evictEntity(EntityId entityId) { + public void evictOwnerEntity(EntityId entityId) { ownerEntities.values().forEach(entities -> entities.remove(entityId)); } @Override public void evictOwner(EntityId owner) { - ownerEntities.remove(owner); + Set removedEntities = ownerEntities.remove(owner); + if (removedEntities != null) { + Set removedCustomers = removedEntities + .stream() + .filter(entityId -> entityId.getEntityType() == EntityType.CUSTOMER) + .collect(Collectors.toSet()); + removedCustomers.forEach(this::evictOwner); + } } private Set getOwnedEntities(TenantId tenantId, EntityId ownerId) { @@ -290,6 +303,113 @@ public class DefaultCalculatedFieldCache implements CalculatedFieldCache { }); } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + TenantProfileId tenantProfileId = new TenantProfileId(event.getEntityId().getId()); + handleTenantProfileUpdate(tenantProfileId); + } + break; + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + evictTenantCfs(tenantId); + evictOwner(tenantId); + } + break; + case CUSTOMER: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictOwner(event.getEntityId()); + evictOwnerEntity(event.getEntityId()); + } + break; + case DEVICE, ASSET: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED && event.isOwnerChanged()) { + updateOwnerEntity(event.getTenantId(), event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictOwnerEntity(event.getEntityId()); + evictEntityCfs(event.getEntityId()); + } + break; + case DEVICE_PROFILE, ASSET_PROFILE: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + evictEntityCfs(event.getEntityId()); + } + break; + case CALCULATED_FIELD: + if (event.getEvent() == ComponentLifecycleEvent.CREATED) { + addCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else if (event.getEvent() == ComponentLifecycleEvent.UPDATED) { + updateCalculatedField(event.getTenantId(), (CalculatedFieldId) event.getEntityId()); + } else { + evict((CalculatedFieldId) event.getEntityId()); + } + break; + } + } + + private void evictTenantCfs(TenantId tenantId) { + var removedCfEntityIds = new HashSet(); + var removedLinkEntityIds = new HashSet(); + var toRemove = calculatedFields.entrySet().stream() + .filter(e -> e.getValue().getTenantId().equals(tenantId)) + .map(Map.Entry::getKey) + .collect(Collectors.toSet()); + toRemove.forEach(cfId -> { + CalculatedField cf = calculatedFields.remove(cfId); + List links = calculatedFieldLinks.remove(cfId); + if (links != null) { + links.forEach(link -> removedLinkEntityIds.add(link.entityId())); + } + calculatedFieldsCtx.remove(cfId); + if (cf != null) { + removedCfEntityIds.add(cf.getEntityId()); + } + }); + removedCfEntityIds.forEach(entityId -> { + entityIdCalculatedFields.compute(entityId, (k, cfs) -> { + if (cfs != null) { + cfs.removeIf(cf -> toRemove.contains(cf.getId())); + return cfs.isEmpty() ? null : cfs; + } + return null; + }); + }); + removedLinkEntityIds.forEach(entityId -> { + entityIdCalculatedFieldLinks.compute(entityId, ((entityId1, links) -> { + if (links != null) { + links.removeIf(link -> toRemove.contains(link.calculatedFieldId())); + return links.isEmpty() ? null : links; + } + return null; + })); + }); + } + + private void evictEntityCfs(EntityId entityId) { + List cfs = entityIdCalculatedFields.remove(entityId); + if (cfs != null) { + var cfIds = new HashSet(); + cfs.forEach(cf -> { + calculatedFields.remove(cf.getId()); + calculatedFieldLinks.remove(cf.getId()); + calculatedFieldsCtx.remove(cf.getId()); + cfIds.add(cf.getId()); + log.debug("[{}] evict calculated field from cache on entity deletion: {}", cf.getId(), cf); + }); + entityIdCalculatedFieldLinks.values().forEach(list -> list.removeIf(link -> cfIds.contains(link.calculatedFieldId()))); + } + entityIdCalculatedFieldLinks.remove(entityId); + } + private Lock getFetchLock(CalculatedFieldId id) { return calculatedFieldFetchLocks.computeIfAbsent(id, __ -> new ReentrantLock()); } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java index da79e41e5f..4c6128f4ba 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldQueueService.java @@ -189,12 +189,12 @@ public class DefaultCalculatedFieldQueueService implements CalculatedFieldQueueS } } - boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter); + boolean hasMatchesEntityAggCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(tenantId, CalculatedFieldType.ENTITY_AGGREGATION).anyMatch(filter); if (hasMatchesEntityAggCfs) { return true; } - List relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) + List relatedEntitiesAggregationCfs = calculatedFieldCache.getCalculatedFieldCtxsByType(tenantId, CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) .filter(relatedEntityFilter) .toList(); for (CalculatedFieldCtx cfCtx : relatedEntitiesAggregationCfs) { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java index ab38287b89..1a0b609c5a 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/CalculatedFieldCtx.java @@ -305,7 +305,8 @@ public class CalculatedFieldCtx implements Closeable { public void setTenantProfileProperties() { TenantProfile tenantProfile = systemContext.getTenantProfileCache().get(tenantId); if (tenantProfile == null) { - throw new IllegalStateException("Tenant Profile not found for tenant: " + tenantId); + log.warn("[{}][{}][{}] Tenant Profile not found for tenant: {}. CF limits and thresholds will not be updated.", tenantId, entityId, cfId, tenantId); + return; } tenantProfile.getProfileConfiguration().ifPresent(config -> { this.maxStateSize = config.getMaxStateSizeInKBytes() * 1024L; @@ -387,7 +388,7 @@ public class CalculatedFieldCtx implements Closeable { tbelExpressions.put(expression, engine); } catch (Exception e) { initialized = false; - throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax.", e); + throw new RuntimeException("Failed to initialize CF context. The script expression is invalid. Please check for syntax errors or unsupported functions.", e); } } @@ -404,7 +405,7 @@ public class CalculatedFieldCtx implements Closeable { simpleExpressions.put(expression, compiledExpression); } else { initialized = false; - throw new RuntimeException("Failed to init calculated field ctx. Invalid expression syntax."); + throw new RuntimeException("Failed to initialize CF context. The expression has invalid syntax or unknown variables. Ensure all mathematical operators are correct."); } } @@ -726,6 +727,9 @@ public class CalculatedFieldCtx implements Closeable { return true; } } + if (cfType == CalculatedFieldType.PROPAGATION && !propagationArgument.equals(other.propagationArgument)) { + return true; + } if (hasGeofencingZoneGroupConfigurationChanges(other)) { return true; } @@ -801,7 +805,7 @@ public class CalculatedFieldCtx implements Closeable { } public String getSizeExceedsLimitMessage() { - return "Failed to init CF state. State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; + return "State size exceeds limit of " + (maxStateSize / 1024) + "Kb!"; } public boolean hasCurrentOwnerSourceArguments() { diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java index 1719c95f7a..f9c06dbda7 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmCalculatedFieldState.java @@ -40,7 +40,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionTyp import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; @@ -179,6 +179,7 @@ public class AlarmCalculatedFieldState extends BaseCalculatedFieldState { ruleState.setActive(null); AlarmCondition condition = rule.getCondition(); if (condition.hasSchedule() || (condition.getType() == AlarmConditionType.DURATION && !ruleState.isEmpty())) { + ruleState.cancelDurationCheckFuture(); reevalNeeded.set(true); } } diff --git a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java index 19c48272cc..ada94dacda 100644 --- a/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java +++ b/application/src/main/java/org/thingsboard/server/service/cf/ctx/state/alarm/AlarmRuleState.java @@ -256,10 +256,7 @@ public class AlarmRuleState { firstEventTs = 0L; lastCheckTs = 0L; duration = 0L; - if (durationCheckFuture != null) { - durationCheckFuture.cancel(true); - durationCheckFuture = null; - } + cancelDurationCheckFuture(); } public void setDurationCheckFuture(ScheduledFuture durationCheckFuture) { @@ -270,6 +267,13 @@ public class AlarmRuleState { this.durationCheckFuture = durationCheckFuture; } + public void cancelDurationCheckFuture() { + if (durationCheckFuture != null) { + durationCheckFuture.cancel(true); + durationCheckFuture = null; + } + } + public boolean isEmpty() { return eventCount == 0L && firstEventTs == 0L && lastCheckTs == 0L && durationCheckFuture == null; } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java index a3b9ba2810..065c3f2cec 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeContextComponent.java @@ -15,16 +15,20 @@ */ package org.thingsboard.server.service.edge; +import jakarta.annotation.PreDestroy; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.cache.limits.RateLimitService; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.edge.EdgeEventType; import org.thingsboard.server.common.msg.notification.NotificationRuleProcessor; import org.thingsboard.server.dao.ai.AiModelService; +import org.thingsboard.server.dao.pat.ApiKeyService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.alarm.AlarmService; import org.thingsboard.server.dao.asset.AssetProfileService; @@ -61,6 +65,7 @@ import org.thingsboard.server.service.edge.rpc.EdgeEventStorageSettings; import org.thingsboard.server.service.edge.rpc.EdgeRpcService; import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; import org.thingsboard.server.service.edge.rpc.processor.ai.AiModelProcessor; +import org.thingsboard.server.service.edge.rpc.processor.apikey.ApiKeyProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.AlarmProcessor; import org.thingsboard.server.service.edge.rpc.processor.alarm.comment.AlarmCommentProcessor; import org.thingsboard.server.service.edge.rpc.processor.asset.AssetEdgeProcessor; @@ -78,11 +83,14 @@ import org.thingsboard.server.service.edge.rpc.processor.telemetry.TelemetryEdge import org.thingsboard.server.service.edge.rpc.processor.user.UserProcessor; import org.thingsboard.server.service.edge.rpc.sync.EdgeRequestsService; import org.thingsboard.server.service.executors.GrpcCallbackExecutorService; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; @Lazy @Data @@ -93,17 +101,31 @@ public class EdgeContextComponent { private final Map processorMap = new EnumMap<>(EdgeEventType.class); + private ScheduledExecutorService edgeEventProcessingExecutorService; + @Autowired - public EdgeContextComponent(List processors) { + public EdgeContextComponent(List processors, + @Value("${edges.scheduler_pool_size}") int schedulerPoolSize) { processors.forEach(processor -> { EdgeEventType eventType = processor.getEdgeEventType(); if (eventType != null) { processorMap.put(eventType, processor); } }); + this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); + } + + @PreDestroy + private void destroy() { + if (edgeEventProcessingExecutorService != null && !edgeEventProcessingExecutorService.isShutdown()) { + edgeEventProcessingExecutorService.shutdownNow(); + } } // services + @Autowired + private TelemetrySubscriptionService tsSubService; + @Autowired private AdminSettingsService adminSettingsService; @@ -269,6 +291,12 @@ public class EdgeContextComponent { @Autowired private AiModelProcessor aiModelProcessor; + @Autowired + private ApiKeyService apiKeyService; + + @Autowired + private ApiKeyProcessor apiKeyProcessor; + @Autowired private UserProcessor userProcessor; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java index 9ecfd2be08..01d2178cd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtils.java @@ -26,6 +26,7 @@ import com.google.gson.JsonPrimitive; import com.google.gson.reflect.TypeToken; import lombok.extern.slf4j.Slf4j; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.edge.rpc.EdgeVersionComparator; import org.thingsboard.rule.engine.action.TbSaveToCustomCassandraTableNode; import org.thingsboard.rule.engine.ai.TbAiNode; import org.thingsboard.rule.engine.aws.lambda.TbAwsLambdaNode; @@ -40,6 +41,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TbResource; @@ -47,6 +49,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.asset.Asset; @@ -59,6 +62,7 @@ import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.id.AiModelId; +import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.CalculatedFieldId; @@ -96,6 +100,7 @@ import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetsBundle; import org.thingsboard.server.common.transport.util.JsonUtils; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -192,6 +197,10 @@ public class EdgeMsgConstructorUtils { ) ); + private static void resetVersion(HasVersion entity) { + entity.setVersion(null); + } + public static AlarmUpdateMsg constructAlarmUpdatedMsg(UpdateMsgType msgType, Alarm alarm) { return AlarmUpdateMsg.newBuilder().setMsgType(msgType) .setEntity(JacksonUtil.toString(alarm)) @@ -204,6 +213,7 @@ public class EdgeMsgConstructorUtils { } public static AssetUpdateMsg constructAssetUpdatedMsg(UpdateMsgType msgType, Asset asset) { + resetVersion(asset); return AssetUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(asset)) .setIdMSB(asset.getUuidId().getMostSignificantBits()) .setIdLSB(asset.getUuidId().getLeastSignificantBits()).build(); @@ -217,6 +227,7 @@ public class EdgeMsgConstructorUtils { } public static AssetProfileUpdateMsg constructAssetProfileUpdatedMsg(UpdateMsgType msgType, AssetProfile assetProfile) { + resetVersion(assetProfile); return AssetProfileUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(assetProfile)) .setIdMSB(assetProfile.getId().getId().getMostSignificantBits()) .setIdLSB(assetProfile.getId().getId().getLeastSignificantBits()).build(); @@ -230,6 +241,7 @@ public class EdgeMsgConstructorUtils { } public static CustomerUpdateMsg constructCustomerUpdatedMsg(UpdateMsgType msgType, Customer customer) { + resetVersion(customer); return CustomerUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(customer)) .setIdMSB(customer.getId().getId().getMostSignificantBits()) .setIdLSB(customer.getId().getId().getLeastSignificantBits()).build(); @@ -243,6 +255,7 @@ public class EdgeMsgConstructorUtils { } public static DashboardUpdateMsg constructDashboardUpdatedMsg(UpdateMsgType msgType, Dashboard dashboard) { + resetVersion(dashboard); return DashboardUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(dashboard)) .setIdMSB(dashboard.getId().getId().getMostSignificantBits()) .setIdLSB(dashboard.getId().getId().getLeastSignificantBits()).build(); @@ -256,6 +269,7 @@ public class EdgeMsgConstructorUtils { } public static DeviceUpdateMsg constructDeviceUpdatedMsg(UpdateMsgType msgType, Device device) { + resetVersion(device); return DeviceUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(device)) .setIdMSB(device.getId().getId().getMostSignificantBits()) .setIdLSB(device.getId().getId().getLeastSignificantBits()).build(); @@ -269,10 +283,12 @@ public class EdgeMsgConstructorUtils { } public static DeviceCredentialsUpdateMsg constructDeviceCredentialsUpdatedMsg(DeviceCredentials deviceCredentials) { + resetVersion(deviceCredentials); return DeviceCredentialsUpdateMsg.newBuilder().setEntity(JacksonUtil.toString(deviceCredentials)).build(); } public static DeviceProfileUpdateMsg constructDeviceProfileUpdatedMsg(UpdateMsgType msgType, DeviceProfile deviceProfile, EdgeVersion edgeVersion) { + resetVersion(deviceProfile); String entity = getEntityAndFixLwm2mBootstrapShortServerId(deviceProfile, edgeVersion); return DeviceProfileUpdateMsg.newBuilder().setMsgType(msgType).setEntity(entity) .setIdMSB(deviceProfile.getId().getId().getMostSignificantBits()) @@ -281,7 +297,7 @@ public class EdgeMsgConstructorUtils { public static String getEntityAndFixLwm2mBootstrapShortServerId(DeviceProfile deviceProfile, EdgeVersion edgeVersion) { DeviceProfileTransportConfiguration transportConfiguration = deviceProfile.getProfileData().getTransportConfiguration(); - if (!(transportConfiguration instanceof Lwm2mDeviceProfileTransportConfiguration) || edgeVersion.getNumber() >= EdgeVersion.V_4_3_0.getNumber()) { + if (!(transportConfiguration instanceof Lwm2mDeviceProfileTransportConfiguration) || EdgeVersionComparator.INSTANCE.compare(edgeVersion, EdgeVersion.V_4_3_0) >= 0) { return JacksonUtil.toString(deviceProfile); } JsonNode jsonNode = JacksonUtil.valueToTree(deviceProfile); @@ -386,6 +402,7 @@ public class EdgeMsgConstructorUtils { } public static EntityViewUpdateMsg constructEntityViewUpdatedMsg(UpdateMsgType msgType, EntityView entityView) { + resetVersion(entityView); return EntityViewUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(entityView)) .setIdMSB(entityView.getId().getId().getMostSignificantBits()) .setIdLSB(entityView.getId().getId().getLeastSignificantBits()).build(); @@ -485,6 +502,7 @@ public class EdgeMsgConstructorUtils { } public static RelationUpdateMsg constructRelationUpdatedMsg(UpdateMsgType msgType, EntityRelation entityRelation) { + resetVersion(entityRelation); return RelationUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(entityRelation)).build(); } @@ -502,6 +520,7 @@ public class EdgeMsgConstructorUtils { } public static RuleChainUpdateMsg constructRuleChainUpdatedMsg(UpdateMsgType msgType, RuleChain ruleChain, boolean isRoot) { + resetVersion(ruleChain); boolean isTemplateRoot = ruleChain.isRoot(); ruleChain.setRoot(isRoot); RuleChainUpdateMsg result = RuleChainUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(ruleChain)) @@ -519,6 +538,7 @@ public class EdgeMsgConstructorUtils { } public static RuleChainMetadataUpdateMsg constructRuleChainMetadataUpdatedMsg(UpdateMsgType msgType, RuleChainMetaData ruleChainMetaData, EdgeVersion edgeVersion) { + resetVersion(ruleChainMetaData); String metaData = sanitizeMetadataForLegacyEdgeVersion(ruleChainMetaData, edgeVersion); return RuleChainMetadataUpdateMsg.newBuilder() @@ -639,6 +659,7 @@ public class EdgeMsgConstructorUtils { } public static TenantUpdateMsg constructTenantUpdateMsg(UpdateMsgType msgType, Tenant tenant) { + resetVersion(tenant); return TenantUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(tenant)).build(); } @@ -647,6 +668,7 @@ public class EdgeMsgConstructorUtils { } public static UserUpdateMsg constructUserUpdatedMsg(UpdateMsgType msgType, User user) { + resetVersion(user); return UserUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(user)) .setIdMSB(user.getId().getId().getMostSignificantBits()) .setIdLSB(user.getId().getId().getLeastSignificantBits()).build(); @@ -664,6 +686,7 @@ public class EdgeMsgConstructorUtils { } public static WidgetsBundleUpdateMsg constructWidgetsBundleUpdateMsg(UpdateMsgType msgType, WidgetsBundle widgetsBundle, List widgets) { + resetVersion(widgetsBundle); return WidgetsBundleUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(widgetsBundle)) .setWidgets(JacksonUtil.toString(widgets)) .setIdMSB(widgetsBundle.getId().getId().getMostSignificantBits()) @@ -679,6 +702,7 @@ public class EdgeMsgConstructorUtils { } public static WidgetTypeUpdateMsg constructWidgetTypeUpdateMsg(UpdateMsgType msgType, WidgetTypeDetails widgetTypeDetails) { + resetVersion(widgetTypeDetails); return WidgetTypeUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(widgetTypeDetails)) .setIdMSB(widgetTypeDetails.getId().getId().getMostSignificantBits()) .setIdLSB(widgetTypeDetails.getId().getId().getLeastSignificantBits()).build(); @@ -693,6 +717,7 @@ public class EdgeMsgConstructorUtils { } public static CalculatedFieldUpdateMsg constructCalculatedFieldUpdatedMsg(UpdateMsgType msgType, CalculatedField calculatedField) { + resetVersion(calculatedField); return CalculatedFieldUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(calculatedField)) .setIdMSB(calculatedField.getId().getId().getMostSignificantBits()) .setIdLSB(calculatedField.getId().getId().getLeastSignificantBits()).build(); @@ -706,6 +731,7 @@ public class EdgeMsgConstructorUtils { } public static AiModelUpdateMsg constructAiModelUpdatedMsg(UpdateMsgType msgType, AiModel aiModel) { + resetVersion(aiModel); return AiModelUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(aiModel)) .setIdMSB(aiModel.getId().getId().getMostSignificantBits()) .setIdLSB(aiModel.getId().getId().getLeastSignificantBits()).build(); @@ -718,6 +744,19 @@ public class EdgeMsgConstructorUtils { .setIdLSB(aiModelId.getId().getLeastSignificantBits()).build(); } + public static ApiKeyUpdateMsg constructApiKeyUpdatedMsg(UpdateMsgType msgType, ApiKey apiKey) { + return ApiKeyUpdateMsg.newBuilder().setMsgType(msgType).setEntity(JacksonUtil.toString(apiKey)) + .setIdMSB(apiKey.getId().getId().getMostSignificantBits()) + .setIdLSB(apiKey.getId().getId().getLeastSignificantBits()).build(); + } + + public static ApiKeyUpdateMsg constructApiKeyDeleteMsg(ApiKeyId apiKeyId) { + return ApiKeyUpdateMsg.newBuilder() + .setMsgType(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE) + .setIdMSB(apiKeyId.getId().getMostSignificantBits()) + .setIdLSB(apiKeyId.getId().getLeastSignificantBits()).build(); + } + public static List mergeAndFilterDownlinkDuplicates(List edgeEvents) { try { edgeEvents = removeDownlinkDuplicates(edgeEvents); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java new file mode 100644 index 0000000000..a83e3036f4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/DownlinkMessageMapper.java @@ -0,0 +1,85 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; +import org.thingsboard.server.service.edge.rpc.utils.EdgeVersionUtils; + +import java.util.ArrayList; +import java.util.List; + +@Component +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class DownlinkMessageMapper { + + private final EdgeContextComponent ctx; + + public List convertToDownlinkMsgsPack(EdgeSessionState state, List edgeEvents) { + List result = new ArrayList<>(); + List filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents); + for (EdgeEvent edgeEvent : filtered) { + log.trace("[{}][{}] converting edge event to downlink msg [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent); + DownlinkMsg downlinkMsg = null; + try { + switch (edgeEvent.getAction()) { + case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR, + ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL, + ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> { + downlinkMsg = convertEntityEventToDownlink(state, edgeEvent); + if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) { + log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", + state.getTenantId(), state.getEdgeId(), downlinkMsg.getDownlinkMsgId()); + } else { + log.trace("[{}][{}] entity message processed [{}]", state.getTenantId(), state.getEdgeId(), downlinkMsg); + } + } + case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> + downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(state.getEdge(), edgeEvent); + default -> log.warn("[{}][{}] Unsupported action type [{}]", state.getTenantId(), state.getEdgeId(), edgeEvent.getAction()); + } + } catch (Exception e) { + log.trace("[{}][{}] Exception during converting edge event to downlink msg", state.getTenantId(), state.getEdgeId(), e); + } + if (downlinkMsg != null) { + result.add(downlinkMsg); + } + } + return result; + } + + protected DownlinkMsg convertEntityEventToDownlink(EdgeSessionState state, EdgeEvent edgeEvent) { + log.trace("[{}] Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent.getTenantId(), edgeEvent, edgeEvent.getAction()); + if ((EdgeEventType.OAUTH2_CLIENT.equals(edgeEvent.getType()) || EdgeEventType.DOMAIN.equals(edgeEvent.getType())) && + (EdgeVersionUtils.isEdgeVersionOlderThan(state.getEdgeVersion(), EdgeVersion.V_3_8_0))) { + return null; + } + + return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, state.getEdgeVersion()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java deleted file mode 100644 index 76070bec53..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcService.java +++ /dev/null @@ -1,711 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.edge.rpc; - -import com.fasterxml.jackson.databind.node.ObjectNode; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import io.grpc.Server; -import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; -import io.grpc.stub.StreamObserver; -import jakarta.annotation.Nullable; -import jakarta.annotation.PreDestroy; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.context.annotation.Lazy; -import org.springframework.stereotype.Service; -import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.common.util.ThingsBoardExecutors; -import org.thingsboard.rule.engine.api.AttributesSaveRequest; -import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; -import org.thingsboard.server.cache.TbTransactionalCache; -import org.thingsboard.server.cluster.TbClusterService; -import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.ResourceUtils; -import org.thingsboard.server.common.data.edge.Edge; -import org.thingsboard.server.common.data.edge.EdgeEvent; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.kv.BooleanDataEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.msg.TbMsgType; -import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; -import org.thingsboard.server.common.msg.TbMsg; -import org.thingsboard.server.common.msg.TbMsgDataType; -import org.thingsboard.server.common.msg.TbMsgMetaData; -import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; -import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg; -import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; -import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; -import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; -import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; -import org.thingsboard.server.gen.edge.v1.RequestMsg; -import org.thingsboard.server.gen.edge.v1.ResponseMsg; -import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; -import org.thingsboard.server.queue.discovery.TopicService; -import org.thingsboard.server.queue.kafka.KafkaAdmin; -import org.thingsboard.server.queue.provider.TbCoreQueueFactory; -import org.thingsboard.server.queue.util.AfterStartUp; -import org.thingsboard.server.queue.util.TbCoreComponent; -import org.thingsboard.server.service.edge.EdgeContextComponent; -import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; - -import java.io.IOException; -import java.io.InputStream; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Consumer; -import java.util.function.Function; - -import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE; -import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME; -import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME; - -@Service -@Slf4j -@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") -@TbCoreComponent -public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { - - private final ConcurrentMap sessions = new ConcurrentHashMap<>(); - private final ConcurrentMap sessionsById = new ConcurrentHashMap<>(); - private final ConcurrentMap sessionNewEventsLocks = new ConcurrentHashMap<>(); - private final Map sessionNewEvents = new HashMap<>(); - private final ConcurrentMap> sessionEdgeEventChecks = new ConcurrentHashMap<>(); - private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); - private final ConcurrentMap edgeEventsMigrationProcessed = new ConcurrentHashMap<>(); - private final List zombieSessions = new ArrayList<>(); - - @Value("${edges.rpc.port}") - private int rpcPort; - @Value("${edges.rpc.ssl.enabled}") - private boolean sslEnabled; - @Value("${edges.rpc.ssl.cert}") - private String certFileResource; - @Value("${edges.rpc.ssl.private_key}") - private String privateKeyResource; - @Value("${edges.state.persistToTelemetry:false}") - private boolean persistToTelemetry; - @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") - private int clientMaxKeepAliveTimeSec; - @Value("${edges.rpc.max_inbound_message_size:4194304}") - private int maxInboundMessageSize; - @Value("${edges.rpc.keep_alive_time_sec:10}") - private int keepAliveTimeSec; - @Value("${edges.rpc.keep_alive_timeout_sec:5}") - private int keepAliveTimeoutSec; - @Value("${edges.scheduler_pool_size}") - private int schedulerPoolSize; - - @Value("${edges.send_scheduler_pool_size}") - private int sendSchedulerPoolSize; - - @Value("${edges.max_high_priority_queue_size_per_session:10000}") - private int maxHighPriorityQueueSizePerSession; - - @Autowired - @Lazy - private EdgeContextComponent ctx; - - @Autowired - private TelemetrySubscriptionService tsSubService; - - @Autowired - private TbClusterService clusterService; - - @Autowired - private TbServiceInfoProvider serviceInfoProvider; - - @Autowired - private TbTransactionalCache edgeIdServiceIdCache; - - @Autowired - private TopicService topicService; - - @Autowired - private TbCoreQueueFactory tbCoreQueueFactory; - - @Autowired - private Optional kafkaAdmin; - - private Server server; - - private ScheduledExecutorService edgeEventProcessingExecutorService; - - private ScheduledExecutorService sendDownlinkExecutorService; - - private ScheduledExecutorService executorService; - - @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) - public void onStartUp() { - log.info("Initializing Edge RPC service!"); - NettyServerBuilder builder = NettyServerBuilder.forPort(rpcPort) - .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) - .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) - .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) - .permitKeepAliveWithoutCalls(true) - .maxInboundMessageSize(maxInboundMessageSize) - .addService(this); - if (sslEnabled) { - try { - InputStream certFileIs = ResourceUtils.getInputStream(this, certFileResource); - InputStream privateKeyFileIs = ResourceUtils.getInputStream(this, privateKeyResource); - builder.useTransportSecurity(certFileIs, privateKeyFileIs); - } catch (Exception e) { - log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); - throw new RuntimeException("Unable to set up SSL context!", e); - } - } - server = builder.build(); - log.info("Going to start Edge RPC server using port: {}", rpcPort); - try { - server.start(); - } catch (IOException e) { - log.error("Failed to start Edge RPC server!", e); - throw new RuntimeException("Failed to start Edge RPC server!"); - } - this.edgeEventProcessingExecutorService = ThingsBoardExecutors.newScheduledThreadPool(schedulerPoolSize, "edge-event-check-scheduler"); - this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); - this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); - this.executorService.scheduleAtFixedRate(this::cleanupZombieSessions, 60, 60, TimeUnit.SECONDS); - log.info("Edge RPC service initialized!"); - } - - @PreDestroy - public void destroy() { - if (server != null) { - server.shutdownNow(); - } - for (Map.Entry> entry : sessionEdgeEventChecks.entrySet()) { - EdgeId edgeId = entry.getKey(); - ScheduledFuture sessionEdgeEventCheck = entry.getValue(); - if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { - sessionEdgeEventCheck.cancel(true); - sessionEdgeEventChecks.remove(edgeId); - } - } - if (edgeEventProcessingExecutorService != null) { - edgeEventProcessingExecutorService.shutdownNow(); - } - if (sendDownlinkExecutorService != null) { - sendDownlinkExecutorService.shutdownNow(); - } - if (executorService != null) { - executorService.shutdownNow(); - } - } - - @Override - public StreamObserver handleMsgs(StreamObserver outputStream) { - EdgeGrpcSession session = createEdgeGrpcSession(outputStream); - return session.getInputStream(); - } - - private EdgeGrpcSession createEdgeGrpcSession(StreamObserver outputStream) { - return kafkaAdmin.isPresent() - ? new KafkaEdgeGrpcSession(ctx, topicService, tbCoreQueueFactory, kafkaAdmin.get(), outputStream, this::onEdgeConnect, this::onEdgeDisconnect, - sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession) - : new PostgresEdgeGrpcSession(ctx, outputStream, this::onEdgeConnect, this::onEdgeDisconnect, - sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession); - } - - @Override - public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) { - switch (msg.getMsgType()) { - case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> { - EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg; - log.trace("[{}] edgeEventMsg [{}]", tenantId, msg); - onEdgeHighPriorityEvent(edgeHighPriorityMsg); - } - case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> { - EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg; - log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg); - onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId()); - } - case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> { - ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg; - log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg); - startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId()); - } - case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> { - FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg; - log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg); - processSyncResponse(fromEdgeSyncResponse); - } - } - } - - @Override - public void updateEdge(TenantId tenantId, Edge edge) { - if (edge == null) { - log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId); - return; - } - EdgeGrpcSession session = sessions.get(edge.getId()); - if (session != null && session.isConnected()) { - log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); - session.onConfigurationUpdate(edge); - } else { - log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); - } - } - - @Override - public void deleteEdge(TenantId tenantId, EdgeId edgeId) { - EdgeGrpcSession session = sessions.get(edgeId); - if (session != null && session.isConnected()) { - log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId); - destroySession(session); - session.cleanUp(); - sessions.remove(edgeId); - sessionsById.remove(session.getSessionId()); - final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); - newEventLock.lock(); - try { - sessionNewEvents.remove(edgeId); - } finally { - newEventLock.unlock(); - } - cancelScheduleEdgeEventsCheck(edgeId); - } - } - - private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) { - EdgeGrpcSession session = sessions.get(edgeId); - if (session != null && session.isConnected()) { - log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId()); - updateSessionEventsFlag(tenantId, edgeId); - } - } - - private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) { - TenantId tenantId = msg.getTenantId(); - EdgeEvent edgeEvent = msg.getEdgeEvent(); - EdgeId edgeId = edgeEvent.getEdgeId(); - EdgeGrpcSession session = sessions.get(edgeId); - if (session != null && session.isConnected()) { - log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId); - session.addEventToHighPriorityQueue(edgeEvent); - updateSessionEventsFlag(tenantId, edgeId); - } - } - - private void updateSessionEventsFlag(TenantId tenantId, EdgeId edgeId) { - final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); - newEventLock.lock(); - try { - if (Boolean.FALSE.equals(sessionNewEvents.get(edgeId))) { - log.trace("[{}] set session new events flag to true [{}]", tenantId, edgeId.getId()); - sessionNewEvents.put(edgeId, true); - } - } finally { - newEventLock.unlock(); - } - } - - private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSession edgeGrpcSession) { - Edge edge = edgeGrpcSession.getEdge(); - TenantId tenantId = edge.getTenantId(); - log.info("[{}][{}] edge [{}] connected successfully.", tenantId, edgeGrpcSession.getSessionId(), edgeId); - if (sessions.containsKey(edgeId)) { - EdgeGrpcSession existing = sessions.get(edgeId); - if (existing != null) { - log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, edgeGrpcSession.getSessionId(), existing.getSessionId(), edgeId); - destroySession(existing); - sessionsById.remove(existing.getSessionId()); - } - } - sessions.put(edgeId, edgeGrpcSession); - sessionsById.put(edgeGrpcSession.getSessionId(), edgeGrpcSession); - final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); - newEventLock.lock(); - try { - sessionNewEvents.put(edgeId, true); - } finally { - newEventLock.unlock(); - } - save(tenantId, edgeId, ACTIVITY_STATE, true); - long lastConnectTs = System.currentTimeMillis(); - save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs); - edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId()); - pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); - cancelScheduleEdgeEventsCheck(edgeId); - edgeEventsMigrationProcessed.putIfAbsent(edgeId, Boolean.FALSE); - scheduleEdgeEventsCheck(edgeGrpcSession); - } - - private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) { - EdgeGrpcSession session = sessions.get(edgeId); - if (session != null) { - if (session.isSyncInProgress()) { - clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId); - } else { - boolean success = false; - if (session.isConnected()) { - session.startSyncProcess(true); - success = true; - } - clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId); - } - } - } - - @Override - public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer responseConsumer) { - ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId()); - - UUID requestId = request.getId(); - EdgeGrpcSession session = sessions.get(request.getEdgeId()); - if (session != null && session.isSyncInProgress()) { - responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment")); - } else { - log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId()); - localSyncEdgeRequests.put(requestId, responseConsumer); - clusterService.pushEdgeSyncRequestToEdge(request); - scheduleSyncRequestTimeout(request, requestId); - } - } - - private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) { - log.trace("[{}] scheduling sync edge request", requestId); - executorService.schedule(() -> { - log.trace("[{}] checking if sync edge request is not processed...", requestId); - Consumer consumer = localSyncEdgeRequests.remove(requestId); - if (consumer != null) { - log.trace("[{}] timeout for processing sync edge request.", requestId); - consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected")); - } - }, 20, TimeUnit.SECONDS); - } - - private void processSyncResponse(FromEdgeSyncResponse response) { - log.trace("[{}] Received response from sync service: [{}]", response.getId(), response); - UUID requestId = response.getId(); - Consumer consumer = localSyncEdgeRequests.remove(requestId); - if (consumer != null) { - consumer.accept(response); - } else { - log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response); - } - } - - private void scheduleEdgeEventsCheck(EdgeGrpcSession session) { - EdgeId edgeId = session.getEdge().getId(); - TenantId tenantId = session.getEdge().getTenantId(); - - cancelScheduleEdgeEventsCheck(edgeId); - - if (sessions.containsKey(edgeId)) { - ScheduledFuture edgeEventCheckTask = edgeEventProcessingExecutorService.schedule(() -> { - try { - final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); - newEventLock.lock(); - try { - if (Boolean.TRUE.equals(sessionNewEvents.get(edgeId))) { - log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId()); - sessionNewEvents.put(edgeId, false); - session.processHighPriorityEvents(); - processEdgeEventMigrationIfNeeded(session, edgeId); - if (Boolean.TRUE.equals(edgeEventsMigrationProcessed.get(edgeId))) { - Futures.addCallback(session.processEdgeEvents(), new FutureCallback<>() { - @Override - public void onSuccess(Boolean newEventsAdded) { - if (Boolean.TRUE.equals(newEventsAdded)) { - log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId()); - sessionNewEvents.put(edgeId, true); - } - scheduleEdgeEventsCheck(session); - } - - @Override - public void onFailure(Throwable t) { - log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), t); - scheduleEdgeEventsCheck(session); - } - }, ctx.getGrpcCallbackExecutorService()); - } else { - scheduleEdgeEventsCheck(session); - } - } else { - scheduleEdgeEventsCheck(session); - } - } finally { - newEventLock.unlock(); - } - } catch (Exception e) { - log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, session.getEdge().getId().getId(), e); - } - }, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS); - sessionEdgeEventChecks.put(edgeId, edgeEventCheckTask); - log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId()); - } else { - log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]", - tenantId, edgeId.getId()); - } - } - - private void processEdgeEventMigrationIfNeeded(EdgeGrpcSession session, EdgeId edgeId) throws Exception { - boolean isMigrationProcessed = edgeEventsMigrationProcessed.getOrDefault(edgeId, Boolean.FALSE); - if (!isMigrationProcessed) { - log.info("Starting edge event migration for edge [{}]", edgeId.getId()); - Boolean eventsExist = session.migrateEdgeEvents().get(); - if (Boolean.TRUE.equals(eventsExist)) { - log.info("Migration still in progress for edge [{}]", edgeId.getId()); - sessionNewEvents.put(edgeId, true); - scheduleEdgeEventsCheck(session); - } else if (Boolean.FALSE.equals(eventsExist)) { - log.info("Migration completed for edge [{}]", edgeId.getId()); - edgeEventsMigrationProcessed.put(edgeId, true); - } - } - } - - private void cancelScheduleEdgeEventsCheck(EdgeId edgeId) { - log.trace("[{}] cancelling edge event check for edge", edgeId); - if (sessionEdgeEventChecks.containsKey(edgeId)) { - ScheduledFuture sessionEdgeEventCheck = sessionEdgeEventChecks.get(edgeId); - if (sessionEdgeEventCheck != null && !sessionEdgeEventCheck.isCancelled() && !sessionEdgeEventCheck.isDone()) { - sessionEdgeEventCheck.cancel(true); - sessionEdgeEventChecks.remove(edgeId); - } - } - } - - private void onEdgeDisconnect(Edge edge, UUID sessionId) { - EdgeId edgeId = edge.getId(); - log.info("[{}][{}] edge disconnected!", edgeId, sessionId); - EdgeGrpcSession current = sessions.get(edgeId); - if (current != null && current.getSessionId().equals(sessionId)) { - EdgeGrpcSession toRemove = sessions.remove(edgeId); - final Lock newEventLock = sessionNewEventsLocks.computeIfAbsent(edgeId, id -> new ReentrantLock()); - newEventLock.lock(); - try { - sessionNewEvents.remove(edgeId); - } finally { - newEventLock.unlock(); - } - destroySession(toRemove); - sessionsById.remove(sessionId); - TenantId tenantId = toRemove.getEdge().getTenantId(); - save(tenantId, edgeId, ACTIVITY_STATE, false); - long lastDisconnectTs = System.currentTimeMillis(); - save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs); - pushRuleEngineMessage(toRemove.getEdge().getTenantId(), edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT); - cancelScheduleEdgeEventsCheck(edgeId); - } else { - log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId); - EdgeGrpcSession stale = sessionsById.remove(sessionId); - if (stale != null) { - try { - destroySession(stale); - log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId); - } catch (Exception e) { - log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getTenantId(), sessionId, edgeId, e); - } - } else { - log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId); - } - } - edgeIdServiceIdCache.evict(edgeId); - } - - private void destroySession(EdgeGrpcSession session) { - try (session) { - if (!session.destroy()) { - log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.", - session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId()); - zombieSessions.add(session); - } - } catch (Exception e) { - log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]", - session.getTenantId(), session.getEdge().getId(), session.getEdge().getName(), session.getSessionId(), e); - } - } - - private void save(TenantId tenantId, EdgeId edgeId, String key, long value) { - log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value); - if (persistToTelemetry) { - tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .entry(new LongDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } else { - tsSubService.saveAttributes(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .scope(AttributeScope.SERVER_SCOPE) - .entry(new LongDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } - } - - private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) { - log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value); - if (persistToTelemetry) { - tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .entry(new BooleanDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } else { - tsSubService.saveAttributes(AttributesSaveRequest.builder() - .tenantId(tenantId) - .entityId(edgeId) - .scope(AttributeScope.SERVER_SCOPE) - .entry(new BooleanDataEntry(key, value)) - .callback(new AttributeSaveCallback(tenantId, edgeId, key, value)) - .build()); - } - } - - private static class AttributeSaveCallback implements FutureCallback { - - private final TenantId tenantId; - private final EdgeId edgeId; - private final String key; - private final Object value; - - AttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) { - this.tenantId = tenantId; - this.edgeId = edgeId; - this.key = key; - this.value = value; - } - - @Override - public void onSuccess(@Nullable Void result) { - log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); - } - - @Override - public void onFailure(Throwable t) { - log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); - } - - } - - private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) { - try { - EdgeId edgeId = edge.getId(); - ObjectNode edgeState = JacksonUtil.newObjectNode(); - boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType); - if (isConnected) { - edgeState.put(ACTIVITY_STATE, true); - edgeState.put(LAST_CONNECT_TIME, ts); - } else { - edgeState.put(ACTIVITY_STATE, false); - edgeState.put(LAST_DISCONNECT_TIME, ts); - } - ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder() - .tenantId(tenantId) - .customerId(edge.getCustomerId()) - .edgeId(edgeId) - .edgeName(edge.getName()) - .connected(isConnected).build()); - String data = JacksonUtil.toString(edgeState); - TbMsgMetaData md = new TbMsgMetaData(); - if (!persistToTelemetry) { - md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE); - md.putValue("edgeName", edge.getName()); - md.putValue("edgeType", edge.getType()); - } - TbMsg tbMsg = TbMsg.newMsg() - .type(msgType) - .originator(edgeId) - .copyMetaData(md) - .dataType(TbMsgDataType.JSON) - .data(data) - .build(); - clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null); - } catch (Exception e) { - log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e); - } - } - - private void cleanupZombieSessions() { - try { - tryToDestroyZombieSessions(getZombieSessions(sessions.values()), s -> sessions.remove(s.getEdge().getId())); - tryToDestroyZombieSessions(getZombieSessions(sessionsById.values()), s -> sessionsById.remove(s.getSessionId())); - - zombieSessions.removeIf(zombie -> { - if (zombie.destroy()) { - log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].", - zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); - return true; - } else { - log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].", - zombie.getTenantId(), zombie.getEdge().getId(), zombie.getSessionId(), zombie.getEdge().getName()); - return false; - } - }); - } catch (Exception e) { - log.warn("Failed to cleanup kafka sessions", e); - } - } - - private List getZombieSessions(Collection sessions) { - List result = new ArrayList<>(); - for (EdgeGrpcSession session : sessions) { - if (isKafkaSessionAndZombie(session)) { - result.add(session); - } - } - return result; - } - - private void tryToDestroyZombieSessions(List sessionsToRemove, Function removeFunc) { - for (EdgeGrpcSession toRemove : sessionsToRemove) { - log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getEdge().getId()); - if (toRemove.destroy()) { - removeFunc.apply(toRemove); - } - } - } - - private boolean isKafkaSessionAndZombie(EdgeGrpcSession session) { - if (session instanceof KafkaEdgeGrpcSession kafkaSession) { - log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}", - kafkaSession.getEdge().getId(), - kafkaSession.isConnected(), - kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null); - return !kafkaSession.isConnected() && - kafkaSession.getConsumer() != null && - kafkaSession.getConsumer().getConsumer() != null && - !kafkaSession.getConsumer().getConsumer().isStopped(); - } - return false; - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java deleted file mode 100644 index 2b026f207f..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSession.java +++ /dev/null @@ -1,999 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.edge.rpc; - -import com.datastax.oss.driver.api.core.uuid.Uuids; -import com.google.common.util.concurrent.FutureCallback; -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.SettableFuture; -import io.grpc.stub.StreamObserver; -import lombok.Data; -import lombok.extern.slf4j.Slf4j; -import org.checkerframework.checker.nullness.qual.Nullable; -import org.springframework.data.util.Pair; -import org.thingsboard.server.common.data.AttributeScope; -import org.thingsboard.server.common.data.DataConstants; -import org.thingsboard.server.common.data.EdgeUtils; -import org.thingsboard.server.common.data.StringUtils; -import org.thingsboard.server.common.data.edge.Edge; -import org.thingsboard.server.common.data.edge.EdgeEvent; -import org.thingsboard.server.common.data.edge.EdgeEventType; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.kv.AttributeKvEntry; -import org.thingsboard.server.common.data.kv.AttributesSaveResult; -import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.common.data.limit.LimitedApi; -import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.common.data.page.TimePageLink; -import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; -import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; -import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; -import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; -import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; -import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; -import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; -import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; -import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; -import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; -import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg; -import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; -import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; -import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; -import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; -import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; -import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; -import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; -import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; -import org.thingsboard.server.gen.edge.v1.DownlinkMsg; -import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg; -import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; -import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg; -import org.thingsboard.server.gen.edge.v1.EdgeVersion; -import org.thingsboard.server.gen.edge.v1.EntityDataProto; -import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; -import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; -import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; -import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; -import org.thingsboard.server.gen.edge.v1.RequestMsg; -import org.thingsboard.server.gen.edge.v1.RequestMsgType; -import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg; -import org.thingsboard.server.gen.edge.v1.ResponseMsg; -import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; -import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; -import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; -import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; -import org.thingsboard.server.gen.edge.v1.UplinkMsg; -import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; -import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; -import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; -import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; -import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; -import org.thingsboard.server.service.edge.EdgeContextComponent; -import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; -import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher; -import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher; -import org.thingsboard.server.service.edge.rpc.utils.EdgeVersionUtils; - -import java.io.Closeable; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.UUID; -import java.util.concurrent.ConcurrentLinkedQueue; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.BiConsumer; - -@Slf4j -@Data -public abstract class EdgeGrpcSession implements Closeable { - - private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs"; - private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId"; - - private static final int MAX_DOWNLINK_ATTEMPTS = 3; - private static final String RATE_LIMIT_REACHED = "Rate limit reached"; - - protected final ConcurrentLinkedQueue highPriorityQueue = new ConcurrentLinkedQueue<>(); - - protected UUID sessionId; - private BiConsumer sessionOpenListener; - private BiConsumer sessionCloseListener; - - private final EdgeSessionState sessionState = new EdgeSessionState(); - private final ReentrantLock downlinkMsgLock = new ReentrantLock(); - private final Lock sequenceDependencyLock = new ReentrantLock(); - - protected EdgeContextComponent ctx; - protected Edge edge; - protected TenantId tenantId; - - private Long newStartTs; - private Long previousStartTs; - private Long newStartSeqId; - private Long previousStartSeqId; - - private StreamObserver inputStream; - private StreamObserver outputStream; - - private volatile boolean connected; - private volatile boolean syncInProgress; - - private EdgeVersion edgeVersion; - private int maxInboundMessageSize; - private int clientMaxInboundMessageSize; - private int maxHighPriorityQueueSizePerSession; - - private ScheduledExecutorService sendDownlinkExecutorService; - - public EdgeGrpcSession(EdgeContextComponent ctx, StreamObserver outputStream, - BiConsumer sessionOpenListener, - BiConsumer sessionCloseListener, - ScheduledExecutorService sendDownlinkExecutorService, - int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) { - this.sessionId = UUID.randomUUID(); - this.ctx = ctx; - this.outputStream = outputStream; - this.sessionOpenListener = sessionOpenListener; - this.sessionCloseListener = sessionCloseListener; - this.sendDownlinkExecutorService = sendDownlinkExecutorService; - this.maxInboundMessageSize = maxInboundMessageSize; - this.maxHighPriorityQueueSizePerSession = maxHighPriorityQueueSizePerSession; - initInputStream(); - } - - protected abstract ListenableFuture migrateEdgeEvents() throws Exception; - - public void initInputStream() { - inputStream = new StreamObserver<>() { - @Override - public void onNext(RequestMsg requestMsg) { - if (!connected && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) { - ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg()); - outputStream.onNext(ResponseMsg.newBuilder() - .setConnectResponseMsg(responseMsg) - .build()); - if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) { - outputStream.onError(new RuntimeException(responseMsg.getErrorMsg())); - } else { - if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) { - log.debug("[{}][{}] Client max inbound message size: {}", tenantId, sessionId, requestMsg.getConnectRequestMsg().getMaxInboundMessageSize()); - clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize(); - } - connected = true; - } - } - if (connected) { - if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) { - if (requestMsg.hasSyncRequestMsg()) { - boolean fullSync = false; - if (requestMsg.getSyncRequestMsg().hasFullSync()) { - fullSync = requestMsg.getSyncRequestMsg().getFullSync(); - } - startSyncProcess(fullSync); - } else { - syncInProgress = false; - } - } - if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) { - if (requestMsg.hasUplinkMsg()) { - onUplinkMsg(requestMsg.getUplinkMsg()); - } - if (requestMsg.hasDownlinkResponseMsg()) { - onDownlinkResponse(requestMsg.getDownlinkResponseMsg()); - } - } - } - } - - @Override - public void onError(Throwable t) { - log.trace("[{}][{}] Stream was terminated due to error:", tenantId, sessionId, t); - closeSession(); - } - - @Override - public void onCompleted() { - log.info("[{}][{}] Stream was closed and completed successfully!", tenantId, sessionId); - closeSession(); - } - - private void closeSession() { - connected = false; - if (edge != null) { - try { - sessionCloseListener.accept(edge, sessionId); - } catch (Exception ignored) { - } - } - try { - outputStream.onCompleted(); - } catch (Exception ignored) { - } - } - }; - } - - public void onConfigurationUpdate(Edge edge) { - log.debug("[{}] onConfigurationUpdate [{}]", sessionId, edge); - this.tenantId = edge.getTenantId(); - this.edge = edge; - EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder() - .setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build(); - ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder() - .setEdgeUpdateMsg(edgeConfig) - .build(); - sendDownlinkMsg(edgeConfigMsg); - } - - public void startSyncProcess(boolean fullSync) { - if (!syncInProgress) { - log.info("[{}][{}][{}] Staring edge sync process", tenantId, edge.getId(), sessionId); - syncInProgress = true; - interruptGeneralProcessingOnSync(); - doSync(new EdgeSyncCursor(ctx, edge, fullSync)); - } else { - log.info("[{}][{}][{}] Sync is already started, skipping starting it now", tenantId, edge.getId(), sessionId); - } - } - - private void doSync(EdgeSyncCursor cursor) { - if (cursor.hasNext()) { - EdgeEventFetcher next = cursor.getNext(); - log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}", - tenantId, edge.getId(), cursor.getCurrentIdx(), next.getClass().getSimpleName()); - ListenableFuture> future = startProcessingEdgeEvents(next); - Futures.addCallback(future, new FutureCallback<>() { - @Override - public void onSuccess(@Nullable Pair result) { - doSync(cursor); - } - - @Override - public void onFailure(Throwable t) { - log.error("[{}][{}] Exception during sync process", tenantId, edge.getId(), t); - } - }, ctx.getGrpcCallbackExecutorService()); - } else { - log.info("[{}][{}] sync process completed", tenantId, edge.getId()); - DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder() - .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) - .setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build()) - .build(); - Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() { - @Override - public void onSuccess(Boolean isInterrupted) { - markSyncCompletedSendEdgeEventUpdate(); - } - - @Override - public void onFailure(Throwable t) { - log.error("[{}][{}] Exception during sending sync complete", tenantId, edge.getId(), t); - markSyncCompletedSendEdgeEventUpdate(); - } - }, ctx.getGrpcCallbackExecutorService()); - } - } - - protected void processEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture> result) { - try { - log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); - processHighPriorityEvents(); - PageData pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink); - if (isConnected() && !pageData.getData().isEmpty()) { - if (fetcher instanceof GeneralEdgeEventFetcher) { - long queueSize = pageData.getTotalElements() - ((long) pageLink.getPageSize() * pageLink.getPage()); - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_LAG, tenantId, edge.getId(), queueSize)); - } - log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size()); - List downlinkMsgsPack = convertToDownlinkMsgsPack(pageData.getData()); - Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() { - @Override - public void onSuccess(@Nullable Boolean isInterrupted) { - if (Boolean.TRUE.equals(isInterrupted)) { - log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), sessionId); - result.set(null); - } else { - if (isConnected() && pageData.hasNext()) { - processEdgeEvents(fetcher, pageLink.nextPageLink(), result); - } else { - EdgeEvent latestEdgeEvent = pageData.getData().get(pageData.getData().size() - 1); - UUID idOffset = latestEdgeEvent.getUuidId(); - if (idOffset != null) { - Long newStartTs = Uuids.unixTimestamp(idOffset); - long newStartSeqId = latestEdgeEvent.getSeqId(); - result.set(Pair.of(newStartTs, newStartSeqId)); - } else { - result.set(null); - } - } - } - } - - @Override - public void onFailure(Throwable t) { - log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t); - result.setException(t); - } - }, ctx.getGrpcCallbackExecutorService()); - } else { - log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); - result.set(null); - } - } catch (Exception e) { - log.error("[{}] Failed to fetch edge events", edge.getId(), e); - result.setException(e); - } - } - - private ConnectResponseMsg processConnect(ConnectRequestMsg request) { - log.trace("[{}] processConnect [{}]", sessionId, request); - Optional optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey()); - if (optional.isPresent()) { - edge = optional.get(); - tenantId = edge.getTenantId(); - try { - if (edge.getSecret().equals(request.getEdgeSecret())) { - sessionOpenListener.accept(edge.getId(), this); - edgeVersion = request.getEdgeVersion(); - processSaveEdgeVersionAsAttribute(request.getEdgeVersion().name()); - return ConnectResponseMsg.newBuilder() - .setResponseCode(ConnectResponseCode.ACCEPTED) - .setErrorMsg("") - .setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)) - .setMaxInboundMessageSize(maxInboundMessageSize) - .build(); - } - String error = "Failed to validate the edge!"; - String failureMsg = String.format("%s Provided request secret: %s", error, request.getEdgeSecret()); - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) - .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build()); - return ConnectResponseMsg.newBuilder() - .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) - .setErrorMsg(failureMsg) - .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); - } catch (Exception e) { - String failureMsg = "Failed to process edge connection!"; - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) - .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build()); - log.error(failureMsg, e); - return ConnectResponseMsg.newBuilder() - .setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE) - .setErrorMsg(failureMsg) - .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); - } - } - return ConnectResponseMsg.newBuilder() - .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) - .setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey()) - .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); - } - - private void processSaveEdgeVersionAsAttribute(String edgeVersion) { - AttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(new StringDataEntry(DataConstants.EDGE_VERSION_ATTR_KEY, edgeVersion), System.currentTimeMillis()); - ctx.getAttributesService().save(tenantId, edge.getId(), AttributeScope.SERVER_SCOPE, attributeKvEntry); - } - - private void interruptGeneralProcessingOnSync() { - log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", tenantId, edge.getId(), sessionId); - stopCurrentSendDownlinkMsgsTask(true); - } - - protected ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) { - interruptPreviousSendDownlinkMsgsTask(); - - sessionState.setSendDownlinkMsgsFuture(SettableFuture.create()); - sessionState.getPendingMsgsMap().clear(); - - downlinkMsgsPack.forEach(msg -> sessionState.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg)); - scheduleDownlinkMsgsPackSend(1); - - return sessionState.getSendDownlinkMsgsFuture(); - } - - private void interruptPreviousSendDownlinkMsgsTask() { - if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone() - || sessionState.getScheduledSendDownlinkTask() != null && !sessionState.getScheduledSendDownlinkTask().isCancelled()) { - log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!", tenantId, edge.getId(), sessionId); - stopCurrentSendDownlinkMsgsTask(true); - } else { - log.trace("[{}][{}][{}] Previous send downlink future is not active", tenantId, edge.getId(), sessionId); - } - } - - private void onUplinkMsg(UplinkMsg uplinkMsg) { - if (isRateLimitViolated(uplinkMsg)) { - return; - } - ListenableFuture> future = processUplinkMsg(uplinkMsg); - Futures.addCallback(future, new FutureCallback<>() { - @Override - public void onSuccess(@Nullable List result) { - sendResponseMessage(uplinkMsg.getUplinkMsgId(), true, null); - } - - @Override - public void onFailure(Throwable t) { - String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t); - sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg); - } - }, ctx.getGrpcCallbackExecutorService()); - } - - private boolean isRateLimitViolated(UplinkMsg uplinkMsg) { - if (!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES, tenantId) || - !ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, tenantId, edge.getId())) { - String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED); - sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg); - return true; - } - return false; - } - - private void scheduleDownlinkMsgsPackSend(int attempt) { - Runnable sendDownlinkMsgsTask = () -> { - try { - if (!isConnected()) { - stopCurrentSendDownlinkMsgsTask(true); - return; - } - if (!sessionState.getPendingMsgsMap().values().isEmpty()) { - List copy = new ArrayList<>(sessionState.getPendingMsgsMap().values()); - if (attempt > 1) { - String error = "Failed to deliver the batch"; - String failureMsg = String.format("{%s} (size: {%s})", error, copy.size()); - if (attempt == 2) { - // Send a failure notification only on the second attempt. - // This ensures that failure alerts are sent just once to avoid redundant notifications. - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId) - .edgeId(edge.getId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build()); - } - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), edge.getId(), 1)); - log.warn("[{}][{}] {} on attempt {}", tenantId, edge.getId(), failureMsg, attempt); - log.debug("[{}][{}] entities in failed batch: {}", tenantId, edge.getId(), copy); - } - log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", tenantId, edge.getId(), copy.size()); - for (DownlinkMsg downlinkMsg : copy) { - if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) { - String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " + - "env variable on the edge and restart it.", clientMaxInboundMessageSize); - String message = String.format("Downlink msg size %s exceeds client max inbound message size %s. " + - "Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it.", downlinkMsg.getSerializedSize(), clientMaxInboundMessageSize); - log.error("[{}][{}][{}] {} Message {}", tenantId, edge.getId(), sessionId, message, downlinkMsg); - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId) - .edgeId(edge.getId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build()); - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), edge.getId(), 1)); - sessionState.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId()); - } else { - sendDownlinkMsg(ResponseMsg.newBuilder() - .setDownlinkMsg(downlinkMsg) - .build()); - } - } - if (attempt < MAX_DOWNLINK_ATTEMPTS) { - scheduleDownlinkMsgsPackSend(attempt + 1); - } else { - String failureMsg = String.format("Failed to deliver messages: %s", copy); - log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}", - tenantId, edge.getId(), MAX_DOWNLINK_ATTEMPTS, copy); - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) - .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg) - .error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build()); - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), edge.getId(), copy.size())); - stopCurrentSendDownlinkMsgsTask(false); - } - } else { - stopCurrentSendDownlinkMsgsTask(false); - } - } catch (Exception e) { - log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", tenantId, edge.getId(), e.getMessage(), e); - stopCurrentSendDownlinkMsgsTask(true); - } - }; - - if (attempt == 1) { - sendDownlinkExecutorService.submit(sendDownlinkMsgsTask); - } else { - sessionState.setScheduledSendDownlinkTask( - sendDownlinkExecutorService.schedule( - sendDownlinkMsgsTask, - ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(), - TimeUnit.MILLISECONDS) - ); - } - } - - private void sendResponseMessage(int uplinkMsgId, boolean success, String errorMsg) { - UplinkResponseMsg.Builder responseBuilder = UplinkResponseMsg.newBuilder() - .setUplinkMsgId(uplinkMsgId) - .setSuccess(success); - if (errorMsg != null) { - responseBuilder.setErrorMsg(errorMsg); - } - sendDownlinkMsg(ResponseMsg.newBuilder() - .setUplinkResponseMsg(responseBuilder.build()) - .build()); - } - - private void onDownlinkResponse(DownlinkResponseMsg msg) { - try { - if (msg.getSuccess()) { - sessionState.getPendingMsgsMap().remove(msg.getDownlinkMsgId()); - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, edge.getTenantId(), edge.getId(), 1)); - log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", tenantId, edge.getId(), sessionId, msg.getDownlinkMsgId(), msg); - } else { - log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", tenantId, edge.getId(), sessionId, msg.getDownlinkMsgId(), msg.getErrorMsg()); - DownlinkMsg downlinkMsg = sessionState.getPendingMsgsMap().get(msg.getDownlinkMsgId()); - // if NOT timeseries or attributes failures - ack failed downlink - if (downlinkMsg.getEntityDataCount() == 0) { - sessionState.getPendingMsgsMap().remove(msg.getDownlinkMsgId()); - } - } - if (sessionState.getPendingMsgsMap().isEmpty()) { - log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", tenantId, edge.getId(), sessionId); - stopCurrentSendDownlinkMsgsTask(false); - } - } catch (Exception e) { - log.error("[{}][{}] Can't process downlink response message [{}]", tenantId, edge.getId(), msg, e); - } - } - - public void processHighPriorityEvents() { - try { - if (isConnected() && !isSyncInProgress()) { - if (highPriorityQueue.isEmpty()) { - return; - } - List highPriorityEvents = new ArrayList<>(); - EdgeEvent event; - while ((event = highPriorityQueue.poll()) != null) { - highPriorityEvents.add(event); - } - log.trace("[{}][{}] Sending high priority events {}", tenantId, edge.getId(), highPriorityEvents.size()); - List downlinkMsgsPack = convertToDownlinkMsgsPack(highPriorityEvents); - sendDownlinkMsgsPack(downlinkMsgsPack).get(); - } - } catch (Exception e) { - log.error("[{}] Failed to process high priority events", edge.getId(), e); - } - } - - public ListenableFuture processEdgeEvents() throws Exception { - SettableFuture result = SettableFuture.create(); - if (isConnected() && !isSyncInProgress()) { - Pair startTsAndSeqId = getQueueStartTsAndSeqId().get(); - previousStartTs = startTsAndSeqId.getFirst(); - previousStartSeqId = startTsAndSeqId.getSecond(); - GeneralEdgeEventFetcher fetcher = new GeneralEdgeEventFetcher( - previousStartTs, - previousStartSeqId, - false, - Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()), - ctx.getEdgeEventService(), - ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis()); - log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}", - tenantId, edge.getId(), previousStartTs, previousStartSeqId); - Futures.addCallback(startProcessingEdgeEvents(fetcher), new FutureCallback<>() { - @Override - public void onSuccess(@Nullable Pair newStartTsAndSeqId) { - if (newStartTsAndSeqId != null) { - ListenableFuture updateFuture = updateQueueStartTsAndSeqId(newStartTsAndSeqId); - Futures.addCallback(updateFuture, new FutureCallback<>() { - @Override - public void onSuccess(@Nullable AttributesSaveResult saveResult) { - log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edge.getId(), newStartTsAndSeqId); - boolean newEventsAvailable; - if (fetcher.isSeqIdNewCycleStarted()) { - newEventsAvailable = isNewEdgeEventsAvailable(); - } else { - newEventsAvailable = isSeqIdStartedNewCycle(); - if (!newEventsAvailable) { - newEventsAvailable = isNewEdgeEventsAvailable(); - } - } - result.set(newEventsAvailable); - } - - @Override - public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edge.getId(), newStartTsAndSeqId, t); - result.setException(t); - } - }, ctx.getGrpcCallbackExecutorService()); - } else { - log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edge.getId()); - result.set(Boolean.FALSE); - } - } - - @Override - public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to process events", tenantId, edge.getId(), t); - result.setException(t); - } - }, ctx.getGrpcCallbackExecutorService()); - } else { - if (isSyncInProgress()) { - log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edge.getId()); - result.set(Boolean.TRUE); - } else { - log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edge.getId()); - result.set(null); - } - } - return result; - } - - protected List convertToDownlinkMsgsPack(List edgeEvents) { - List result = new ArrayList<>(); - List filtered = EdgeMsgConstructorUtils.mergeAndFilterDownlinkDuplicates(edgeEvents); - for (EdgeEvent edgeEvent : filtered) { - log.trace("[{}][{}] converting edge event to downlink msg [{}]", tenantId, edge.getId(), edgeEvent); - DownlinkMsg downlinkMsg = null; - try { - switch (edgeEvent.getAction()) { - case UPDATED, ADDED, DELETED, ASSIGNED_TO_EDGE, UNASSIGNED_FROM_EDGE, ALARM_ACK, ALARM_CLEAR, - ALARM_DELETE, CREDENTIALS_UPDATED, RELATION_ADD_OR_UPDATE, RELATION_DELETED, RPC_CALL, - ASSIGNED_TO_CUSTOMER, UNASSIGNED_FROM_CUSTOMER, ADDED_COMMENT, UPDATED_COMMENT, DELETED_COMMENT -> { - downlinkMsg = convertEntityEventToDownlink(edgeEvent); - if (downlinkMsg != null && downlinkMsg.getWidgetTypeUpdateMsgCount() > 0) { - log.trace("[{}][{}] widgetTypeUpdateMsg message processed, downlinkMsgId = {}", tenantId, edge.getId(), downlinkMsg.getDownlinkMsgId()); - } else { - log.trace("[{}][{}] entity message processed [{}]", tenantId, edge.getId(), downlinkMsg); - } - } - case ATTRIBUTES_UPDATED, POST_ATTRIBUTES, ATTRIBUTES_DELETED, TIMESERIES_UPDATED -> - downlinkMsg = ctx.getTelemetryProcessor().convertTelemetryEventToDownlink(edge, edgeEvent); - default -> log.warn("[{}][{}] Unsupported action type [{}]", tenantId, edge.getId(), edgeEvent.getAction()); - } - } catch (Exception e) { - log.trace("[{}][{}] Exception during converting edge event to downlink msg", tenantId, edge.getId(), e); - } - if (downlinkMsg != null) { - result.add(downlinkMsg); - } - } - return result; - } - - private ListenableFuture> getQueueStartTsAndSeqId() { - ListenableFuture> future = - ctx.getAttributesService().find(edge.getTenantId(), edge.getId(), AttributeScope.SERVER_SCOPE, Arrays.asList(QUEUE_START_TS_ATTR_KEY, QUEUE_START_SEQ_ID_ATTR_KEY)); - return Futures.transform(future, attributeKvEntries -> { - long startTs = 0L; - long startSeqId = 0L; - for (AttributeKvEntry attributeKvEntry : attributeKvEntries) { - if (QUEUE_START_TS_ATTR_KEY.equals(attributeKvEntry.getKey())) { - startTs = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L; - } - if (QUEUE_START_SEQ_ID_ATTR_KEY.equals(attributeKvEntry.getKey())) { - startSeqId = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L; - } - } - if (startSeqId == 0L) { - startSeqId = findStartSeqIdFromOldestEventIfAny(); - } - return Pair.of(startTs, startSeqId); - }, ctx.getGrpcCallbackExecutorService()); - } - - private boolean isSeqIdStartedNewCycle() { - try { - log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edge.getId(), sessionId); - TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis()); - PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), 0L, previousStartSeqId == 0 ? null : previousStartSeqId - 1, pageLink); - boolean result = !edgeEvents.getData().isEmpty(); - log.trace("[{}][{}][{}] Result of check if seq id started new cycle, result = {}", tenantId, edge.getId(), sessionId, result); - return result; - } catch (Exception e) { - log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edge.getId(), sessionId, e); - } - return false; - } - - private boolean isNewEdgeEventsAvailable() { - try { - log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edge.getId(), sessionId); - TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis()); - PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), newStartSeqId, null, pageLink); - boolean result = !edgeEvents.getData().isEmpty() || !highPriorityQueue.isEmpty(); - log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edge.getId(), sessionId, result); - return result; - } catch (Exception e) { - log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edge.getId(), sessionId, e); - } - return false; - } - - private long findStartSeqIdFromOldestEventIfAny() { - long startSeqId = 0L; - try { - TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null); - PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(edge.getTenantId(), edge.getId(), null, null, pageLink); - if (!edgeEvents.getData().isEmpty()) { - startSeqId = edgeEvents.getData().get(0).getSeqId() - 1; - } - } catch (Exception e) { - log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edge.getId(), sessionId, e); - } - return startSeqId; - } - - private ListenableFuture updateQueueStartTsAndSeqId(Pair pair) { - newStartTs = pair.getFirst(); - newStartSeqId = pair.getSecond(); - log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", sessionId, edge.getId(), newStartTs, newStartSeqId); - List attributes = List.of( - new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()), - new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis()) - ); - return ctx.getAttributesService().save(edge.getTenantId(), edge.getId(), AttributeScope.SERVER_SCOPE, attributes); - } - - protected ListenableFuture> startProcessingEdgeEvents(EdgeEventFetcher fetcher) { - SettableFuture> result = SettableFuture.create(); - PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()); - processEdgeEvents(fetcher, pageLink, result); - return result; - } - - private void markSyncCompletedSendEdgeEventUpdate() { - syncInProgress = false; - ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(edge.getTenantId(), edge.getId())); - } - - private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) { - if (sessionState.getSendDownlinkMsgsFuture() != null && !sessionState.getSendDownlinkMsgsFuture().isDone()) { - sessionState.getSendDownlinkMsgsFuture().set(isInterrupted); - } - if (sessionState.getScheduledSendDownlinkTask() != null) { - sessionState.getScheduledSendDownlinkTask().cancel(true); - } - } - - private void sendDownlinkMsg(ResponseMsg responseMsg) { - if (isConnected()) { - String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000); - log.trace("[{}][{}] Sending downlink msg [{}]", tenantId, edge.getId(), responseMsgStr); - downlinkMsgLock.lock(); - String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr; - try { - outputStream.onNext(responseMsg); - } catch (Exception e) { - log.trace("[{}][{}] Failed to send downlink message [{}]", tenantId, edge.getId(), downlinkMsgStr, e); - connected = false; - sessionCloseListener.accept(edge, sessionId); - } finally { - downlinkMsgLock.unlock(); - } - log.trace("[{}][{}] downlink msg successfully sent [{}]", tenantId, edge.getId(), downlinkMsgStr); - } - } - - protected DownlinkMsg convertEntityEventToDownlink(EdgeEvent edgeEvent) { - log.trace("[{}] Executing convertEntityEventToDownlink, edgeEvent [{}], action [{}]", edgeEvent.getTenantId(), edgeEvent, edgeEvent.getAction()); - if ((EdgeEventType.OAUTH2_CLIENT.equals(edgeEvent.getType()) || EdgeEventType.DOMAIN.equals(edgeEvent.getType())) && - (EdgeVersionUtils.isEdgeVersionOlderThan(edgeVersion, EdgeVersion.V_3_8_0))) { - return null; - } - - return ctx.getProcessor(edgeEvent.getType()).convertEdgeEventToDownlink(edgeEvent, edgeVersion); - } - - public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) { - while (highPriorityQueue.size() > maxHighPriorityQueueSizePerSession) { - EdgeEvent oldestHighPriority = highPriorityQueue.poll(); - if (oldestHighPriority != null) { - log.warn("[{}][{}][{}] High priority queue is full. Removing oldest high priority event from queue {}", - tenantId, edge.getId(), sessionId, oldestHighPriority); - } - } - highPriorityQueue.add(edgeEvent); - ctx.getStatsCounterService().ifPresent(statsCounterService -> - statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, edge.getTenantId(), edgeEvent.getEdgeId(), 1)); - } - - protected ListenableFuture> processUplinkMsg(UplinkMsg uplinkMsg) { - List> result = new ArrayList<>(); - try { - if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) { - for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) { - result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg)); - } - } - if (uplinkMsg.getDeviceUpdateMsgCount() > 0) { - for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) { - result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg)); - } - } - if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) { - for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) { - result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsgFromEdge(edge.getTenantId(), edge.getId(), deviceCredentialsUpdateMsg)); - } - } - if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) { - for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) { - result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg)); - } - } - if (uplinkMsg.getAssetUpdateMsgCount() > 0) { - for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) { - result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg)); - } - } - if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) { - for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) { - result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg)); - } - } - if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) { - for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) { - result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg)); - } - } - if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) { - for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) { - result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg)); - } - } - if (uplinkMsg.getEntityDataCount() > 0) { - for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) { - result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData)); - } - } - if (uplinkMsg.getAlarmUpdateMsgCount() > 0) { - for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) { - result.add(ctx.getAlarmProcessor().processAlarmMsgFromEdge(edge.getTenantId(), edge.getId(), alarmUpdateMsg)); - } - } - if (uplinkMsg.getAlarmCommentUpdateMsgCount() > 0) { - for (AlarmCommentUpdateMsg alarmCommentUpdateMsg : uplinkMsg.getAlarmCommentUpdateMsgList()) { - result.add(ctx.getAlarmCommentProcessor().processAlarmCommentMsgFromEdge(edge.getTenantId(), edge.getId(), alarmCommentUpdateMsg)); - } - } - if (uplinkMsg.getRelationUpdateMsgCount() > 0) { - for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) { - result.add(ctx.getRelationProcessor().processRelationMsgFromEdge(edge.getTenantId(), edge, relationUpdateMsg)); - } - } - if (uplinkMsg.getDashboardUpdateMsgCount() > 0) { - for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) { - result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg)); - } - } - if (uplinkMsg.getResourceUpdateMsgCount() > 0) { - for (ResourceUpdateMsg resourceUpdateMsg : uplinkMsg.getResourceUpdateMsgList()) { - result.add(ctx.getResourceProcessor().processResourceMsgFromEdge(edge.getTenantId(), edge, resourceUpdateMsg)); - } - } - if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) { - for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg)); - } - } - if (uplinkMsg.getAttributesRequestMsgCount() > 0) { - for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg)); - } - } - if (uplinkMsg.getRelationRequestMsgCount() > 0) { - for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg)); - } - } - if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) { - for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg)); - } - } - if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) { - for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg)); - } - } - if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) { - for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg)); - } - } - if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) { - for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) { - result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg)); - } - } - if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) { - for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg)); - } - } - if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) { - for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) { - result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg)); - } - } - if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) { - for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) { - result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); - } - } - if (uplinkMsg.getAiModelUpdateMsgCount() > 0) { - for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) { - result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg)); - } - } - if (uplinkMsg.getUserUpdateMsgCount() > 0) { - for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) { - sequenceDependencyLock.lock(); - try { - result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg)); - } finally { - sequenceDependencyLock.unlock(); - } - } - } - if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) { - for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) { - sequenceDependencyLock.lock(); - try { - result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg)); - } finally { - sequenceDependencyLock.unlock(); - } - } - } - } catch (Exception e) { - String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); - log.trace("[{}][{}] Can't process uplink msg [{}]", tenantId, edge.getId(), uplinkMsg, e); - ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) - .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build()); - return Futures.immediateFailedFuture(e); - } - return Futures.allAsList(result); - } - - protected boolean destroy() { - return true; - } - - protected void cleanUp() {} - - @Override - public void close() { - log.debug("[{}][{}] Closing session", tenantId, sessionId); - connected = false; - try { - outputStream.onCompleted(); - } catch (Exception e) { - log.debug("[{}][{}] Failed to close output stream: {}", tenantId, sessionId, e.getMessage()); - } - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java index 7e06c01bd3..af51247720 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeRpcService.java @@ -20,7 +20,6 @@ import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; -import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; import java.util.function.Consumer; diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java index be79b51e19..3cb3920bfb 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSessionState.java @@ -16,19 +16,70 @@ package org.thingsboard.server.service.edge.rpc; import com.google.common.util.concurrent.SettableFuture; -import lombok.Data; +import lombok.Getter; +import lombok.Setter; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; import java.util.Collections; import java.util.LinkedHashMap; import java.util.Map; +import java.util.UUID; import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; -@Data +@Getter public class EdgeSessionState { - private final Map pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>()); + @Setter private SettableFuture sendDownlinkMsgsFuture; + @Setter private ScheduledFuture scheduledSendDownlinkTask; + @Setter + private volatile boolean connected; + @Setter + private EdgeVersion edgeVersion; + private final UUID sessionId = UUID.randomUUID(); + private final Map pendingMsgsMap = Collections.synchronizedMap(new LinkedHashMap<>()); + private final Lock sequenceDependencyLock = new ReentrantLock(); + private final AtomicBoolean syncInProgress = new AtomicBoolean(false); + private TenantId tenantId; + private Edge edge; + + public void setEdge(Edge edge) { + if (edge == null) { + return; + } + this.edge = edge; + this.tenantId = edge.getTenantId(); + } + + public EdgeId getEdgeId() { + return edge != null ? edge.getId() : null; + } + + public boolean tryStartSync() { + return syncInProgress.compareAndSet(false, true); + } + + public void finishSync() { + syncInProgress.set(false); + } + + public boolean isSyncInProgress() { + return syncInProgress.get(); + } + + public void lockSequenceDependency() { + sequenceDependencyLock.lock(); + } + public void unlockSequenceDependency() { + sequenceDependencyLock.unlock(); + } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java index 55f61ed54f..e9134a0b60 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeSyncCursor.java @@ -19,6 +19,7 @@ import lombok.Getter; import org.thingsboard.server.common.data.Customer; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.service.edge.EdgeContextComponent; import org.thingsboard.server.service.edge.rpc.fetch.AdminSettingsEdgeEventFetcher; import org.thingsboard.server.service.edge.rpc.fetch.AiModelEdgeEventFetcher; @@ -63,7 +64,8 @@ public class EdgeSyncCursor { fetchers.add(new TenantEdgeEventFetcher(ctx.getTenantService())); fetchers.add(new QueuesEdgeEventFetcher(ctx.getQueueService())); fetchers.add(new RuleChainsEdgeEventFetcher(ctx.getRuleChainService())); - fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService())); + fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), TenantId.SYS_TENANT_ID)); + fetchers.add(new AdminSettingsEdgeEventFetcher(ctx.getAdminSettingsService(), edge.getTenantId())); fetchers.add(new TenantAdminUsersEdgeEventFetcher(ctx.getUserService())); fetchers.add(new OAuth2EdgeEventFetcher(ctx.getDomainService())); fetchers.add(new SystemWidgetTypesEdgeEventFetcher(ctx.getWidgetTypeService())); diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java new file mode 100644 index 0000000000..e5dd0738ac --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/EdgeUplinkMessageDispatcher.java @@ -0,0 +1,238 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger; +import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AssetUpdateMsg; +import org.thingsboard.server.gen.edge.v1.AttributesRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldRequestMsg; +import org.thingsboard.server.gen.edge.v1.CalculatedFieldUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DashboardUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.DeviceCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceProfileUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DeviceRpcCallMsg; +import org.thingsboard.server.gen.edge.v1.DeviceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.EntityDataProto; +import org.thingsboard.server.gen.edge.v1.EntityViewUpdateMsg; +import org.thingsboard.server.gen.edge.v1.EntityViewsRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationRequestMsg; +import org.thingsboard.server.gen.edge.v1.RelationUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ResourceUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataRequestMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainMetadataUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RuleChainUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsRequestMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; +import org.thingsboard.server.gen.edge.v1.WidgetBundleTypesRequestMsg; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeContextComponent; + +import java.util.ArrayList; +import java.util.List; + +@Service +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class EdgeUplinkMessageDispatcher { + + private final EdgeContextComponent ctx; + + public ListenableFuture> processUplinkMsg(EdgeSessionState state, UplinkMsg uplinkMsg) { + Edge edge = state.getEdge(); + List> result = new ArrayList<>(); + try { + if (uplinkMsg.getDeviceProfileUpdateMsgCount() > 0) { + for (DeviceProfileUpdateMsg deviceProfileUpdateMsg : uplinkMsg.getDeviceProfileUpdateMsgList()) { + result.add(ctx.getDeviceProfileProcessor().processDeviceProfileMsgFromEdge(edge.getTenantId(), edge, deviceProfileUpdateMsg)); + } + } + if (uplinkMsg.getDeviceUpdateMsgCount() > 0) { + for (DeviceUpdateMsg deviceUpdateMsg : uplinkMsg.getDeviceUpdateMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceMsgFromEdge(edge.getTenantId(), edge, deviceUpdateMsg)); + } + } + if (uplinkMsg.getDeviceCredentialsUpdateMsgCount() > 0) { + for (DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg : uplinkMsg.getDeviceCredentialsUpdateMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceCredentialsMsgFromEdge(edge.getTenantId(), edge.getId(), deviceCredentialsUpdateMsg)); + } + } + if (uplinkMsg.getAssetProfileUpdateMsgCount() > 0) { + for (AssetProfileUpdateMsg assetProfileUpdateMsg : uplinkMsg.getAssetProfileUpdateMsgList()) { + result.add(ctx.getAssetProfileProcessor().processAssetProfileMsgFromEdge(edge.getTenantId(), edge, assetProfileUpdateMsg)); + } + } + if (uplinkMsg.getAssetUpdateMsgCount() > 0) { + for (AssetUpdateMsg assetUpdateMsg : uplinkMsg.getAssetUpdateMsgList()) { + result.add(ctx.getAssetProcessor().processAssetMsgFromEdge(edge.getTenantId(), edge, assetUpdateMsg)); + } + } + if (uplinkMsg.getRuleChainUpdateMsgCount() > 0) { + for (RuleChainUpdateMsg ruleChainUpdateMsg : uplinkMsg.getRuleChainUpdateMsgList()) { + result.add(ctx.getRuleChainProcessor().processRuleChainMsgFromEdge(edge.getTenantId(), edge, ruleChainUpdateMsg)); + } + } + if (uplinkMsg.getRuleChainMetadataUpdateMsgCount() > 0) { + for (RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg : uplinkMsg.getRuleChainMetadataUpdateMsgList()) { + result.add(ctx.getRuleChainProcessor().processRuleChainMetadataMsgFromEdge(edge.getTenantId(), edge, ruleChainMetadataUpdateMsg)); + } + } + if (uplinkMsg.getEntityViewUpdateMsgCount() > 0) { + for (EntityViewUpdateMsg entityViewUpdateMsg : uplinkMsg.getEntityViewUpdateMsgList()) { + result.add(ctx.getEntityViewProcessor().processEntityViewMsgFromEdge(edge.getTenantId(), edge, entityViewUpdateMsg)); + } + } + if (uplinkMsg.getEntityDataCount() > 0) { + for (EntityDataProto entityData : uplinkMsg.getEntityDataList()) { + result.addAll(ctx.getTelemetryProcessor().processTelemetryMsg(edge.getTenantId(), entityData)); + } + } + if (uplinkMsg.getAlarmUpdateMsgCount() > 0) { + for (AlarmUpdateMsg alarmUpdateMsg : uplinkMsg.getAlarmUpdateMsgList()) { + result.add(ctx.getAlarmProcessor().processAlarmMsgFromEdge(edge.getTenantId(), edge.getId(), alarmUpdateMsg)); + } + } + if (uplinkMsg.getAlarmCommentUpdateMsgCount() > 0) { + for (AlarmCommentUpdateMsg alarmCommentUpdateMsg : uplinkMsg.getAlarmCommentUpdateMsgList()) { + result.add(ctx.getAlarmCommentProcessor().processAlarmCommentMsgFromEdge(edge.getTenantId(), edge.getId(), alarmCommentUpdateMsg)); + } + } + if (uplinkMsg.getRelationUpdateMsgCount() > 0) { + for (RelationUpdateMsg relationUpdateMsg : uplinkMsg.getRelationUpdateMsgList()) { + result.add(ctx.getRelationProcessor().processRelationMsgFromEdge(edge.getTenantId(), edge, relationUpdateMsg)); + } + } + if (uplinkMsg.getDashboardUpdateMsgCount() > 0) { + for (DashboardUpdateMsg dashboardUpdateMsg : uplinkMsg.getDashboardUpdateMsgList()) { + result.add(ctx.getDashboardProcessor().processDashboardMsgFromEdge(edge.getTenantId(), edge, dashboardUpdateMsg)); + } + } + if (uplinkMsg.getResourceUpdateMsgCount() > 0) { + for (ResourceUpdateMsg resourceUpdateMsg : uplinkMsg.getResourceUpdateMsgList()) { + result.add(ctx.getResourceProcessor().processResourceMsgFromEdge(edge.getTenantId(), edge, resourceUpdateMsg)); + } + } + if (uplinkMsg.getRuleChainMetadataRequestMsgCount() > 0) { + for (RuleChainMetadataRequestMsg ruleChainMetadataRequestMsg : uplinkMsg.getRuleChainMetadataRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processRuleChainMetadataRequestMsg(edge.getTenantId(), edge, ruleChainMetadataRequestMsg)); + } + } + if (uplinkMsg.getAttributesRequestMsgCount() > 0) { + for (AttributesRequestMsg attributesRequestMsg : uplinkMsg.getAttributesRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processAttributesRequestMsg(edge.getTenantId(), edge, attributesRequestMsg)); + } + } + if (uplinkMsg.getRelationRequestMsgCount() > 0) { + for (RelationRequestMsg relationRequestMsg : uplinkMsg.getRelationRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processRelationRequestMsg(edge.getTenantId(), edge, relationRequestMsg)); + } + } + if (uplinkMsg.getCalculatedFieldRequestMsgCount() > 0) { + for (CalculatedFieldRequestMsg calculatedFieldRequestMsg : uplinkMsg.getCalculatedFieldRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processCalculatedFieldRequestMsg(edge.getTenantId(), edge, calculatedFieldRequestMsg)); + } + } + if (uplinkMsg.getUserCredentialsRequestMsgCount() > 0) { + for (UserCredentialsRequestMsg userCredentialsRequestMsg : uplinkMsg.getUserCredentialsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processUserCredentialsRequestMsg(edge.getTenantId(), edge, userCredentialsRequestMsg)); + } + } + if (uplinkMsg.getDeviceCredentialsRequestMsgCount() > 0) { + for (DeviceCredentialsRequestMsg deviceCredentialsRequestMsg : uplinkMsg.getDeviceCredentialsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processDeviceCredentialsRequestMsg(edge.getTenantId(), edge, deviceCredentialsRequestMsg)); + } + } + if (uplinkMsg.getDeviceRpcCallMsgCount() > 0) { + for (DeviceRpcCallMsg deviceRpcCallMsg : uplinkMsg.getDeviceRpcCallMsgList()) { + result.add(ctx.getDeviceProcessor().processDeviceRpcCallFromEdge(edge.getTenantId(), edge, deviceRpcCallMsg)); + } + } + if (uplinkMsg.getWidgetBundleTypesRequestMsgCount() > 0) { + for (WidgetBundleTypesRequestMsg widgetBundleTypesRequestMsg : uplinkMsg.getWidgetBundleTypesRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processWidgetBundleTypesRequestMsg(edge.getTenantId(), edge, widgetBundleTypesRequestMsg)); + } + } + if (uplinkMsg.getEntityViewsRequestMsgCount() > 0) { + for (EntityViewsRequestMsg entityViewRequestMsg : uplinkMsg.getEntityViewsRequestMsgList()) { + result.add(ctx.getEdgeRequestsService().processEntityViewsRequestMsg(edge.getTenantId(), edge, entityViewRequestMsg)); + } + } + if (uplinkMsg.getCalculatedFieldUpdateMsgCount() > 0) { + for (CalculatedFieldUpdateMsg calculatedFieldUpdateMsg : uplinkMsg.getCalculatedFieldUpdateMsgList()) { + result.add(ctx.getCalculatedFieldProcessor().processCalculatedFieldMsgFromEdge(edge.getTenantId(), edge, calculatedFieldUpdateMsg)); + } + } + if (uplinkMsg.getAiModelUpdateMsgCount() > 0) { + for (AiModelUpdateMsg aiModelUpdateMsg : uplinkMsg.getAiModelUpdateMsgList()) { + result.add(ctx.getAiModelProcessor().processAiModelMsgFromEdge(edge.getTenantId(), edge, aiModelUpdateMsg)); + } + } + if (uplinkMsg.getUserUpdateMsgCount() > 0) { + for (UserUpdateMsg userUpdateMsg : uplinkMsg.getUserUpdateMsgList()) { + state.lockSequenceDependency(); + try { + result.add(ctx.getUserProcessor().processUserMsgFromEdge(edge.getTenantId(), edge, userUpdateMsg)); + } finally { + state.unlockSequenceDependency(); + } + } + } + if (uplinkMsg.getUserCredentialsUpdateMsgCount() > 0) { + for (UserCredentialsUpdateMsg userCredentialsUpdateMsg : uplinkMsg.getUserCredentialsUpdateMsgList()) { + state.lockSequenceDependency(); + try { + result.add(ctx.getUserProcessor().processUserCredentialsMsgFromEdge(edge.getTenantId(), edge, userCredentialsUpdateMsg)); + } finally { + state.unlockSequenceDependency(); + } + } + } + if (uplinkMsg.getApiKeyUpdateMsgCount() > 0) { + for (ApiKeyUpdateMsg apiKeyUpdateMsg : uplinkMsg.getApiKeyUpdateMsgList()) { + state.lockSequenceDependency(); + try { + result.add(ctx.getApiKeyProcessor().processApiKeyMsgFromEdge(edge.getTenantId(), edge, apiKeyUpdateMsg)); + } finally { + state.unlockSequenceDependency(); + } + } + } + } catch (Exception e) { + String failureMsg = String.format("Can't process uplink msg [%s] from edge", uplinkMsg); + log.trace("[{}][{}] Can't process uplink msg [{}]", edge.getTenantId(), edge.getId(), uplinkMsg, e); + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(edge.getTenantId()).edgeId(edge.getId()) + .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build()); + return Futures.immediateFailedFuture(e); + } + return Futures.allAsList(result); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java new file mode 100644 index 0000000000..0b19f10aa4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/GrpcServer.java @@ -0,0 +1,131 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc.EdgeRpcServiceImplBase; +import org.thingsboard.server.queue.util.AfterStartUp; +import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +@Component +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class GrpcServer { + + @Value("${edges.rpc.port}") + private int rpcPort; + @Value("${edges.rpc.ssl.enabled}") + private boolean sslEnabled; + @Value("${edges.rpc.ssl.cert}") + private String certFileResource; + @Value("${edges.rpc.ssl.private_key}") + private String privateKeyResource; + @Value("${edges.rpc.ssl.key_password:}") + private String keyPassword; + @Value("${edges.rpc.client_max_keep_alive_time_sec:1}") + private int clientMaxKeepAliveTimeSec; + @Value("${edges.rpc.max_inbound_message_size:4194304}") + private int maxInboundMessageSize; + @Value("${edges.rpc.keep_alive_time_sec:10}") + private int keepAliveTimeSec; + @Value("${edges.rpc.keep_alive_timeout_sec:5}") + private int keepAliveTimeoutSec; + + private final EdgeRpcServiceImplBase edgeGrpcService; + + private Server server; + + @AfterStartUp(order = AfterStartUp.REGULAR_SERVICE) + public void onStartUp() { + log.info("Initializing Edge RPC server!"); + NettyServerBuilder serverBuilder = NettyServerBuilder.forPort(rpcPort) + .permitKeepAliveTime(clientMaxKeepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTime(keepAliveTimeSec, TimeUnit.SECONDS) + .keepAliveTimeout(keepAliveTimeoutSec, TimeUnit.SECONDS) + .permitKeepAliveWithoutCalls(true) + .maxInboundMessageSize(maxInboundMessageSize) + .addService(edgeGrpcService); + + if (sslEnabled) { + setupSsl(serverBuilder); + } + server = serverBuilder.build(); + log.info("Going to start Edge RPC server using port: {}", rpcPort); + try { + server.start(); + } catch (IOException e) { + log.error("Failed to start Edge RPC server!", e); + throw new RuntimeException("Failed to start Edge RPC server!", e); + } + log.info("Edge RPC server initialized!"); + } + + /** + * Configures TLS for the Edge gRPC server. + *

+ * Delegates PEM parsing and key management to {@link PemSslCredentials} — the same + * class used by MQTT, CoAP, and LwM2M transports — which supports: + *

    + *
  • Separate certificate and private key files (classic two-file setup)
  • + *
  • Combined PEM: certificate chain + private key in a single {@code cert} file + * ({@code private_key} left empty)
  • + *
  • Encrypted private keys (password supplied via {@code key_password})
  • + *
+ * Path resolution (for both {@code cert} and {@code private_key}) is handled by + * {@link org.thingsboard.server.common.data.ResourceUtils#getInputStream ResourceUtils}: + * absolute path → relative / working-dir → classpath → {@code classpath:} prefix. + */ + void setupSsl(NettyServerBuilder builder) { + try { + PemSslCredentials credentials = new PemSslCredentials(); + credentials.setCertFile(certFileResource); + credentials.setKeyFile(StringUtils.isEmpty(privateKeyResource) ? null : privateKeyResource); + credentials.setKeyPassword(keyPassword); + credentials.init(false); + + SslContext sslContext = GrpcSslContexts.configure( + SslContextBuilder.forServer(credentials.createKeyManagerFactory())).build(); + builder.sslContext(sslContext); + } catch (Exception e) { + log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + throw new RuntimeException("Unable to set up SSL context!", e); + } + } + + @PreDestroy + public void destroy() { + if (server != null) { + server.shutdownNow(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java deleted file mode 100644 index f7e729c6b7..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/KafkaEdgeGrpcSession.java +++ /dev/null @@ -1,186 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.edge.rpc; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import io.grpc.stub.StreamObserver; -import lombok.Getter; -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.common.util.ThingsBoardThreadFactory; -import org.thingsboard.server.common.data.edge.Edge; -import org.thingsboard.server.common.data.edge.EdgeEvent; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.common.util.ProtoUtils; -import org.thingsboard.server.gen.edge.v1.DownlinkMsg; -import org.thingsboard.server.gen.edge.v1.ResponseMsg; -import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; -import org.thingsboard.server.queue.TbQueueConsumer; -import org.thingsboard.server.queue.common.TbProtoQueueMsg; -import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; -import org.thingsboard.server.queue.discovery.TopicService; -import org.thingsboard.server.queue.kafka.KafkaAdmin; -import org.thingsboard.server.queue.provider.TbCoreQueueFactory; -import org.thingsboard.server.service.edge.EdgeContextComponent; - -import java.util.ArrayList; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.function.BiConsumer; - -@Slf4j -public class KafkaEdgeGrpcSession extends EdgeGrpcSession { - - private final TopicService topicService; - private final TbCoreQueueFactory tbCoreQueueFactory; - private final KafkaAdmin kafkaAdmin; - - private volatile boolean isHighPriorityProcessing; - - @Getter - private QueueConsumerManager> consumer; - - private ExecutorService consumerExecutor; - - public KafkaEdgeGrpcSession(EdgeContextComponent ctx, TopicService topicService, TbCoreQueueFactory tbCoreQueueFactory, - KafkaAdmin kafkaAdmin, StreamObserver outputStream, - BiConsumer sessionOpenListener, BiConsumer sessionCloseListener, - ScheduledExecutorService sendDownlinkExecutorService, int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) { - super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession); - this.topicService = topicService; - this.tbCoreQueueFactory = tbCoreQueueFactory; - this.kafkaAdmin = kafkaAdmin; - } - - private void processMsgs(List> msgs, TbQueueConsumer> consumer) { - log.trace("[{}][{}] starting processing edge events", tenantId, edge.getId()); - if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) { - log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " + - "connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration", - tenantId, edge.getId(), isConnected(), isSyncInProgress(), isHighPriorityProcessing); - return; - } - List edgeEvents = new ArrayList<>(); - for (TbProtoQueueMsg msg : msgs) { - EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg()); - edgeEvents.add(edgeEvent); - } - List downlinkMsgsPack = convertToDownlinkMsgsPack(edgeEvents); - boolean isInterrupted = true; - try { - isInterrupted = sendDownlinkMsgsPack(downlinkMsgsPack).get(); - if (isInterrupted) { - log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId()); - } - } catch (Exception e) { - log.error("[{}][{}] Failed to process downlink messages", tenantId, edge.getId(), e); - } - if (!isInterrupted) { - consumer.commit(); - } - } - - @Override - public ListenableFuture migrateEdgeEvents() throws Exception { - return super.processEdgeEvents(); - } - - @Override - public ListenableFuture processEdgeEvents() { - if (!isConnected() || isSyncInProgress() || isHighPriorityProcessing) { - log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer", - tenantId, edge != null ? edge.getId() : null, isConnected(), isSyncInProgress(), isHighPriorityProcessing); - return Futures.immediateFuture(Boolean.FALSE); - } - if (consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped())) { - try { - if (consumerExecutor != null && !consumerExecutor.isShutdown()) { - try { - consumerExecutor.shutdown(); - awaitConsumerTermination(); - } catch (Exception e) { - log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edge.getId(), e); - } - } - this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer")); - this.consumer = QueueConsumerManager.>builder() - .name("TB Edge events [" + edge.getId() + "]") - .msgPackProcessor(this::processMsgs) - .pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval()) - .consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edge.getId())) - .consumerExecutor(consumerExecutor) - .threadPrefix("edge-events-" + edge.getId()) - .build(); - consumer.subscribe(); - consumer.launch(); - } catch (Exception e) { - destroy(); - log.warn("[{}][{}] Failed to start edge event consumer", sessionId, edge.getId(), e); - } - } - return Futures.immediateFuture(Boolean.FALSE); - } - - @Override - public void processHighPriorityEvents() { - isHighPriorityProcessing = true; - super.processHighPriorityEvents(); - isHighPriorityProcessing = false; - } - - @Override - public boolean destroy() { - try { - if (consumer != null) { - log.info("[{}][{}] Stopping edge event consumer...", tenantId, edge != null ? edge.getId() : null); - consumer.stop(); - } - } catch (Exception e) { - log.warn("[{}][{}] Failed to stop edge event consumer", tenantId, edge.getId(), e); - return false; - } - consumer = null; - try { - if (consumerExecutor != null && !consumerExecutor.isShutdown()) { - consumerExecutor.shutdown(); - awaitConsumerTermination(); - } - } catch (Exception e) { - log.warn("[{}][{}] Failed to shutdown edge event consumer executor", tenantId, edge.getId(), e); - return false; - } - return true; - } - - private void awaitConsumerTermination() { - try { - consumerExecutor.awaitTermination(5, java.util.concurrent.TimeUnit.SECONDS); - } catch (InterruptedException ie) { - log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", tenantId, edge.getId()); - } - } - - @Override - public void cleanUp() { - String topic = topicService.buildEdgeEventNotificationsTopicPartitionInfo(tenantId, edge.getId()).getTopic(); - kafkaAdmin.deleteTopic(topic); - kafkaAdmin.deleteConsumerGroup(topic); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java deleted file mode 100644 index bfeb3f09aa..0000000000 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/PostgresEdgeGrpcSession.java +++ /dev/null @@ -1,46 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.service.edge.rpc; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import io.grpc.stub.StreamObserver; -import lombok.extern.slf4j.Slf4j; -import org.thingsboard.server.common.data.edge.Edge; -import org.thingsboard.server.common.data.id.EdgeId; -import org.thingsboard.server.gen.edge.v1.ResponseMsg; -import org.thingsboard.server.service.edge.EdgeContextComponent; - -import java.util.UUID; -import java.util.concurrent.ScheduledExecutorService; -import java.util.function.BiConsumer; - -@Slf4j -public class PostgresEdgeGrpcSession extends EdgeGrpcSession { - - PostgresEdgeGrpcSession(EdgeContextComponent ctx, StreamObserver outputStream, - BiConsumer sessionOpenListener, - BiConsumer sessionCloseListener, ScheduledExecutorService sendDownlinkExecutorService, - int maxInboundMessageSize, int maxHighPriorityQueueSizePerSession) { - super(ctx, outputStream, sessionOpenListener, sessionCloseListener, sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession); - } - - @Override - public ListenableFuture migrateEdgeEvents() { - return Futures.immediateFuture(Boolean.FALSE); - } - -} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java index 7e4b463573..47e004ac82 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/AdminSettingsEdgeEventFetcher.java @@ -17,50 +17,32 @@ package org.thingsboard.server.service.edge.rpc.fetch; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; -import org.thingsboard.server.common.data.id.EdgeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.settings.AdminSettingsService; -import java.util.ArrayList; -import java.util.List; - @AllArgsConstructor @Slf4j -public class AdminSettingsEdgeEventFetcher implements EdgeEventFetcher { +public class AdminSettingsEdgeEventFetcher extends BasePageableEdgeEventFetcher { private final AdminSettingsService adminSettingsService; + private final TenantId fetcherTenantId; @Override - public PageLink getPageLink(int pageSize) { - return null; - } - - public PageData fetchEdgeEvents(TenantId tenantId, Edge edge, PageLink pageLink) { - List result = fetchAdminSettingsForKeys(tenantId, edge.getId(), List.of("general", "mail", "connectivity", "jwt")); - - // return PageData object to be in sync with other fetchers - return new PageData<>(result, 1, result.size(), false); + PageData fetchEntities(TenantId tenantId, Edge edge, PageLink pageLink) { + return adminSettingsService.findAllByTenantId(fetcherTenantId, pageLink); } - private List fetchAdminSettingsForKeys(TenantId tenantId, EdgeId edgeId, List keys) { - List result = new ArrayList<>(); - for (String key : keys) { - AdminSettings adminSettings = adminSettingsService.findAdminSettingsByKey(TenantId.SYS_TENANT_ID, key); - if (adminSettings != null) { - result.add(EdgeUtils.constructEdgeEvent(tenantId, edgeId, EdgeEventType.ADMIN_SETTINGS, - EdgeEventActionType.UPDATED, null, JacksonUtil.valueToTree(adminSettings))); - } - } - return result; + @Override + EdgeEvent constructEdgeEvent(TenantId tenantId, Edge edge, AdminSettings adminSettings) { + return EdgeUtils.constructEdgeEvent(tenantId, edge.getId(), EdgeEventType.ADMIN_SETTINGS, + EdgeEventActionType.UPDATED, adminSettings.getId(), null); } - } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java index b7337173cd..6c65d141d8 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/fetch/OAuth2EdgeEventFetcher.java @@ -41,7 +41,7 @@ public class OAuth2EdgeEventFetcher extends BasePageableEdgeEventFetcher true; default -> switch (type) { case ALARM, ALARM_COMMENT, RULE_CHAIN, RULE_CHAIN_METADATA, USER, CUSTOMER, TENANT, TENANT_PROFILE, - WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, NOTIFICATION_TEMPLATE, + WIDGETS_BUNDLE, WIDGET_TYPE, ADMIN_SETTINGS, OTA_PACKAGE, QUEUE, RELATION, CALCULATED_FIELD, AI_MODEL, API_KEY, NOTIFICATION_TEMPLATE, NOTIFICATION_TARGET, NOTIFICATION_RULE -> true; default -> false; }; @@ -412,6 +412,9 @@ public abstract class BaseEdgeProcessor implements EdgeProcessor { } protected boolean isSaveRequired(HasVersion current, HasVersion updated) { + if (current != null) { + current.setVersion(null); + } updated.setVersion(null); return !updated.equals(current); } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java new file mode 100644 index 0000000000..f218148d30 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/EdgeEventsDispatcher.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.ListenableFuture; + +public interface EdgeEventsDispatcher { + + ListenableFuture processNewEvents() throws Exception; +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java new file mode 100644 index 0000000000..d483706485 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/PostgresGeneralEdgeEventsDispatcher.java @@ -0,0 +1,218 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.data.util.Pair; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.AttributesSaveResult; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.session.EdgeSession; + +import java.util.Arrays; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.ExecutionException; + +@Slf4j +@RequiredArgsConstructor +public class PostgresGeneralEdgeEventsDispatcher implements EdgeEventsDispatcher { + + private static final String QUEUE_START_TS_ATTR_KEY = "queueStartTs"; + private static final String QUEUE_START_SEQ_ID_ATTR_KEY = "queueStartSeqId"; + + private final EdgeSession session; + private final EdgeContextComponent ctx; + + private Long newStartTs; + private Long previousStartTs; + private Long newStartSeqId; + private Long previousStartSeqId; + + @Override + public ListenableFuture processNewEvents() throws Exception { + EdgeSessionState state = session.getState(); + TenantId tenantId = state.getTenantId(); + EdgeId edgeId = state.getEdgeId(); + UUID sessionId = state.getSessionId(); + + SettableFuture result = SettableFuture.create(); + if (state.isConnected() && !state.isSyncInProgress()) { + setLocalPrevStartTsAndSeqId(tenantId, edgeId, sessionId); + GeneralEdgeEventFetcher fetcher = initGeneralEdgeEventFetcher(); + log.trace("[{}][{}] starting processing edge events, previousStartTs = {}, previousStartSeqId = {}", + tenantId, edgeId, previousStartTs, previousStartSeqId); + + Futures.addCallback(session.fetchAndSendEdgeEvents(fetcher), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Pair newStartTsAndSeqId) { + if (newStartTsAndSeqId != null) { + Futures.addCallback(updateQueueStartTsAndSeqId(tenantId, edgeId, newStartTsAndSeqId), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable AttributesSaveResult saveResult) { + log.debug("[{}][{}] queue offset was updated [{}]", tenantId, edgeId, newStartTsAndSeqId); + boolean newEventsAvailable; + if (fetcher.isSeqIdNewCycleStarted()) { + newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId); + } else { + newEventsAvailable = isSeqIdStartedNewCycle(tenantId, edgeId, sessionId); + if (!newEventsAvailable) { + newEventsAvailable = isNewEdgeEventsAvailable(tenantId, edgeId, sessionId); + } + } + result.set(newEventsAvailable); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Failed to update queue offset [{}]", tenantId, edgeId, newStartTsAndSeqId, t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.trace("[{}][{}] newStartTsAndSeqId is null. Skipping iteration without db update", tenantId, edgeId); + result.set(Boolean.FALSE); + } + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Failed to process events", tenantId, edgeId, t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + if (state.isSyncInProgress()) { + log.trace("[{}][{}] edge sync is not completed yet. Skipping iteration", tenantId, edgeId); + result.set(Boolean.TRUE); + } else { + log.trace("[{}][{}] edge is not connected. Skipping iteration", tenantId, edgeId); + result.set(null); + } + } + return result; + } + + private boolean isSeqIdStartedNewCycle(TenantId tenantId, EdgeId edgeId, UUID sessionId) { + try { + log.trace("[{}][{}][{}] Checking if seq id started new cycle", tenantId, edgeId, sessionId); + TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis()); + PageData edgeEvents = ctx.getEdgeEventService() + .findEdgeEvents(tenantId, edgeId, 0L, previousStartSeqId == 0 ? null : previousStartSeqId - 1, pageLink); + + boolean result = !edgeEvents.getData().isEmpty(); + log.trace("[{}][{}][{}] Result of check if seq id started new cycle, result = {}", tenantId, edgeId, sessionId, result); + return result; + } catch (Exception e) { + log.error("[{}][{}][{}] Failed to execute isSeqIdStartedNewCycle", tenantId, edgeId, sessionId, e); + } + return false; + } + + private boolean isNewEdgeEventsAvailable(TenantId tenantId, EdgeId edgeId, UUID sessionId) { + try { + log.trace("[{}][{}][{}] Checking if new edge events available", tenantId, edgeId, sessionId); + TimePageLink pageLink = new TimePageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount(), 0, null, null, newStartTs, System.currentTimeMillis()); + PageData edgeEvents = ctx.getEdgeEventService().findEdgeEvents(tenantId, edgeId, newStartSeqId, null, pageLink); + boolean available = !edgeEvents.getData().isEmpty() || session.hasHighPriorityEvents(); + log.trace("[{}][{}][{}] Result of check if new edge events available, result = {}", tenantId, edgeId, sessionId, available); + return available; + } catch (Exception e) { + log.error("[{}][{}][{}] Failed to execute isNewEdgeEventsAvailable", tenantId, edgeId, sessionId, e); + } + return false; + } + + private ListenableFuture updateQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, Pair pair) { + newStartTs = pair.getFirst(); + newStartSeqId = pair.getSecond(); + log.trace("[{}] updateQueueStartTsAndSeqId [{}][{}][{}]", session.getState().getSessionId(), edgeId, newStartTs, newStartSeqId); + List attributes = List.of( + new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_TS_ATTR_KEY, newStartTs), System.currentTimeMillis()), + new BaseAttributeKvEntry(new LongDataEntry(QUEUE_START_SEQ_ID_ATTR_KEY, newStartSeqId), System.currentTimeMillis()) + ); + return ctx.getAttributesService().save(tenantId, edgeId, AttributeScope.SERVER_SCOPE, attributes); + } + + private GeneralEdgeEventFetcher initGeneralEdgeEventFetcher() { + return new GeneralEdgeEventFetcher( + previousStartTs, + previousStartSeqId, + false, + Integer.toUnsignedLong(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()), + ctx.getEdgeEventService(), + ctx.getEdgeEventStorageSettings().getMisorderingCompensationMillis()); + } + + private void setLocalPrevStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) throws InterruptedException, ExecutionException { + Pair startTsAndSeqId = getQueueStartTsAndSeqId(tenantId, edgeId, sessionId).get(); + previousStartTs = startTsAndSeqId.getFirst(); + previousStartSeqId = startTsAndSeqId.getSecond(); + } + + private ListenableFuture> getQueueStartTsAndSeqId(TenantId tenantId, EdgeId edgeId, UUID sessionId) { + ListenableFuture> future = ctx.getAttributesService() + .find(tenantId, edgeId, AttributeScope.SERVER_SCOPE, Arrays.asList(QUEUE_START_TS_ATTR_KEY, QUEUE_START_SEQ_ID_ATTR_KEY)); + + return Futures.transform(future, attributeKvEntries -> { + long startTs = 0L; + long startSeqId = 0L; + for (AttributeKvEntry attributeKvEntry : attributeKvEntries) { + if (QUEUE_START_TS_ATTR_KEY.equals(attributeKvEntry.getKey())) { + startTs = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L; + } + if (QUEUE_START_SEQ_ID_ATTR_KEY.equals(attributeKvEntry.getKey())) { + startSeqId = attributeKvEntry.getLongValue().isPresent() ? attributeKvEntry.getLongValue().get() : 0L; + } + } + if (startSeqId == 0L) { + startSeqId = findStartSeqIdFromOldestEventIfAny(tenantId, edgeId, sessionId); + } + return Pair.of(startTs, startSeqId); + }, ctx.getGrpcCallbackExecutorService()); + } + + private long findStartSeqIdFromOldestEventIfAny(TenantId tenantId, EdgeId edgeId, UUID sessionId) { + long startSeqId = 0L; + try { + TimePageLink pageLink = new TimePageLink(1, 0, null, null, null, null); + PageData edgeEvents = ctx.getEdgeEventService() + .findEdgeEvents(tenantId, edgeId, null, null, pageLink); + if (!edgeEvents.getData().isEmpty()) { + startSeqId = edgeEvents.getData().get(0).getSeqId() - 1; + } + } catch (Exception e) { + log.error("[{}][{}][{}] Failed to execute findStartSeqIdFromOldestEventIfAny", tenantId, edgeId, sessionId, e); + } + return startSeqId; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java new file mode 100644 index 0000000000..960c0dd695 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyEdgeProcessor.java @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor.apikey; + +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; + +import java.util.UUID; + +@Slf4j +@Component +@TbCoreComponent +public class ApiKeyEdgeProcessor extends BaseApiKeyProcessor implements ApiKeyProcessor { + + @Override + public ListenableFuture processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg) { + ApiKeyId apiKeyId = new ApiKeyId(new UUID(apiKeyUpdateMsg.getIdMSB(), apiKeyUpdateMsg.getIdLSB())); + try { + edgeSynchronizationManager.getEdgeId().set(edge.getId()); + + return switch (apiKeyUpdateMsg.getMsgType()) { + case ENTITY_CREATED_RPC_MESSAGE, ENTITY_UPDATED_RPC_MESSAGE -> { + boolean created = saveOrUpdateApiKey(tenantId, apiKeyId, apiKeyUpdateMsg); + if (created) { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (apiKey != null) { + pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_CREATED); + } + } + yield Futures.immediateFuture(null); + } + case ENTITY_DELETED_RPC_MESSAGE -> { + deleteApiKey(tenantId, edge, apiKeyId); + yield Futures.immediateFuture(null); + } + default -> handleUnsupportedMsgType(apiKeyUpdateMsg.getMsgType()); + }; + } catch (DataValidationException e) { + return Futures.immediateFailedFuture(e); + } finally { + edgeSynchronizationManager.getEdgeId().remove(); + } + } + + @Override + public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { + ApiKeyId apiKeyId = new ApiKeyId(edgeEvent.getEntityId()); + switch (edgeEvent.getAction()) { + case ADDED, UPDATED -> { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(edgeEvent.getTenantId(), apiKeyId); + if (apiKey != null) { + UpdateMsgType msgType = getUpdateMsgType(edgeEvent.getAction()); + ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyUpdatedMsg(msgType, apiKey); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addApiKeyUpdateMsg(apiKeyUpdateMsg) + .build(); + } + } + case DELETED -> { + ApiKeyUpdateMsg apiKeyUpdateMsg = EdgeMsgConstructorUtils.constructApiKeyDeleteMsg(apiKeyId); + return DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .addApiKeyUpdateMsg(apiKeyUpdateMsg) + .build(); + } + } + return null; + } + + @Override + public EdgeEventType getEdgeEventType() { + return EdgeEventType.API_KEY; + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java new file mode 100644 index 0000000000..f50594c9b7 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/ApiKeyProcessor.java @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor.apikey; + +import com.google.common.util.concurrent.ListenableFuture; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.EdgeProcessor; + +public interface ApiKeyProcessor extends EdgeProcessor { + + ListenableFuture processApiKeyMsgFromEdge(TenantId tenantId, Edge edge, ApiKeyUpdateMsg apiKeyUpdateMsg); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java new file mode 100644 index 0000000000..7d110c3670 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/apikey/BaseApiKeyProcessor.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.processor.apikey; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.service.edge.rpc.processor.BaseEdgeProcessor; + +@Slf4j +public abstract class BaseApiKeyProcessor extends BaseEdgeProcessor { + + protected boolean saveOrUpdateApiKey(TenantId tenantId, ApiKeyId apiKeyId, ApiKeyUpdateMsg apiKeyUpdateMsg) { + boolean isCreated = false; + try { + ApiKey apiKey = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + if (apiKey == null) { + throw new RuntimeException("[{" + tenantId + "}] apiKeyUpdateMsg {" + apiKeyUpdateMsg + " } cannot be converted to apiKey"); + } + + ApiKey existingApiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (existingApiKey == null) { + apiKey.setCreatedTime(Uuids.unixTimestamp(apiKeyId.getId())); + isCreated = true; + } + + apiKey.setId(apiKeyId); + edgeCtx.getApiKeyService().saveApiKey(tenantId, apiKey, apiKey.getValue(), false); + } catch (Exception e) { + log.error("[{}] Failed to process apiKey update msg [{}]", tenantId, apiKeyUpdateMsg, e); + throw e; + } + return isCreated; + } + + protected void deleteApiKey(TenantId tenantId, Edge edge, ApiKeyId apiKeyId) { + ApiKey apiKey = edgeCtx.getApiKeyService().findApiKeyById(tenantId, apiKeyId); + if (apiKey != null) { + edgeCtx.getApiKeyService().deleteApiKey(tenantId, apiKey, false); + pushEntityEventToRuleEngine(tenantId, edge, apiKey, TbMsgType.ENTITY_DELETED); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java index 5f11abb478..bc085afc08 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/cf/CalculatedFieldEdgeProcessor.java @@ -22,6 +22,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.data.util.Pair; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.edge.rpc.EdgeVersionComparator; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -116,7 +117,7 @@ public class CalculatedFieldEdgeProcessor extends BaseCalculatedFieldProcessor i } private boolean isValidCfToSend(CalculatedFieldType type, EdgeVersion edgeVersion) { - return edgeVersion.getNumber() >= EdgeVersion.V_4_3_0.getNumber() || (type == SIMPLE || type == SCRIPT); + return EdgeVersionComparator.INSTANCE.compare(edgeVersion, EdgeVersion.V_4_3_0) >= 0 || (type == SIMPLE || type == SCRIPT); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java index f325f32a4d..2e0a65f324 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/settings/AdminSettingsEdgeProcessor.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; @@ -35,7 +36,14 @@ public class AdminSettingsEdgeProcessor extends BaseEdgeProcessor { @Override public DownlinkMsg convertEdgeEventToDownlink(EdgeEvent edgeEvent, EdgeVersion edgeVersion) { - AdminSettings adminSettings = JacksonUtil.convertValue(edgeEvent.getBody(), AdminSettings.class); + AdminSettings adminSettings = null; + if (edgeEvent.getEntityId() != null) { + AdminSettingsId adminSettingsId = new AdminSettingsId(edgeEvent.getEntityId()); + adminSettings = edgeCtx.getAdminSettingsService().findAdminSettingsById(edgeEvent.getTenantId(), adminSettingsId); + } else if (edgeEvent.getBody() != null && !edgeEvent.getBody().isEmpty()) { + // legacy + adminSettings = JacksonUtil.convertValue(edgeEvent.getBody(), AdminSettings.class); + } if (adminSettings == null) { return null; } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java index 027b1225c4..03fc29ae3c 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/processor/user/UserEdgeProcessor.java @@ -23,6 +23,7 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.EdgeUtils; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; @@ -34,6 +35,7 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.security.UserCredentials; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.DownlinkMsg; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -42,6 +44,7 @@ import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; +import java.util.List; import java.util.UUID; @Slf4j @@ -135,6 +138,10 @@ public class UserEdgeProcessor extends BaseUserProcessor implements UserProcesso if (userCredentialsByUserId != null) { builder.addUserCredentialsUpdateMsg(EdgeMsgConstructorUtils.constructUserCredentialsUpdatedMsg(userCredentialsByUserId)); } + List apiKeys = edgeCtx.getApiKeyService().findApiKeysByUserId(edgeEvent.getTenantId(), userId); + for (ApiKey apiKey : apiKeys) { + builder.addApiKeyUpdateMsg(EdgeMsgConstructorUtils.constructApiKeyUpdatedMsg(msgType, apiKey)); + } return builder.build(); } } diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java new file mode 100644 index 0000000000..b50d643634 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/service/EdgeGrpcService.java @@ -0,0 +1,392 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.service; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.grpc.stub.StreamObserver; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Lazy; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.rule.engine.api.TimeseriesSaveRequest; +import org.thingsboard.server.cache.TbTransactionalCache; +import org.thingsboard.server.cluster.TbClusterService; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeConnectionTrigger; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgDataType; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.common.msg.edge.EdgeHighPriorityMsg; +import org.thingsboard.server.common.msg.edge.EdgeSessionMsg; +import org.thingsboard.server.common.msg.edge.FromEdgeSyncResponse; +import org.thingsboard.server.common.msg.edge.ToEdgeSyncRequest; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.rpc.EdgeRpcService; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.session.EdgeAttributeSaveCallback; +import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder; +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; +import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; + +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Consumer; + +import static org.thingsboard.server.service.state.DefaultDeviceStateService.ACTIVITY_STATE; +import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_CONNECT_TIME; +import static org.thingsboard.server.service.state.DefaultDeviceStateService.LAST_DISCONNECT_TIME; + +@Service +@Slf4j +@RequiredArgsConstructor +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class EdgeGrpcService extends EdgeRpcServiceGrpc.EdgeRpcServiceImplBase implements EdgeRpcService { + + @Value("${edges.send_scheduler_pool_size}") + private int sendSchedulerPoolSize; + + @Value("${edges.state.persistToTelemetry:false}") + private boolean persistToTelemetry; + + @Autowired + @Lazy + private EdgeContextComponent ctx; + + private final EdgeSessionsHolder sessions; + private final ApplicationContext applicationContext; + private final TbClusterService clusterService; + private final TbServiceInfoProvider serviceInfoProvider; + private final TelemetrySubscriptionService tsSubService; + private final TbTransactionalCache edgeIdServiceIdCache; + + private final ConcurrentMap> localSyncEdgeRequests = new ConcurrentHashMap<>(); + private ScheduledExecutorService executorService; + private ScheduledExecutorService sendDownlinkExecutorService; + + @PostConstruct + public void onStartUp() { + this.executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("edge-service"); + this.sendDownlinkExecutorService = ThingsBoardExecutors.newScheduledThreadPool(sendSchedulerPoolSize, "edge-send-scheduler"); + } + + @PreDestroy + private void preDestroy() { + shutdownExecutorSafely(executorService); + shutdownExecutorSafely(sendDownlinkExecutorService); + sessions.forEach(EdgeGrpcSessionManager::onEdgeDisconnect); + } + + @Override + public StreamObserver handleMsgs(StreamObserver outputStream) { + EdgeGrpcSessionManager sessionManager = applicationContext.getBean(EdgeGrpcSessionManager.class); + return sessionManager.initInputStream(outputStream, this::onEdgeConnect, this::onEdgeDisconnect, sendDownlinkExecutorService); + } + + @Override + public void onToEdgeSessionMsg(TenantId tenantId, EdgeSessionMsg msg) { + switch (msg.getMsgType()) { + case EDGE_HIGH_PRIORITY_TO_EDGE_SESSION_MSG -> { + EdgeHighPriorityMsg edgeHighPriorityMsg = (EdgeHighPriorityMsg) msg; + log.trace("[{}] edgeEventMsg [{}]", tenantId, msg); + onEdgeHighPriorityEvent(edgeHighPriorityMsg); + } + case EDGE_EVENT_UPDATE_TO_EDGE_SESSION_MSG -> { + EdgeEventUpdateMsg edgeEventUpdateMsg = (EdgeEventUpdateMsg) msg; + log.trace("[{}] onToEdgeEventUpdateMsg [{}]", tenantId, msg); + onEdgeEventUpdate(tenantId, edgeEventUpdateMsg.getEdgeId()); + } + case EDGE_SYNC_REQUEST_TO_EDGE_SESSION_MSG -> { + ToEdgeSyncRequest toEdgeSyncRequest = (ToEdgeSyncRequest) msg; + log.trace("[{}] toEdgeSyncRequest [{}]", tenantId, msg); + startSyncProcess(tenantId, toEdgeSyncRequest.getEdgeId(), toEdgeSyncRequest.getId(), toEdgeSyncRequest.getServiceId()); + } + case EDGE_SYNC_RESPONSE_FROM_EDGE_SESSION_MSG -> { + FromEdgeSyncResponse fromEdgeSyncResponse = (FromEdgeSyncResponse) msg; + log.trace("[{}] fromEdgeSyncResponse [{}]", tenantId, msg); + processSyncResponse(fromEdgeSyncResponse); + } + } + } + + @Override + public void updateEdge(TenantId tenantId, Edge edge) { + if (edge == null) { + log.warn("[{}] Edge is null - edge is removed and outdated notification is in process!", tenantId); + return; + } + EdgeGrpcSessionManager session = sessions.getByEdgeId(edge.getId()); + if (session != null && session.getState().isConnected()) { + log.debug("[{}] Updating configuration for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); + session.onConfigurationUpdate(edge); + } else { + log.debug("[{}] Session doesn't exist for edge [{}] [{}]", tenantId, edge.getName(), edge.getId()); + } + } + + @Override + public void deleteEdge(TenantId tenantId, EdgeId edgeId) { + EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId); + if (session != null && session.getState().isConnected()) { + log.info("[{}] Closing and removing session for edge [{}]", tenantId, edgeId); + session.destroyAndMarkAsZombieIfFailed(); + session.onEdgeRemoval(); + sessions.remove(session); + } + } + + @Override + public void processSyncRequest(TenantId tenantId, EdgeId edgeId, Consumer responseConsumer) { + ToEdgeSyncRequest request = new ToEdgeSyncRequest(UUID.randomUUID(), tenantId, edgeId, serviceInfoProvider.getServiceId()); + + UUID requestId = request.getId(); + EdgeGrpcSessionManager session = sessions.getByEdgeId(request.getEdgeId()); + if (session != null && session.getState().isSyncInProgress()) { + responseConsumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Sync process is active at the moment")); + } else { + log.trace("[{}][{}] Processing sync edge request [{}], serviceId [{}]", request.getTenantId(), request.getId(), request.getEdgeId(), request.getServiceId()); + localSyncEdgeRequests.put(requestId, responseConsumer); + clusterService.pushEdgeSyncRequestToEdge(request); + scheduleSyncRequestTimeout(request, requestId); + } + } + + private void onEdgeConnect(EdgeId edgeId, EdgeGrpcSessionManager edgeSession) { + EdgeSessionState state = edgeSession.getState(); + Edge edge = state.getEdge(); + TenantId tenantId = state.getTenantId(); + log.info("[{}][{}] edge [{}] connected successfully.", tenantId, state.getSessionId(), edgeId); + if (sessions.hasByEdgeId(edgeId)) { + EdgeGrpcSessionManager existingSession = sessions.getByEdgeId(edgeId); + if (existingSession != null) { + UUID sessionId = existingSession.getState().getSessionId(); + log.info("[{}][{}] Replacing existing session [{}] for edge [{}]", tenantId, state.getSessionId(), sessionId, edgeId); + existingSession.destroyAndMarkAsZombieIfFailed(); + sessions.removeBySessionId(sessionId); + } + } + sessions.put(edgeSession); + save(tenantId, edgeId, ACTIVITY_STATE, true); + long lastConnectTs = System.currentTimeMillis(); + save(tenantId, edgeId, LAST_CONNECT_TIME, lastConnectTs); + edgeIdServiceIdCache.put(edgeId, serviceInfoProvider.getServiceId()); + pushRuleEngineMessage(tenantId, edge, lastConnectTs, TbMsgType.CONNECT_EVENT); + edgeSession.onEdgeConnect(); + } + + private void onEdgeDisconnect(Edge edge, UUID sessionId) { + TenantId tenantId = edge.getTenantId(); + EdgeId edgeId = edge.getId(); + log.info("[{}][{}] edge disconnected!", edgeId, sessionId); + EdgeGrpcSessionManager current = sessions.getByEdgeId(edgeId); + if (current != null && current.getState().getSessionId().equals(sessionId)) { + EdgeGrpcSessionManager toRemove = sessions.removeByEdgeId(edgeId); + if (toRemove != null) { + toRemove.onEdgeDisconnect(); + toRemove.destroyAndMarkAsZombieIfFailed(); + } + sessions.removeBySessionId(sessionId); + save(tenantId, edgeId, ACTIVITY_STATE, false); + long lastDisconnectTs = System.currentTimeMillis(); + save(tenantId, edgeId, LAST_DISCONNECT_TIME, lastDisconnectTs); + pushRuleEngineMessage(tenantId, edge, lastDisconnectTs, TbMsgType.DISCONNECT_EVENT); + } else { + log.info("[{}] edge session [{}] is not current anymore. Attempting to destroy it by sessionId.", edgeId, sessionId); + EdgeGrpcSessionManager stale = sessions.removeBySessionId(sessionId); + if (stale != null) { + try { + stale.destroyAndMarkAsZombieIfFailed(); + log.info("[{}][{}] Successfully destroyed stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId); + } catch (Exception e) { + log.warn("[{}][{}] Failed to destroy stale session for edge [{}]", stale.getState().getTenantId(), sessionId, edgeId, e); + } + } else { + log.debug("[{}] No session found by sessionId [{}] to destroy", edgeId, sessionId); + } + } + edgeIdServiceIdCache.evict(edgeId); + } + + private void scheduleSyncRequestTimeout(ToEdgeSyncRequest request, UUID requestId) { + log.trace("[{}] scheduling sync edge request", requestId); + executorService.schedule(() -> { + log.trace("[{}] checking if sync edge request is not processed...", requestId); + Consumer consumer = localSyncEdgeRequests.remove(requestId); + if (consumer != null) { + log.trace("[{}] timeout for processing sync edge request.", requestId); + consumer.accept(new FromEdgeSyncResponse(requestId, request.getTenantId(), request.getEdgeId(), false, "Edge is not connected")); + } + }, 20, TimeUnit.SECONDS); + } + + private void onEdgeHighPriorityEvent(EdgeHighPriorityMsg msg) { + TenantId tenantId = msg.getTenantId(); + EdgeEvent edgeEvent = msg.getEdgeEvent(); + EdgeId edgeId = edgeEvent.getEdgeId(); + EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId); + if (session != null && session.getState().isConnected()) { + log.trace("[{}] onEdgeEvent [{}]", tenantId, edgeId); + session.addEventToHighPriorityQueue(edgeEvent); + } + } + + private void onEdgeEventUpdate(TenantId tenantId, EdgeId edgeId) { + EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId); + if (session != null && session.getState().isConnected()) { + log.trace("[{}] onEdgeEventUpdate [{}]", tenantId, edgeId.getId()); + session.onEdgeEventUpdate(); + } + } + + private void startSyncProcess(TenantId tenantId, EdgeId edgeId, UUID requestId, String requestServiceId) { + EdgeGrpcSessionManager session = sessions.getByEdgeId(edgeId); + if (session != null) { + EdgeSessionState sessionState = session.getState(); + if (sessionState.isSyncInProgress()) { + clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, false, "Sync process is active at the moment"), requestServiceId); + } else { + boolean success = false; + if (sessionState.isConnected()) { + session.startSyncProcess(true); + success = true; + } + clusterService.pushEdgeSyncResponseToCore(new FromEdgeSyncResponse(requestId, tenantId, edgeId, success, ""), requestServiceId); + } + } + } + + private void processSyncResponse(FromEdgeSyncResponse response) { + log.trace("[{}] Received response from sync service: [{}]", response.getId(), response); + UUID requestId = response.getId(); + Consumer consumer = localSyncEdgeRequests.remove(requestId); + if (consumer != null) { + consumer.accept(response); + } else { + log.trace("[{}] Unknown or stale sync response received [{}]", requestId, response); + } + } + + private void pushRuleEngineMessage(TenantId tenantId, Edge edge, long ts, TbMsgType msgType) { + try { + EdgeId edgeId = edge.getId(); + ObjectNode edgeState = JacksonUtil.newObjectNode(); + boolean isConnected = TbMsgType.CONNECT_EVENT.equals(msgType); + if (isConnected) { + edgeState.put(ACTIVITY_STATE, true); + edgeState.put(LAST_CONNECT_TIME, ts); + } else { + edgeState.put(ACTIVITY_STATE, false); + edgeState.put(LAST_DISCONNECT_TIME, ts); + } + ctx.getRuleProcessor().process(EdgeConnectionTrigger.builder() + .tenantId(tenantId) + .customerId(edge.getCustomerId()) + .edgeId(edgeId) + .edgeName(edge.getName()) + .connected(isConnected).build()); + String data = JacksonUtil.toString(edgeState); + TbMsgMetaData md = new TbMsgMetaData(); + if (!persistToTelemetry) { + md.putValue(DataConstants.SCOPE, DataConstants.SERVER_SCOPE); + md.putValue("edgeName", edge.getName()); + md.putValue("edgeType", edge.getType()); + } + TbMsg tbMsg = TbMsg.newMsg() + .type(msgType) + .originator(edgeId) + .copyMetaData(md) + .dataType(TbMsgDataType.JSON) + .data(data) + .build(); + clusterService.pushMsgToRuleEngine(tenantId, edgeId, tbMsg, null); + } catch (Exception e) { + log.warn("[{}][{}] Failed to push {}", tenantId, edge.getId(), msgType, e); + } + } + + private void save(TenantId tenantId, EdgeId edgeId, String key, long value) { + log.debug("[{}][{}] Updating long edge telemetry [{}] [{}]", tenantId, edgeId, key, value); + if (persistToTelemetry) { + tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() + .tenantId(tenantId) + .entityId(edgeId) + .entry(new LongDataEntry(key, value)) + .callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value)) + .build()); + } else { + tsSubService.saveAttributes(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(edgeId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new LongDataEntry(key, value)) + .callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value)) + .build()); + } + } + + private void save(TenantId tenantId, EdgeId edgeId, String key, boolean value) { + log.debug("[{}][{}] Updating boolean edge telemetry [{}] [{}]", tenantId, edgeId, key, value); + if (persistToTelemetry) { + tsSubService.saveTimeseries(TimeseriesSaveRequest.builder() + .tenantId(tenantId) + .entityId(edgeId) + .entry(new BooleanDataEntry(key, value)) + .callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value)) + .build()); + } else { + tsSubService.saveAttributes(AttributesSaveRequest.builder() + .tenantId(tenantId) + .entityId(edgeId) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new BooleanDataEntry(key, value)) + .callback(new EdgeAttributeSaveCallback(tenantId, edgeId, key, value)) + .build()); + } + } + + private void shutdownExecutorSafely(ExecutorService e) { + if (e != null && !e.isShutdown()) { + e.shutdown(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java new file mode 100644 index 0000000000..780dcbe196 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/DefaultZombieSessionCleanupService.java @@ -0,0 +1,131 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; +import org.thingsboard.server.service.edge.rpc.session.manager.KafkaBasedEdgeGrpcSessionManager; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.function.Function; + +@Service +@Slf4j +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class DefaultZombieSessionCleanupService implements ZombieSessionCleanupService { + + @Autowired + private EdgeSessionsHolder edgeSessionsHolder; + + private final Queue zombieSessions; + private final ScheduledExecutorService zombieSessionsExecutorService; + + public DefaultZombieSessionCleanupService() { + this.zombieSessions = new ConcurrentLinkedQueue<>(); + this.zombieSessionsExecutorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("zombie-sessions"); + } + + @PostConstruct + public void init() { + this.zombieSessionsExecutorService.scheduleAtFixedRate(this::cleanupZombieSessions, 30, 60, TimeUnit.SECONDS); + } + + @PreDestroy + public void destroy() { + if (zombieSessionsExecutorService != null && !zombieSessionsExecutorService.isShutdown()) { + zombieSessionsExecutorService.shutdown(); + } + } + + @Override + public void add(EdgeGrpcSessionManager session) { + zombieSessions.add(session); + } + + private void cleanupZombieSessions() { + try { + tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessions().values()), + s -> edgeSessionsHolder.removeByEdgeId(s.getState().getEdge().getId())); + + tryToDestroyZombieSessions(getZombieSessions(edgeSessionsHolder.getSessionsById().values()), + s -> edgeSessionsHolder.removeBySessionId(s.getState().getSessionId())); + + zombieSessions.removeIf(zombie -> { + EdgeSessionState state = zombie.getState(); + if (zombie.destroy()) { + log.info("[{}][{}] Successfully cleaned up zombie session [{}] for edge [{}].", + state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName()); + return true; + } else { + log.warn("[{}][{}] Failed to remove zombie session [{}] for edge [{}].", + state.getTenantId(), state.getEdgeId(), state.getSessionId(), state.getEdge().getName()); + return false; + } + }); + } catch (Exception e) { + log.warn("Failed to cleanup zombie sessions", e); + } + } + + private List getZombieSessions(Collection sessions) { + List result = new ArrayList<>(); + for (EdgeGrpcSessionManager session : sessions) { + if (isKafkaSessionAndZombie(session)) { + result.add(session); + } + } + return result; + } + + private boolean isKafkaSessionAndZombie(EdgeGrpcSessionManager session) { + if (session instanceof KafkaBasedEdgeGrpcSessionManager kafkaSession) { + EdgeSessionState sessionState = kafkaSession.getState(); + log.debug("[{}] kafkaSession.isConnected() = {}, kafkaSession.getConsumer().getConsumer().isStopped() = {}", + sessionState.getEdgeId(), + sessionState.isConnected(), + kafkaSession.getConsumer() != null ? kafkaSession.getConsumer().getConsumer() != null ? kafkaSession.getConsumer().getConsumer().isStopped() : null : null); + return !sessionState.isConnected() && + kafkaSession.getConsumer() != null && + kafkaSession.getConsumer().getConsumer() != null && + !kafkaSession.getConsumer().getConsumer().isStopped(); + } + return false; + } + + private void tryToDestroyZombieSessions(List sessionsToRemove, Function removeFunc) { + for (EdgeGrpcSessionManager toRemove : sessionsToRemove) { + log.info("[{}] Destroying session for edge because edge is not connected", toRemove.getState().getEdge().getId()); + if (toRemove.destroy()) { + removeFunc.apply(toRemove); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java new file mode 100644 index 0000000000..a5e063de2c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeAttributeSaveCallback.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import com.google.common.util.concurrent.FutureCallback; +import jakarta.annotation.Nullable; +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; + +@Slf4j +public class EdgeAttributeSaveCallback implements FutureCallback { + + private final TenantId tenantId; + private final EdgeId edgeId; + private final String key; + private final Object value; + + public EdgeAttributeSaveCallback(TenantId tenantId, EdgeId edgeId, String key, Object value) { + this.tenantId = tenantId; + this.edgeId = edgeId; + this.key = key; + this.value = value; + } + + @Override + public void onSuccess(@Nullable Void result) { + log.trace("[{}][{}] Successfully updated attribute [{}] with value [{}]", tenantId, edgeId, key, value); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to update attribute [{}] with value [{}]", tenantId, edgeId, key, value, t); + } + +} \ No newline at end of file diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java new file mode 100644 index 0000000000..73a3b53e4c --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSession.java @@ -0,0 +1,621 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.SettableFuture; +import io.grpc.stub.StreamObserver; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.checkerframework.checker.nullness.qual.Nullable; +import org.springframework.data.util.Pair; +import org.thingsboard.rule.engine.api.AttributesSaveRequest; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.EdgeUtils; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.limit.LimitedApi; +import org.thingsboard.server.common.data.notification.rule.trigger.EdgeCommunicationFailureTrigger; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.edge.EdgeEventUpdateMsg; +import org.thingsboard.server.dao.edge.stats.EdgeStatsKey; +import org.thingsboard.server.gen.edge.v1.ConnectRequestMsg; +import org.thingsboard.server.gen.edge.v1.ConnectResponseCode; +import org.thingsboard.server.gen.edge.v1.ConnectResponseMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.DownlinkResponseMsg; +import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsgType; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.gen.edge.v1.SyncCompletedMsg; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; +import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.EdgeSyncCursor; +import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher; +import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.fetch.GeneralEdgeEventFetcher; +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; + +@Slf4j +@RequiredArgsConstructor +public class EdgeGrpcSession implements EdgeSession { + + private static final int MAX_DOWNLINK_ATTEMPTS = 3; + private static final String RATE_LIMIT_REACHED = "Rate limit reached"; + + private final EdgeGrpcSessionManager parentManagerRef; + private final EdgeContextComponent ctx; + private final StreamObserver outputStream; + private final DownlinkMessageMapper downlinkMessageMapper; + private final EdgeUplinkMessageDispatcher uplinkMessageDispatcher; + private final BiConsumer sessionOpenListener; + private final BiConsumer sessionCloseListener; + private final ScheduledExecutorService sendDownlinkExecutorService; + private final int maxInboundMessageSize; + private final int maxHighPriorityQueueSizePerSession; + + private final EdgeSessionState state = new EdgeSessionState(); + private final Lock downlinkMsgLock = new ReentrantLock(); + private final ConcurrentLinkedQueue highPriorityQueue = new ConcurrentLinkedQueue<>(); + + private int clientMaxInboundMessageSize; + + @Override + public EdgeSessionState getState() { + return state; + } + + @Override + public StreamObserver initInputStream() { + return new StreamObserver<>() { + @Override + public void onNext(RequestMsg requestMsg) { + if (!state.isConnected() && requestMsg.getMsgType().equals(RequestMsgType.CONNECT_RPC_MESSAGE)) { + ConnectResponseMsg responseMsg = processConnect(requestMsg.getConnectRequestMsg()); + outputStream.onNext(ResponseMsg.newBuilder() + .setConnectResponseMsg(responseMsg) + .build()); + if (ConnectResponseCode.ACCEPTED != responseMsg.getResponseCode()) { + outputStream.onError(new RuntimeException(responseMsg.getErrorMsg())); + } else { + if (requestMsg.getConnectRequestMsg().hasMaxInboundMessageSize()) { + log.debug("[{}][{}] Client max inbound message size: {}", + getTenantId(), getSessionId(), requestMsg.getConnectRequestMsg().getMaxInboundMessageSize()); + clientMaxInboundMessageSize = requestMsg.getConnectRequestMsg().getMaxInboundMessageSize(); + } + state.setConnected(true); + } + } + if (state.isConnected()) { + if (requestMsg.getMsgType().equals(RequestMsgType.SYNC_REQUEST_RPC_MESSAGE)) { + if (requestMsg.hasSyncRequestMsg()) { + boolean fullSync = false; + if (requestMsg.getSyncRequestMsg().hasFullSync()) { + fullSync = requestMsg.getSyncRequestMsg().getFullSync(); + } + startSyncProcess(fullSync); + } else { + state.finishSync(); + saveSyncInProgressAsAttribute(false); + } + } + if (requestMsg.getMsgType().equals(RequestMsgType.UPLINK_RPC_MESSAGE)) { + if (requestMsg.hasUplinkMsg()) { + onUplinkMsg(requestMsg.getUplinkMsg()); + } + if (requestMsg.hasDownlinkResponseMsg()) { + onDownlinkResponse(requestMsg.getDownlinkResponseMsg()); + } + } + } + } + + @Override + public void onError(Throwable t) { + log.trace("[{}][{}] Stream was terminated due to error:", getTenantId(), getSessionId(), t); + closeSession(); + } + + @Override + public void onCompleted() { + log.info("[{}][{}] Stream was closed and completed successfully!", getTenantId(), getSessionId()); + closeSession(); + } + + private void closeSession() { + state.setConnected(false); + if (state.getEdge() != null) { + try { + sessionCloseListener.accept(state.getEdge(), getSessionId()); + } catch (Exception ignored) { + } + } + try { + outputStream.onCompleted(); + } catch (Exception ignored) { + } + } + }; + } + + @Override + public void startSyncProcess(boolean fullSync) { + if (state.tryStartSync()) { + saveSyncInProgressAsAttribute(true); + log.info("[{}][{}][{}] Staring edge sync process", getTenantId(), getEdgeId(), getSessionId()); + interruptGeneralProcessingOnSync(); + doSync(new EdgeSyncCursor(ctx, state.getEdge(), fullSync)); + } else { + log.info("[{}][{}][{}] Sync is already started, skipping starting it now", getTenantId(), getEdgeId(), getSessionId()); + } + } + + @Override + public void sendDownlinkMsg(ResponseMsg responseMsg) { + if (state.isConnected()) { + String responseMsgStr = StringUtils.truncate(responseMsg.toString(), 10000); + log.trace("[{}][{}] Sending downlink msg [{}]", getTenantId(), getEdgeId(), responseMsgStr); + downlinkMsgLock.lock(); + String downlinkMsgStr = responseMsg.hasDownlinkMsg() ? String.valueOf(responseMsg.getDownlinkMsg().getDownlinkMsgId()) : responseMsgStr; + try { + outputStream.onNext(responseMsg); + } catch (Exception e) { + log.trace("[{}][{}] Failed to send downlink message [{}]", getTenantId(), getEdgeId(), downlinkMsgStr, e); + state.setConnected(false); + sessionCloseListener.accept(state.getEdge(), getSessionId()); + } finally { + downlinkMsgLock.unlock(); + } + log.trace("[{}][{}] downlink msg successfully sent [{}]", getTenantId(), getEdgeId(), downlinkMsgStr); + } + } + + @Override + public void addHighPriorityEvent(EdgeEvent edgeEvent) { + while (highPriorityQueue.size() > maxHighPriorityQueueSizePerSession) { + EdgeEvent oldestHighPriority = highPriorityQueue.poll(); + if (oldestHighPriority != null) { + log.warn("[{}][{}][{}] High priority queue is full. Removing oldest high priority event from queue {}", + state.getTenantId(), state.getEdgeId(), getSessionId(), oldestHighPriority); + } + } + highPriorityQueue.add(edgeEvent); + ctx.getStatsCounterService().ifPresent(statsCounterService -> + statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_ADDED, state.getEdge().getTenantId(), edgeEvent.getEdgeId(), 1)); + } + + @Override + public void processHighPriorityEvents() { + try { + if (state.isConnected() && !state.isSyncInProgress()) { + if (highPriorityQueue.isEmpty()) { + return; + } + List highPriorityEvents = new ArrayList<>(); + EdgeEvent event; + while ((event = highPriorityQueue.poll()) != null) { + highPriorityEvents.add(event); + } + log.trace("[{}][{}] Sending high priority events {}", getTenantId(), getEdgeId(), highPriorityEvents.size()); + List downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, highPriorityEvents); + sendDownlinkMsgsPack(downlinkMsgsPack).get(); + } + } catch (Exception e) { + log.error("[{}] Failed to process high priority events", getEdgeId(), e); + } + } + + @Override + public boolean hasHighPriorityEvents() { + return !highPriorityQueue.isEmpty(); + } + + @Override + public ListenableFuture> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher) { + SettableFuture> result = SettableFuture.create(); + PageLink pageLink = fetcher.getPageLink(ctx.getEdgeEventStorageSettings().getMaxReadRecordsCount()); + fetchAndSendEdgeEvents(fetcher, pageLink, result); + return result; + } + + @Override + public ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack) { + interruptPreviousSendDownlinkMsgsTask(); + + state.setSendDownlinkMsgsFuture(SettableFuture.create()); + state.getPendingMsgsMap().clear(); + + downlinkMsgsPack.forEach(msg -> state.getPendingMsgsMap().put(msg.getDownlinkMsgId(), msg)); + scheduleDownlinkMsgsPackSend(1); + + return state.getSendDownlinkMsgsFuture(); + } + + @Override + public void close() { + log.debug("[{}][{}] Closing session", getTenantId(), getSessionId()); + state.setConnected(false); + try { + outputStream.onCompleted(); + } catch (Exception e) { + log.debug("[{}][{}] Failed to close output stream: {}", getTenantId(), getSessionId(), e.getMessage()); + } + } + + private void onUplinkMsg(UplinkMsg uplinkMsg) { + if (isRateLimitViolated(uplinkMsg)) { + return; + } + Futures.addCallback(uplinkMessageDispatcher.processUplinkMsg(state, uplinkMsg), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable List result) { + sendResponseMessage(uplinkMsg.getUplinkMsgId(), true, null); + } + + @Override + public void onFailure(Throwable t) { + String errorMsg = EdgeUtils.createErrorMsgFromRootCauseAndStackTrace(t); + sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg); + } + }, ctx.getGrpcCallbackExecutorService()); + } + + private boolean isRateLimitViolated(UplinkMsg uplinkMsg) { + if (!ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES, getTenantId()) || + !ctx.getRateLimitService().checkRateLimit(LimitedApi.EDGE_UPLINK_MESSAGES_PER_EDGE, getTenantId(), getEdgeId())) { + String errorMsg = String.format("Failed to process uplink message. %s", RATE_LIMIT_REACHED); + sendResponseMessage(uplinkMsg.getUplinkMsgId(), false, errorMsg); + return true; + } + return false; + } + + private void sendResponseMessage(int uplinkMsgId, boolean success, String errorMsg) { + UplinkResponseMsg.Builder responseBuilder = UplinkResponseMsg.newBuilder() + .setUplinkMsgId(uplinkMsgId) + .setSuccess(success); + if (errorMsg != null) { + responseBuilder.setErrorMsg(errorMsg); + } + sendDownlinkMsg(ResponseMsg.newBuilder() + .setUplinkResponseMsg(responseBuilder.build()) + .build()); + } + + private void onDownlinkResponse(DownlinkResponseMsg msg) { + try { + if (msg.getSuccess()) { + state.getPendingMsgsMap().remove(msg.getDownlinkMsgId()); + ctx.getStatsCounterService().ifPresent(statsCounterService -> statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PUSHED, getTenantId(), getEdgeId(), 1)); + log.debug("[{}][{}][{}] Msg has been processed successfully! Msg Id: [{}], Msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg); + } else { + log.debug("[{}][{}][{}] Msg processing failed! Msg Id: [{}], Error msg: {}", getTenantId(), getEdgeId(), getSessionId(), msg.getDownlinkMsgId(), msg.getErrorMsg()); + DownlinkMsg downlinkMsg = state.getPendingMsgsMap().get(msg.getDownlinkMsgId()); + // if NOT timeseries or attributes failures - ack failed downlink + if (downlinkMsg != null && downlinkMsg.getEntityDataCount() == 0) { + state.getPendingMsgsMap().remove(msg.getDownlinkMsgId()); + } + } + if (state.getPendingMsgsMap().isEmpty()) { + log.debug("[{}][{}][{}] Pending msgs map is empty. Stopping current iteration", getTenantId(), getEdgeId(), getSessionId()); + stopCurrentSendDownlinkMsgsTask(false); + } + } catch (Exception e) { + log.error("[{}][{}] Can't process downlink response message [{}]", getTenantId(), getEdgeId(), msg, e); + } + } + + private void interruptGeneralProcessingOnSync() { + log.debug("[{}][{}][{}] Sync process started. General processing interrupted!", getTenantId(), getEdgeId(), getSessionId()); + stopCurrentSendDownlinkMsgsTask(true); + } + + private void doSync(EdgeSyncCursor cursor) { + if (cursor.hasNext()) { + EdgeEventFetcher next = cursor.getNext(); + log.debug("[{}][{}] starting sync process, cursor current idx = {}, class = {}", + getTenantId(), getEdgeId(), cursor.getCurrentIdx(), next.getClass().getSimpleName()); + ListenableFuture> future = fetchAndSendEdgeEvents(next); + Futures.addCallback(future, new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Pair result) { + doSync(cursor); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Exception during sync process", getTenantId(), getEdgeId(), t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.info("[{}][{}] sync process completed", getTenantId(), getEdgeId()); + DownlinkMsg syncCompleteDownlinkMsg = DownlinkMsg.newBuilder() + .setDownlinkMsgId(EdgeUtils.nextPositiveInt()) + .setSyncCompletedMsg(SyncCompletedMsg.newBuilder().build()) + .build(); + Futures.addCallback(sendDownlinkMsgsPack(Collections.singletonList(syncCompleteDownlinkMsg)), new FutureCallback<>() { + @Override + public void onSuccess(Boolean isInterrupted) { + markSyncCompletedSendEdgeEventUpdate(); + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}][{}] Exception during sending sync complete", getTenantId(), getEdgeId(), t); + markSyncCompletedSendEdgeEventUpdate(); + } + }, ctx.getGrpcCallbackExecutorService()); + } + } + + private void markSyncCompletedSendEdgeEventUpdate() { + state.finishSync(); + saveSyncInProgressAsAttribute(false); + ctx.getClusterService().onEdgeEventUpdate(new EdgeEventUpdateMsg(getTenantId(), getEdgeId())); + } + + private void fetchAndSendEdgeEvents(EdgeEventFetcher fetcher, PageLink pageLink, SettableFuture> result) { + Edge edge = state.getEdge(); + TenantId tenantId = getTenantId(); + try { + log.trace("[{}] Start processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); + processHighPriorityEvents(); + PageData pageData = fetcher.fetchEdgeEvents(edge.getTenantId(), edge, pageLink); + if (state.isConnected() && !pageData.getData().isEmpty()) { + if (fetcher instanceof GeneralEdgeEventFetcher) { + long queueSize = pageData.getTotalElements() - ((long) pageLink.getPageSize() * pageLink.getPage()); + ctx.getStatsCounterService().ifPresent(statsCounterService -> + statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_LAG, tenantId, edge.getId(), queueSize)); + } + log.trace("[{}][{}][{}] event(s) are going to be processed.", tenantId, edge.getId(), pageData.getData().size()); + List downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, pageData.getData()); + Futures.addCallback(sendDownlinkMsgsPack(downlinkMsgsPack), new FutureCallback<>() { + @Override + public void onSuccess(@Nullable Boolean isInterrupted) { + if (Boolean.TRUE.equals(isInterrupted)) { + log.debug("[{}][{}][{}] Send downlink messages task was interrupted", tenantId, edge.getId(), getSessionId()); + result.set(null); + } else { + if (state.isConnected() && pageData.hasNext()) { + fetchAndSendEdgeEvents(fetcher, pageLink.nextPageLink(), result); + } else { + EdgeEvent latestEdgeEvent = pageData.getData().get(pageData.getData().size() - 1); + UUID idOffset = latestEdgeEvent.getUuidId(); + if (idOffset != null) { + Long newStartTs = Uuids.unixTimestamp(idOffset); + long newStartSeqId = latestEdgeEvent.getSeqId(); + result.set(Pair.of(newStartTs, newStartSeqId)); + } else { + result.set(null); + } + } + } + } + + @Override + public void onFailure(Throwable t) { + log.error("[{}] Failed to send downlink msgs pack", edge.getId(), t); + result.setException(t); + } + }, ctx.getGrpcCallbackExecutorService()); + } else { + log.trace("[{}] no event(s) found. Stop processing edge events, fetcher = {}, pageLink = {}", edge.getId(), fetcher.getClass().getSimpleName(), pageLink); + result.set(null); + } + } catch (Exception e) { + log.error("[{}] Failed to fetch edge events", edge.getId(), e); + result.setException(e); + } + } + + private void scheduleDownlinkMsgsPackSend(int attempt) { + Runnable sendDownlinkMsgsTask = () -> { + try { + if (!state.isConnected()) { + stopCurrentSendDownlinkMsgsTask(true); + return; + } + if (!state.getPendingMsgsMap().values().isEmpty()) { + Edge edge = state.getEdge(); + List copy = new ArrayList<>(state.getPendingMsgsMap().values()); + if (attempt > 1) { + String error = "Failed to deliver the batch"; + String failureMsg = String.format("{%s} (size: {%s})", error, copy.size()); + if (attempt == 2) { + // Send a failure notification only on the second attempt. + // This ensures that failure alerts are sent just once to avoid redundant notifications. + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()) + .edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build()); + } + ctx.getStatsCounterService().ifPresent(statsCounterService -> + statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_TMP_FAILED, edge.getTenantId(), getEdgeId(), 1)); + log.warn("[{}][{}] {} on attempt {}", getTenantId(), getEdgeId(), failureMsg, attempt); + log.debug("[{}][{}] entities in failed batch: {}", getTenantId(), getEdgeId(), copy); + } + log.trace("[{}][{}][{}] downlink msg(s) are going to be send.", getTenantId(), getEdgeId(), copy.size()); + for (DownlinkMsg downlinkMsg : copy) { + if (clientMaxInboundMessageSize != 0 && downlinkMsg.getSerializedSize() > clientMaxInboundMessageSize) { + String error = String.format("Client max inbound message size %s is exceeded. Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE " + + "env variable on the edge and restart it.", clientMaxInboundMessageSize); + String message = String.format("Downlink msg size %s exceeds client max inbound message size %s. " + + "Please increase value of CLOUD_RPC_MAX_INBOUND_MESSAGE_SIZE env variable on the edge and restart it.", downlinkMsg.getSerializedSize(), clientMaxInboundMessageSize); + log.error("[{}][{}][{}] {} Message {}", getTenantId(), getEdgeId(), getSessionId(), message, downlinkMsg); + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()) + .edgeId(getEdgeId()).customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(message).error(error).build()); + ctx.getStatsCounterService().ifPresent(statsCounterService -> + statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), 1)); + state.getPendingMsgsMap().remove(downlinkMsg.getDownlinkMsgId()); + } else { + sendDownlinkMsg(ResponseMsg.newBuilder() + .setDownlinkMsg(downlinkMsg) + .build()); + } + } + if (attempt < MAX_DOWNLINK_ATTEMPTS) { + scheduleDownlinkMsgsPackSend(attempt + 1); + } else { + String failureMsg = String.format("Failed to deliver messages: %s", copy); + log.warn("[{}][{}] Failed to deliver the batch after {} attempts. Next messages are going to be discarded {}", + getTenantId(), getEdgeId(), MAX_DOWNLINK_ATTEMPTS, copy); + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(getTenantId()).edgeId(getEdgeId()) + .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg) + .error("Failed to deliver messages after " + MAX_DOWNLINK_ATTEMPTS + " attempts").build()); + ctx.getStatsCounterService().ifPresent(statsCounterService -> + statsCounterService.recordEvent(EdgeStatsKey.DOWNLINK_MSGS_PERMANENTLY_FAILED, edge.getTenantId(), getEdgeId(), copy.size())); + stopCurrentSendDownlinkMsgsTask(false); + } + } else { + stopCurrentSendDownlinkMsgsTask(false); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to send downlink msgs. Error msg {}", getTenantId(), getEdgeId(), e.getMessage(), e); + stopCurrentSendDownlinkMsgsTask(true); + } + }; + + if (attempt == 1) { + sendDownlinkExecutorService.submit(sendDownlinkMsgsTask); + } else { + state.setScheduledSendDownlinkTask( + sendDownlinkExecutorService.schedule( + sendDownlinkMsgsTask, + ctx.getEdgeEventStorageSettings().getSleepIntervalBetweenBatches(), + TimeUnit.MILLISECONDS) + ); + } + } + + private void interruptPreviousSendDownlinkMsgsTask() { + if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone() + || state.getScheduledSendDownlinkTask() != null && !state.getScheduledSendDownlinkTask().isCancelled()) { + log.debug("[{}][{}][{}] Previous send downlink future was not properly completed, stopping it now!", + getTenantId(), getEdgeId(), getSessionId()); + stopCurrentSendDownlinkMsgsTask(true); + } else { + log.trace("[{}][{}][{}] Previous send downlink future is not active", + getTenantId(), getEdgeId(), getSessionId()); + } + } + + private void stopCurrentSendDownlinkMsgsTask(Boolean isInterrupted) { + if (state.getSendDownlinkMsgsFuture() != null && !state.getSendDownlinkMsgsFuture().isDone()) { + state.getSendDownlinkMsgsFuture().set(isInterrupted); + } + if (state.getScheduledSendDownlinkTask() != null) { + state.getScheduledSendDownlinkTask().cancel(true); + } + } + + private ConnectResponseMsg processConnect(ConnectRequestMsg request) { + log.trace("[{}] processConnect [{}]", getSessionId(), request); + Optional optional = ctx.getEdgeService().findEdgeByRoutingKey(TenantId.SYS_TENANT_ID, request.getEdgeRoutingKey()); + if (optional.isPresent()) { + Edge edge = optional.get(); + TenantId tenantId = edge.getTenantId(); + state.setEdge(edge); + try { + if (edge.getSecret().equals(request.getEdgeSecret())) { + sessionOpenListener.accept(edge.getId(), parentManagerRef); + state.setEdgeVersion(request.getEdgeVersion()); + processSaveEdgeVersionAsAttribute(request.getEdgeVersion().name()); + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.ACCEPTED) + .setErrorMsg("") + .setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)) + .setMaxInboundMessageSize(maxInboundMessageSize) + .build(); + } + String error = "Failed to validate the edge!"; + String failureMsg = String.format("%s Provided request secret: %s", error, request.getEdgeSecret()); + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) + .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(error).build()); + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) + .setErrorMsg(failureMsg) + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } catch (Exception e) { + String failureMsg = "Failed to process edge connection!"; + ctx.getRuleProcessor().process(EdgeCommunicationFailureTrigger.builder().tenantId(tenantId).edgeId(edge.getId()) + .customerId(edge.getCustomerId()).edgeName(edge.getName()).failureMsg(failureMsg).error(e.getMessage()).build()); + log.error(failureMsg, e); + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.SERVER_UNAVAILABLE) + .setErrorMsg(failureMsg) + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } + } + return ConnectResponseMsg.newBuilder() + .setResponseCode(ConnectResponseCode.BAD_CREDENTIALS) + .setErrorMsg("Failed to find the edge! Routing key: " + request.getEdgeRoutingKey()) + .setConfiguration(EdgeConfiguration.getDefaultInstance()).build(); + } + + private void processSaveEdgeVersionAsAttribute(String edgeVersion) { + AttributeKvEntry attributeKvEntry = new BaseAttributeKvEntry(new StringDataEntry(DataConstants.EDGE_VERSION_ATTR_KEY, edgeVersion), System.currentTimeMillis()); + ctx.getAttributesService().save(getTenantId(), getEdgeId(), AttributeScope.SERVER_SCOPE, attributeKvEntry); + } + + private void saveSyncInProgressAsAttribute(Boolean value) { + ctx.getTsSubService().saveAttributes(AttributesSaveRequest.builder() + .tenantId(getTenantId()) + .entityId(getEdgeId()) + .scope(AttributeScope.SERVER_SCOPE) + .entry(new BooleanDataEntry(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) + .callback(new EdgeAttributeSaveCallback(getTenantId(), getEdgeId(), DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY, value)) + .build()); + } + + private TenantId getTenantId() { + return state.getTenantId(); + } + + private EdgeId getEdgeId() { + return state.getEdgeId(); + } + + private UUID getSessionId() { + return state.getSessionId(); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java new file mode 100644 index 0000000000..9c8c5eddb9 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeGrpcSessionDelegate.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; + +public abstract class EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager { + + protected abstract EdgeSession getSession(); + + @Override + public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) { + getSession().addHighPriorityEvent(edgeEvent); + } + + @Override + public void startSyncProcess(boolean fullSync) { + getSession().startSyncProcess(fullSync); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java new file mode 100644 index 0000000000..0b7158211d --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSession.java @@ -0,0 +1,43 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import com.google.common.util.concurrent.ListenableFuture; +import io.grpc.stub.StreamObserver; +import org.springframework.data.util.Pair; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.fetch.EdgeEventFetcher; + +import java.io.Closeable; +import java.util.List; + +public interface EdgeSession extends Closeable { + + StreamObserver initInputStream(); + EdgeSessionState getState(); + void startSyncProcess(boolean fullSync); + void sendDownlinkMsg(ResponseMsg responseMsg); + void addHighPriorityEvent(EdgeEvent edgeEvent); + void processHighPriorityEvents(); + boolean hasHighPriorityEvents(); + ListenableFuture> fetchAndSendEdgeEvents(EdgeEventFetcher fetcher); + ListenableFuture sendDownlinkMsgsPack(List downlinkMsgsPack); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java new file mode 100644 index 0000000000..2d7c3dd8e3 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/EdgeSessionsHolder.java @@ -0,0 +1,83 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; + +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.function.Consumer; + +@Data +@Slf4j +@Component +@ConditionalOnProperty(prefix = "edges", value = "enabled", havingValue = "true") +@TbCoreComponent +public class EdgeSessionsHolder { + + private final ConcurrentMap sessions = new ConcurrentHashMap<>(); + private final ConcurrentMap sessionsById = new ConcurrentHashMap<>(); + + public void forEach(Consumer consumer) { + Set unique = new HashSet<>(sessions.values()); + unique.addAll(sessionsById.values()); + + unique.forEach(consumer); + } + + public void put(EdgeGrpcSessionManager session) { + UUID sessionId = session.getState().getSessionId(); + EdgeId edgeId = session.getState().getEdgeId(); + sessionsById.put(sessionId, session); + sessions.put(edgeId, session); + } + + public EdgeGrpcSessionManager getByEdgeId(EdgeId id) { + return sessions.get(id); + } + + public boolean hasByEdgeId(EdgeId id) { + return sessions.containsKey(id); + } + + public EdgeGrpcSessionManager removeByEdgeId(EdgeId id) { + return sessions.remove(id); + } + + public EdgeGrpcSessionManager removeBySessionId(UUID sessionId) { + return sessionsById.remove(sessionId); + } + + public void remove(EdgeGrpcSessionManager session) { + if (session == null) { + log.warn("Can't remove session from holder because it's null"); + return; + } + EdgeSessionState sessionState = session.getState(); + removeByEdgeId(sessionState.getEdgeId()); + removeBySessionId(sessionState.getSessionId()); + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java new file mode 100644 index 0000000000..4f65ae38a0 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/ZombieSessionCleanupService.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session; + +import org.thingsboard.server.service.edge.rpc.session.manager.EdgeGrpcSessionManager; + +public interface ZombieSessionCleanupService { + + void add(EdgeGrpcSessionManager session); +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java new file mode 100644 index 0000000000..0df1af1ada --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/AbstractEdgeGrpcSessionManager.java @@ -0,0 +1,149 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session.manager; + +import io.grpc.stub.StreamObserver; +import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Lazy; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.gen.edge.v1.EdgeUpdateMsg; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.service.edge.EdgeContextComponent; +import org.thingsboard.server.service.edge.EdgeMsgConstructorUtils; +import org.thingsboard.server.service.edge.rpc.DownlinkMessageMapper; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.EdgeUplinkMessageDispatcher; +import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSession; +import org.thingsboard.server.service.edge.rpc.session.EdgeGrpcSessionDelegate; +import org.thingsboard.server.service.edge.rpc.session.EdgeSession; +import org.thingsboard.server.service.edge.rpc.session.ZombieSessionCleanupService; + +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.BiConsumer; + +@Slf4j +public abstract class AbstractEdgeGrpcSessionManager extends EdgeGrpcSessionDelegate implements EdgeGrpcSessionManager { + + @Value("${edges.max_high_priority_queue_size_per_session:10000}") + protected int maxHighPriorityQueueSizePerSession; + + @Value("${edges.rpc.max_inbound_message_size:4194304}") + protected int maxInboundMessageSize; + + @Autowired + protected ZombieSessionCleanupService zombieSessionCleanupService; + + @Autowired + @Lazy + protected EdgeContextComponent ctx; + + @Autowired + protected DownlinkMessageMapper downlinkMessageMapper; + + @Autowired + protected EdgeUplinkMessageDispatcher uplinkMessageDispatcher; + + @Getter + protected EdgeSession session; + + private final Lock initLock = new ReentrantLock(); + + @Override + public StreamObserver initInputStream(StreamObserver outputStream, + BiConsumer sessionOpenListener, + BiConsumer sessionCloseListener, + ScheduledExecutorService sendDownlinkExecutorService) { + if (session != null) { + throw new IllegalStateException("Session already initialized"); + } + initLock.lock(); + try { + this.session = new EdgeGrpcSession( + this, ctx, outputStream, downlinkMessageMapper, uplinkMessageDispatcher, + sessionOpenListener, sessionCloseListener, + sendDownlinkExecutorService, maxInboundMessageSize, maxHighPriorityQueueSizePerSession); + return session.initInputStream(); + } finally { + initLock.unlock(); + } + } + + @Override + public EdgeSessionState getState() { + if (session == null) { + return null; + } + return session.getState(); + } + + @Override + public void onConfigurationUpdate(Edge edge) { + EdgeSessionState state = getState(); + if (state == null) { + log.warn("Session state is null, skipping configuration update for edge [{}]", edge.getId()); + return; + } + CustomerId stateCustomerId = state.getEdge().getCustomerId(); + state.setEdge(edge); + if (stateCustomerId != null && !stateCustomerId.equals(edge.getCustomerId())) { + // do not send edge configuration message on customer update + // message send by separate flow from assign_to or unassing_from customer + return; + } + EdgeUpdateMsg edgeConfig = EdgeUpdateMsg.newBuilder() + .setConfiguration(EdgeMsgConstructorUtils.constructEdgeConfiguration(edge)).build(); + + ResponseMsg edgeConfigMsg = ResponseMsg.newBuilder() + .setEdgeUpdateMsg(edgeConfig) + .build(); + + session.sendDownlinkMsg(edgeConfigMsg); + } + + @Override + public void destroyAndMarkAsZombieIfFailed() { + EdgeSessionState state = getState(); + final var finalSession = session; + + try (finalSession) { + if (!destroy()) { + if (state != null) { + log.warn("[{}][{}] Session destroy failed for edge [{}] with session id [{}]. Adding to zombie queue for later cleanup.", + state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId()); + } else { + log.warn("Session destroy failed (state is null). Adding to zombie queue for later cleanup."); + } + zombieSessionCleanupService.add(this); + } + } catch (Exception e) { + if (state != null) { + log.warn("[{}][{}] Exception during session destroy for edge [{}] with session id [{}]", + state.getTenantId(), state.getEdgeId(), state.getEdge().getName(), state.getSessionId(), e); + } else { + log.warn("Exception during session destroy (state is null)", e); + } + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java new file mode 100644 index 0000000000..9bf51107e4 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/EdgeGrpcSessionManager.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session.manager; + +import io.grpc.stub.StreamObserver; +import org.thingsboard.server.common.data.edge.Edge; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.gen.edge.v1.RequestMsg; +import org.thingsboard.server.gen.edge.v1.ResponseMsg; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; + +import java.util.UUID; +import java.util.concurrent.ScheduledExecutorService; +import java.util.function.BiConsumer; + +public interface EdgeGrpcSessionManager { + + StreamObserver initInputStream(StreamObserver outputStream, + BiConsumer sessionOpenListener, + BiConsumer sessionCloseListener, + ScheduledExecutorService sendDownlinkExecutorService); + EdgeSessionState getState(); + void addEventToHighPriorityQueue(EdgeEvent edgeEvent); + void startSyncProcess(boolean fullSync); + void onEdgeConnect(); + void onEdgeEventUpdate(); + void onConfigurationUpdate(Edge edge); + void onEdgeDisconnect(); + void onEdgeRemoval(); + void destroyAndMarkAsZombieIfFailed(); + boolean destroy(); + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java new file mode 100644 index 0000000000..ebc5531320 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/KafkaBasedEdgeGrpcSessionManager.java @@ -0,0 +1,302 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session.manager; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.util.ProtoUtils; +import org.thingsboard.server.gen.edge.v1.DownlinkMsg; +import org.thingsboard.server.gen.transport.TransportProtos.ToEdgeEventNotificationMsg; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.common.consumer.QueueConsumerManager; +import org.thingsboard.server.queue.discovery.TopicService; +import org.thingsboard.server.queue.kafka.KafkaAdmin; +import org.thingsboard.server.queue.provider.TbCoreQueueFactory; +import org.thingsboard.server.queue.util.TbKafkaComponent; + +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher; +import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder; + +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Service +@Slf4j +@TbKafkaComponent +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@RequiredArgsConstructor +public class KafkaBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager { + + private static final int NO_INITIAL_DELAY_VALUE = 0; + + private final TbCoreQueueFactory tbCoreQueueFactory; + private final TopicService topicService; + private final KafkaAdmin kafkaAdmin; + private final EdgeSessionsHolder sessions; + + private final AtomicReference> initMigrationAndProcessingFutureRef = new AtomicReference<>(); + private final AtomicReference> highPriorityProcessingFutureRef = new AtomicReference<>(); + private final Lock initLock = new ReentrantLock(); + private volatile boolean migrationProcessed; + private volatile boolean isHighPriorityProcessing; + @Getter + private QueueConsumerManager> consumer; + private PostgresGeneralEdgeEventsDispatcher psqlGeneralEdgeEventsDispatcher; + private ExecutorService consumerExecutor; + + @Override + public void onEdgeConnect() { + scheduleMigrationAndProcessing(); + } + + @Override + public void onEdgeEventUpdate() {} + + @Override + public void onEdgeDisconnect() {} + + @Override + public void onEdgeRemoval() { + EdgeSessionState state = getState(); + String topic = topicService + .buildEdgeEventNotificationsTopicPartitionInfo(state.getTenantId(), state.getEdgeId()) + .getTopic(); + kafkaAdmin.deleteTopic(topic); + kafkaAdmin.deleteConsumerGroup(topic); + } + + @Override + public boolean destroy() { + cancelHighPriorityProcessing(); + EdgeSessionState state = getState(); + try { + if (consumer != null) { + log.info("[{}][{}] Stopping edge event consumer...", state.getTenantId(), state.getEdgeId()); + consumer.stop(); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to stop edge event consumer", state.getTenantId(), state.getEdgeId(), e); + return false; + } + consumer = null; + try { + if (consumerExecutor != null && !consumerExecutor.isShutdown()) { + consumerExecutor.shutdown(); + awaitConsumerTermination(); + } + } catch (Exception e) { + log.warn("[{}][{}] Failed to shutdown edge event consumer executor", state.getTenantId(), state.getEdgeId(), e); + return false; + } + return true; + } + + private void scheduleMigrationAndProcessing() { + cancelMigrationAndProcessingInit(); + EdgeSessionState state = getSession().getState(); + TenantId tenantId = state.getTenantId(); + EdgeId edgeId = state.getEdgeId(); + + if (!sessions.hasByEdgeId(edgeId)) { + log.debug("[{}] Session was removed and edge event consumer must not be started [{}]", + tenantId, edgeId.getId()); + return; + } + ScheduledFuture initMigrationAndProcessingTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> { + log.info("[{}] Edge event processing task started for edge [{}]", tenantId, edgeId); + initLock.lock(); + try { + processRemainingPostgresEventsIfRequired(edgeId); + if (migrationProcessed) { + initAndLaunchConsumerOrRetry(tenantId, edgeId, state); + } + } catch (Exception e) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e); + } + finally { + initLock.unlock(); + } + }, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS); + + initMigrationAndProcessingFutureRef.set(initMigrationAndProcessingTask); + } + + private void initAndLaunchConsumerOrRetry(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) { + if (initAndLaunchConsumer(tenantId, edgeId, state)) { + launchProcessingOfHighPriorityEvents(tenantId, edgeId); + } else { + scheduleMigrationAndProcessing(); + } + } + + private void launchProcessingOfHighPriorityEvents(TenantId tenantId, EdgeId edgeId) { + cancelHighPriorityProcessing(); + ScheduledFuture highPriorityProcessingTask = ctx.getEdgeEventProcessingExecutorService().scheduleAtFixedRate(() -> { + try { + isHighPriorityProcessing = true; + session.processHighPriorityEvents(); + } catch (Exception e) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e); + } finally { + isHighPriorityProcessing = false; + } + }, NO_INITIAL_DELAY_VALUE, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS); + highPriorityProcessingFutureRef.set(highPriorityProcessingTask); + } + + private boolean initAndLaunchConsumer(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) { + if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) { + log.warn("[{}][{}] Session is not ready (connected={}, syncInProgress={}, highPriority={}), skip starting edge event consumer", + tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing); + return false; + } + if (isConsumerInitializationRequired()) { + initConsumerAndExecutor(tenantId, edgeId, state); + } + return true; + } + + private void initConsumerAndExecutor(TenantId tenantId, EdgeId edgeId, EdgeSessionState state) { + try { + if (consumerExecutor != null && !consumerExecutor.isShutdown()) { + try { + consumerExecutor.shutdown(); + awaitConsumerTermination(); + } catch (Exception e) { + log.warn("[{}][{}] Failed to shutdown previous consumer executor", tenantId, edgeId, e); + } + } + this.consumerExecutor = Executors.newSingleThreadExecutor(ThingsBoardThreadFactory.forName("edge-event-consumer")); + this.consumer = QueueConsumerManager.>builder() + .name("TB Edge events [" + edgeId + "]") + .msgPackProcessor(this::processMsgs) + .pollInterval(ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval()) + .consumerCreator(() -> tbCoreQueueFactory.createEdgeEventMsgConsumer(tenantId, edgeId)) + .consumerExecutor(consumerExecutor) + .threadPrefix("edge-events-" + edgeId) + .build(); + consumer.subscribe(); + consumer.launch(); + } catch (Exception e) { + destroy(); + log.warn("[{}][{}] Failed to start edge event consumer", state.getSessionId(), edgeId, e); + } + } + + private boolean isConsumerInitializationRequired() { + return consumer == null || (consumer.getConsumer() != null && consumer.getConsumer().isStopped()); + } + + private void processMsgs(List> msgs, TbQueueConsumer> consumer) { + EdgeSessionState state = getSession().getState(); + TenantId tenantId = state.getTenantId(); + EdgeId edgeId = state.getEdgeId(); + + log.trace("[{}][{}] starting processing edge events", tenantId, edgeId); + if (!state.isConnected() || state.isSyncInProgress() || isHighPriorityProcessing) { + log.debug("[{}][{}] edge not connected, edge sync is not completed or high priority processing in progress, " + + "connected = {}, sync in progress = {}, high priority in progress = {}. Skipping iteration", + tenantId, edgeId, state.isConnected(), state.isSyncInProgress(), isHighPriorityProcessing); + return; + } + List edgeEvents = new ArrayList<>(); + for (TbProtoQueueMsg msg : msgs) { + EdgeEvent edgeEvent = ProtoUtils.fromProto(msg.getValue().getEdgeEventMsg()); + edgeEvents.add(edgeEvent); + } + List downlinkMsgsPack = downlinkMessageMapper.convertToDownlinkMsgsPack(state, edgeEvents); + boolean isInterrupted = true; + try { + isInterrupted = session.sendDownlinkMsgsPack(downlinkMsgsPack).get(); + if (isInterrupted) { + log.debug("[{}][{}] Send downlink messages task was interrupted", tenantId, edgeId); + } + } catch (Exception e) { + log.error("[{}][{}] Failed to process downlink messages", tenantId, edgeId, e); + } + if (!isInterrupted) { + consumer.commit(); + } + } + + private void cancelMigrationAndProcessingInit() { + EdgeSessionState state = getState(); + log.trace("[{}] cancelling edge migration & processing init for edge", state != null ? state.getEdgeId() : "unknown"); + cancelFuture(initMigrationAndProcessingFutureRef); + } + + private void cancelHighPriorityProcessing() { + EdgeSessionState state = getState(); + log.trace("[{}] cancelling high priority processing task for edge", state != null ? state.getEdgeId() : "unknown"); + cancelFuture(highPriorityProcessingFutureRef); + } + + private void cancelFuture(AtomicReference> futureReference) { + Future f = futureReference.getAndSet(null); + if (f != null && !f.isCancelled() && !f.isDone()) { + f.cancel(true); + } + } + + private void processRemainingPostgresEventsIfRequired(EdgeId edgeId) throws Exception { + log.debug("Processing remaining postgres edge events for edge [{}]", edgeId); + + if (psqlGeneralEdgeEventsDispatcher == null) { + psqlGeneralEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx); + } + if (migrationProcessed) { + return; + } + Boolean hasMorePostgresEvents = psqlGeneralEdgeEventsDispatcher.processNewEvents().get(); + if (Boolean.TRUE.equals(hasMorePostgresEvents)) { + log.info("Migration still in progress for edge [{}]", edgeId.getId()); + scheduleMigrationAndProcessing(); + } else if (Boolean.FALSE.equals(hasMorePostgresEvents)) { + log.info("Migration completed for edge [{}]", edgeId.getId()); + migrationProcessed = true; + } + } + + private void awaitConsumerTermination() { + EdgeSessionState state = getState(); + try { + if (!consumerExecutor.awaitTermination(10, java.util.concurrent.TimeUnit.SECONDS)) { + consumerExecutor.shutdownNow(); + } + } catch (InterruptedException ie) { + log.warn("[{}][{}] Interrupted while awaiting consumer executor termination", state.getTenantId(), state.getEdgeId()); + Thread.currentThread().interrupt(); + } + } +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java new file mode 100644 index 0000000000..0190cd2784 --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/session/manager/PostgresBasedEdgeGrpcSessionManager.java @@ -0,0 +1,183 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc.session.manager; + +import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.config.ConfigurableBeanFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.context.annotation.Scope; +import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.edge.EdgeEvent; +import org.thingsboard.server.common.data.id.EdgeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.service.edge.rpc.EdgeSessionState; +import org.thingsboard.server.service.edge.rpc.processor.PostgresGeneralEdgeEventsDispatcher; +import org.thingsboard.server.service.edge.rpc.session.EdgeSessionsHolder; + +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +@Service +@Slf4j +@ConditionalOnExpression("'${queue.type:null}'!='kafka'") +@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) +@RequiredArgsConstructor +public class PostgresBasedEdgeGrpcSessionManager extends AbstractEdgeGrpcSessionManager { + + private final EdgeSessionsHolder edgeSessions; + + private final Lock newEventsLock = new ReentrantLock(); + private final AtomicReference> edgeEventCheckFutureRef = new AtomicReference<>(); + private PostgresGeneralEdgeEventsDispatcher generalEdgeEventsDispatcher; + private volatile boolean hasNewEvents; + + @Override + public void addEventToHighPriorityQueue(EdgeEvent edgeEvent) { + super.addEventToHighPriorityQueue(edgeEvent); + markHasNewEvents(); + } + + @Override + public void onEdgeConnect() { + markHasNewEvents(); + scheduleEdgeEventsCheck(); + } + + @Override + public void onEdgeEventUpdate() { + markHasNewEvents(); + } + + @Override + public void onEdgeDisconnect() { + markHasNoEvents(); + cancelScheduleEdgeEventsCheck(); + } + + @Override + public void onEdgeRemoval() { + markHasNoEvents(); + cancelScheduleEdgeEventsCheck(); + } + + @Override + public boolean destroy() { + markHasNoEvents(); + cancelScheduleEdgeEventsCheck(); + return true; + } + + private void scheduleEdgeEventsCheck() { + cancelScheduleEdgeEventsCheck(); + EdgeId edgeId = getState().getEdgeId(); + TenantId tenantId = getState().getTenantId(); + + if (!edgeSessions.hasByEdgeId(edgeId)) { + log.debug("[{}] Session was removed and edge event check schedule must not be started [{}]", + tenantId, edgeId.getId()); + return; + } + ScheduledFuture edgeEventCheckTask = ctx.getEdgeEventProcessingExecutorService().schedule(() -> { + try { + newEventsLock.lock(); + try { + if (!hasNewEvents) { + scheduleEdgeEventsCheck(); + return; + } + log.trace("[{}][{}] set session new events flag to false", tenantId, edgeId.getId()); + hasNewEvents = false; + session.processHighPriorityEvents(); + processEdgeEventsWithCallback(tenantId, edgeId); + } finally { + newEventsLock.unlock(); + } + } catch (Exception e) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, e); + } + }, ctx.getEdgeEventStorageSettings().getNoRecordsSleepInterval(), TimeUnit.MILLISECONDS); + + edgeEventCheckFutureRef.set(edgeEventCheckTask); + log.trace("[{}] Check edge event scheduled for edge [{}]", tenantId, edgeId.getId()); + } + + private void processEdgeEventsWithCallback(TenantId tenantId, EdgeId edgeId) throws Exception { + Futures.addCallback(processEdgeEvents(), new FutureCallback<>() { + @Override + public void onSuccess(Boolean newEventsAdded) { + if (Boolean.TRUE.equals(newEventsAdded)) { + log.trace("[{}][{}] new events added. set session new events flag to true", tenantId, edgeId.getId()); + hasNewEvents = true; + } + scheduleEdgeEventsCheck(); + } + + @Override + public void onFailure(Throwable t) { + log.warn("[{}] Failed to process edge events for edge [{}]!", tenantId, edgeId, t); + scheduleEdgeEventsCheck(); + } + }, ctx.getGrpcCallbackExecutorService()); + } + + public ListenableFuture processEdgeEvents() throws Exception { + if (generalEdgeEventsDispatcher == null) { + generalEdgeEventsDispatcher = new PostgresGeneralEdgeEventsDispatcher(session, ctx); + } + return generalEdgeEventsDispatcher.processNewEvents(); + } + + private void cancelScheduleEdgeEventsCheck() { + EdgeSessionState state = getState(); + log.trace("[{}] cancelling edge event check for edge", state != null ? state.getEdgeId() : "unknown"); + + ScheduledFuture sf = edgeEventCheckFutureRef.getAndSet(null); + if (sf != null && !sf.isCancelled() && !sf.isDone()) { + sf.cancel(true); + } + } + + private void markHasNoEvents() { + markHasEvents(false); + } + + private void markHasNewEvents() { + markHasEvents(true); + } + + private void markHasEvents(boolean newEventsPresent) { + newEventsLock.lock(); + try { + if (hasNewEvents != newEventsPresent) { + EdgeSessionState state = getState(); + if (state != null) { + log.trace("[{}] set session new events flag to {} [{}]", state.getTenantId(), newEventsPresent, state.getEdgeId()); + } + hasNewEvents = newEventsPresent; + } + } finally { + newEventsLock.unlock(); + } + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java b/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java index b2b72db5ad..ca35b30e59 100644 --- a/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java +++ b/application/src/main/java/org/thingsboard/server/service/edge/rpc/utils/EdgeVersionUtils.java @@ -16,13 +16,14 @@ package org.thingsboard.server.service.edge.rpc.utils; import lombok.extern.slf4j.Slf4j; +import org.thingsboard.edge.rpc.EdgeVersionComparator; import org.thingsboard.server.gen.edge.v1.EdgeVersion; @Slf4j public final class EdgeVersionUtils { public static boolean isEdgeVersionOlderThan(EdgeVersion currentVersion, EdgeVersion requiredVersion) { - return currentVersion.ordinal() < requiredVersion.ordinal(); + return EdgeVersionComparator.INSTANCE.compare(currentVersion, requiredVersion) < 0; } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java index 279ed918c8..efa000e521 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/AbstractTbEntityService.java @@ -97,6 +97,10 @@ public abstract class AbstractTbEntityService { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } + protected I getOrEmptyId(I entityId, EntityType entityType) { + return entityId == null ? emptyId(entityType) : entityId; + } + protected ListenableFuture autoCommit(User user, EntityId entityId) { if (vcService != null) { return vcService.autoCommit(user, entityId); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java index 05ec142e9d..685e447887 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/DefaultTbLogEntityActionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -60,7 +61,7 @@ public class DefaultTbLogEntityActionService implements TbLogEntityActionService } @Override - public void logEntityAction(TenantId tenantId, I entityId, E entity, + public void logEntityAction(TenantId tenantId, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Exception e, Object... additionalInfo) { if (user != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java index 5f4a6d29ee..c3d275883e 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/EntityStateSourcingListener.java @@ -45,6 +45,7 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.data.job.JobStatus; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.notification.NotificationRequest; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; @@ -360,10 +361,12 @@ public class EntityStateSourcingListener { jobManager.onJobUpdate(job); ComponentLifecycleEvent event; - if (job.getResult().getCancellationTs() > 0) { + if (job.getStatus() == JobStatus.CANCELLED) { event = ComponentLifecycleEvent.STOPPED; - } else if (job.getResult().getGeneralError() != null) { + } else if (job.getStatus() == JobStatus.FAILED) { event = ComponentLifecycleEvent.FAILED; + } else if (job.getStatus() == JobStatus.COMPLETED) { + event = ComponentLifecycleEvent.UPDATED; } else { return; } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java index 712bca5b49..f574d4df23 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/TbLogEntityActionService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; @@ -37,7 +38,7 @@ public interface TbLogEntityActionService { void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Object... additionalInfo); - void logEntityAction(TenantId tenantId, I entityId, E entity, CustomerId customerId, + void logEntityAction(TenantId tenantId, @NotNull I entityId, E entity, CustomerId customerId, ActionType actionType, User user, Exception e, Object... additionalInfo); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java index f175c46706..4adb97f380 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/DefaultTbCalculatedFieldService.java @@ -15,10 +15,22 @@ */ package org.thingsboard.server.service.entitiy.cf; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.ObjectUtils; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.script.api.tbel.TbelCfArg; +import org.thingsboard.script.api.tbel.TbelCfCtx; +import org.thingsboard.script.api.tbel.TbelCfSingleValueArg; +import org.thingsboard.script.api.tbel.TbelCfTsDoubleVal; +import org.thingsboard.script.api.tbel.TbelCfTsRollingArg; +import org.thingsboard.script.api.tbel.TbelInvokeService; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.cf.CalculatedField; @@ -31,10 +43,16 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.cf.ctx.state.CalculatedFieldTbelScriptEngine; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.security.model.SecurityUser; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; import java.util.Set; +import java.util.concurrent.TimeUnit; @TbCoreComponent @Service @@ -42,8 +60,13 @@ import java.util.Set; @RequiredArgsConstructor public class DefaultTbCalculatedFieldService extends AbstractTbEntityService implements TbCalculatedFieldService { + private static final int TIMEOUT = 20; + private final CalculatedFieldService calculatedFieldService; + @Autowired(required = false) + private TbelInvokeService tbelInvokeService; + @Override public CalculatedField save(CalculatedField calculatedField, SecurityUser user) throws ThingsboardException { ActionType actionType = calculatedField.getId() == null ? ActionType.ADDED : ActionType.UPDATED; @@ -89,6 +112,75 @@ public class DefaultTbCalculatedFieldService extends AbstractTbEntityService imp } } + @Override + public JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams) { + String expression = inputParams.get("expression").asText(); + Map arguments = Objects.requireNonNullElse( + JacksonUtil.convertValue(inputParams.get("arguments"), new TypeReference<>() {}), + Collections.emptyMap() + ); + + ArrayList ctxAndArgNames = new ArrayList<>(arguments.size() + 1); + ctxAndArgNames.add("ctx"); + ctxAndArgNames.addAll(arguments.keySet()); + + String output = ""; + String errorText = ""; + + CalculatedFieldTbelScriptEngine engine = null; + try { + if (tbelInvokeService == null) { + throw new IllegalArgumentException("TBEL script engine is disabled!"); + } + + engine = new CalculatedFieldTbelScriptEngine( + tenantId, + tbelInvokeService, + expression, + ctxAndArgNames.toArray(String[]::new) + ); + + Object[] args = new Object[ctxAndArgNames.size()]; + args[0] = new TbelCfCtx(arguments, getLatestTimestamp(arguments)); + for (int i = 1; i < ctxAndArgNames.size(); i++) { + var arg = arguments.get(ctxAndArgNames.get(i)); + if (arg instanceof TbelCfSingleValueArg svArg) { + args[i] = svArg.getValue(); + } else { + args[i] = arg; + } + } + + JsonNode json = engine.executeJsonAsync(args).get(TIMEOUT, TimeUnit.SECONDS); + output = JacksonUtil.toString(json); + } catch (Exception e) { + log.error("Error evaluating expression", e); + Throwable rootCause = ObjectUtils.firstNonNull(ExceptionUtils.getRootCause(e), e); + errorText = ObjectUtils.firstNonNull(rootCause.getMessage(), e.getClass().getSimpleName()); + } finally { + if (engine != null) { + engine.destroy(); + } + } + return JacksonUtil.newObjectNode() + .put("output", output) + .put("error", errorText); + } + + private static long getLatestTimestamp(Map arguments) { + long lastUpdateTimestamp = -1; + for (TbelCfArg entry : arguments.values()) { + if (entry instanceof TbelCfSingleValueArg singleValueArg) { + long ts = singleValueArg.getTs(); + lastUpdateTimestamp = Math.max(lastUpdateTimestamp, ts); + } else if (entry instanceof TbelCfTsRollingArg tsRollingArg) { + long maxTs = tsRollingArg.getValues().stream().mapToLong(TbelCfTsDoubleVal::getTs).max().orElse(-1); + lastUpdateTimestamp = Math.max(lastUpdateTimestamp, maxTs); + } + } + return lastUpdateTimestamp == -1 ? System.currentTimeMillis() : lastUpdateTimestamp; + } + private void checkForEntityChange(CalculatedField oldCalculatedField, CalculatedField newCalculatedField) { if (!oldCalculatedField.getEntityId().equals(newCalculatedField.getEntityId())) { throw new IllegalArgumentException("Changing the calculated field target entity after initialization is prohibited."); diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java index 5fbb96e636..9dbb20dc2f 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/cf/TbCalculatedFieldService.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.entitiy.cf; +import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.exception.ThingsboardException; @@ -35,4 +36,6 @@ public interface TbCalculatedFieldService { void delete(CalculatedField calculatedField, SecurityUser user); + JsonNode executeTestScript(TenantId tenantId, JsonNode inputParams); + } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java index fce44972d8..25dadbe316 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/DefaultTbTenantProfileService.java @@ -15,21 +15,28 @@ */ package org.thingsboard.server.service.entitiy.tenant.profile; +import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.ActionType; +import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.User; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.entitiy.AbstractTbEntityService; import org.thingsboard.server.service.entitiy.queue.TbQueueService; import java.util.List; +import static org.thingsboard.server.common.data.EntityType.TENANT_PROFILE; + @Slf4j @Service @TbCoreComponent @@ -41,18 +48,62 @@ public class DefaultTbTenantProfileService extends AbstractTbEntityService imple private final TbTenantProfileCache tenantProfileCache; @Override - public TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException { - TenantProfile savedTenantProfile = checkNotNull(tenantProfileService.saveTenantProfile(tenantId, tenantProfile)); - tenantProfileCache.put(savedTenantProfile); + public TenantProfile + save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile, User user) throws ThingsboardException { + ActionType actionType = tenantProfile.getId() == null ? ActionType.ADDED : ActionType.UPDATED; + try { + TenantProfile savedTenantProfile = checkNotNull(tenantProfileService.saveTenantProfile(tenantId, tenantProfile)); + tenantProfileCache.put(savedTenantProfile); + logEntityActionService.logEntityAction(tenantId, savedTenantProfile.getId(), savedTenantProfile, null, + actionType, user); + + List tenantIds = tenantService.findTenantIdsByTenantProfileId(savedTenantProfile.getId()); + tbQueueService.updateQueuesByTenants(tenantIds, savedTenantProfile, oldTenantProfile); - List tenantIds = tenantService.findTenantIdsByTenantProfileId(savedTenantProfile.getId()); - tbQueueService.updateQueuesByTenants(tenantIds, savedTenantProfile, oldTenantProfile); + return savedTenantProfile; + } catch (ThingsboardException e) { + log.debug("Failed to save tenant profile because ThingsboardException [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw e; + } catch (DataValidationException e) { + log.debug("Failed to save tenant profile because data validation [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } catch (Exception e) { + log.debug("Failed to save tenant profile because Exception [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, getOrEmptyId(tenantProfile.getId(), TENANT_PROFILE), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.GENERAL); + } - return savedTenantProfile; } @Override - public void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException { - tenantProfileService.deleteTenantProfile(tenantId, tenantProfile.getId()); + public void delete(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException { + ActionType actionType = ActionType.DELETED; + try { + tenantProfileService.deleteTenantProfile(tenantId, tenantProfile.getId()); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, null, actionType, user); + } catch (Exception e) { + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, null, actionType, user, e); + throw e; + } + } + + @Override + public TenantProfile setDefaultTenantProfile(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException { + ActionType actionType = ActionType.UPDATED; + try { + TenantProfile savedTenantProfile = tenantProfileService.setDefaultTenantProfile(tenantId, tenantProfile.getId()); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), savedTenantProfile, null, actionType, user); + return savedTenantProfile; + } catch (DataValidationException e) { + log.debug("Failed to set default tenant profile due to data validation [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.BAD_REQUEST_PARAMS); + } catch (Exception e) { + log.debug("Failed to set default tenant profile [{}]", tenantProfile, e); + logEntityActionService.logEntityAction(tenantId, tenantProfile.getId(), tenantProfile, actionType, user, e); + throw new ThingsboardException(e.getMessage(), e, ThingsboardErrorCode.GENERAL); + } } } diff --git a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java index 3ca8f0570b..d15e9c84c8 100644 --- a/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java +++ b/application/src/main/java/org/thingsboard/server/service/entitiy/tenant/profile/TbTenantProfileService.java @@ -15,13 +15,17 @@ */ package org.thingsboard.server.service.entitiy.tenant.profile; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.User; public interface TbTenantProfileService { - TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile) throws ThingsboardException; + TenantProfile save(TenantId tenantId, TenantProfile tenantProfile, TenantProfile oldTenantProfile, User user) throws ThingsboardException; - void delete(TenantId tenantId, TenantProfile tenantProfile) throws ThingsboardException; + void delete(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException; + + TenantProfile setDefaultTenantProfile(TenantId tenantId, @NotNull TenantProfile tenantProfile, User user) throws ThingsboardException; } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index d8bcf666f7..72307b2159 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java @@ -21,17 +21,19 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Service; import org.thingsboard.server.service.install.update.DefaultDataUpdateService; -import java.util.List; +import java.util.Map; @Service @Slf4j @RequiredArgsConstructor public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSettingsService { - // This list should include all versions that are compatible for the upgrade in 4 digits format (like 4.2.0.0, etc.). - // The compatibility cycle usually breaks when we have some scripts written in Java that may not work after a new release. - // TODO: don't check the "patch" number, since upgrade is not required for patch releases - private static final List SUPPORTED_VERSIONS_FOR_UPGRADE = List.of("4.2.1.0", "4.2.1.1", "4.2.1.2"); + // map of versions from which the upgrade to the current version is possible + // key - supported version prefix, value - display name + private static final Map SUPPORTED_VERSIONS_FOR_UPGRADE = Map.of( + "4.3.0", "4.3.0.x", + "4.3.1", "4.3.1.x" + ); private final ProjectInfo projectInfo; private final JdbcTemplate jdbcTemplate; @@ -56,9 +58,9 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti onSchemaSettingsError("Upgrade failed: database already upgraded to current version. You can set SKIP_SCHEMA_VERSION_CHECK to 'true' if force re-upgrade needed."); } - if (!SUPPORTED_VERSIONS_FOR_UPGRADE.contains(dbSchemaVersion)) { + if (SUPPORTED_VERSIONS_FOR_UPGRADE.keySet().stream().noneMatch(dbSchemaVersion::startsWith)) { onSchemaSettingsError(String.format("Upgrade failed: database version '%s' is not supported for upgrade. Supported versions are: %s.", - dbSchemaVersion, SUPPORTED_VERSIONS_FOR_UPGRADE + dbSchemaVersion, SUPPORTED_VERSIONS_FOR_UPGRADE.values() )); } } diff --git a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java index 965d7105a9..d528f10899 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/DefaultSystemDataLoaderService.java @@ -53,7 +53,7 @@ import org.thingsboard.server.common.data.alarm.rule.AlarmRule; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.BooleanFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NumericFilterPredicate; diff --git a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java index 96f58707b5..aaf066acdc 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java +++ b/application/src/main/java/org/thingsboard/server/service/install/InstallScripts.java @@ -135,6 +135,10 @@ public class InstallScripts { return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_TYPES_DIR); } + public Path getWidgetBundlesDir() { + return Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + } + public String getDataDir() { if (!StringUtils.isEmpty(dataDir)) { if (!Paths.get(this.dataDir).toFile().isDirectory()) { @@ -207,7 +211,7 @@ public class InstallScripts { public void loadSystemWidgets() { log.info("Loading system widgets"); Map widgetsBundlesMap = new HashMap<>(); - Path widgetBundlesDir = Paths.get(getDataDir(), JSON_DIR, SYSTEM_DIR, WIDGET_BUNDLES_DIR); + Path widgetBundlesDir = getWidgetBundlesDir(); try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(JSON_EXT))) { dirStream.forEach( path -> { diff --git a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java index 2babc3feeb..3932aa9dcf 100644 --- a/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java +++ b/application/src/main/java/org/thingsboard/server/service/install/SqlDatabaseUpgradeService.java @@ -54,6 +54,7 @@ public class SqlDatabaseUpgradeService implements DatabaseEntitiesUpgradeService public void upgradeDatabase() { log.info("Updating schema..."); loadSql(getSchemaUpdateFile("basic")); + loadSql(getSchemaUpdateFile("lts")); log.info("Schema updated."); } diff --git a/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java index f17f10bfd2..d14ba2067a 100644 --- a/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java +++ b/application/src/main/java/org/thingsboard/server/service/job/DefaultJobManager.java @@ -15,15 +15,20 @@ */ package org.thingsboard.server.service.job; +import com.google.common.util.concurrent.FutureCallback; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import jakarta.annotation.Nullable; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.ObjectUtils; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.rule.engine.api.JobManager; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; @@ -33,7 +38,10 @@ import org.thingsboard.server.common.data.job.JobStatus; import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.task.Task; import org.thingsboard.server.common.data.job.task.TaskResult; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; import org.thingsboard.server.dao.job.JobService; import org.thingsboard.server.gen.transport.TransportProtos.TaskProto; @@ -50,7 +58,10 @@ import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -65,6 +76,8 @@ public class DefaultJobManager implements JobManager { private final Map jobProcessors; private final Map>> taskProducers; private final ExecutorService executor; + private final ConcurrentHashMap finishCallbacks = new ConcurrentHashMap<>(); + private final ScheduledExecutorService cleanupScheduler; public DefaultJobManager(JobService jobService, JobStatsService jobStatsService, PartitionService partitionService, TaskProducerQueueFactory queueFactory, TasksQueueConfig queueConfig, @@ -76,6 +89,8 @@ public class DefaultJobManager implements JobManager { this.jobProcessors = jobProcessors.stream().collect(Collectors.toMap(JobProcessor::getType, Function.identity())); this.taskProducers = Arrays.stream(JobType.values()).collect(Collectors.toMap(Function.identity(), queueFactory::createTaskProducer)); this.executor = ThingsBoardExecutors.newWorkStealingPool(Math.max(4, Runtime.getRuntime().availableProcessors()), getClass()); + this.cleanupScheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("job-callback-cleanup"); + this.cleanupScheduler.scheduleWithFixedDelay(this::cleanupStaleCallbacks, 1, 1, TimeUnit.HOURS); } @Override @@ -84,6 +99,25 @@ public class DefaultJobManager implements JobManager { return Futures.submit(() -> jobService.saveJob(job.getTenantId(), job), executor); } + @Override + public ListenableFuture submitJob(Job job, TbCallback finishCallback) { + ListenableFuture saveFuture = submitJob(job); + if (finishCallback != null) { + Futures.addCallback(saveFuture, new FutureCallback<>() { + @Override + public void onSuccess(Job savedJob) { + finishCallbacks.put(savedJob.getId(), finishCallback); + } + + @Override + public void onFailure(Throwable t) { + finishCallback.onFailure(t); + } + }, MoreExecutors.directExecutor()); + } + return saveFuture; + } + @Override public void onJobUpdate(Job job) { JobStatus status = job.getStatus(); @@ -109,6 +143,35 @@ public class DefaultJobManager implements JobManager { } } + @EventListener + public void onJobUpdateEvent(ComponentLifecycleMsg event) { + EntityId entityId = event.getEntityId(); + if (entityId.getEntityType() != EntityType.JOB) { + return; + } + + ComponentLifecycleEvent lifecycleEvent = event.getEvent(); + if (!lifecycleEvent.equals(ComponentLifecycleEvent.STOPPED) && + !lifecycleEvent.equals(ComponentLifecycleEvent.FAILED) && + !lifecycleEvent.equals(ComponentLifecycleEvent.UPDATED)) { + return; + } + JobId jobId = new JobId(entityId.getId()); + TbCallback callback = finishCallbacks.remove(jobId); + if (callback == null) { + return; + } + executor.execute(() -> { + try { + Job job = jobService.findJobById(event.getTenantId(), jobId); + invokeFinishCallback(job, callback); + } catch (Throwable e) { + log.error("[{}] Failed to invoke finish callback", jobId, e); + callback.onFailure(e); + } + }); + } + private void processJob(Job job) { TenantId tenantId = job.getTenantId(); JobId jobId = job.getId(); @@ -195,12 +258,41 @@ public class DefaultJobManager implements JobManager { }); } + private void invokeFinishCallback(@Nullable Job job, TbCallback callback) { + if (job == null) { + callback.onFailure(new RuntimeException("Job not found")); + } else if (job.getStatus() == JobStatus.COMPLETED) { + callback.onSuccess(); + } else { + callback.onFailure(new RuntimeException(job.getError())); + } + } + + private void cleanupStaleCallbacks() { + finishCallbacks.entrySet().removeIf(entry -> { + JobId jobId = entry.getKey(); + try { + Job job = jobService.findJobById(TenantId.SYS_TENANT_ID, jobId); + if (job == null || job.getStatus().isOneOf(JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED)) { + invokeFinishCallback(job, entry.getValue()); + return true; + } + return false; + } catch (Throwable e) { + log.error("[{}] Failed to cleanup stale callback", jobId, e); + entry.getValue().onFailure(e); + return true; + } + }); + } + private JobProcessor getJobProcessor(JobType jobType) { return jobProcessors.get(jobType); } @PreDestroy private void destroy() { + cleanupScheduler.shutdownNow(); executor.shutdownNow(); } diff --git a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java index df163283ce..d023ed9b53 100644 --- a/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java +++ b/application/src/main/java/org/thingsboard/server/service/notification/channels/MicrosoftTeamsNotificationChannel.java @@ -29,6 +29,7 @@ import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.info.NotificationInfo; @@ -109,10 +110,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel request = new HttpEntity<>(JacksonUtil.toString(teamsAdaptiveCard), headers); - restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + restTemplate.postForEntity(webhookUri, request, String.class); } private void sendTeamsMessageCard(MicrosoftTeamsNotificationTargetConfig targetConfig, MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException, URISyntaxException { @@ -139,10 +143,13 @@ public class MicrosoftTeamsNotificationChannel implements NotificationChannel request = new HttpEntity<>(JacksonUtil.toString(teamsMessageCard), headers); - restTemplate.postForEntity(new URI(targetConfig.getWebhookUrl()), request, String.class); + restTemplate.postForEntity(webhookUri, request, String.class); } private String getButtonUri(MicrosoftTeamsDeliveryMethodNotificationTemplate processedTemplate, NotificationProcessingContext ctx) throws JsonProcessingException { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java index 28fa68d803..e0a9917509 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbAssetProfileCache implements TbAssetProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = assetProfilesMap.values().stream() + .filter(assetProfile -> assetProfile.getTenantId().equals(tenantId)) + .map(AssetProfile::getId) + .collect(Collectors.toSet()); + assetProfilesMap.keySet().removeAll(toRemove); + assetsMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + assetProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(AssetProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java index 6b356adf94..4729a8c118 100644 --- a/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java +++ b/application/src/main/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCache.java @@ -16,6 +16,7 @@ package org.thingsboard.server.service.profile; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -23,15 +24,19 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.function.BiConsumer; import java.util.function.Consumer; +import java.util.stream.Collectors; @Service @Slf4j @@ -143,6 +148,25 @@ public class DefaultTbDeviceProfileCache implements TbDeviceProfileCache { } } + @EventListener(ComponentLifecycleMsg.class) + public void onComponentLifecycleEvent(ComponentLifecycleMsg event) { + switch (event.getEntityId().getEntityType()) { + case TENANT: + if (event.getEvent() == ComponentLifecycleEvent.DELETED) { + TenantId tenantId = event.getTenantId(); + Set toRemove = deviceProfilesMap.values().stream() + .filter(deviceProfile -> deviceProfile.getTenantId().equals(tenantId)) + .map(DeviceProfile::getId) + .collect(Collectors.toSet()); + deviceProfilesMap.keySet().removeAll(toRemove); + devicesMap.entrySet().removeIf(entry -> toRemove.contains(entry.getValue())); + profileListeners.remove(tenantId); + deviceProfileListeners.remove(tenantId); + } + break; + } + } + private void notifyProfileListeners(DeviceProfile profile) { ConcurrentMap> tenantListeners = profileListeners.get(profile.getTenantId()); if (tenantListeners != null) { diff --git a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 4d76989a92..92d76693f9 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java @@ -15,13 +15,15 @@ */ package org.thingsboard.server.service.query; +import com.fasterxml.jackson.databind.JsonNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.util.CollectionUtils; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.KvUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.EntityType; @@ -30,11 +32,17 @@ import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.DataType; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeySample; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.EntityCountQuery; @@ -59,11 +67,13 @@ import org.thingsboard.server.service.security.model.SecurityUser; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.TreeMap; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; @@ -211,7 +221,7 @@ public class DefaultEntityQueryService implements EntityQueryService { private EntityDataQuery buildEntityDataQuery(AlarmCountQuery query) { EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.ENTITY_FIELD, EntityKeyMapping.CREATED_TIME))); - return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, null, null, query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } private EntityDataQuery buildEntityDataQuery(AlarmDataQuery query) { @@ -223,7 +233,7 @@ public class DefaultEntityQueryService implements EntityQueryService { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } @Override @@ -253,7 +263,7 @@ public class DefaultEntityQueryService implements EntityQueryService { if (isAttributes) { Map> typesMap = ids.stream().collect(Collectors.groupingBy(EntityId::getEntityType)); List>> futures = new ArrayList<>(typesMap.size()); - typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIds(tenantId, entityIds, scope)))); + typesMap.forEach((type, entityIds) -> futures.add(dbCallbackExecutor.submit(() -> attributesService.findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope)))); attributesKeysFuture = Futures.transform(Futures.allAsList(futures), lists -> { if (CollectionUtils.isEmpty(lists)) { return Collections.emptyList(); @@ -274,4 +284,135 @@ public class DefaultEntityQueryService implements EntityQueryService { }, dbCallbackExecutor); } + @Override + public ListenableFuture findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + Set scopes, boolean includeSamples) { + if (!includeTimeseries && !includeAttributes) { + return Futures.immediateFailedFuture( + new IllegalArgumentException("At least one of 'includeTimeseries' or 'includeAttributes' must be true")); + } + + return Futures.transformAsync(findEntityIdsByQueryAsync(securityUser, query), ids -> { + if (ids.isEmpty()) { + return immediateFuture(new AvailableEntityKeysV2( + Collections.emptySet(), + includeTimeseries ? Collections.emptyList() : null, + includeAttributes ? Collections.emptyMap() : null)); + } + + TenantId tenantId = securityUser.getTenantId(); + Set entityTypes = ids.stream().map(EntityId::getEntityType).collect(Collectors.toSet()); + + var tsFuture = includeTimeseries ? fetchTimeseriesKeys(tenantId, ids, includeSamples) : null; + + Set effectiveScopes = includeAttributes + ? resolveAttributeScopes(scopes, entityTypes) : Collections.emptySet(); + var attrFutures = effectiveScopes.stream() + .map(scope -> fetchAttributeKeys(tenantId, ids, scope, includeSamples)) + .toList(); + + return assembleResult(entityTypes, tsFuture, attrFutures); + }, dbCallbackExecutor); + } + + private ListenableFuture> findEntityIdsByQueryAsync(SecurityUser securityUser, EntityDataQuery query) { + return Futures.transform(entityService.findEntityDataByQueryAsync(securityUser.getTenantId(), securityUser.getCustomerId(), query), + page -> page.getData().stream() + .map(EntityData::getEntityId) + .toList(), + dbCallbackExecutor); + } + + private static Set resolveAttributeScopes(Set requestedScopes, Set entityTypes) { + boolean hasDevices = entityTypes.contains(EntityType.DEVICE); + Set scopes; + if (CollectionUtils.isNotEmpty(requestedScopes)) { + scopes = requestedScopes; + } else { // auto-determine scopes + scopes = hasDevices + ? Set.of(AttributeScope.SERVER_SCOPE, AttributeScope.CLIENT_SCOPE, AttributeScope.SHARED_SCOPE) + : Collections.singleton(AttributeScope.SERVER_SCOPE); + } + // Non-device entities only support SERVER_SCOPE + if (!hasDevices) { + return scopes.contains(AttributeScope.SERVER_SCOPE) + ? Collections.singleton(AttributeScope.SERVER_SCOPE) + : Collections.emptySet(); + } + return scopes; + } + + private ListenableFuture> fetchTimeseriesKeys(TenantId tenantId, List entityIds, boolean includeSamples) { + if (includeSamples) { + return Futures.transform( + timeseriesService.findLatestByEntityIdsAsync(tenantId, entityIds), + entries -> toKeyInfos(entries, true), + dbCallbackExecutor); + } + return Futures.transform( + timeseriesService.findAllKeysByEntityIdsAsync(tenantId, entityIds), + keys -> keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList(), + dbCallbackExecutor); + } + + private ListenableFuture>> fetchAttributeKeys( + TenantId tenantId, List entityIds, AttributeScope scope, boolean includeSamples) { + if (includeSamples) { + return Futures.transform( + attributesService.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope), + entries -> Map.entry(scope, toKeyInfos(entries, true)), + dbCallbackExecutor); + } + return Futures.transform( + attributesService.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope), + keys -> Map.entry(scope, keys.stream().sorted().map(k -> new KeyInfo(k, null)).toList()), + dbCallbackExecutor); + } + + private ListenableFuture assembleResult( + Set entityTypes, + ListenableFuture> tsFuture, + List>>> attrFutures) { + var allAttrFuture = attrFutures.isEmpty() + ? immediateFuture(List.>>of()) + : Futures.allAsList(attrFutures); + + List> allFutures = new ArrayList<>(); + if (tsFuture != null) { + allFutures.add(tsFuture); + } + allFutures.add(allAttrFuture); + + var finalTsFuture = tsFuture; + return Futures.whenAllComplete(allFutures) + .call(() -> { + List tsKeys = finalTsFuture != null ? Futures.getDone(finalTsFuture) : null; + Map> attrMap = attrFutures.isEmpty() ? null : new TreeMap<>(); + if (attrMap != null) { + for (var entry : Futures.getDone(allAttrFuture)) { + attrMap.put(entry.getKey(), entry.getValue()); + } + } + return new AvailableEntityKeysV2(entityTypes, tsKeys, attrMap); + }, dbCallbackExecutor); + } + + private static List toKeyInfos(List entries, boolean includeSamples) { + return entries.stream() + .map(e -> new KeyInfo(e.getKey(), includeSamples ? toKeySample(e) : null)) + .sorted(Comparator.comparing(KeyInfo::key)) + .toList(); + } + + private static KeySample toKeySample(KvEntry entry) { + long ts = entry instanceof TsKvEntry tsKv ? tsKv.getTs() + : entry instanceof AttributeKvEntry attr ? attr.getLastUpdateTs() + : 0; + JsonNode value = entry.getDataType() == DataType.JSON + ? JacksonUtil.toJsonNode(entry.getJsonValue().get()) + : JacksonUtil.valueToTree(entry.getValue()); + return new KeySample(ts, value); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java index a90cf684a1..354f8e9278 100644 --- a/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java +++ b/application/src/main/java/org/thingsboard/server/service/query/EntityQueryService.java @@ -23,11 +23,14 @@ import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.service.security.model.SecurityUser; +import java.util.Set; + public interface EntityQueryService { long countEntitiesByQuery(SecurityUser securityUser, EntityCountQuery query); @@ -41,4 +44,8 @@ public interface EntityQueryService { ListenableFuture getKeysByQuery(SecurityUser securityUser, TenantId tenantId, EntityDataQuery query, boolean isTimeseries, boolean isAttributes, AttributeScope scope); + ListenableFuture findAvailableEntityKeysByQuery(SecurityUser securityUser, EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + Set scopes, boolean includeSamples); + } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java index d0f7bfa81c..8a3f05f0b1 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCalculatedFieldConsumerService.java @@ -51,7 +51,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.cf.CalculatedFieldStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; @@ -91,9 +90,8 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache, CalculatedFieldStateService stateService) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.queueFactory = tbQueueFactory; this.stateService = stateService; @@ -147,15 +145,15 @@ public class DefaultTbCalculatedFieldConsumerService extends AbstractPartitionBa private void processMsgs(List> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java index 6399e55e03..519601c30a 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbCoreConsumerService.java @@ -86,7 +86,6 @@ import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.provider.TbCoreQueueFactory; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.notification.NotificationSchedulerService; import org.thingsboard.server.service.ota.OtaPackageStateService; import org.thingsboard.server.service.profile.TbAssetProfileCache; @@ -179,9 +178,8 @@ public class DefaultTbCoreConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig config) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future packSubmitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating main callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java index 53da1bb8a1..8498c3e205 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbEdgeConsumerService.java @@ -87,7 +87,7 @@ public class DefaultTbEdgeConsumerService extends AbstractConsumerService> msgs, TbQueueConsumer> consumer, Object consumerKey, QueueConfig edgeQueueConfig) throws InterruptedException { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); PendingMsgHolder pendingMsgHolder = new PendingMsgHolder<>(); Future submitFuture = consumersExecutor.submit(() -> { orderedMsgList.forEach((element) -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); TbCallback callback = new TbPackCallback<>(id, ctx); try { ToEdgeMsg toEdgeMsg = msg.getValue(); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java index 63de93d6ad..e79510decf 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/DefaultTbRuleEngineConsumerService.java @@ -46,7 +46,6 @@ import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.util.TbRuleEngineComponent; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.queue.processing.AbstractPartitionBasedConsumerService; @@ -70,6 +69,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo private final TbRuleEngineConsumerContext ctx; private final QueueService queueService; private final TbRuleEngineDeviceRpcService tbDeviceRpcService; + private final TbMsgPackProcessingContextFactory packProcessingContextFactory; private final ConcurrentMap consumers = new ConcurrentHashMap<>(); @@ -85,11 +85,12 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo PartitionService partitionService, ApplicationEventPublisher eventPublisher, JwtSettingsService jwtSettingsService, - CalculatedFieldCache calculatedFieldCache) { - super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, calculatedFieldCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); + TbMsgPackProcessingContextFactory packProcessingContextFactory) { + super(actorContext, tenantProfileCache, deviceProfileCache, assetProfileCache, tbResourceDataCache, apiUsageStateService, partitionService, eventPublisher, jwtSettingsService); this.ctx = ctx; this.tbDeviceRpcService = tbDeviceRpcService; this.queueService = queueService; + this.packProcessingContextFactory = packProcessingContextFactory; } @Override @@ -255,6 +256,7 @@ public class DefaultTbRuleEngineConsumerService extends AbstractPartitionBasedCo .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) .build(); consumers.put(queueKey, consumer); consumer.init(queue); diff --git a/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java new file mode 100644 index 0000000000..08307a68ca --- /dev/null +++ b/application/src/main/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextFactory.java @@ -0,0 +1,35 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue; + +import org.springframework.stereotype.Component; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; + +public interface TbMsgPackProcessingContextFactory { + + TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts); + + @Component + class DefaultTbMsgPackProcessingContextFactory implements TbMsgPackProcessingContextFactory { + + @Override + public TbMsgPackProcessingContext create(String queueName, TbRuleEngineSubmitStrategy submitStrategy, boolean skipTimeouts) { + return new TbMsgPackProcessingContext(queueName, submitStrategy, skipTimeouts); + } + + } + +} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java index ec3b50b7ce..4243d71ce5 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/AbstractConsumerService.java @@ -26,7 +26,6 @@ import org.thingsboard.server.actors.ActorSystemContext; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.AssetProfileId; -import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; @@ -47,7 +46,6 @@ import org.thingsboard.server.queue.discovery.TbApplicationEventListener; import org.thingsboard.server.queue.discovery.event.PartitionChangeEvent; import org.thingsboard.server.queue.util.AfterStartUp; import org.thingsboard.server.service.apiusage.TbApiUsageStateService; -import org.thingsboard.server.service.cf.CalculatedFieldCache; import org.thingsboard.server.service.profile.TbAssetProfileCache; import org.thingsboard.server.service.profile.TbDeviceProfileCache; import org.thingsboard.server.service.queue.TbPackCallback; @@ -75,7 +73,6 @@ public abstract class AbstractConsumerService> msgs, TbQueueConsumer> consumer) throws Exception { List> orderedMsgList = msgs.stream().map(msg -> new IdMsgPair<>(UUID.randomUUID(), msg)).toList(); ConcurrentMap> pendingMap = orderedMsgList.stream().collect( - Collectors.toConcurrentMap(IdMsgPair::getUuid, IdMsgPair::getMsg)); + Collectors.toConcurrentMap(IdMsgPair::uuid, IdMsgPair::msg)); CountDownLatch processingTimeoutLatch = new CountDownLatch(1); TbPackProcessingContext> ctx = new TbPackProcessingContext<>( processingTimeoutLatch, pendingMap, new ConcurrentHashMap<>()); orderedMsgList.forEach(element -> { - UUID id = element.getUuid(); - TbProtoQueueMsg msg = element.getMsg(); + UUID id = element.uuid(); + TbProtoQueueMsg msg = element.msg(); log.trace("[{}] Creating notification callback for message: {}", id, msg.getValue()); TbCallback callback = new TbPackCallback<>(id, ctx); try { @@ -166,7 +163,6 @@ public abstract class AbstractConsumerService> getPendingMap() { - return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid, pair -> pair.msg)); + return orderedMsgList.stream().collect(Collectors.toConcurrentMap(pair -> pair.uuid(), pair -> pair.msg())); } @Override public void update(ConcurrentMap> reprocessMap) { List> newOrderedMsgList = new ArrayList<>(reprocessMap.size()); for (IdMsgPair pair : orderedMsgList) { - if (reprocessMap.containsKey(pair.uuid)) { - if (StringUtils.isNotEmpty(pair.getMsg().getValue().getFailureMessage())) { - var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.getMsg().getValue()) + if (reprocessMap.containsKey(pair.uuid())) { + if (StringUtils.isNotEmpty(pair.msg().getValue().getFailureMessage())) { + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder(pair.msg().getValue()) .clearFailureMessage() .clearRelationTypes() .build(); - var newMsg = new TbProtoQueueMsg<>(pair.getMsg().getKey(), toRuleEngineMsg, pair.getMsg().getHeaders()); - newOrderedMsgList.add(new IdMsgPair<>(pair.getUuid(), newMsg)); + var newMsg = new TbProtoQueueMsg<>(pair.msg().getKey(), toRuleEngineMsg, pair.msg().getHeaders()); + newOrderedMsgList.add(new IdMsgPair<>(pair.uuid(), newMsg)); } else { newOrderedMsgList.add(pair); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java index 32fcd65ca4..605c5fe977 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BatchTbRuleEngineSubmitStrategy.java @@ -73,7 +73,7 @@ public class BatchTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS pendingPack.clear(); for (int i = startIdx; i < endIdx; i++) { IdMsgPair pair = orderedMsgList.get(i); - pendingPack.put(pair.uuid, pair.msg); + pendingPack.put(pair.uuid(), pair.msg()); } tmpPack = new LinkedHashMap<>(pendingPack); } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java index 9f48c7bb70..5da8b85dc4 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/BurstTbRuleEngineSubmitStrategy.java @@ -34,7 +34,7 @@ public class BurstTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSubmitS if (log.isDebugEnabled()) { log.debug("[{}] submitting [{}] messages to rule engine", queueName, orderedMsgList.size()); } - orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid, pair.msg)); + orderedMsgList.forEach(pair -> msgConsumer.accept(pair.uuid(), pair.msg())); } @Override diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java index 81d2854dac..a0e440f7cd 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/IdMsgPair.java @@ -15,19 +15,9 @@ */ package org.thingsboard.server.service.queue.processing; -import lombok.Getter; +import com.google.protobuf.GeneratedMessageV3; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import java.util.UUID; -public class IdMsgPair { - @Getter - final UUID uuid; - @Getter - final TbProtoQueueMsg msg; - - public IdMsgPair(UUID uuid, TbProtoQueueMsg msg) { - this.uuid = uuid; - this.msg = msg; - } -} +public record IdMsgPair(UUID uuid, TbProtoQueueMsg msg) {} diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java index cfc3bb4a38..5acb629824 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialByEntityIdTbRuleEngineSubmitStrategy.java @@ -51,7 +51,7 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs entityIdToListMap.forEach((entityId, queue) -> { IdMsgPair msg = queue.peek(); if (msg != null) { - msgConsumer.accept(msg.uuid, msg.msg); + msgConsumer.accept(msg.uuid(), msg.msg()); } }); } @@ -71,13 +71,13 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs IdMsgPair next = null; synchronized (queue) { IdMsgPair expected = queue.peek(); - if (expected != null && expected.uuid.equals(id)) { + if (expected != null && expected.uuid().equals(id)) { queue.poll(); next = queue.peek(); } } if (next != null) { - msgConsumer.accept(next.uuid, next.msg); + msgConsumer.accept(next.uuid(), next.msg()); } } } @@ -87,9 +87,9 @@ public abstract class SequentialByEntityIdTbRuleEngineSubmitStrategy extends Abs msgToEntityIdMap.clear(); entityIdToListMap.clear(); for (IdMsgPair pair : orderedMsgList) { - EntityId entityId = getEntityId(pair.msg.getValue()); + EntityId entityId = getEntityId(pair.msg().getValue()); if (entityId != null) { - msgToEntityIdMap.put(pair.uuid, entityId); + msgToEntityIdMap.put(pair.uuid(), entityId); entityIdToListMap.computeIfAbsent(entityId, id -> new LinkedList<>()).add(pair); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java index 64be9bd378..be32f155dd 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/processing/SequentialTbRuleEngineSubmitStrategy.java @@ -60,11 +60,11 @@ public class SequentialTbRuleEngineSubmitStrategy extends AbstractTbRuleEngineSu int idx = msgIdx.get(); if (idx < listSize) { IdMsgPair pair = orderedMsgList.get(idx); - expectedMsgId = pair.uuid; + expectedMsgId = pair.uuid(); if (log.isDebugEnabled()) { - log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg); + log.debug("[{}] submitting [{}] message to rule engine", queueName, pair.msg()); } - msgConsumer.accept(pair.uuid, pair.msg); + msgConsumer.accept(pair.uuid(), pair.msg()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java index 8ba98b817a..3733b49f1f 100644 --- a/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java +++ b/application/src/main/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManager.java @@ -42,6 +42,7 @@ import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.Consumer import org.thingsboard.server.queue.discovery.QueueKey; import org.thingsboard.server.service.queue.TbMsgPackCallback; import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.TbRuleEngineConsumerStats; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingDecision; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingResult; @@ -67,13 +68,15 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager { Integer partitionId = tpi != null ? tpi.getPartition().orElse(-1) : null; @@ -82,6 +85,7 @@ public class TbRuleEngineQueueConsumerManager extends MainQueueConsumerManager submitMessage(packCtx, id, msg)); final boolean timeout = !packCtx.await(queue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); diff --git a/application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java b/application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java index b669e3fb6d..c3fa87412e 100644 --- a/application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java +++ b/application/src/main/java/org/thingsboard/server/service/ruleengine/DefaultRuleEngineCallService.java @@ -73,7 +73,7 @@ public class DefaultRuleEngineCallService implements RuleEngineCallService { UUID requestId = new UUID(restApiCallResponseMsg.getRequestIdMSB(), restApiCallResponseMsg.getRequestIdLSB()); Consumer consumer = requests.remove(requestId); if (consumer != null) { - consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), restApiCallResponseMsg.getResponse(), TbMsgCallback.EMPTY)); + consumer.accept(TbMsg.fromProto(null, restApiCallResponseMsg.getResponseProto(), TbMsgCallback.EMPTY)); } else { log.trace("[{}] Unknown or stale rest api call response received", requestId); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java index d6238524c4..a2862a5cda 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/AppleOAuth2ClientMapper.java @@ -79,9 +79,13 @@ public class AppleOAuth2ClientMapper extends AbstractOAuth2ClientMapper implemen } } if (user.has(EMAIL)) { - JsonNode email = user.get(EMAIL); - if (email != null && email.isTextual()) { - updated.put(EMAIL, email.asText()); + JsonNode emailNode = user.get(EMAIL); + if (emailNode != null && emailNode.isTextual()) { + Object tokenEmail = attributes.get(EMAIL); + if (tokenEmail != null && !emailNode.asText().equals(tokenEmail.toString())) { + log.warn("Apple OAuth2 callback: ignoring email [{}] from user POST parameter " + + "that differs from validated ID token email [{}]", emailNode.asText(), tokenEmail); + } } } } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java index 8477c69a99..97d24b8601 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/oauth2/CustomOAuth2ClientMapper.java @@ -23,12 +23,15 @@ import org.springframework.security.oauth2.client.authentication.OAuth2Authentic import org.springframework.stereotype.Service; import org.springframework.web.client.RestTemplate; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.oauth2.OAuth2CustomMapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2MapperConfig; import org.thingsboard.server.common.data.oauth2.OAuth2Client; import org.thingsboard.server.dao.oauth2.OAuth2User; import org.thingsboard.server.queue.util.TbCoreComponent; + +import java.net.URI; import org.thingsboard.server.service.security.model.SecurityUser; @Service(value = "customOAuth2ClientMapper") @@ -64,6 +67,7 @@ public class CustomOAuth2ClientMapper extends AbstractOAuth2ClientMapper impleme throw new RuntimeException("Can't convert principal to JSON string", e); } try { + SsrfProtectionValidator.validateUri(new URI(custom.getUrl())); return restTemplate.postForEntity(custom.getUrl(), request, OAuth2User.class).getBody(); } catch (Exception e) { log.error("There was an error during connection to custom mapper endpoint", e); diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java index 162a780bbb..46af0120da 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/pat/ApiKeyAuthenticationProvider.java @@ -46,7 +46,7 @@ public class ApiKeyAuthenticationProvider extends AbstractAuthenticationProvider return new ApiKeyAuthenticationToken(securityUser); } - private SecurityUser authenticate(String key) { + public SecurityUser authenticate(String key) { if (StringUtils.isEmpty(key)) { throw new BadCredentialsException("Empty API key"); } diff --git a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java index aa084a7968..e75a49ccb2 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java +++ b/application/src/main/java/org/thingsboard/server/service/security/auth/rest/RestAuthenticationDetails.java @@ -29,18 +29,10 @@ public class RestAuthenticationDetails implements Serializable { private final Client userAgent; public RestAuthenticationDetails(HttpServletRequest request) { - this.clientAddress = getClientIP(request); + this.clientAddress = request.getRemoteAddr(); this.userAgent = getUserAgent(request); } - private static String getClientIP(HttpServletRequest request) { - String xfHeader = request.getHeader("X-Forwarded-For"); - if (xfHeader == null) { - return request.getRemoteAddr(); - } - return xfHeader.split(",")[0]; - } - private static Client getUserAgent(HttpServletRequest request) { Parser uaParser = new Parser(); return uaParser.parse(request.getHeader("User-Agent")); diff --git a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java index 7707d8efa2..f5c4f40928 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java +++ b/application/src/main/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityService.java @@ -80,11 +80,15 @@ public class DefaultSystemSecurityService implements SystemSecurityService { @Override public void validateUserCredentials(TenantId tenantId, UserCredentials userCredentials, String username, String password) throws AuthenticationException { + if (!userCredentials.isEnabled()) { + throw new DisabledException("User is not active"); + } + if (!encoder.matches(password, userCredentials.getPassword())) { int failedLoginAttempts = userService.increaseFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); if (securitySettings.getMaxFailedLoginAttempts() != null && securitySettings.getMaxFailedLoginAttempts() > 0) { - if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts() && userCredentials.isEnabled()) { + if (failedLoginAttempts > securitySettings.getMaxFailedLoginAttempts()) { lockAccount(userCredentials.getUserId(), username, securitySettings.getUserLockoutNotificationEmail(), securitySettings.getMaxFailedLoginAttempts()); throw new LockedException("Authentication Failed. Username was locked due to security policy."); } @@ -92,10 +96,6 @@ public class DefaultSystemSecurityService implements SystemSecurityService { throw new BadCredentialsException("Authentication Failed. Username or Password not valid."); } - if (!userCredentials.isEnabled()) { - throw new DisabledException("User is not active"); - } - userService.resetFailedLoginAttempts(tenantId, userCredentials.getUserId()); SecuritySettings securitySettings = securitySettingsService.getSecuritySettings(); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java index f0f2e1eaa7..fc8c5c3be2 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbEntityDataSubscriptionService.java @@ -34,6 +34,7 @@ import org.springframework.web.socket.CloseStatus; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; @@ -242,7 +243,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc @Override public void onFailure(Throwable t) { - log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId()); + log.warn("[{}][{}] Failed to process command", finalCtx.getSessionId(), finalCtx.getCmdId(), t); + if (t instanceof ResultSetSizeLimitExceededException) { + sendError(finalCtx, t); + } } }, wsCallBackExecutor); } @@ -258,7 +262,18 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc handleLatestCmd(ctx, cmd.getLatestCmd()); } if (cmd.getTsCmd() != null) { - handleTimeSeriesCmd(ctx, cmd.getTsCmd()); + Futures.addCallback(handleTimeSeriesCmd(ctx, cmd.getTsCmd()), new FutureCallback<>() { + @Override + public void onSuccess(TbEntityDataSubCtx result) {} + + @Override + public void onFailure(Throwable t) { + log.warn("[{}][{}] Failed to process timeseries command", ctx.getSessionId(), ctx.getCmdId(), t); + if (t instanceof ResultSetSizeLimitExceededException) { + sendError(ctx, t); + } + } + }, wsCallBackExecutor); } } else { checkAndSendInitialData(ctx); @@ -268,6 +283,10 @@ public class DefaultTbEntityDataSubscriptionService implements TbEntityDataSubsc } } + private void sendError(TbEntityDataSubCtx ctx, Throwable t) { + ctx.sendWsMsg(new EntityDataUpdate(ctx.getCmdId(), SubscriptionErrorCode.INTERNAL_ERROR.getCode(), t.getMessage())); + } + private void checkAndSendInitialData(@Nullable TbEntityDataSubCtx theCtx) { if (!theCtx.isInitialDataSent()) { EntityDataUpdate update = new EntityDataUpdate(theCtx.getCmdId(), theCtx.getData(), null, theCtx.getMaxEntitiesPerDataSubscription()); diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java index 51663eea9c..2b1a810924 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/DefaultTbLocalSubscriptionService.java @@ -348,7 +348,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer if (sub.isLatestValues()) { for (TsKvEntry kv : data) { Long stateTs = keyStates.get(kv.getKey()); - if (stateTs == null || kv.getTs() >= stateTs) { + if (stateTs == null || kv.getTs() >= stateTs || kv.isDeletedEntry()) { if (updateData == null) { updateData = new ArrayList<>(); } @@ -362,7 +362,7 @@ public class DefaultTbLocalSubscriptionService implements TbLocalSubscriptionSer for (TsKvEntry kv : data) { Long stateTs = keyStates.get(kv.getKey()); if (stateTs != null) { - if (!sub.isLatestValues() || kv.getTs() >= stateTs) { + if (!sub.isLatestValues() || kv.getTs() >= stateTs || kv.isDeletedEntry()) { if (updateData == null) { updateData = new ArrayList<>(); } diff --git a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java index c2fe743bb4..5b4287acd1 100644 --- a/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java +++ b/application/src/main/java/org/thingsboard/server/service/subscription/TbAlarmCountSubCtx.java @@ -130,7 +130,7 @@ public class TbAlarmCountSubCtx extends TbAbstractEntityQuerySubCtx { entitiesSortOrder = sortOrder; } EntityDataPageLink edpl = new EntityDataPageLink(maxEntitiesPerAlarmSubscription, 0, null, entitiesSortOrder); - return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters()); + return new EntityDataQuery(query.getEntityFilter(), edpl, query.getEntityFields(), query.getLatestValues(), query.getKeyFilters(), query.getKeyFiltersOperationOrDefault()); } } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java index cf8d9b1139..27bd88fc1a 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/DefaultEntitiesExportImportService.java @@ -64,7 +64,7 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS private final RateLimitService rateLimitService; private final TbLogEntityActionService logEntityActionService; - protected static final List SUPPORTED_ENTITY_TYPES = List.of( + public static final List SUPPORTED_ENTITY_TYPES = List.of( EntityType.CUSTOMER, EntityType.RULE_CHAIN, EntityType.TB_RESOURCE, EntityType.DASHBOARD, EntityType.ASSET_PROFILE, EntityType.ASSET, EntityType.DEVICE_PROFILE, EntityType.OTA_PACKAGE, EntityType.DEVICE, @@ -131,7 +131,10 @@ public class DefaultEntitiesExportImportService implements EntitiesExportImportS @Override public Comparator getEntityTypeComparatorForImport() { - return Comparator.comparing(SUPPORTED_ENTITY_TYPES::indexOf); + return Comparator.comparingInt(type -> { + int index = SUPPORTED_ENTITY_TYPES.indexOf(type); + return index >= 0 ? index : Integer.MAX_VALUE; + }); } @SuppressWarnings("unchecked") diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java index d6ed0754e3..586febd686 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/BaseEntityExportService.java @@ -42,10 +42,6 @@ public abstract class BaseEntityExportService ctx, E mainEntity, D exportData) { } - protected D newExportData() { - return (D) new EntityExportData(); - } - public abstract Set getSupportedEntityTypes(); protected void replaceUuidsRecursively(EntitiesExportCtx ctx, JsonNode node, Set skippedRootFields, Pattern includedFieldsPattern) { diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java index a488ad165e..a38e2e058e 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DefaultEntityExportService.java @@ -70,7 +70,8 @@ public class DefaultEntityExportService ctx, I entityId) throws ThingsboardException { - D exportData = newExportData(); + @SuppressWarnings("unchecked") + D exportData = (D) EntityExportData.newInstance(entityId.getEntityType()); E entity = exportableEntitiesService.findEntityByTenantIdAndId(ctx.getTenantId(), entityId); if (entity == null) { @@ -78,7 +79,6 @@ public class DefaultEntityExportService(); - } - } diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java index 57beddef59..4a18a23b80 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/DeviceExportService.java @@ -48,11 +48,6 @@ public class DeviceExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.DEVICE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java index af29e3c5df..775ab87fcb 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/OtaPackageExportService.java @@ -36,11 +36,6 @@ public class OtaPackageExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.OTA_PACKAGE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java index 52452bcca4..ce93574103 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/RuleChainExportService.java @@ -61,11 +61,6 @@ public class RuleChainExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.RULE_CHAIN); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java index cb2df97a57..7e381b39bb 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetTypeExportService.java @@ -38,11 +38,6 @@ public class WidgetTypeExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.WIDGET_TYPE); diff --git a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java index 3f579bbc43..3d4b80ce1c 100644 --- a/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java +++ b/application/src/main/java/org/thingsboard/server/service/sync/ie/exporting/impl/WidgetsBundleExportService.java @@ -45,11 +45,6 @@ public class WidgetsBundleExportService extends BaseEntityExportService getSupportedEntityTypes() { return Set.of(EntityType.WIDGETS_BUNDLE); diff --git a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java index d80961923d..a256888107 100644 --- a/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java +++ b/application/src/main/java/org/thingsboard/server/service/system/SystemPatchApplier.java @@ -28,7 +28,10 @@ import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; import org.thingsboard.server.queue.util.TbCoreComponent; import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; @@ -40,7 +43,12 @@ import java.net.URL; import java.nio.file.Files; import java.nio.file.NoSuchFileException; import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; @@ -48,7 +56,7 @@ import java.util.stream.Stream; /** * Runs at application startup and applies no-downtime data updates - * when the package PATCH version increases (e.g., 4.2.1.0 -> 4.2.1.1). + * when the package version increases within the same LTS family (e.g., 4.3.0.0 -> 4.3.1.0 or 4.3.0.0 -> 4.3.0.1). */ @Slf4j @Component @@ -64,6 +72,8 @@ public class SystemPatchApplier { private final InstallScripts installScripts; private final DatabaseSchemaSettingsService schemaSettingsService; private final WidgetTypeService widgetTypeService; + private final WidgetsBundleService widgetsBundleService; + private final ImageService imageService; @PostConstruct private void init() { @@ -91,11 +101,19 @@ public class SystemPatchApplier { } try { + updateLtsSqlSchema(); + updateSqlViews(); log.info("Updated sql database views"); - int updated = updateWidgetTypes(); - log.info("Updated {} widget types", updated); + WidgetTypeStats widgetStats = updateWidgetTypes(); + log.info("System widget types: {} created, {} updated", widgetStats.created(), widgetStats.updated()); + + int updatedBundles = updateWidgetBundles(); + log.info("System widget bundles: {} updated", updatedBundles); + + int createdImages = createMissingSystemImages(); + log.info("Created {} new system images", createdImages); schemaSettingsService.updateSchemaVersion(); log.info("System data patch update completed successfully"); @@ -119,17 +137,37 @@ public class SystemPatchApplier { return false; } - if (!isPatchVersionChanged(packageVersionInfo, dbVersionInfo)) { + if (!isVersionIncreased(packageVersionInfo, dbVersionInfo)) { return false; } - log.info("Patch version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); + log.info("Version increased from {} to {}. Starting system data update.", dbVersion, packageVersion); return true; } - private boolean isPatchVersionChanged(VersionInfo packageVersion, VersionInfo dbVersion) { - return packageVersion.major == dbVersion.major && packageVersion.minor == dbVersion.minor - && packageVersion.maintenance == dbVersion.maintenance && packageVersion.patch > dbVersion.patch; + private boolean isVersionIncreased(VersionInfo packageVersion, VersionInfo dbVersion) { + if (packageVersion.major != dbVersion.major || packageVersion.minor != dbVersion.minor) { + return false; + } + if (packageVersion.maintenance != dbVersion.maintenance) { + return packageVersion.maintenance > dbVersion.maintenance; + } + return packageVersion.patch > dbVersion.patch; + } + + private void updateLtsSqlSchema() { + Path sqlFile = Paths.get(installScripts.getDataDir(), "upgrade", "lts", "schema_update.sql"); + if (!Files.exists(sqlFile)) { + log.trace("LTS schema update file does not exist: {}", sqlFile); + return; + } + try { + String sql = Files.readString(sqlFile); + jdbcTemplate.execute(sql); + log.info("Applied LTS SQL schema update from {}", sqlFile); + } catch (IOException e) { + throw new RuntimeException("Failed to read LTS schema update file: " + sqlFile, e); + } } private void updateSqlViews() { @@ -142,20 +180,24 @@ public class SystemPatchApplier { } } - private int updateWidgetTypes() { + private WidgetTypeStats updateWidgetTypes() { + AtomicInteger created = new AtomicInteger(); AtomicInteger updated = new AtomicInteger(); Path widgetTypesDir = installScripts.getWidgetTypesDir(); if (!Files.exists(widgetTypesDir)) { log.trace("Widget types directory does not exist: {}", widgetTypesDir); - return 0; + return new WidgetTypeStats(0, 0); } try (Stream dirStream = listDir(widgetTypesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { dirStream.forEach( path -> { try { - if (updateWidgetTypeFromFile(path)) { + WidgetTypeChange change = updateWidgetTypeFromFile(path); + if (change == WidgetTypeChange.CREATED) { + created.incrementAndGet(); + } else if (change == WidgetTypeChange.UPDATED) { updated.incrementAndGet(); } } catch (Exception e) { @@ -166,18 +208,26 @@ public class SystemPatchApplier { ); } - return updated.get(); + return new WidgetTypeStats(created.get(), updated.get()); } - private boolean updateWidgetTypeFromFile(Path filePath) { + private WidgetTypeChange updateWidgetTypeFromFile(Path filePath) { JsonNode json = JacksonUtil.toJsonNode(filePath.toFile()); WidgetTypeDetails fileWidgetType = JacksonUtil.treeToValue(json, WidgetTypeDetails.class); + return saveOrUpdateSystemWidgetType(fileWidgetType); + } + + private WidgetTypeChange saveOrUpdateSystemWidgetType(WidgetTypeDetails fileWidgetType) { String fqn = fileWidgetType.getFqn(); + if (fqn == null || fqn.isBlank()) { + throw new RuntimeException("Widget type fqn is missing or blank: " + fileWidgetType.getName()); + } WidgetTypeDetails existingWidgetType = widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, fqn); if (existingWidgetType == null) { - // We expect only update here, so it's probably never happening, but for test purpose leave it like this: - throw new RuntimeException("Widget type not found: " + fqn); + widgetTypeService.saveWidgetType(fileWidgetType); + log.trace("Created widget type: {}", fqn); + return WidgetTypeChange.CREATED; } if (isWidgetTypeChanged(existingWidgetType, fileWidgetType)) { existingWidgetType.setDescription(fileWidgetType.getDescription()); @@ -185,11 +235,128 @@ public class SystemPatchApplier { existingWidgetType.setDescriptor(fileWidgetType.getDescriptor()); widgetTypeService.saveWidgetType(existingWidgetType); log.trace("Updated widget type: {}", fqn); - return true; + return WidgetTypeChange.UPDATED; } log.trace("Widget type unchanged: {}", fqn); - return false; + return WidgetTypeChange.UNCHANGED; + } + + private int updateWidgetBundles() { + AtomicInteger updated = new AtomicInteger(); + Path widgetBundlesDir = installScripts.getWidgetBundlesDir(); + + if (!Files.exists(widgetBundlesDir)) { + log.trace("Widget bundles directory does not exist: {}", widgetBundlesDir); + return 0; + } + + try (Stream dirStream = listDir(widgetBundlesDir).filter(path -> path.toString().endsWith(InstallScripts.JSON_EXT))) { + dirStream.forEach(path -> { + try { + if (processWidgetBundleFile(path)) { + updated.incrementAndGet(); + } + } catch (Exception e) { + log.error("Unable to process widgets bundle from json: [{}]", path); + throw new RuntimeException("Unable to process widgets bundle from json", e); + } + }); + } + + return updated.get(); + } + + private boolean processWidgetBundleFile(Path filePath) { + JsonNode bundleJson = JacksonUtil.toJsonNode(filePath.toFile()); + if (bundleJson == null || !bundleJson.has("widgetsBundle")) { + throw new RuntimeException("Invalid widgets bundle json: " + filePath); + } + WidgetsBundle fileBundle = JacksonUtil.treeToValue(bundleJson.get("widgetsBundle"), WidgetsBundle.class); + String alias = fileBundle.getAlias(); + if (alias == null || alias.isBlank()) { + throw new RuntimeException("Widgets bundle alias is missing or blank: " + filePath); + } + + WidgetsBundle existingBundle = widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, alias); + if (existingBundle == null) { + log.warn("Widgets bundle '{}' not found in DB; bundle creation is not supported by the patch applier, skipping.", alias); + return false; + } + + if (bundleJson.has("widgetTypes")) { + throw new RuntimeException("Inline widgetTypes in bundle JSON are not supported by the patch applier; " + + "place widget definitions in widget_types/*.json and reference them via widgetTypeFqns: " + filePath); + } + List fileWidgetFqns = new ArrayList<>(); + if (bundleJson.has("widgetTypeFqns")) { + bundleJson.get("widgetTypeFqns").forEach(fqnJson -> fileWidgetFqns.add(fqnJson.asText())); + } + + boolean changed = false; + if (isWidgetsBundleChanged(existingBundle, fileBundle)) { + existingBundle.setTitle(fileBundle.getTitle()); + existingBundle.setDescription(fileBundle.getDescription()); + existingBundle.setImage(fileBundle.getImage()); + existingBundle.setOrder(fileBundle.getOrder()); + existingBundle.setScada(fileBundle.isScada()); + widgetsBundleService.saveWidgetsBundle(existingBundle); + log.trace("Updated widgets bundle metadata: {}", alias); + changed = true; + } + + List existingFqns = widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId()); + LinkedHashSet mergedFqns = new LinkedHashSet<>(existingFqns); + if (mergedFqns.addAll(fileWidgetFqns)) { + widgetTypeService.updateWidgetsBundleWidgetFqns(TenantId.SYS_TENANT_ID, existingBundle.getId(), new ArrayList<>(mergedFqns)); + log.trace("Linked {} new widget fqn(s) to bundle: {}", mergedFqns.size() - existingFqns.size(), alias); + changed = true; + } + return changed; + } + + private boolean isWidgetsBundleChanged(WidgetsBundle existing, WidgetsBundle file) { + // Image is intentionally NOT compared: the file always carries a base64 data URI, while the DB stores + // the system-image URL produced by ImageService.replaceBase64WithImageUrl on save. A naive string compare + // would always report a diff and re-save every system bundle on every patch run. Image content changes + // are out of scope for the patch applier — full reinstall covers them. + return !Objects.equals(existing.getTitle(), file.getTitle()) + || !Objects.equals(existing.getDescription(), file.getDescription()) + || !Objects.equals(existing.getOrder(), file.getOrder()) + || existing.isScada() != file.isScada(); + } + + private int createMissingSystemImages() { + AtomicInteger created = new AtomicInteger(); + Path imagesDir = Paths.get(installScripts.getDataDir(), InstallScripts.RESOURCES_DIR, "images"); + + if (!Files.exists(imagesDir)) { + log.warn("System images directory does not exist: {}", imagesDir); + return 0; + } + + Set existingKeys = imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + + try (Stream dirStream = listDir(imagesDir).filter(Files::isRegularFile)) { + dirStream.forEach(path -> { + String resourceKey = path.getFileName().toString(); + if (existingKeys.contains(resourceKey)) { + log.trace("System image already exists, skipping: {}", resourceKey); + return; + } + try { + byte[] data = Files.readAllBytes(path); + imageService.createOrUpdateSystemImage(resourceKey, data); + created.incrementAndGet(); + log.trace("Created system image: {}", resourceKey); + } catch (Exception e) { + log.error("Unable to create system image from file: [{}]", path); + throw new RuntimeException("Unable to create system image " + resourceKey, e); + } + }); + } + + return created.get(); } private boolean isWidgetTypeChanged(WidgetTypeDetails existing, WidgetTypeDetails file) { @@ -287,4 +454,8 @@ public class SystemPatchApplier { public record VersionInfo(int major, int minor, int maintenance, int patch) {} + public record WidgetTypeStats(int created, int updated) {} + + private enum WidgetTypeChange { CREATED, UPDATED, UNCHANGED } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java index 5865d9e39d..596f5a8754 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/AbstractCleanUpService.java @@ -32,4 +32,8 @@ public abstract class AbstractCleanUpService { return partitionService.resolve(ServiceType.TB_CORE, TenantId.SYS_TENANT_ID, TenantId.SYS_TENANT_ID).isMyPartition(); } + protected boolean isTenantPartitionMine(TenantId tenantId) { + return partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition(); + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java index ddc95b74e9..83855bc8f8 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/NotificationsCleanUpService.java @@ -20,33 +20,41 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; +import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.notification.NotificationRequestConfig; +import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.dao.notification.NotificationRequestDao; import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.queue.discovery.PartitionService; +import java.time.Instant; import java.util.concurrent.TimeUnit; import static org.thingsboard.server.dao.model.ModelConstants.NOTIFICATION_TABLE_NAME; +@Slf4j @Service @ConditionalOnExpression("${sql.ttl.notifications.enabled:true} && ${sql.ttl.notifications.ttl:0} > 0") -@Slf4j public class NotificationsCleanUpService extends AbstractCleanUpService { private final SqlPartitioningRepository partitioningRepository; private final NotificationRequestDao notificationRequestDao; + private final TenantService tenantService; @Value("${sql.ttl.notifications.ttl:2592000}") private long ttlInSec; @Value("${sql.notifications.partition_size:168}") private int partitionSizeInHours; + @Value("${sql.ttl.notifications.removal_batch_size:10000}") + private int removalBatchSize; public NotificationsCleanUpService(PartitionService partitionService, SqlPartitioningRepository partitioningRepository, - NotificationRequestDao notificationRequestDao) { + NotificationRequestDao notificationRequestDao, TenantService tenantService) { super(partitionService); this.partitioningRepository = partitioningRepository; this.notificationRequestDao = notificationRequestDao; + this.tenantService = tenantService; } @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.notifications.checking_interval_ms:86400000})}", @@ -54,18 +62,65 @@ public class NotificationsCleanUpService extends AbstractCleanUpService { public void cleanUp() { long expTime = System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(ttlInSec); long partitionDurationMs = TimeUnit.HOURS.toMillis(partitionSizeInHours); - if (!isSystemTenantPartitionMine()) { + if (isSystemTenantPartitionMine()) { + partitioningRepository.dropPartitionsBefore(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs); + } else { partitioningRepository.cleanupPartitionsCache(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs); - return; } - long lastRemovedNotificationTs = partitioningRepository.dropPartitionsBefore(NOTIFICATION_TABLE_NAME, expTime, partitionDurationMs); - if (lastRemovedNotificationTs > 0) { - long gap = TimeUnit.MINUTES.toMillis(10); - long requestExpTime = lastRemovedNotificationTs - TimeUnit.SECONDS.toMillis(NotificationRequestConfig.MAX_SENDING_DELAY) - gap; - int removed = notificationRequestDao.removeAllByCreatedTimeBefore(requestExpTime); - log.info("Removed {} outdated notification requests older than {}", removed, requestExpTime); + long gap = TimeUnit.MINUTES.toMillis(10); + long requestExpTime = expTime - TimeUnit.SECONDS.toMillis(NotificationRequestConfig.MAX_SENDING_DELAY) - gap; + cleanUpNotificationRequests(requestExpTime); + } + + private void cleanUpNotificationRequests(long expirationTime) { + log.info("Starting notification requests cleanup for records older than {}", Instant.ofEpochMilli(expirationTime)); + int totalRemoved = 0; + int tenantsProcessed = 0; + + // Clean up SYSADMIN's notification requests on the system node only + if (isSystemTenantPartitionMine()) { + try { + totalRemoved += cleanUpByTenant(TenantId.SYS_TENANT_ID, expirationTime); + } catch (Exception e) { + log.warn("Failed to clean up notification requests for sysadmin {}", TenantId.SYS_TENANT_ID, e); + } + } + // Each node cleans up notification requests for its own tenants + PageDataIterable tenants = new PageDataIterable<>(tenantService::findTenantsIds, 10_000); + for (TenantId tenantId : tenants) { + try { + if (!isTenantPartitionMine(tenantId)) { + continue; + } + int tenantRemoved = cleanUpByTenant(tenantId, expirationTime); + totalRemoved += tenantRemoved; + tenantsProcessed++; + if (tenantRemoved > 0) { + log.trace("Removed {} notification requests for tenant {}", tenantRemoved, tenantId); + } + } catch (Exception e) { + log.warn("Failed to clean up notification requests for tenant {}", tenantId, e); + } } + + log.info("Notification requests cleanup completed. Processed {} tenants, removed {} total records older than {}", tenantsProcessed, totalRemoved, Instant.ofEpochMilli(expirationTime)); + } + + private int cleanUpByTenant(TenantId tenantId, long expirationTime) { + int totalRemoved = 0; + int batchRemoved; + + do { + batchRemoved = notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, removalBatchSize); + totalRemoved += batchRemoved; + + if (batchRemoved > 0) { + log.trace("Removed {} notification requests in batch for tenant {}", batchRemoved, tenantId); + } + } while (batchRemoved >= removalBatchSize); + + return totalRemoved; } } diff --git a/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java b/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java index 9404a0ae72..a945bfd8a5 100644 --- a/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java +++ b/application/src/main/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpService.java @@ -15,69 +15,87 @@ */ package org.thingsboard.server.service.ttl.rpc; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.PageDataIterable; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; -import org.thingsboard.server.common.msg.queue.ServiceType; import org.thingsboard.server.dao.rpc.RpcDao; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantService; import org.thingsboard.server.queue.discovery.PartitionService; import org.thingsboard.server.queue.util.TbCoreComponent; +import org.thingsboard.server.service.ttl.AbstractCleanUpService; -import java.util.Date; +import java.time.Instant; import java.util.Optional; import java.util.concurrent.TimeUnit; -@TbCoreComponent -@Service @Slf4j -@RequiredArgsConstructor -public class RpcCleanUpService { - @Value("${sql.ttl.rpc.enabled}") - private boolean ttlTaskExecutionEnabled; +@Service +@TbCoreComponent +@ConditionalOnExpression("${sql.ttl.rpc.enabled:true}") +public class RpcCleanUpService extends AbstractCleanUpService { + + @Value("${sql.ttl.rpc.removal_batch_size:10000}") + private int removalBatchSize; + private final RpcDao rpcDao; private final TenantService tenantService; - private final PartitionService partitionService; private final TbTenantProfileCache tenantProfileCache; - private final RpcDao rpcDao; + + public RpcCleanUpService(TenantService tenantService, PartitionService partitionService, TbTenantProfileCache tenantProfileCache, RpcDao rpcDao) { + super(partitionService); + this.tenantService = tenantService; + this.tenantProfileCache = tenantProfileCache; + this.rpcDao = rpcDao; + } @Scheduled(initialDelayString = "#{T(org.apache.commons.lang3.RandomUtils).nextLong(0, ${sql.ttl.rpc.checking_interval})}", fixedDelayString = "${sql.ttl.rpc.checking_interval}") public void cleanUp() { - if (ttlTaskExecutionEnabled) { - PageLink tenantsBatchRequest = new PageLink(10_000, 0); - PageData tenantsIds; - do { - tenantsIds = tenantService.findTenantsIds(tenantsBatchRequest); - for (TenantId tenantId : tenantsIds.getData()) { - if (!partitionService.resolve(ServiceType.TB_CORE, tenantId, tenantId).isMyPartition()) { - continue; - } - - Optional tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration(); - if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getRpcTtlDays() == 0) { - continue; - } - - long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getRpcTtlDays()); - long expirationTime = System.currentTimeMillis() - ttl; - - int totalRemoved = rpcDao.deleteOutdatedRpcByTenantId(tenantId, expirationTime); - - if (totalRemoved > 0) { - log.info("Removed {} outdated rpc(s) for tenant {} older than {}", totalRemoved, tenantId, new Date(expirationTime)); - } + PageDataIterable tenants = new PageDataIterable<>(tenantService::findTenantsIds, 10_000); + for (TenantId tenantId : tenants) { + try { + if (!isTenantPartitionMine(tenantId)) { + continue; } - tenantsBatchRequest = tenantsBatchRequest.nextPageLink(); - } while (tenantsIds.hasNext()); + Optional tenantProfileConfiguration = tenantProfileCache.get(tenantId).getProfileConfiguration(); + if (tenantProfileConfiguration.isEmpty() || tenantProfileConfiguration.get().getRpcTtlDays() == 0) { + continue; + } + + long ttl = TimeUnit.DAYS.toMillis(tenantProfileConfiguration.get().getRpcTtlDays()); + long expirationTime = System.currentTimeMillis() - ttl; + + int totalRemoved = cleanUpByTenant(tenantId, expirationTime); + + if (totalRemoved > 0) { + log.info("Removed {} outdated rpc(s) for tenant {} older than {}", totalRemoved, tenantId, Instant.ofEpochMilli(expirationTime)); + } + } catch (Exception e) { + log.warn("Failed to clean up rpc by ttl for tenant {}", tenantId, e); + } } } + private int cleanUpByTenant(TenantId tenantId, long expirationTime) { + int totalRemoved = 0; + int batchRemoved; + + do { + batchRemoved = rpcDao.deleteOutdatedRpcByTenantIdBatch(tenantId, expirationTime, removalBatchSize); + totalRemoved += batchRemoved; + + if (batchRemoved > 0) { + log.trace("Removed {} rpc in batch for tenant {}", batchRemoved, tenantId); + } + } while (batchRemoved >= removalBatchSize); + + return totalRemoved; + } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java b/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java index e139498d04..a2dc87e4af 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/AuthCmd.java @@ -23,11 +23,14 @@ import lombok.NoArgsConstructor; @NoArgsConstructor @AllArgsConstructor public class AuthCmd implements WsCmd { + private int cmdId; private String token; + private String apiKey; @Override public WsCmdType getType() { return WsCmdType.AUTH; } + } diff --git a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java index 4b1a81dd2d..27a8ca275c 100644 --- a/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java +++ b/application/src/main/java/org/thingsboard/server/service/ws/DefaultWebSocketService.java @@ -144,7 +144,7 @@ public class DefaultWebSocketService implements WebSocketService { private final ConcurrentMap> tenantSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> customerSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> regularUserSubscriptionsMap = new ConcurrentHashMap<>(); - private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); + private final ConcurrentMap> publicUserSubscriptionsMap = new ConcurrentHashMap<>(); private final ConcurrentMap> sessionCmdMap = new ConcurrentHashMap<>(); private ExecutorService executor; @@ -315,7 +315,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private void processSessionClose(WebSocketSessionRef sessionRef) { + void processSessionClose(WebSocketSessionRef sessionRef) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration != null) { String sessionId = "[" + sessionRef.getSessionId() + "]"; @@ -340,7 +340,7 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { publicUserSessions.removeIf(subId -> subId.startsWith(sessionId)); } @@ -349,7 +349,7 @@ public class DefaultWebSocketService implements WebSocketService { } } - private boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { + boolean processSubscription(WebSocketSessionRef sessionRef, SubscriptionCmd cmd) { var tenantProfileConfiguration = getTenantProfileConfiguration(sessionRef); if (tenantProfileConfiguration == null) return true; @@ -401,9 +401,11 @@ public class DefaultWebSocketService implements WebSocketService { } } if (tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser() > 0 && UserPrincipal.Type.PUBLIC_ID.equals(sessionRef.getSecurityCtx().getUserPrincipal().getType())) { - Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getId(), id -> ConcurrentHashMap.newKeySet()); + Set publicUserSessions = publicUserSubscriptionsMap.computeIfAbsent(sessionRef.getSecurityCtx().getTenantId(), id -> ConcurrentHashMap.newKeySet()); synchronized (publicUserSessions) { - if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { + if (cmd.isUnsubscribe()) { + publicUserSessions.remove(subId); + } else if (publicUserSessions.size() < tenantProfileConfiguration.getMaxWsSubscriptionsPerPublicUser()) { publicUserSessions.add(subId); } else { log.info("[{}][{}][{}] Failed to start subscription. Max public user subscriptions limit reached" diff --git a/application/src/main/resources/thingsboard-openapi.properties b/application/src/main/resources/thingsboard-openapi.properties new file mode 100644 index 0000000000..410c4072a5 --- /dev/null +++ b/application/src/main/resources/thingsboard-openapi.properties @@ -0,0 +1,51 @@ +# +# Copyright © 2016-2026 The Thingsboard Authors +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +# Lightweight startup for OpenAPI spec generation. +# Activated only via: --spring.profiles.active=openapi + +spring.main.banner-mode=off + +# Testcontainers PostgreSQL +database.ts.type=sql +database.ts_latest.type=sql +spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver +spring.datasource.username=postgres +spring.datasource.password=postgres +spring.datasource.hikari.maximumPoolSize=5 +spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true +spring.jpa.properties.hibernate.order_by.default_null_ordering=last +spring.jpa.show-sql=false +spring.jpa.hibernate.ddl-auto=none + +# Disable transports +transport.http.enabled=false +transport.mqtt.enabled=false +transport.coap.enabled=false +transport.lwm2m.enabled=false +transport.snmp.enabled=false +coap.server.enabled=false + +# Disable edges, integrations, EDQS +edges.enabled=false +integrations.rpc.enabled=false +service.integrations.supported=NONE +transport.gateway.dashboard.sync.enabled=false +queue.edqs.sync.enabled=false +queue.edqs.api.supported=false +usage.stats.report.enabled=false +service.type=monolith diff --git a/application/src/main/resources/thingsboard.yml b/application/src/main/resources/thingsboard.yml index 072e18af69..6ceef7bc2e 100644 --- a/application/src/main/resources/thingsboard.yml +++ b/application/src/main/resources/thingsboard.yml @@ -15,6 +15,7 @@ # # Server common parameters +# Configures HTTP/HTTPS bind address, port, SSL, WebSocket, and REST API settings. server: # Server bind-address address: "${HTTP_BIND_ADDRESS:0.0.0.0}" @@ -58,6 +59,14 @@ server: http2: # Enable/disable HTTP/2 support enabled: "${HTTP2_ENABLED:true}" + # HTTP response compression + compression: + # Enable/disable HTTP response compression + enabled: "${HTTP_COMPRESSION_ENABLED:false}" + # Minimum size (in bytes) required for a response before compression is applied + min_response_size: "${HTTP_COMPRESSION_MIN_RESPONSE_SIZE:2048}" + # Comma-separated list of MIME types that should be compressed + mime_types: "${HTTP_COMPRESSION_MIME_TYPES:text/html,text/xml,text/plain,text/css,text/javascript,application/javascript,application/json,application/xml}" # Log errors with stacktrace when REST API throws an exception with the message "Please contact sysadmin" log_controller_error_stack_trace: "${HTTP_LOG_CONTROLLER_ERROR_STACK_TRACE:false}" ws: @@ -78,6 +87,10 @@ server: max_entities_per_data_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_DATA_SUBSCRIPTION:10000}" # Maximum number of alarms returned for single alarm subscription. For example, no more than 10,000 alarms on the alarm widget max_entities_per_alarm_subscription: "${TB_SERVER_WS_MAX_ENTITIES_PER_ALARM_SUBSCRIPTION:10000}" + # Maximum size (bytes) of incoming WS text message + max_text_message_buffer_size: "${TB_SERVER_WS_MAX_TEXT_MESSAGE_BUFFER_SIZE:32768}" + # Maximum size (bytes) of incoming WS binary message + max_binary_message_buffer_size: "${TB_SERVER_WS_MAX_BINARY_MESSAGE_BUFFER_SIZE:32768}" # Maximum queue size of the websocket updates per session. This restriction prevents infinite updates of WS max_queue_messages_per_session: "${TB_SERVER_WS_DEFAULT_QUEUE_MESSAGES_PER_SESSION:1000}" # Maximum time between WS session opening and sending auth command @@ -105,23 +118,25 @@ server: response_timeout: "${DEFAULT_RULE_ENGINE_RESPONSE_TIMEOUT:10000}" # Application info parameters +# Exposes application metadata such as version string injected at build time. app: # Application version version: "@project.version@" -# Zookeeper connection parameters +# ZooKeeper connection parameters +# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -129,6 +144,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cluster parameters +# Controls cluster statistics — tracks the number of messages exchanged between cluster nodes. cluster: stats: # Enable/Disable the cluster statistics. Calculates the number of messages sent between cluster nodes based on each type @@ -137,11 +153,13 @@ cluster: print_interval_ms: "${TB_CLUSTER_STATS_PRINT_INTERVAL_MS:10000}" # Plugins configuration parameters +# Defines classpath scan packages used to discover and register ThingsBoard extension plugins. plugins: # Comma-separated package list used during classpath scanning for plugins scan_packages: "${PLUGINS_SCAN_PACKAGES:org.thingsboard.server.extensions,org.thingsboard.rule.engine}" # Security parameters +# Configures JWT tokens, user login, device claiming, OAuth2, and CA certificate trust store. security: # JWT Token parameters jwt: # Since 3.4.2 values are persisted in the database during installation or upgrade. On Install, the key will be generated randomly if no custom value set. You can change it later from Web UI under SYS_ADMIN @@ -178,8 +196,63 @@ security: path: "${SECURITY_JAVA_CACERTS_PATH:${java.home}/lib/security/cacerts}" # The password of the cacerts keystore file password: "${SECURITY_JAVA_CACERTS_PASSWORD:changeit}" + # HTTP security response headers configuration. + # These headers are set on responses from the ThingsBoard backend (tb-node). + # In microservice deployments, the web-ui (Express.js) has its own header configuration + # under msa/web-ui/config/ using the same environment variable names. + headers: + # X-Content-Type-Options header prevents browsers from MIME-sniffing the Content-Type. + # Safe to enable. Only disable if you intentionally serve resources with mismatched Content-Type. + x-content-type-options: + # Enable/disable X-Content-Type-Options header. Prevents browsers from MIME-sniffing the Content-Type + enabled: "${SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED:true}" + # Referrer-Policy header controls how much referrer info the browser sends with requests. + # The default 'strict-origin-when-cross-origin' matches the browser's built-in default, + # so enabling this does not change existing behavior — it just makes the policy explicit. + # Valid values: no-referrer, no-referrer-when-downgrade, origin, origin-when-cross-origin, + # same-origin, strict-origin, strict-origin-when-cross-origin, unsafe-url + referrer-policy: + # Enable/disable Referrer-Policy header + enabled: "${SECURITY_HEADERS_REFERRER_POLICY_ENABLED:true}" + # Referrer-Policy header value + value: "${SECURITY_HEADERS_REFERRER_POLICY_VALUE:strict-origin-when-cross-origin}" + # X-Frame-Options header protects against clickjacking attacks by preventing the page + # from being loaded in iframes on other domains. + # Disabled by default because ThingsBoard supports multi-domain deployments where + # the platform may be embedded in iframes on customer domains. + # WARNING: Enabling with DENY will block ALL iframe embedding including dashboards + # embedded on external sites. Use SAMEORIGIN to allow same-domain iframes only. + x-frame-options: + # Enable/disable X-Frame-Options header. Protects against clickjacking attacks + enabled: "${SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED:false}" + # Valid values: DENY, SAMEORIGIN + value: "${SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE:SAMEORIGIN}" + # Content-Security-Policy header mitigates XSS and data injection attacks by restricting + # which resources the browser is allowed to load. + # Disabled by default because ThingsBoard supports multi-domain deployments and + # because custom HTML Card widgets may use inline scripts, inline styles, and + # external resources that a restrictive CSP would block. + # WARNING when enabling: A strict CSP (e.g. script-src 'self') will break: + # - HTML Card widgets with inline JavaScript + # - Custom widget types with inline scripts/styles + # - Widgets loading external resources (images, fonts, scripts) + # - Dashboard embedding via iframes (if frame-ancestors is restrictive) + # Use 'report-only: true' first to test the impact before enforcing. + # The default value covers core ThingsBoard functionality including OpenStreetMap, ArcGIS maps, + # solution template previews, WebSocket telemetry, and chart Web Workers. + # NOTE: Google Maps requires adding Google domains to script-src, which is not included by default. + # To add Google Maps support, append to script-src: https://maps.googleapis.com https://maps.gstatic.com + content-security-policy: + # Enable/disable Content-Security-Policy header. Mitigates XSS and data injection attacks + enabled: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED:false}" + # Full CSP directive string. The default value is a working policy for most ThingsBoard deployments + value: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE:default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https://img.thingsboard.io https://tile.openstreetmap.org https://*.tile.openstreetmap.org https://*.arcgis.com; font-src 'self' data:; connect-src 'self' ws: wss: https://*.arcgis.com https://*.arcgisonline.com; worker-src 'self' blob:; frame-ancestors 'self'}" + # If true, uses Content-Security-Policy-Report-Only header instead — the browser + # reports violations but does not enforce them. Use for testing before enforcing. + report-only: "${SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY:false}" # Mail settings parameters +# Configures mail service OAuth2 token refresh and per-tenant sending rate limits. mail: oauth2: # Interval for checking refresh token expiration in seconds(by default, 1 day). @@ -188,6 +261,7 @@ mail: per_tenant_rate_limits: "${MAIL_PER_TENANT_RATE_LIMITS:}" # Usage statistics parameters +# Controls collection and reporting intervals for API usage stats at system, tenant, and customer levels. usage: stats: report: @@ -197,6 +271,8 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Statistics reporting interval, set to send summarized data every 10 seconds by default interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" check: @@ -209,6 +285,7 @@ usage: report_interval: "${DEVICES_STATS_REPORT_INTERVAL:60}" # UI settings parameters +# Configures dashboard data limits and base URL for UI help assets. ui: # Dashboard parameters dashboard: @@ -217,9 +294,10 @@ ui: # Help parameters help: # Base URL for UI help assets - base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.3}" + base-url: "${UI_HELP_BASE_URL:https://raw.githubusercontent.com/thingsboard/thingsboard-ui-help/release-4.4}" # Database telemetry parameters +# Selects the storage backend (SQL, Cassandra, or TimescaleDB) for time-series and latest telemetry data. database: ts_max_intervals: "${DATABASE_TS_MAX_INTERVALS:700}" # Max number of DB queries generated by a single API call to fetch telemetry records ts: @@ -228,6 +306,7 @@ database: type: "${DATABASE_TS_LATEST_TYPE:sql}" # cassandra, sql, or timescale (for hybrid mode, DATABASE_TS_TYPE value should be cassandra, or timescale) # Cassandra driver configuration parameters +# Configures cluster name, keyspace, SSL, credentials, socket, and query settings for the Cassandra driver. cassandra: # Thingsboard cluster name cluster_name: "${CASSANDRA_CLUSTER_NAME:Thingsboard Cluster}" @@ -339,11 +418,14 @@ cassandra: set_null_values_enabled: "${CASSANDRA_QUERY_SET_NULL_VALUES_ENABLED:true}" # log one of cassandra queries with specified frequency (0 - logging is disabled) print_queries_freq: "${CASSANDRA_QUERY_PRINT_FREQ:0}" + # Maximum total size in bytes of a Cassandra query result set across all pages. Default is 50MB. 0 means unlimited + max_result_set_size_in_bytes: "${CASSANDRA_QUERY_MAX_RESULT_SET_SIZE_IN_BYTES:52428800}" tenant_rate_limits: # Whether to print rate-limited tenant names when printing Cassandra query queue statistic print_tenant_names: "${CASSANDRA_QUERY_TENANT_RATE_LIMITS_PRINT_TENANT_NAMES:false}" # SQL configuration parameters +# Tunes batch sizes, delays, thread counts, TTL, and partitioning for SQL-backed persistence of telemetry, events, and audit logs. sql: # Specify batch size for persisting attribute updates attributes: @@ -395,6 +477,11 @@ sql: log_tenant_stats: "${SQL_LOG_TENANT_STATS:true}" # Interval in milliseconds for printing the latest statistic information about the tenant log_tenant_stats_interval_ms: "${SQL_LOG_TENANT_STATS_INTERVAL_MS:60000}" + entity_data_query_nulls_order_strategy: "${SQL_ENTITY_DATA_QUERY_NULLS_ORDER_STRATEGY:default}" # Nulls ordering strategy for sql entity data query. Possible values: default, nulls_first, nulls_last. The default value is 'default', which means postgres default behavior will be applied: NULLS LAST for ASC and NULLS FIRST for DESC. The 'nulls_first' value means that NULL values will be ordered before non-NULL values regardless of the sort order. The 'nulls_last' value means that NULL values will be ordered after non-NULL values regardless of the sort order. + query: + key-filters-or-conditions: + # Enable/disable OR conditions between key filters in entity data queries + enabled: "${SQL_QUERY_KEY_FILTERS_OR_CONDITIONS_ENABLED:true}" postgres: # Specify partitioning size for timestamp key-value storage. Example: DAYS, MONTHS, YEARS, INDEFINITE. ts_key_value_partitioning: "${SQL_POSTGRES_TS_KV_PARTITIONING:MONTHS}" @@ -424,10 +511,11 @@ sql: edge_events_ttl: "${SQL_TTL_EDGE_EVENTS_TTL:2628000}" # Number of seconds. The current value corresponds to one month alarms: checking_interval: "${SQL_ALARMS_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours - removal_batch_size: "${SQL_ALARMS_TTL_REMOVAL_BATCH_SIZE:3000}" # To delete outdated alarms not all at once but in batches + removal_batch_size: "${SQL_ALARMS_TTL_REMOVAL_BATCH_SIZE:3000}" # Batch size for records removal rpc: enabled: "${SQL_TTL_RPC_ENABLED:true}" # Enable/disable TTL (Time To Live) for rpc call records checking_interval: "${SQL_RPC_TTL_CHECKING_INTERVAL:7200000}" # Number of milliseconds. The current value corresponds to two hours + removal_batch_size: "${SQL_RPC_TTL_REMOVAL_BATCH_SIZE:10000}" # Batch size for records removal audit_logs: enabled: "${SQL_TTL_AUDIT_LOGS_ENABLED:true}" # Enable/disable TTL (Time To Live) for audit log records ttl: "${SQL_TTL_AUDIT_LOGS_SECS:0}" # Disabled by default. The accuracy of the cleanup depends on the sql.audit_logs.partition_size @@ -436,6 +524,7 @@ sql: enabled: "${SQL_TTL_NOTIFICATIONS_ENABLED:true}" # Enable/disable TTL (Time To Live) for notification center records ttl: "${SQL_TTL_NOTIFICATIONS_SECS:2592000}" # Default value - 30 days checking_interval_ms: "${SQL_TTL_NOTIFICATIONS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day + removal_batch_size: "${SQL_TTL_NOTIFICATIONS_REMOVAL_BATCH_SIZE:10000}" # Batch size for records removal api_keys: enabled: "${SQL_TTL_API_KEYS_ENABLED:true}" # Enable/disable TTL (Time To Live) for expired api keys records checking_interval_ms: "${SQL_TTL_API_KEYS_CHECKING_INTERVAL_MS:86400000}" # Default value - 1 day @@ -445,6 +534,7 @@ sql: query_timeout: "${SQL_RELATIONS_QUERY_TIMEOUT_SEC:20}" # This value has to be reasonably small to prevent the relation query from blocking all other DB calls # Actor system parameters +# Configures thread pools, timeouts, and behavior for the internal actor system processing devices, rules, and RPCs. actors: system: throughput: "${ACTORS_SYSTEM_THROUGHPUT:5}" # Number of messages the actor system will process per actor before switching to processing of messages for the next actor @@ -507,6 +597,28 @@ actors: # Force acknowledgment of the incoming message for external rule nodes to decrease processing latency. # Enqueue the result of external node processing as a separate message to the rule engine. force_ack: "${ACTORS_RULE_EXTERNAL_NODE_FORCE_ACK:false}" + # Enable Server-Side Request Forgery (SSRF) protection for external HTTP rule nodes (rest api call). + # When enabled, requests to private/internal network addresses are blocked. + ssrf_protection_enabled: "${SSRF_PROTECTION_ENABLED:false}" + # Comma-separated list of additional blocked destinations (IPs, CIDR subnets, or hostnames). + # Example: "198.51.100.0/24,metadata.tencentyun.com,rancher-metadata" + ssrf_additional_blocked_hosts: "${SSRF_ADDITIONAL_BLOCKED_HOSTS:}" + # Comma-separated list of allowed destinations that bypass SSRF blocking (IPs, CIDR subnets, or hostnames). + # Use this when your rule chains need to reach devices on private networks (e.g., 192.168.1.0/24). + # Example: "192.168.1.0/24,10.0.0.0/8,my-internal-service.corp" + ssrf_allowed_hosts: "${SSRF_ALLOWED_HOSTS:}" + http_client: + # Server-level ceiling for parallel in-flight HTTP requests per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction (node config wins). + max_parallel_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PARALLEL_REQUESTS:0}" + # Server-level ceiling for the pending-request queue depth per external HTTP rule node instance. + # Applied as min(nodeConfig, systemMax) when set; 0 = no system-level restriction. + max_pending_requests: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_MAX_PENDING_REQUESTS:0}" + # Maximum number of TCP connections in the reactor-netty connection pool per external HTTP rule node instance. + # Defaults to reactor-netty's ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS: max(availableProcessors, 8) * 2 + # (e.g. 16 on an 8-core host). Increase for high-throughput nodes calling remote services that support many connections. + # 0 = use reactor-netty default. + pool_max_connections: "${ACTORS_RULE_EXTERNAL_HTTP_CLIENT_POOL_MAX_CONNECTIONS:${TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS:0}}" rpc: # Maximum number of persistent RPC call retries in case of failed request delivery. max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" @@ -539,6 +651,8 @@ actors: # Time in seconds to receive calculation result. calculation_timeout: "${ACTORS_CALCULATION_TIMEOUT_SEC:5}" +# Debug settings parameters +# Configures the global default duration for debug mode used by rule chains and calculated fields. debug: settings: # Default duration (in minutes) for debug mode. Min value is 1 minute. Tenant profile settings override this one. @@ -546,6 +660,7 @@ debug: default_duration: "${DEBUG_SETTINGS_DEFAULT_DURATION_MINUTES:15}" # Cache settings parameters +# Configures cache type (Caffeine or Redis), pool size, and per-entity TTL and max-size limits for all caches. cache: # caffeine or redis(7.2 - latest compatible version) type: "${CACHE_TYPE:caffeine}" @@ -705,6 +820,7 @@ cache: spring.data.redis.repositories.enabled: false # Disable this because it is not required. # Redis/Valkey configuration parameters +# Configures standalone, cluster, or sentinel Redis connection, SSL, and connection pool settings used for caching. redis: # standalone or cluster or sentinel connection: @@ -741,9 +857,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # If set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -788,27 +904,36 @@ redis: # Update version parameters +# Controls whether the platform periodically checks for new ThingsBoard releases. updates: # Enable/disable checks for the new version enabled: "${UPDATES_ENABLED:true}" -# Spring CORS configuration parameters +# Spring CORS configuration parameters. +# Controls the Access-Control-Allow-Origin and Access-Control-Allow-Credentials response headers. +# WARNING: The default configuration allows cross-origin requests from ANY domain with credentials. +# This means any website can make API requests on behalf of an authenticated user if the token +# is accessible (e.g., via XSS). For production deployments, restrict to your domain(s): +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://your-domain.com +# For multi-domain deployments, list all allowed domains comma-separated: +# TB_CORS_ALLOWED_ORIGIN_PATTERNS=https://domain1.com,https://domain2.com spring.mvc.cors: mappings: # Intercept path "[/api/**]": #Comma-separated list of origins to allow. '*' allows all origins. When not set, CORS support is disabled. - allowed-origin-patterns: "*" + allowed-origin-patterns: "${TB_CORS_ALLOWED_ORIGIN_PATTERNS:*}" #Comma-separated list of methods to allow. '*' allows all methods. - allowed-methods: "*" + allowed-methods: "${TB_CORS_ALLOWED_METHODS:*}" #Comma-separated list of headers to allow in a request. '*' allows all headers. - allowed-headers: "*" + allowed-headers: "${TB_CORS_ALLOWED_HEADERS:*}" #How long, in seconds, the response from a pre-flight request can be cached by clients. - max-age: "1800" + max-age: "${TB_CORS_MAX_AGE:1800}" #Set whether credentials are supported. When not set, credentials are not supported. - allow-credentials: "true" + allow-credentials: "${TB_CORS_ALLOW_CREDENTIALS:true}" # General spring parameters +# Miscellaneous Spring Boot settings for circular references, Freemarker, MVC, multipart, and JPA dialect. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. spring.freemarker.checkTemplateLocation: "false" # spring freemarker configuration spring.mvc.async.request-timeout: "${SPRING_MVC_ASYNC_REQUEST_TIMEOUT:30000}" # The default timeout for asynchronous requests in milliseconds @@ -827,6 +952,7 @@ spring.jpa.properties.hibernate.order_by.default_null_ordering: "${SPRING_JPA_PR spring.jpa.properties.hibernate.dialect: "${SPRING_JPA_DIALECT:org.thingsboard.server.dao.ThingsboardPostgreSQLDialect}" # we use custom dialect that contains ilike(arg1, arg2) function (is interpreted to postgres ILIKE operator) # SQL DAO Configuration parameters +# Configures Spring JPA, Hibernate DDL, and HikariCP datasource connections for the primary and events databases. spring: data: jpa: @@ -877,6 +1003,7 @@ spring: registerMbeans: "${SPRING_EVENTS_DATASOURCE_HIKARI_REGISTER_MBEANS:false}" # Audit log parameters +# Configures audit logging levels per entity type and optional forwarding to an external sink such as Elasticsearch. audit-log: # Enable/disable audit log functionality. enabled: "${AUDIT_LOG_ENABLED:true}" @@ -919,6 +1046,7 @@ audit-log: password: "${AUDIT_LOG_SINK_PASSWORD:}" # Password used to access external sink system # Device state parameters +# Configures device inactivity detection, state persistence strategy, and rate limits for state rule nodes. state: # Device inactivity timeout is a global configuration parameter that defines when the device will be marked as "inactive" by the server. # The parameter value is in seconds. A user can overwrite this parameter for an individual device by setting the “inactivityTimeout” server-side attribute (NOTE: expects value in milliseconds). @@ -949,7 +1077,8 @@ state: # Refill is set to be greedy. Please refer to Bucket4j library documentation for more details. rateLimit: "${DEVICE_STATE_NODE_RATE_LIMIT_CONFIGURATION:1:1,30:60,60:3600}" -# Tbel parameters +# TBEL parameters +# Configures the ThingsBoard Expression Language (TBEL) engine: limits, timeouts, thread pool, and blacklisting. tbel: # Enable/Disable TBEL feature. enabled: "${TBEL_ENABLED:true}" @@ -978,6 +1107,7 @@ tbel: print_interval_ms: "${TB_TBEL_STATS_PRINT_INTERVAL_MS:10000}" # JS parameters +# Configures the JavaScript execution engine (local Nashorn or remote Node.js): limits, sandboxing, and thread pools. js: # local (Nashorn Engine, deprecated) OR remote JS-Executors (NodeJS) evaluator: "${JS_EVALUATOR:local}" @@ -1025,6 +1155,7 @@ js: print_interval_ms: "${TB_JS_REMOTE_STATS_PRINT_INTERVAL_MS:10000}" # Transport configuration parameters +# Configures session management, rate limits, and protocol-specific settings for HTTP, MQTT, CoAP, LwM2M, and SNMP transports. transport: sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. @@ -1333,8 +1464,18 @@ transport: branch: "${TB_GATEWAY_DASHBOARD_SYNC_BRANCH:release/4.3.0}" # Fetch frequency in hours for gateways dashboard repository fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Interval in seconds for certificate reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters +# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings. coap: server: # Enable/disable coap server. @@ -1419,6 +1560,7 @@ coap: dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}" # Device connectivity parameters +# Specifies the hosts, ports, and credentials exposed to the UI for generating device connection check commands. device: connectivity: http: @@ -1472,6 +1614,7 @@ device: image_version: "${DEVICE_CONNECTIVITY_GATEWAY_IMAGE_VERSION:3.8-stable}" # Edges parameters +# Controls Edge instance gRPC communication, event storage, state persistence, and statistics reporting. edges: # Enable/disable Edge instance enabled: "${EDGES_ENABLED:true}" @@ -1493,10 +1636,17 @@ edges: ssl: # Enable/disable SSL support enabled: "${EDGES_RPC_SSL_ENABLED:false}" - # Cert file to be used during TLS connectivity to the cloud + # Path to the server certificate file (holds server certificate or certificate chain, may include server private key). + # Accepts an absolute filesystem path (e.g. /etc/thingsboard/certChainFile.pem), + # a relative path resolved against the working directory first then the classpath, + # or a classpath resource with the explicit "classpath:" prefix (e.g. classpath:conf/certChainFile.pem). cert: "${EDGES_RPC_SSL_CERT:certChainFile.pem}" - # Private key file associated with the Cert certificate. This key is used in the encryption process during a secure connection + # Path to the server certificate private key file. Optional if the private key is already present in the cert file above. + # Supports the same path resolution as 'cert': absolute, relative/classpath, or "classpath:" prefix. + # Leave empty when using a combined PEM cert that already contains the private key. private_key: "${EDGES_RPC_SSL_PRIVATE_KEY:privateKeyFile.pem}" + # Server certificate private key password (optional). Leave empty if the key is not encrypted. + key_password: "${EDGES_RPC_SSL_KEY_PASSWORD:}" # Maximum size (in bytes) of inbound messages the cloud can handle from the edge. By default, it can handle messages up to 4 Megabytes max_inbound_message_size: "${EDGES_RPC_MAX_INBOUND_MESSAGE_SIZE:4194304}" # Maximum length of telemetry (time-series and attributes) message the cloud sends to the edge. By default, there is no limitation. @@ -1533,6 +1683,7 @@ edges: report-interval-millis: "${EDGES_STATS_REPORT_INTERVAL_MS:600000}" # Spring doc common parameters +# Enables or disables the OpenAPI/Swagger documentation endpoint and sets the default media type. springdoc: # If false swagger API docs will be unavailable api-docs.enabled: "${SWAGGER_ENABLED:true}" @@ -1540,6 +1691,7 @@ springdoc: default-produces-media-type: "${SWAGGER_DEFAULT_PRODUCES_MEDIA_TYPE:application/json}" # Swagger common parameters +# Configures Swagger UI metadata: title, description, contact, license, version, and API path patterns. swagger: # General swagger match pattern of swagger UI links api_path: "${SWAGGER_API_PATH:/api/**}" @@ -1571,6 +1723,7 @@ swagger: doc_expansion: "${SWAGGER_DOC_EXPANSION:list}" # Queue configuration parameters +# Configures the message queue backend (in-memory or Kafka) and all topic/partition/consumer settings for each service. queue: type: "${TB_QUEUE_TYPE:in-memory}" # in-memory or kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -1598,8 +1751,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -1910,6 +2063,9 @@ queue: print-interval-ms: "${TB_QUEUE_RULE_ENGINE_STATS_PRINT_INTERVAL_MS:60000}" # Max length of the error message that is printed by statistics max-error-message-length: "${TB_QUEUE_RULE_ENGINE_MAX_ERROR_MESSAGE_LENGTH:4096}" + prometheus-stats: + # Enable/disable Prometheus statistics for individual Rule Engine message processing (records time in ms for success/failure). + enabled: "${TB_QUEUE_RULE_ENGINE_PROMETHEUS_STATS_ENABLED:false}" # After a queue is deleted (or the profile's isolation option was disabled), Rule Engine will continue reading related topics during this period before deleting the actual topics topic-deletion-delay: "${TB_QUEUE_RULE_ENGINE_TOPIC_DELETION_DELAY_SEC:15}" # Size of the thread pool that handles such operations as partition changes, config updates, queue deletion @@ -1981,12 +2137,14 @@ queue: processing_interval: "${TB_QUEUE_TASKS_STATS_PROCESSING_INTERVAL_MS:1000}" # Event configuration parameters +# Controls limits on debug event content size stored for rule chain and rule node execution. event: debug: # Maximum number of symbols per debug event. The event content will be truncated if needed max-symbols: "${TB_MAX_DEBUG_EVENT_SYMBOLS:4096}" # General service parameters +# Sets the deployment type (monolith or microservice) and assigns tenant profiles to specific Rule Engine instances. service: type: "${TB_SERVICE_TYPE:monolith}" # monolith or tb-core or tb-rule-engine # Unique id for this service (autogenerated if empty) @@ -2000,6 +2158,7 @@ service: executor_thread_pool_size: "${TB_RULE_ENGINE_PUBSUB_EXECUTOR_THREAD_POOL_SIZE:0}" # Metrics parameters +# Enables actuator metrics endpoint and configures system info (CPU, memory) persistence frequency and TTL. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" @@ -2013,6 +2172,7 @@ metrics: ttl: "${METRICS_SYSTEM_INFO_TTL_DAYS:7}" # Version control parameters +# Configures thread pools and Git repository folder for entity version control (export/import) operations. vc: # Pool size for handling export tasks thread_pool_size: "${TB_VC_POOL_SIZE:6}" @@ -2023,6 +2183,7 @@ vc: repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}" # Notification system parameters +# Configures thread pool size and deduplication intervals for the notification rule processing engine. notification_system: # Specify thread pool size for Notification System processing notification rules and notification sending. Recommend value <= 10 thread_pool_size: "${TB_NOTIFICATION_SYSTEM_THREAD_POOL_SIZE:10}" @@ -2031,18 +2192,20 @@ notification_system: deduplication_durations: "${TB_NOTIFICATION_RULES_DEDUPLICATION_DURATIONS:NEW_PLATFORM_VERSION:0;RATE_LIMITS:14400000;}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure and health indicator settings. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" health: elasticsearch: # Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck enabled: "false" # Mobile application settings for Thingsboard mobile application +# Specifies domain name and store links used for the ThingsBoard Live mobile application QR code and deep links. mobileApp: # Server domain name for Thingsboard Live mobile application domain: "${TB_MOBILE_APP_DOMAIN:demo.thingsboard.io}" @@ -2051,6 +2214,8 @@ mobileApp: # Link to App Store for Thingsboard Live mobile application appStoreLink: "${TB_MOBILE_APP_APP_STORE_LINK:https://apps.apple.com/us/app/thingsboard-live/id1594355695}" +# MQTT client parameters +# Configures MQTT client retransmission behavior including max attempts, initial delay, and jitter factor. mqtt: # MQTT client configuration parameters client: diff --git a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java index 759b7ffa2d..6337067b9c 100644 --- a/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java +++ b/application/src/test/java/org/thingsboard/server/actors/rule/DefaultTbContextTest.java @@ -42,6 +42,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.msg.TbActorMsg; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import org.thingsboard.server.common.msg.TbMsgProcessingStackItem; @@ -134,6 +135,48 @@ class DefaultTbContextTest { then(clusterService).should().pushMsgToRuleEngine(eq(tpi), eq(msg.getId()), any(), any()); } + @Test + public void givenMsgWithCurrentNodeAlreadyInStack_whenInput_thenTellFailureToPreventLoop() { + // GIVEN - simulate the second iteration: current node is already in the return stack, + // which happens when forwardMsgToDefaultRuleChain=true and the device's default rule chain + // is the same as the rule chain containing this node + var callbackMock = mock(TbMsgCallback.class); + given(callbackMock.isMsgValid()).willReturn(true); + + var ruleNode = new RuleNode(RULE_NODE_ID); + ruleNode.setRuleChainId(RULE_CHAIN_ID); + ruleNode.setDebugSettings(DebugSettings.off()); + given(nodeCtxMock.getSelf()).willReturn(ruleNode); + given(nodeCtxMock.getTenantId()).willReturn(TENANT_ID); + given(nodeCtxMock.getChainActor()).willReturn(chainActorMock); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TENANT_ID) + .copyMetaData(TbMsgMetaData.EMPTY) + .data(TbMsg.EMPTY_STRING) + .callback(callbackMock) + .build(); + // Push current node into the stack - simulates a previous iteration that already forwarded to this rule chain + msg.pushToStack(RULE_CHAIN_ID, RULE_NODE_ID); + + var targetRuleChainId = new RuleChainId(UUID.randomUUID()); + + // WHEN + defaultTbContext.input(msg, targetRuleChainId); + + // THEN - loop detected: tellFailure is called and no message is enqueued + ArgumentCaptor tellCaptor = ArgumentCaptor.forClass(TbActorMsg.class); + then(chainActorMock).should().tell(tellCaptor.capture()); + TbActorMsg capturedTellMsg = tellCaptor.getValue(); + assertThat(capturedTellMsg).isInstanceOf(RuleNodeToRuleChainTellNextMsg.class); + RuleNodeToRuleChainTellNextMsg failureMsg = (RuleNodeToRuleChainTellNextMsg) capturedTellMsg; + assertThat(failureMsg.getRelationTypes()).containsOnly(TbNodeConnectionType.FAILURE); + assertThat(failureMsg.getFailureMessage()).containsIgnoringCase("loop"); + + then(mainCtxMock).shouldHaveNoInteractions(); // no message was enqueued + } + @MethodSource @ParameterizedTest public void givenMsgWithQueueName_whenEnqueue_thenVerifyEnqueueWithCorrectTpi(String queueName) { diff --git a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java index cf94940e07..9a001e0575 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -38,7 +38,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.RepeatingAlarmCon import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmConditionFilter; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.alarm.rule.condition.expression.SimpleAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.NoDataFilterPredicate; @@ -48,8 +48,7 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.predic import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.StringFilterPredicate.StringOperation; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.SpecificTimeSchedule; -import org.thingsboard.server.common.data.cf.CalculatedField; -import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; @@ -128,32 +127,32 @@ public class AlarmRulesTest extends AbstractControllerTest { ); Condition clearRule = new Condition("return temperature <= 25;", null, null); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, clearRule); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":100}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":101}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postTelemetry(deviceId, "{\"temperature\":20}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); @@ -185,11 +184,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition(simpleExpression, null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":100}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -209,11 +208,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"values\": {\"temperature\": 50}, \"ts\": " + (System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30) + "}")); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -236,15 +235,15 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); for (int i = 0; i < 4; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -255,12 +254,12 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> alarmResult.getConditionRepeats() == 9, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); }); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -288,14 +287,14 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(null, "eventsCount"), null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"eventsCount\":" + eventsCount + "}"); for (int i = 0; i < eventsCount; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -319,13 +318,13 @@ public class AlarmRulesTest extends AbstractControllerTest { ); Condition clearRule = new Condition("return powerConsumption < 3000;", null, clearDurationMs); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 5 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 5 seconds", arguments, createRules, clearRule); postTelemetry(deviceId, "{\"powerConsumption\":3500}"); Thread.sleep(createDurationMs - 2000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -334,9 +333,9 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"powerConsumption\":2000}"); Thread.sleep(clearDurationMs - 2000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); assertThat(alarmResult.getConditionDuration()).isBetween(clearDurationMs, clearDurationMs + 2000); @@ -363,12 +362,12 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue(null, "duration"), null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds", arguments, createRules, null); postTelemetry(deviceId, "{\"powerConsumption\":3500}"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"duration\":" + createDurationMs + "}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -391,10 +390,10 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue(2000L, null), null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High power consumption during 2 seconds", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High power consumption during 2 seconds", arguments, createRules, null); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -402,6 +401,57 @@ public class AlarmRulesTest extends AbstractControllerTest { }); } + @Test + public void testChangeDurationConditionFromStaticToDynamic() throws Exception { + Argument temperatureArgument = new Argument(); + temperatureArgument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + temperatureArgument.setDefaultValue("0"); + Map arguments = Map.of( + "temperature", temperatureArgument + ); + + long staticDurationMs = 5000L; + Map createRules = Map.of( + AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, staticDurationMs) + ); + + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", + arguments, createRules, null); + CalculatedFieldId alarmRuleId = alarmRule.getId(); + + // post telemetry to trigger condition and wait for the static-phase eval to produce a debug event, + // which guarantees firstEventTs > 0 in AlarmRuleState before we trigger REINIT + postTelemetry(deviceId, "{\"temperature\":50}"); + await().atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> getDebugEvents(alarmRuleId, 1), + events -> !events.isEmpty() && !events.get(0).getId().equals(latestEventId)); + + // update CF: add attribute argument and switch duration from static to dynamic + AlarmCalculatedFieldConfiguration configuration = alarmRule.getConfiguration(); + + Argument durationArgument = new Argument(); + durationArgument.setRefEntityKey(new ReferencedEntityKey("durationThreshold", + ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); + durationArgument.setDefaultValue("-1"); + configuration.getArguments().put("durationThreshold", durationArgument); + + DurationAlarmCondition durationCondition = (DurationAlarmCondition) + configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition(); + durationCondition.setValue(new AlarmConditionValue<>(null, "durationThreshold")); + + alarmRule = saveAlarmRule(alarmRule); + + long dynamicDurationMs = 3000L; + postAttributes(deviceId, AttributeScope.SERVER_SCOPE, + "{\"durationThreshold\":" + dynamicDurationMs + "}"); + + checkAlarmResult(alarmRule, alarmResult -> { + assertThat(alarmResult.isCreated()).isTrue(); + assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); + assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); + }); + } + @Test public void testCreateAlarm_currentOwnerArgument() throws Exception { Argument temperatureArgument = new Argument(); @@ -424,12 +474,12 @@ public class AlarmRulesTest extends AbstractControllerTest { device.setCustomerId(customerId); device = doPost("/api/device", device, Device.class); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"temperatureThreshold\":50}"); postTelemetry(deviceId, "{\"temperature\":51}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -462,21 +512,21 @@ public class AlarmRulesTest extends AbstractControllerTest { "location", StringOperation.NOT_CONTAINS, new AlarmConditionValue<>(null, "locationFilter") ), null, null); - CalculatedField calculatedField = createAlarmCf(customerId, "New resident", + AlarmRuleDefinition alarmRule = createAlarmRule(customerId, "New resident", arguments, createRules, clearRule); loginSysAdmin(); postAttributes(tenantId, AttributeScope.SERVER_SCOPE, "{\"locationFilter\":\"Kyiv\"}"); loginTenantAdmin(); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Kyiv\"}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); postAttributes(customerId, AttributeScope.SERVER_SCOPE, "{\"location\":\"Ukraine, Lviv\"}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCleared()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.INDETERMINATE); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.CLEARED_UNACK); @@ -501,7 +551,7 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(null, "schedule")) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); String schedule = """ {"timezone":"Europe/Kiev","items":[{"enabled":false,"dayOfWeek":1,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":2,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":3,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":4,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":5,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":6,"startsOn":0,"endsOn":0},{"enabled":false,"dayOfWeek":7,"startsOn":0,"endsOn":0}]} @@ -510,14 +560,14 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); schedule = schedule.replace("\"enabled\":false", "\"enabled\":true"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"schedule\":" + schedule + "}"); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { // checking multiple debug events due to scheduled reevaluation (which also produces debug events) - CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream() .filter(event -> event.getResult() != null) .findFirst().orElse(null); assertThat(debugEvent).isNotNull(); @@ -565,18 +615,18 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition(criticalExpression, null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "No Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "No Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -596,19 +646,19 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); }); - calculatedField.setName("New alarm type"); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule.setName("New alarm type"); + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -628,18 +678,18 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 100;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration(); ((TbelAlarmConditionExpression) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition().getExpression()) .setExpression("return temperature >= 50;"); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -662,13 +712,13 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", eventsCountCritical, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); for (int i = 0; i < eventsCountMajor; i++) { postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(10); } - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -676,18 +726,18 @@ public class AlarmRulesTest extends AbstractControllerTest { }); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.MAJOR); assertThat(alarmResult.getConditionRepeats()).isEqualTo(6); }); // decreasing required events count for critical rule - AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration(); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration(); ((RepeatingAlarmCondition) configuration.getCreateRules().get(AlarmSeverity.CRITICAL).getCondition()) .setCount(new AlarmConditionValue<>(6, null)); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isSeverityUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -719,20 +769,20 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= temperatureThreshold;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); Thread.sleep(1000); // not created because tenant's threshold 100 is used - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); - ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getArguments().get("temperatureThreshold") + ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getArguments().get("temperatureThreshold") .setRefDynamicSourceConfiguration(null); // using threshold 50 on device level - calculatedField = saveCalculatedField(calculatedField); + alarmRule = saveAlarmRule(alarmRule); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -755,7 +805,7 @@ public class AlarmRulesTest extends AbstractControllerTest { Map createRules = Map.of( AlarmSeverity.CRITICAL, new Condition("return temperature >= 50 && humidity >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature and Humidity Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature and Humidity Alarm", arguments, createRules, null, configuration -> { configuration.getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( "temperature is ${temperature}, humidity is ${humidity}" @@ -765,18 +815,18 @@ public class AlarmRulesTest extends AbstractControllerTest { postTelemetry(deviceId, "{\"temperature\":50}"); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"humidity\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getDetails().get("data").asText()) .isEqualTo("temperature is 50, humidity is 50"); }); - ((AlarmCalculatedFieldConfiguration) calculatedField.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( + ((AlarmCalculatedFieldConfiguration) alarmRule.getConfiguration()).getCreateRules().get(AlarmSeverity.CRITICAL).setAlarmDetails( "UPDATED temperature is ${temperature}, humidity is ${humidity}" ); - calculatedField = saveCalculatedField(calculatedField); - checkAlarmResult(calculatedField, alarmResult -> { + alarmRule = saveAlarmRule(alarmRule); + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isFalse(); assertThat(alarmResult.isUpdated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); @@ -805,16 +855,16 @@ public class AlarmRulesTest extends AbstractControllerTest { new AlarmConditionValue<>(schedule, null)) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "Illegal parking alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "Illegal parking alarm", arguments, createRules, null); postAttributes(deviceId, AttributeScope.SERVER_SCOPE, "{\"parkingSpotOccupied\":true}"); Thread.sleep(10000); - assertThat(getLatestAlarmResult(calculatedField.getId())).isNull(); + assertThat(getLatestAlarmResult(alarmRule.getId())).isNull(); await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { - CalculatedFieldDebugEvent debugEvent = getDebugEvents(calculatedField.getId(), 5).stream() + CalculatedFieldDebugEvent debugEvent = getDebugEvents(alarmRule.getId(), 5).stream() .filter(event -> event.getResult() != null) .findFirst().orElse(null); assertThat(debugEvent).isNotNull(); @@ -838,11 +888,11 @@ public class AlarmRulesTest extends AbstractControllerTest { AlarmSeverity.CRITICAL, new Condition("return temperature >= 50;", null, null) ); - CalculatedField calculatedField = createAlarmCf(deviceId, "High Temperature Alarm", + AlarmRuleDefinition alarmRule = createAlarmRule(deviceId, "High Temperature Alarm", arguments, createRules, null); postTelemetry(deviceId, "{\"temperature\":50}"); - Alarm alarm = checkAlarmResult(calculatedField, alarmResult -> { + Alarm alarm = checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); assertThat(alarmResult.getAlarm().getStatus()).isEqualTo(AlarmStatus.ACTIVE_UNACK); @@ -851,7 +901,7 @@ public class AlarmRulesTest extends AbstractControllerTest { doPost("/api/alarm/" + alarm.getId() + "/clear", AlarmInfo.class); Thread.sleep(1000); postTelemetry(deviceId, "{\"temperature\":50}"); - checkAlarmResult(calculatedField, alarmResult -> { + checkAlarmResult(alarmRule, alarmResult -> { assertThat(alarmResult.getAlarm().getId()).isNotEqualTo(alarm.getId()); assertThat(alarmResult.isCreated()).isTrue(); assertThat(alarmResult.getAlarm().getSeverity()).isEqualTo(AlarmSeverity.CRITICAL); @@ -861,21 +911,21 @@ public class AlarmRulesTest extends AbstractControllerTest { // TODO: MSA tests - private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, Consumer assertion) { - return checkAlarmResult(calculatedField, null, assertion); + private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Consumer assertion) { + return checkAlarmResult(alarmRule, null, assertion); } - private TbAlarmResult checkAlarmResult(CalculatedField calculatedField, + private TbAlarmResult checkAlarmResult(AlarmRuleDefinition alarmRule, Predicate waitFor, Consumer assertion) { TbAlarmResult alarmResult = await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> getLatestAlarmResult(calculatedField.getId()), result -> + .until(() -> getLatestAlarmResult(alarmRule.getId()), result -> result != null && (waitFor == null || waitFor.test(result))); assertion.accept(alarmResult); Alarm alarm = alarmResult.getAlarm(); assertThat(alarm.getOriginator()).isEqualTo(originatorId); - assertThat(alarm.getType()).isEqualTo(calculatedField.getName()); + assertThat(alarm.getType()).isEqualTo(alarmRule.getName()); return alarmResult; } @@ -895,38 +945,37 @@ public class AlarmRulesTest extends AbstractControllerTest { return JacksonUtil.fromString(debugEvent.getResult(), TbAlarmResult.class); } - private CalculatedField createAlarmCf(EntityId entityId, - String alarmType, - Map arguments, - Map createConditions, - Condition clearCondition, - Consumer... modifier) { + private AlarmRuleDefinition createAlarmRule(EntityId entityId, + String alarmType, + Map arguments, + Map createConditions, + Condition clearCondition, + Consumer... modifier) { Map createRules = new HashMap<>(); createConditions.forEach((severity, condition) -> { createRules.put(severity, toAlarmRule(condition)); }); AlarmRule clearRule = clearCondition != null ? toAlarmRule(clearCondition) : null; - CalculatedField calculatedField = new CalculatedField(); - calculatedField.setEntityId(entityId); - calculatedField.setName(alarmType); - calculatedField.setType(CalculatedFieldType.ALARM); + AlarmRuleDefinition alarmRule = new AlarmRuleDefinition(); + alarmRule.setEntityId(entityId); + alarmRule.setName(alarmType); AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); configuration.setArguments(arguments); configuration.setCreateRules(createRules); configuration.setClearRule(clearRule); - calculatedField.setConfiguration(configuration); - calculatedField.setDebugSettings(DebugSettings.all()); + alarmRule.setConfiguration(configuration); + alarmRule.setDebugSettings(DebugSettings.all()); if (modifier.length > 0) { modifier[0].accept(configuration); } - CalculatedField savedCalculatedField = saveCalculatedField(calculatedField); + AlarmRuleDefinition savedAlarmRule = saveAlarmRule(alarmRule); CalculatedFieldDebugEvent debugEvent = await().atMost(TIMEOUT, TimeUnit.SECONDS) - .until(() -> getDebugEvents(savedCalculatedField.getId(), 1), + .until(() -> getDebugEvents(savedAlarmRule.getId(), 1), events -> !events.isEmpty()).get(0); latestEventId = debugEvent.getId(); - return savedCalculatedField; + return savedAlarmRule; } private AlarmRule toAlarmRule(Condition condition) { diff --git a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java index 129d81b400..ba7d11fee9 100644 --- a/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/CalculatedFieldIntegrationTest.java @@ -20,10 +20,12 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.NullNode; import com.fasterxml.jackson.databind.node.ObjectNode; import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EventInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.asset.AssetProfile; @@ -45,7 +47,9 @@ import org.thingsboard.server.common.data.cf.configuration.geofencing.Geofencing import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; import org.thingsboard.server.common.data.debug.DebugSettings; import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationPathLevel; @@ -1390,6 +1394,93 @@ public class CalculatedFieldIntegrationTest extends CalculatedFieldControllerTes }); } + @Test + public void testCalculatedFieldsWhenOneIsInvalid() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + long now = System.currentTimeMillis(); + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + AttributeScope.SERVER_SCOPE, JacksonUtil.toJsonNode(String.format("{\"ts\": %s, \"values\": {\"a\":5}}", now - TimeUnit.MINUTES.toMillis(3)))); + + // Script CF - invalid + CalculatedField invalidCF = new CalculatedField(); + invalidCF.setEntityId(testDevice.getId()); + invalidCF.setType(CalculatedFieldType.SCRIPT); + invalidCF.setName("Script CF"); + invalidCF.setDebugSettings(DebugSettings.all()); + + ScriptCalculatedFieldConfiguration scriptConfig = new ScriptCalculatedFieldConfiguration(); + + ReferencedEntityKey refEntityKeyA = new ReferencedEntityKey("a", ArgumentType.TS_LATEST, null); + Argument argumentA = new Argument(); + argumentA.setRefEntityKey(refEntityKeyA); + scriptConfig.setArguments(Map.of("a", argumentA)); + scriptConfig.setExpression(""" + return { + "temperature": temp + }; + """); + + scriptConfig.setOutput(new TimeSeriesOutput()); + + invalidCF.setConfiguration(scriptConfig); + + invalidCF = doPost("/api/calculatedField", invalidCF, CalculatedField.class); + CalculatedFieldId invalidCfId = invalidCF.getId(); + + await().alias("create invalid CF -> check error").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + PageData debugEvents = getDebugEvents(tenantId, invalidCfId, 1); + if (!debugEvents.getData().isEmpty()) { + EventInfo eventInfo = debugEvents.getData().get(0); + assertThat(eventInfo.getBody().has("error")).isTrue(); + } + }); + + // Simple CF - valid + CalculatedField validCF = new CalculatedField(); + validCF.setEntityId(testDevice.getId()); + validCF.setType(CalculatedFieldType.SIMPLE); + validCF.setName("Simple CF"); + validCF.setDebugSettings(DebugSettings.all()); + + SimpleCalculatedFieldConfiguration simpleConfig = new SimpleCalculatedFieldConfiguration(); + simpleConfig.setArguments(Map.of("a", argumentA)); + simpleConfig.setExpression("a+1"); + + TimeSeriesOutput simpleOutput = new TimeSeriesOutput(); + simpleOutput.setName("a+1"); + simpleOutput.setDecimalsByDefault(0); + simpleConfig.setOutput(simpleOutput); + + validCF.setConfiguration(simpleConfig); + + validCF = doPost("/api/calculatedField", validCF, CalculatedField.class); + CalculatedFieldId validCfId = validCF.getId(); + + await().alias("create CF -> check initial calculation").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + PageData debugEvents = getDebugEvents(tenantId, validCfId, 1); + if (!debugEvents.getData().isEmpty()) { + EventInfo eventInfo = debugEvents.getData().get(0); + assertThat(eventInfo.getBody().has("error")).isFalse(); + } + ObjectNode result = getLatestTelemetry(testDevice.getId(), "a+1"); + assertThat(result).isNotNull(); + assertThat(result.get("a+1").get(0).get("value").asText()).isEqualTo("6"); + }); + + doPost("/api/plugins/telemetry/DEVICE/" + testDevice.getUuidId() + "/timeseries/" + AttributeScope.SERVER_SCOPE, JacksonUtil.toJsonNode("{\"a\":6}")); + + await().alias("update telemetry -> recalculate state").atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + ObjectNode result = getLatestTelemetry(testDevice.getId(), "a+1"); + assertThat(result).isNotNull(); + assertThat(result.get("a+1").get(0).get("value").asText()).isEqualTo("7"); + }); + } + private ObjectNode getLatestTelemetry(EntityId entityId, String... keys) throws Exception { return doGetAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/values/timeseries?keys=" + String.join(",", keys), ObjectNode.class); } diff --git a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java index 0872e21dae..c23c59d137 100644 --- a/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/EntityAggregationCalculatedFieldTest.java @@ -205,6 +205,15 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest CustomInterval customInterval = new CustomInterval(TZ, 0L, 2L); createConsumptionCF(device.getId(), customInterval, null); + long interval = customInterval.getCurrentIntervalDurationMillis(); + + // Wait for a fresh interval + long initialIntervalStart = customInterval.getCurrentIntervalStartTs(); + await().alias("wait for fresh interval") + .atMost(interval + 100, TimeUnit.MILLISECONDS) + .pollInterval(100, TimeUnit.MILLISECONDS) + .until(() -> customInterval.getCurrentIntervalStartTs() != initialIntervalStart); + long currentIntervalStartTs = customInterval.getCurrentIntervalStartTs(); long tsBeforeInterval = currentIntervalStartTs - 1000; @@ -216,8 +225,6 @@ public class EntityAggregationCalculatedFieldTest extends AbstractControllerTest postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":180}}", tsInInterval_2)); postTelemetry(device.getId(), String.format("{\"ts\": \"%s\", \"values\": {\"energy\":120}}", tsInInterval_3)); - long interval = customInterval.getCurrentIntervalDurationMillis(); - await().alias("create CF -> perform aggregation after interval end") .atMost(2 * interval, TimeUnit.MILLISECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) diff --git a/application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java new file mode 100644 index 0000000000..59314378aa --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AIModelApiClientTest.java @@ -0,0 +1,168 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AiModel; +import org.thingsboard.client.model.OpenAiChatModelConfig; +import org.thingsboard.client.model.OpenAiProviderConfig; +import org.thingsboard.client.model.PageDataAiModel; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AIModelApiClientTest extends AbstractApiClientTest { + + private static final String AI_PREFIX = "AiTest_"; + + @Test + public void testSaveAndGetAiModel() throws Exception { + long ts = System.currentTimeMillis(); + String name = AI_PREFIX + "save_" + ts; + + AiModel model = buildAiModel(name, "gpt-4o", 0.7); + AiModel saved = client.saveAiModel(model); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(name, saved.getName()); + assertNotNull(saved.getConfiguration()); + + // get by id + AiModel fetched = client.getAiModelById(saved.getId().getId()); + assertNotNull(fetched); + assertEquals(name, fetched.getName()); + assertEquals(saved.getId().getId(), fetched.getId().getId()); + } + + @Test + public void testGetAiModelById() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("getbyid_" + ts); + + AiModel fetched = client.getAiModelById(saved.getId().getId()); + assertNotNull(fetched); + assertEquals(saved.getName(), fetched.getName()); + assertEquals(saved.getId().getId(), fetched.getId().getId()); + } + + @Test + public void testUpdateAiModel() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("update_" + ts); + + saved.setName(AI_PREFIX + "updated_" + ts); + OpenAiChatModelConfig updatedConfig = new OpenAiChatModelConfig(); + updatedConfig.setModelId("gpt-4o-mini"); + updatedConfig.setTemperature(0.3); + updatedConfig.setMaxOutputTokens(2048); + updatedConfig.setMaxRetries(50); + OpenAiProviderConfig providerConfig = new OpenAiProviderConfig(); + providerConfig.setApiKey("test-api-key"); + providerConfig.setBaseUrl("https://api.openai.com/v1"); + updatedConfig.setProviderConfig(providerConfig); + updatedConfig.setProvider("OPENAI"); + saved.setConfiguration(updatedConfig); + + AiModel updated = client.saveAiModel(saved); + assertNotNull(updated); + assertEquals(saved.getId().getId(), updated.getId().getId()); + assertEquals(AI_PREFIX + "updated_" + ts, updated.getName()); + } + + @Test + public void testDeleteAiModel() throws Exception { + long ts = System.currentTimeMillis(); + AiModel saved = createAiModel("delete_" + ts); + + UUID modelId = saved.getId().getId(); + client.getAiModelById(modelId); + + Boolean deleted = client.deleteAiModelById(modelId); + assertTrue(deleted); + + assertReturns404(() -> client.getAiModelById(modelId)); + } + + @Test + public void testGetAiModels() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 3; i++) { + createAiModel("list_" + ts + "_" + i); + } + + PageDataAiModel page = client.getAiModels(100, 0, AI_PREFIX + "list_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (AiModel m : page.getData()) { + assertTrue(m.getName().startsWith(AI_PREFIX + "list_" + ts)); + } + } + + @Test + public void testGetAiModelById_notFound() { + UUID nonExistentId = UUID.randomUUID(); + assertReturns404(() -> client.getAiModelById(nonExistentId)); + } + + @Test + public void testGetAiModelsPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + createAiModel("paged_" + ts + "_" + i); + } + + PageDataAiModel page1 = client.getAiModels(2, 0, AI_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataAiModel lastPage = client.getAiModels(2, 2, AI_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + private AiModel buildAiModel(String name, String modelId, double temperature) { + OpenAiChatModelConfig config = new OpenAiChatModelConfig(); + config.setModelId(modelId); + config.setTemperature(temperature); + config.setMaxRetries(50); + OpenAiProviderConfig openAiProviderConfig = new OpenAiProviderConfig(); + openAiProviderConfig.setApiKey("test-api-key"); + openAiProviderConfig.setBaseUrl("https://api.openai.com/v1"); + config.setProviderConfig(openAiProviderConfig); + config.setProvider("OPENAI"); + + AiModel model = new AiModel(); + model.setName(name); + model.setConfiguration(config); + return model; + } + + private AiModel createAiModel(String suffix) throws Exception { + return client.saveAiModel(buildAiModel(AI_PREFIX + suffix, "gpt-4o", 0.7)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java new file mode 100644 index 0000000000..6bbda65a59 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AbstractApiClientTest.java @@ -0,0 +1,127 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.ThingsboardClient; +import org.thingsboard.client.model.ActivateUserRequest; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.JwtPair; +import org.thingsboard.client.model.User; +import org.thingsboard.client.model.UserId; +import org.thingsboard.server.common.data.util.ThrowingRunnable; +import org.thingsboard.server.controller.AbstractControllerTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; + +@Slf4j +public abstract class AbstractApiClientTest extends AbstractControllerTest { + + protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + protected static final ObjectMapper MAPPER = new ObjectMapper(); + + protected static final String TEST_PREFIX = "ApiClientTestDevice_"; + protected static final String TEST_PREFIX_2 = "ApiClientTestDevice2_"; + protected static final String CUSTOMER_USERNAME = "javaClientCustomer@thingsboard.org"; + protected static final String TENANT_ADMIN_USERNAME = "javaClientTenant@thingsboard.org"; + protected static final String TEST_PASSWORD = "password123"; + + protected ThingsboardClient client; + + // FQN for Tenant/Customer to avoid collision with AbstractWebTest fields + protected org.thingsboard.client.model.Tenant savedClientTenant; + protected User clientTenantAdmin; + protected org.thingsboard.client.model.Customer savedClientCustomer; + protected User savedClientCustomerUser; + + @Before + public void setUpJavaClient() throws Exception { + client = ThingsboardClient.builder() + .url("http://localhost:" + wsPort) + .build(); + client.login("sysadmin@thingsboard.org", "sysadmin"); + + org.thingsboard.client.model.Tenant tenant = new org.thingsboard.client.model.Tenant(); + tenant.setTitle("Java client test tenant"); + savedClientTenant = client.saveTenant(tenant); + + clientTenantAdmin = new User(); + clientTenantAdmin.setAuthority(Authority.TENANT_ADMIN); + clientTenantAdmin.setTenantId(savedClientTenant.getId()); + clientTenantAdmin.setEmail(TENANT_ADMIN_USERNAME); + clientTenantAdmin = client.saveUser(clientTenantAdmin, "false"); + activateUserAndAuthorize(clientTenantAdmin); + + org.thingsboard.client.model.Customer customer = new org.thingsboard.client.model.Customer(); + customer.setTitle("Java client test customer"); + customer.setTenantId(savedClientTenant.getId()); + savedClientCustomer = client.saveCustomer(customer, null, null, null); + + User customerUser = new User(); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedClientTenant.getId()); + customerUser.setCustomerId(savedClientCustomer.getId()); + customerUser.setEmail(CUSTOMER_USERNAME); + savedClientCustomerUser = client.saveUser(customerUser, "false"); + activateUser(savedClientCustomerUser.getId(), "password123", false); + } + + @After + public void tearDownJavaClient() { + client.login("sysadmin@thingsboard.org", "sysadmin"); + client.deleteTenant(savedClientTenant.getId().getId().toString()); + } + + protected String getBaseUrl() { + return "http://localhost:" + wsPort; + } + + protected void activateUserAndAuthorize(User user) throws ApiException { + JwtPair jwtPair = activateUser(user.getId(), TEST_PASSWORD, false); + client.setToken(jwtPair.getToken()); + } + + protected JwtPair activateUser(UserId userId, String password, boolean sendActivationMail) throws ApiException { + ActivateUserRequest activateRequest = new ActivateUserRequest(); + activateRequest.setActivateToken(getActivateToken(userId)); + activateRequest.setPassword(password); + return client.activateUser(activateRequest, sendActivationMail); + } + + protected String getActivateToken(UserId userId) throws ApiException { + String activateTokenRegex = "/api/noauth/activate?activateToken="; + String activationLink = client.getActivationLink(userId.getId().toString()); + return activationLink.substring(activationLink.lastIndexOf(activateTokenRegex) + activateTokenRegex.length()); + } + + protected void assertReturns404(ThrowingRunnable operation) { + try { + operation.run(); + fail("Expected ApiException with 404 status code"); + } catch (ApiException exception) { + assertEquals("Expected 404 status code but got " + exception.getCode(), + 404, exception.getCode()); + } catch (Exception e) { + fail("Expected ApiException but got " + e.getClass().getName() + ": " + e.getMessage()); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java new file mode 100644 index 0000000000..4d11bf3fbe --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AdminApiClientTest.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.client.model.AdminSettings; +import org.thingsboard.client.model.FeaturesInfo; +import org.thingsboard.client.model.JwtSettings; +import org.thingsboard.client.model.SecuritySettings; +import org.thingsboard.client.model.SystemInfo; +import org.thingsboard.client.model.UpdateMessage; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AdminApiClientTest extends AbstractApiClientTest { + + @Test + public void testAdminSettingsLifecycle() throws Exception { + // authenticate as sysadmin for admin settings management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // get mail settings + AdminSettings mailSettings = client.getAdminSettings("mail"); + assertNotNull(mailSettings); + assertNotNull(mailSettings.getKey()); + assertEquals("mail", mailSettings.getKey()); + assertNotNull(mailSettings.getJsonValue()); + + // get general settings + AdminSettings generalSettings = client.getAdminSettings("general"); + assertNotNull(generalSettings); + assertEquals("general", generalSettings.getKey()); + assertNotNull(generalSettings.getJsonValue()); + assertNotNull(generalSettings.getJsonValue().get("baseUrl").asText()); + + // update general settings and restore + ((ObjectNode) generalSettings.getJsonValue()).put("prohibitDifferentUrl", true); + AdminSettings updatedGeneralSettings = client.saveAdminSettings(generalSettings); + assertTrue(updatedGeneralSettings.getJsonValue().get("prohibitDifferentUrl").asBoolean()); + + // get security settings + SecuritySettings securitySettings = client.getSecuritySettings(); + assertNotNull(securitySettings); + assertNotNull(securitySettings.getPasswordPolicy()); + Integer originalMaxAttempts = securitySettings.getMaxFailedLoginAttempts(); + + // update security settings + securitySettings.setMaxFailedLoginAttempts(10); + SecuritySettings updatedSecurity = client.saveSecuritySettings(securitySettings); + assertNotNull(updatedSecurity); + assertEquals(10, updatedSecurity.getMaxFailedLoginAttempts().intValue()); + + // restore original security settings + updatedSecurity.setMaxFailedLoginAttempts(originalMaxAttempts); + client.saveSecuritySettings(updatedSecurity); + + // get JWT settings + JwtSettings jwtSettings = client.getJwtSettings(); + assertNotNull(jwtSettings); + assertNotNull(jwtSettings.getTokenExpirationTime()); + assertNotNull(jwtSettings.getRefreshTokenExpTime()); + assertEquals("thingsboard.io", jwtSettings.getTokenIssuer()); + assertNotNull(jwtSettings.getTokenSigningKey()); + + // get system info + SystemInfo systemInfo = client.getSystemInfo(); + assertNotNull(systemInfo); + + // get features info + FeaturesInfo featuresInfo = client.getFeaturesInfo(); + assertNotNull(featuresInfo); + assertFalse(featuresInfo.getSmsEnabled()); + assertFalse(featuresInfo.getOauthEnabled()); + + // check updates + UpdateMessage updateMessage = client.checkUpdates(); + assertNotNull(updateMessage); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java new file mode 100644 index 0000000000..0ebc247018 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AlarmApiClientTest.java @@ -0,0 +1,163 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Alarm; +import org.thingsboard.client.model.AlarmInfo; +import org.thingsboard.client.model.AlarmSeverity; +import org.thingsboard.client.model.AlarmStatus; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntitySubtype; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.PageDataAlarmInfo; +import org.thingsboard.client.model.PageDataEntitySubtype; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AlarmApiClientTest extends AbstractApiClientTest { + + @Test + public void testAlarmLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdAlarms = new ArrayList<>(); + + // First, create devices to attach alarms to + Device device1 = new Device(); + device1.setName("Device_For_Alarm_" + timestamp + "_1"); + device1.setType("default"); + Device createdDevice1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName("Device_For_Alarm_" + timestamp + "_2"); + device2.setType("thermostat"); + Device createdDevice2 = client.saveDevice(device2, null, null, null, null); + + // Create 2 alarms (1 for each device) + for (int i = 0; i < 2; i++) { + Alarm alarm = new Alarm(); + alarm.setType(((i % 2 == 0) ? "Temperature Alarm" : "Connection Alarm")); + alarm.setSeverity(((i % 2 == 0) ? AlarmSeverity.CRITICAL : AlarmSeverity.WARNING)); + alarm.setOriginator((i % 2 == 0) ? createdDevice1.getId() : createdDevice2.getId()); + + Alarm createdAlarm = client.saveAlarm(alarm); + assertNotNull(createdAlarm); + assertNotNull(createdAlarm.getId()); + assertEquals(alarm.getType(), createdAlarm.getType()); + assertEquals(alarm.getSeverity(), createdAlarm.getSeverity()); + + createdAlarms.add(createdAlarm); + } + + // Get all alarms + PageDataAlarmInfo allAlarms = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null); + + assertNotNull(allAlarms); + assertNotNull(allAlarms.getData()); + int initialSize = allAlarms.getData().size(); + assertEquals("Expected at least 2 alarms, but got " + initialSize, 2, initialSize); + + // Get alarms by entity (device1) + PageDataAlarmInfo device1Alarms = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null); + assertNotNull(device1Alarms); + assertEquals("Expected 1 alarms for device1", 1, device1Alarms.getData().size()); + + // Get alarm by id + Alarm searchAlarm = createdAlarms.get(0); + Alarm fetchedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(searchAlarm.getType(), fetchedAlarm.getType()); + assertEquals(searchAlarm.getSeverity(), fetchedAlarm.getSeverity()); + + // Get alarm info + AlarmInfo alarmInfo = client.getAlarmInfoById(searchAlarm.getId().getId().toString()); + assertNotNull(alarmInfo); + assertEquals(searchAlarm.getId().getId(), alarmInfo.getId().getId()); + + // Acknowledge alarm + client.ackAlarm(searchAlarm.getId().getId().toString()); + + // Verify alarm is acknowledged + Alarm ackedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(AlarmStatus.ACTIVE_ACK, ackedAlarm.getStatus()); + + // Clear alarm + client.clearAlarm(searchAlarm.getId().getId().toString()); + + // Verify alarm is cleared + Alarm clearedAlarm = client.getAlarmById(searchAlarm.getId().getId().toString()); + assertEquals(AlarmStatus.CLEARED_ACK, clearedAlarm.getStatus()); + + // Get highest severity alarm for device + AlarmSeverity highestSeverity = client.getHighestAlarmSeverity(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), null, null, null); + assertNotNull(highestSeverity); + assertEquals(AlarmSeverity.CRITICAL, highestSeverity); + + // Assign alarm to customer + client.assignAlarm(createdAlarms.get(0).getId().getId().toString(), clientTenantAdmin.getId().getId().toString()); + + // Verify assignment + Alarm assignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString()); + assertEquals(clientTenantAdmin.getId().getId(), assignedAlarm.getAssigneeId().getId()); + + // Unassign alarm + client.unassignAlarm(createdAlarms.get(0).getId().getId().toString()); + + // Verify unassignment + Alarm unassignedAlarm = client.getAlarmById(createdAlarms.get(0).getId().getId().toString()); + assertNull(unassignedAlarm.getAssigneeId()); + + // Get alarm types + PageDataEntitySubtype pageDataEntitySubtype = client.getAlarmTypes(100, 0, null, null); + assertEquals(2, pageDataEntitySubtype.getData().size()); + List alarmTypes = pageDataEntitySubtype.getData().stream() + .map(EntitySubtype::getType) + .collect(Collectors.toList()); + assertTrue(alarmTypes.containsAll(List.of("Temperature Alarm", "Connection Alarm"))); + + // Get alarms V2 (alternative endpoint) + PageDataAlarmInfo alarmsV2 = client.getAlarmsV2(EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), 100, 0, null, null, null, null, null, null, null, null, null); + assertNotNull(alarmsV2); + assertEquals(1, alarmsV2.getData().size()); + + // Get all alarms V2 + PageDataAlarmInfo allAlarmsV2 = client.getAllAlarmsV2(100, 0, null, null, null, null, null, null, null, null, null); + assertEquals(2, allAlarmsV2.getData().size()); + + // Delete alarm + UUID alarmToDeleteId = createdAlarms.get(0).getId().getId(); + client.deleteAlarm(alarmToDeleteId.toString()); + + // Verify the alarm is deleted (should return 404) + assertReturns404(() -> + client.getAlarmById(alarmToDeleteId.toString()) + ); + + // Verify count after deletion + PageDataAlarmInfo alarmsAfterDelete = client.getAllAlarms(100, 0, null, null, null, null, null, null, null, null, null); + assertEquals(initialSize - 1, alarmsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java new file mode 100644 index 0000000000..31da96b7b4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AlarmCommentApiClientTest.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.junit.Test; +import org.thingsboard.client.model.Alarm; +import org.thingsboard.client.model.AlarmComment; +import org.thingsboard.client.model.AlarmCommentInfo; +import org.thingsboard.client.model.AlarmSeverity; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.PageDataAlarmCommentInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class AlarmCommentApiClientTest extends AbstractApiClientTest { + + @Test + public void testAlarmComments() throws Exception { + long timestamp = System.currentTimeMillis(); + + // Create device for alarm + Device device = new Device(); + device.setName("Device_For_Comments_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + // Create alarm + Alarm alarm = new Alarm(); + alarm.setType("Temperature Alarm"); + alarm.setSeverity(AlarmSeverity.CRITICAL); + alarm.setOriginator(createdDevice.getId()); + + Alarm createdAlarm = client.saveAlarm(alarm); + String alarmId = createdAlarm.getId().getId().toString(); + + List createdComments = new ArrayList<>(); + + // Create multiple comments + for (int i = 0; i < 5; i++) { + AlarmComment alarmComment = new AlarmComment(); + String message = "Test comment #" + i + " at " + timestamp; + ObjectNode comment = OBJECT_MAPPER.createObjectNode().put("message", message); + alarmComment.setComment(comment); + + AlarmComment commentInfo = client.saveAlarmComment(alarmId, alarmComment); + + assertNotNull(commentInfo); + assertNotNull(commentInfo.getId()); + JsonNode commentValue = commentInfo.getComment(); + assertEquals(message, commentValue.get("message").asText()); + assertNotNull(commentInfo.getCreatedTime()); + + createdComments.add(commentInfo); + } + + // Get all comments for the alarm + PageDataAlarmCommentInfo allComments = client.getAlarmComments(alarmId, 100, 0, null, null); + assertEquals("Expected 5 comments", 5, allComments.getData().size()); + + // Update a comment + AlarmComment commentToUpdate = createdComments.get(2); + JsonNode comment = commentToUpdate.getComment(); + ((ObjectNode) comment).put("message", "New comment"); + commentToUpdate.setComment(comment); + + AlarmComment updatedComment = client.saveAlarmComment(alarmId, commentToUpdate); + assertEquals("New comment", updatedComment.getComment().get("message").asText()); + + // Delete a comment + UUID commentToDeleteId = createdComments.get(0).getId().getId(); + + client.deleteAlarmComment(alarmId, commentToDeleteId.toString()); + + // Verify comment was updated to "deleted" + PageDataAlarmCommentInfo commentsAfterDelete = client.getAlarmComments(alarmId, 100, 0, null, null); + List data = commentsAfterDelete.getData(); + AlarmCommentInfo deletedComment = data.stream() + .filter(alarmCommentInfo -> alarmCommentInfo.getId().getId().equals(commentToDeleteId)) + .findFirst() + .get(); + assertEquals("User " + clientTenantAdmin.getEmail() + " deleted his comment", deletedComment.getComment().get("text").asText()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java new file mode 100644 index 0000000000..4b774a442a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/ApiKeyApiClientTest.java @@ -0,0 +1,117 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.ApiKey; +import org.thingsboard.client.model.ApiKeyInfo; +import org.thingsboard.client.model.PageDataApiKeyInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class ApiKeyApiClientTest extends AbstractApiClientTest { + + @Test + public void testApiKeyLifecycle() throws Exception { + String userId = clientTenantAdmin.getId().getId().toString(); + + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Test API key"); + request.setUserId(clientTenantAdmin.getId()); + request.setEnabled(true); + ApiKey created = client.saveApiKey(request); + + assertNotNull(created); + assertNotNull(created.getId()); + assertNotNull(created.getValue()); + assertFalse(created.getValue().isBlank()); + assertEquals("Test API key", created.getDescription()); + + UUID keyId = created.getId().getId(); + + PageDataApiKeyInfo keysPage = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertNotNull(keysPage); + assertNotNull(keysPage.getData()); + assertTrue("Newly created API key should appear in user's key list", + keysPage.getData().stream() + .anyMatch(k -> k.getId().getId().equals(keyId))); + + client.deleteApiKey(keyId); + + PageDataApiKeyInfo keysAfterDelete = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertTrue("Deleted API key should not appear in user's key list", + keysAfterDelete.getData().stream() + .noneMatch(k -> k.getId().getId().equals(keyId))); + } + + @Test + public void testEnableDisableApiKey() throws Exception { + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Enable/disable test key"); + request.setUserId(clientTenantAdmin.getId()); + request.setEnabled(true); + ApiKey created = client.saveApiKey(request); + assertNotNull(created); + + UUID keyId = created.getId().getId(); + + ApiKeyInfo disabled = client.enableApiKey(keyId, false); + assertNotNull(disabled); + assertEquals(Boolean.FALSE, disabled.getEnabled()); + + ApiKeyInfo enabled = client.enableApiKey(keyId, true); + assertNotNull(enabled); + assertEquals(Boolean.TRUE, enabled.getEnabled()); + + client.deleteApiKey(keyId); + } + + @Test + public void testGetUserApiKeys() throws Exception { + String userId = clientTenantAdmin.getId().getId().toString(); + + int initialCount = client.getUserApiKeys(userId, 100, 0, null, null, null) + .getData().size(); + + UUID[] createdIds = new UUID[3]; + for (int i = 0; i < 3; i++) { + ApiKeyInfo request = new ApiKeyInfo(); + request.setDescription("Paging test key " + i); + request.setUserId(clientTenantAdmin.getId()); + createdIds[i] = client.saveApiKey(request).getId().getId(); + } + + PageDataApiKeyInfo afterCreate = client.getUserApiKeys(userId, 100, 0, null, null, null); + assertEquals(initialCount + 3, afterCreate.getData().size()); + assertEquals(Long.valueOf(initialCount + 3), afterCreate.getTotalElements()); + + PageDataApiKeyInfo page1 = client.getUserApiKeys(userId, 2, 0, null, null, null); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + for (UUID id : createdIds) { + client.deleteApiKey(id); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java new file mode 100644 index 0000000000..070470ac2a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AssetApiClientTest.java @@ -0,0 +1,84 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.PageDataAsset; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class AssetApiClientTest extends AbstractApiClientTest { + + @Test + public void testAssetLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdAssets = new ArrayList<>(); + + // create 20 assets + for (int i = 0; i < 20; i++) { + Asset asset = new Asset(); + String assetName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + asset.setName(assetName); + asset.setLabel("Test Asset " + i); + asset.setType(((i % 2 == 0) ? "default" : "building")); + + Asset createdAsset = client.saveAsset(asset, null, null, null); + assertNotNull(createdAsset); + assertNotNull(createdAsset.getId()); + assertEquals(assetName, createdAsset.getName()); + + createdAssets.add(createdAsset); + } + + // find all, check count + PageDataAsset allAssets = client.getTenantAssets(100, 0, null, null, null, null); + + assertNotNull(allAssets); + assertNotNull(allAssets.getData()); + int initialSize = allAssets.getData().size(); + assertEquals("Expected at least 20 assets, but got " + allAssets.getData().size(), 20, initialSize); + + //find all with search text, check count + PageDataAsset allAssetsBySearchText = client.getTenantAssets(100, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 test assets", 10, allAssetsBySearchText.getData().size()); + + // find by id + Asset searchAsset = createdAssets.get(10); + Asset asset = client.getAssetById(searchAsset.getId().getId().toString()); + assertEquals(searchAsset.getName(), asset.getName()); + + // delete asset + UUID assetToDeleteId = createdAssets.get(0).getId().getId(); + client.deleteAsset(assetToDeleteId.toString()); + + // Verify the asset is deleted + PageDataAsset assetsAfterDelete = client.getTenantAssets(100, 0, null, null, null, null); + assertEquals(initialSize - 1, assetsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getAssetById(assetToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java new file mode 100644 index 0000000000..7f6a2f667b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/AssetProfileApiClientTest.java @@ -0,0 +1,135 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AssetProfile; +import org.thingsboard.client.model.AssetProfileInfo; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataAssetProfile; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class AssetProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testAssetProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // Get initial count (there should be a default profile) + PageDataAssetProfile initialProfiles = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default asset profile", initialSize == 1); + + // Get default asset profile info + AssetProfileInfo defaultProfileInfo = client.getDefaultAssetProfileInfo(); + assertNotNull(defaultProfileInfo); + assertEquals(defaultProfileInfo.getName(), "default"); + + // Create multiple asset profiles + for (int i = 0; i < 5; i++) { + AssetProfile profile = new AssetProfile(); + profile.setName("Test Asset Profile " + timestamp + "_" + i); + profile.setDescription("Test description " + i); + + AssetProfile created = client.saveAssetProfile(profile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(profile.getName(), created.getName()); + assertEquals(profile.getDescription(), created.getDescription()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // Find all, check count + PageDataAssetProfile allProfiles = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // Find all with text search + PageDataAssetProfile filteredProfiles = client.getAssetProfiles(100, 0, "Test Asset Profile " + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // Get by id + AssetProfile searchProfile = createdProfiles.get(2); + AssetProfile fetchedProfile = client.getAssetProfileById(searchProfile.getId().getId().toString(), false); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // Update asset profile + fetchedProfile.setDescription("Updated description"); + AssetProfile updatedProfile = client.saveAssetProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // Get asset profile info by id + AssetProfileInfo profileInfo = client.getAssetProfileInfoById(searchProfile.getId().getId().toString()); + assertNotNull(profileInfo); + assertEquals(searchProfile.getName(), profileInfo.getName()); + + // Get asset profile infos (paginated) + PageDataAssetProfile profileInfos = client.getAssetProfiles(100, 0, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // Set a profile as default + AssetProfile profileToSetDefault = createdProfiles.get(1); + AssetProfile newDefault = client.setDefaultAssetProfile(profileToSetDefault.getId().getId().toString()); + assertNotNull(newDefault); + assertTrue(newDefault.getDefault()); + + // Verify default profile info now points to the new default + AssetProfileInfo newDefaultInfo = client.getDefaultAssetProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // Get asset profile names + List profileNames = client.getAssetProfileNames(false); + assertNotNull(profileNames); + assertEquals(createdProfiles.size() + 1, profileNames.size()); + + // Delete asset profile (cannot delete the default one, so delete a non-default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteAssetProfile(profileToDeleteId.toString()); + + // Verify the profile is deleted + assertReturns404(() -> + client.getAssetProfileById(profileToDeleteId.toString(), false)); + + // Verify count after deletion + PageDataAssetProfile profilesAfterDelete = client.getAssetProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + + // Restore original default profile + AssetProfile originalDefault = initialProfiles.getData().stream() + .filter(AssetProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultAssetProfile(originalDefault.getId().getId().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java new file mode 100644 index 0000000000..7594485c14 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/CalculatedFieldApiClientTest.java @@ -0,0 +1,286 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AlarmCalculatedFieldConfiguration; +import org.thingsboard.client.model.AlarmConditionValueAlarmSchedule; +import org.thingsboard.client.model.AlarmRule; +import org.thingsboard.client.model.AlarmSeverity; +import org.thingsboard.client.model.Argument; +import org.thingsboard.client.model.ArgumentType; +import org.thingsboard.client.model.CalculatedField; +import org.thingsboard.client.model.CalculatedFieldType; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.PageDataCalculatedField; +import org.thingsboard.client.model.ReferencedEntityKey; +import org.thingsboard.client.model.SimpleAlarmCondition; +import org.thingsboard.client.model.SimpleCalculatedFieldConfiguration; +import org.thingsboard.client.model.SpecificTimeSchedule; +import org.thingsboard.client.model.TbelAlarmConditionExpression; +import org.thingsboard.client.model.TimeSeriesOutput; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class CalculatedFieldApiClientTest extends AbstractApiClientTest { + + @Test + public void testCalculatedFieldLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdFields = new ArrayList<>(); + + // create devices to attach calculated fields to + Device device1 = new Device(); + device1.setName("CalcFieldDevice1_" + timestamp); + device1.setType("default"); + Device createdDevice1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName("CalcFieldDevice2_" + timestamp); + device2.setType("default"); + Device createdDevice2 = client.saveDevice(device2, null, null, null, null); + + // create calculated fields on device1 + for (int i = 0; i < 5; i++) { + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "CalcField_" + timestamp + "_" + i); + cf.setType(CalculatedFieldType.SIMPLE); + + cf.setEntityId(createdDevice1.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument arg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("temperature"); + refKey.setType(ArgumentType.TS_LATEST); + arg.setRefEntityKey(refKey); + config.putArgumentsItem("temp", arg); + + config.setExpression("temp * " + (i + 1)); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("scaledTemp_" + i); + config.setOutput(output); + + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(cf.getName(), created.getName()); + assertEquals(CalculatedFieldType.SIMPLE, created.getType()); + + createdFields.add(created); + } + + // create calculated fields on device2 + for (int i = 0; i < 3; i++) { + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "CalcField2_" + timestamp + "_" + i); + cf.setType(CalculatedFieldType.SIMPLE); + cf.setEntityId(createdDevice2.getId()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + + Argument arg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("humidity"); + refKey.setType(ArgumentType.TS_LATEST); + arg.setRefEntityKey(refKey); + config.putArgumentsItem("hum", arg); + + config.setExpression("hum + " + i); + + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("adjustedHumidity_" + i); + config.setOutput(output); + + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + createdFields.add(created); + } + + // get calculated fields by entity id for device1 + PageDataCalculatedField device1Fields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), + 100, 0, CalculatedFieldType.SIMPLE, null, null, null); + assertNotNull(device1Fields); + assertEquals(5, device1Fields.getData().size()); + + // get calculated fields by entity id for device2 + PageDataCalculatedField device2Fields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice2.getId().getId().toString(), + 100, 0, CalculatedFieldType.SIMPLE, null, null, null); + assertEquals(3, device2Fields.getData().size()); + + // get by id + CalculatedField searchField = createdFields.get(2); + CalculatedField fetchedField = client.getCalculatedFieldById(searchField.getId().getId().toString()); + assertEquals(searchField.getName(), fetchedField.getName()); + assertEquals(searchField.getType(), fetchedField.getType()); + assertNotNull(fetchedField.getConfiguration()); + SimpleCalculatedFieldConfiguration fetchedConfig = + (SimpleCalculatedFieldConfiguration) fetchedField.getConfiguration(); + assertEquals("temp * 3", fetchedConfig.getExpression()); + + // update calculated field + fetchedField.setName(fetchedField.getName() + "_updated"); + fetchedConfig.setExpression("temp * 100"); + CalculatedField updatedField = client.saveCalculatedField(fetchedField); + assertEquals(fetchedField.getName(), updatedField.getName()); + SimpleCalculatedFieldConfiguration updatedConfig = + (SimpleCalculatedFieldConfiguration) updatedField.getConfiguration(); + assertEquals("temp * 100", updatedConfig.getExpression()); + + // delete calculated field + UUID fieldToDeleteId = createdFields.get(0).getId().getId(); + client.deleteCalculatedField(fieldToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getCalculatedFieldById(fieldToDeleteId.toString()) + ); + + PageDataCalculatedField device1FieldsAfterDelete = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice1.getId().getId().toString(), + 100, 0, null, null, null, null); + assertEquals(4, device1FieldsAfterDelete.getData().size()); + } + + @Test + public void testAlarmCalculatedFieldLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create a device to attach the alarm calculated field to + Device device = new Device(); + device.setName("AlarmCalcFieldDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + // build the alarm calculated field configuration + AlarmCalculatedFieldConfiguration config = new AlarmCalculatedFieldConfiguration(); + + // argument: temperature time-series + Argument tempArg = new Argument(); + ReferencedEntityKey refKey = new ReferencedEntityKey(); + refKey.setKey("temperature"); + refKey.setType(ArgumentType.TS_LATEST); + tempArg.setRefEntityKey(refKey); + config.putArgumentsItem("temp", tempArg); + + // create rule: HIGH_TEMPERATURE when temp > 50 (TBEL expression) + TbelAlarmConditionExpression createExpression = new TbelAlarmConditionExpression(); + createExpression.setExpression("return temp > 50;"); + SimpleAlarmCondition createCondition = new SimpleAlarmCondition(); + createCondition.setExpression(createExpression); + SpecificTimeSchedule specificTimeSchedule = new SpecificTimeSchedule().addDaysOfWeekItem(3); + AlarmConditionValueAlarmSchedule schedule = new AlarmConditionValueAlarmSchedule().staticValue(specificTimeSchedule); + createCondition.setSchedule(schedule); + AlarmRule createRule = new AlarmRule(); + createRule.setCondition(createCondition); + createRule.setAlarmDetails("Temperature is too high: ${temp}"); + config.setCreateRules(Map.of( + AlarmSeverity.CRITICAL.name(), createRule + )); + + // clear rule: when temp drops below 30 + TbelAlarmConditionExpression clearExpression = new TbelAlarmConditionExpression(); + clearExpression.setExpression("return temp < 30;"); + SimpleAlarmCondition clearCondition = new SimpleAlarmCondition(); + clearCondition.setExpression(clearExpression); + AlarmRule clearRule = new AlarmRule(); + clearRule.setCondition(clearCondition); + config.setClearRule(clearRule); + + config.setPropagate(true); + config.setPropagateToOwner(false); + + // create calculated field + CalculatedField cf = new CalculatedField(); + cf.setName(TEST_PREFIX + "AlarmCalcField_" + timestamp); + cf.setType(CalculatedFieldType.ALARM); + + cf.setEntityId(createdDevice.getId()); + cf.setConfiguration(config); + + CalculatedField created = client.saveCalculatedField(cf); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(cf.getName(), created.getName()); + assertEquals(CalculatedFieldType.ALARM, created.getType()); + AlarmCalculatedFieldConfiguration configuration = (AlarmCalculatedFieldConfiguration) created.getConfiguration(); + AlarmConditionValueAlarmSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule(); + SpecificTimeSchedule staticSchedule = (SpecificTimeSchedule) createdSchedule.getStaticValue(); + assertEquals(Set.of(3), staticSchedule.getDaysOfWeek()); + + // get by id and verify configuration + CalculatedField fetched = client.getCalculatedFieldById(created.getId().getId().toString()); + assertNotNull(fetched); + assertEquals(created.getName(), fetched.getName()); + assertEquals(CalculatedFieldType.ALARM, fetched.getType()); + assertNotNull(fetched.getConfiguration()); + AlarmCalculatedFieldConfiguration fetchedConfig = + (AlarmCalculatedFieldConfiguration) fetched.getConfiguration(); + assertNotNull(fetchedConfig.getCreateRules()); + assertEquals(1, fetchedConfig.getCreateRules().size()); + assertTrue(fetchedConfig.getCreateRules().containsKey("CRITICAL")); + assertNotNull(fetchedConfig.getClearRule()); + assertEquals(Boolean.TRUE, fetchedConfig.getPropagate()); + + // update: add a second create rule for CRITICAL_TEMPERATURE + TbelAlarmConditionExpression criticalExpression = new TbelAlarmConditionExpression(); + criticalExpression.setExpression("return temp > 80;"); + SimpleAlarmCondition criticalCondition = new SimpleAlarmCondition(); + criticalCondition.setExpression(criticalExpression); + AlarmRule criticalRule = new AlarmRule(); + criticalRule.setCondition(criticalCondition); + fetchedConfig.putCreateRulesItem(AlarmSeverity.INDETERMINATE.name(), criticalRule); + fetched.setConfiguration(fetchedConfig); + + CalculatedField updated = client.saveCalculatedField(fetched); + AlarmCalculatedFieldConfiguration updatedConfig = + (AlarmCalculatedFieldConfiguration) updated.getConfiguration(); + assertEquals(2, updatedConfig.getCreateRules().size()); + assertTrue(updatedConfig.getCreateRules().containsKey("INDETERMINATE")); + + // filter by entity and ALARM type + PageDataCalculatedField deviceFields = client.getCalculatedFieldsByEntityId( + EntityType.DEVICE.toString(), createdDevice.getId().getId().toString(), + 100, 0, CalculatedFieldType.ALARM, null, null, null); + assertNotNull(deviceFields); + assertEquals(1, deviceFields.getData().size()); + + // delete and verify + UUID fieldId = created.getId().getId(); + client.deleteCalculatedField(fieldId.toString()); + assertReturns404(() -> client.getCalculatedFieldById(fieldId.toString())); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java new file mode 100644 index 0000000000..fff3670c85 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/CustomerApiClientTest.java @@ -0,0 +1,113 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Customer; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.PageDataCustomer; +import org.thingsboard.client.model.PageDataDevice; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class CustomerApiClientTest extends AbstractApiClientTest { + + @Test + public void testCustomerLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdCustomers = new ArrayList<>(); + + // create 20 customers + for (int i = 0; i < 20; i++) { + Customer customer = new Customer(); + String customerTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + customer.setTitle(customerTitle); + customer.setEmail("customer_" + timestamp + "_" + i + "@test.com"); + + Customer createdCustomer = client.saveCustomer(customer, null, null, null); + assertNotNull(createdCustomer); + assertNotNull(createdCustomer.getId()); + assertEquals(customerTitle, createdCustomer.getTitle()); + + createdCustomers.add(createdCustomer); + } + + // find all, check count (includes savedClientCustomer from AbstractApiClientTest setup) + PageDataCustomer allCustomers = client.getCustomers(100, 0, null, null, null); + assertNotNull(allCustomers); + assertNotNull(allCustomers.getData()); + int initialSize = allCustomers.getData().size(); + assertEquals("Expected 21 customers (20 created + 1 from setup), but got " + initialSize, 21, initialSize); + + // find all with search text, check count + PageDataCustomer filteredCustomers = client.getCustomers(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 customers matching prefix", 10, filteredCustomers.getData().size()); + + // find by id + Customer searchCustomer = createdCustomers.get(10); + Customer fetchedCustomer = client.getCustomerById(searchCustomer.getId().getId().toString()); + assertEquals(searchCustomer.getTitle(), fetchedCustomer.getTitle()); + + // find by title + Customer fetchedByTitle = client.getTenantCustomer(searchCustomer.getTitle()); + assertEquals(searchCustomer.getId().getId(), fetchedByTitle.getId().getId()); + + // update customer + fetchedCustomer.setCity("New York"); + fetchedCustomer.setCountry("US"); + Customer updatedCustomer = client.saveCustomer(fetchedCustomer, null, null, null); + assertEquals("New York", updatedCustomer.getCity()); + assertEquals("US", updatedCustomer.getCountry()); + + // assign device to customer and verify + Device device = new Device(); + device.setName("CustomerTestDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + + String customerId = createdCustomers.get(0).getId().getId().toString(); + client.assignDeviceToCustomer(customerId, createdDevice.getId().getId().toString()); + + PageDataDevice customerDevices = client.getCustomerDevices(customerId, 100, 0, null, null, null, null); + assertEquals(1, customerDevices.getData().size()); + assertEquals(createdDevice.getName(), customerDevices.getData().get(0).getName()); + + // unassign device from customer + client.unassignDeviceFromCustomer(createdDevice.getId().getId().toString()); + PageDataDevice devicesAfterUnassign = client.getCustomerDevices(customerId, 100, 0, null, null, null, null); + assertEquals(0, devicesAfterUnassign.getData().size()); + + // delete customer + UUID customerToDeleteId = createdCustomers.get(0).getId().getId(); + client.deleteCustomer(customerToDeleteId.toString()); + + // verify deletion + PageDataCustomer customersAfterDelete = client.getCustomers(100, 0, null, null, null); + assertEquals(initialSize - 1, customersAfterDelete.getData().size()); + + assertReturns404(() -> + client.getCustomerById(customerToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java new file mode 100644 index 0000000000..386e2db84f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DashboardApiClientTest.java @@ -0,0 +1,123 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Dashboard; +import org.thingsboard.client.model.DashboardInfo; +import org.thingsboard.client.model.PageDataDashboardInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class DashboardApiClientTest extends AbstractApiClientTest { + + @Test + public void testDashboardLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create 20 dashboards + for (int i = 0; i < 20; i++) { + Dashboard dashboard = new Dashboard(); + String dashboardTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + dashboard.setTitle(dashboardTitle); + + client.saveDashboard(dashboard, null); + } + + // find all, check count + PageDataDashboardInfo allDashboards = client.getTenantDashboards(100, 0, null, null, null, null); + assertNotNull(allDashboards); + assertNotNull(allDashboards.getData()); + int initialSize = allDashboards.getData().size(); + assertEquals("Expected 20 dashboards, but got " + initialSize, 20, initialSize); + + List createdDashboards = allDashboards.getData(); + + // find all with search text, check count + PageDataDashboardInfo filteredDashboards = client.getTenantDashboards(100, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 dashboards matching prefix", 10, filteredDashboards.getData().size()); + + // find by id + DashboardInfo searchDashboard = createdDashboards.get(10); + DashboardInfo fetchedDashboard = client.getDashboardInfoById(searchDashboard.getId().getId().toString()); + assertEquals(searchDashboard.getTitle(), fetchedDashboard.getTitle()); + + // update dashboard + Dashboard dashboardToUpdate = new Dashboard(); + dashboardToUpdate.setId(fetchedDashboard.getId()); + dashboardToUpdate.setTitle(fetchedDashboard.getTitle() + "_updated"); + dashboardToUpdate.setVersion(fetchedDashboard.getVersion()); + client.saveDashboard(dashboardToUpdate, null); + + DashboardInfo updatedDashboard = client.getDashboardInfoById(fetchedDashboard.getId().getId().toString()); + assertEquals(fetchedDashboard.getTitle() + "_updated", updatedDashboard.getTitle()); + + // assign dashboard to customer and verify + String customerId = savedClientCustomer.getId().getId().toString(); + String dashboardId = createdDashboards.get(0).getId().getId().toString(); + client.assignDashboardToCustomer(customerId, dashboardId); + + PageDataDashboardInfo customerDashboards = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null); + assertEquals(1, customerDashboards.getData().size()); + assertEquals(createdDashboards.get(0).getTitle(), customerDashboards.getData().get(0).getTitle()); + + // unassign dashboard from customer + client.unassignDashboardFromCustomer(customerId, dashboardId); + PageDataDashboardInfo dashboardsAfterUnassign = client.getCustomerDashboards(customerId, 100, 0, null, null, null, null); + assertEquals(0, dashboardsAfterUnassign.getData().size()); + + // make dashboard public and verify + client.assignDashboardToPublicCustomer(dashboardId); + DashboardInfo publicDashboard = client.getDashboardInfoById(dashboardId); + assertNotNull(publicDashboard.getAssignedCustomers()); + assertTrue(publicDashboard.getAssignedCustomers().size() > 0); + + // remove public access + client.unassignDashboardFromPublicCustomer(dashboardId); + + // delete dashboard + UUID dashboardToDeleteId = createdDashboards.get(0).getId().getId(); + client.deleteDashboard(dashboardToDeleteId.toString()); + + // verify deletion + PageDataDashboardInfo dashboardsAfterDelete = client.getTenantDashboards(100, 0, null, null, null, null); + assertEquals(initialSize - 1, dashboardsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getDashboardInfoById(dashboardToDeleteId.toString()) + ); + } + + @Test + public void testGetServerTime() throws Exception { + Long serverTime = client.getServerTime(); + assertNotNull(serverTime); + } + + @Test + public void testGetMaxDatapointsLimit() throws Exception { + Long maxDatapointsLimit = client.getMaxDatapointsLimit(); + assertNotNull(maxDatapointsLimit); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java new file mode 100644 index 0000000000..f3695b9fc1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceApiClientTest.java @@ -0,0 +1,114 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.DeviceCredentials; +import org.thingsboard.client.model.DeviceCredentialsType; +import org.thingsboard.client.model.PageDataDevice; +import org.thingsboard.client.model.SaveDeviceWithCredentialsRequest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class DeviceApiClientTest extends AbstractApiClientTest { + + @Test + public void testDeviceLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdDevices = new ArrayList<>(); + + // create 20 devices + for (int i = 0; i < 20; i++) { + Device device = new Device(); + String deviceName = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + device.setName(deviceName); + device.setLabel("Test Device " + i); + device.setType(((i % 2 == 0) ? "default" : "thermostat")); + + Device createdDevice = client.saveDevice(device, null, null, null, null); + assertNotNull(createdDevice); + assertNotNull(createdDevice.getId()); + assertEquals(deviceName, createdDevice.getName()); + + createdDevices.add(createdDevice); + } + + // find all, check count + PageDataDevice allDevices = client.getTenantDevices(100, 0, null, null, null, null); + + assertNotNull(allDevices); + assertNotNull(allDevices.getData()); + int initialSize = allDevices.getData().size(); + assertEquals("Expected at least 20 devices, but got " + allDevices.getData().size(), 20, initialSize); + + //find all with search text, check count + PageDataDevice allDevicesBySearchText = client.getTenantDevices(10, 0, null, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 test devices", 10, allDevicesBySearchText.getData().size()); + + // find by id + Device searchDevice = createdDevices.get(10); + Device device = client.getDeviceById(searchDevice.getId().getId().toString()); + assertEquals(searchDevice.getName(), device.getName()); + + // create device with credentials + Device deviceWithCreds = new Device(); + deviceWithCreds.setName("device-with-creds"); + + DeviceCredentials creds = new DeviceCredentials(); + creds.setCredentialsType(DeviceCredentialsType.ACCESS_TOKEN); + creds.setCredentialsId("TEST_ACCESS_TOKEN"); + + SaveDeviceWithCredentialsRequest request = new SaveDeviceWithCredentialsRequest(); + request.setDevice(deviceWithCreds); + request.setCredentials(creds); + + Device savedDeviceWithCreds = client.saveDeviceWithCredentials(request, null, null, null); + assertEquals("device-with-creds", savedDeviceWithCreds.getName()); + + // find credentials by device id + DeviceCredentials fetchedCreds = client.getDeviceCredentialsByDeviceId(savedDeviceWithCreds.getId().getId().toString()); + assertEquals(creds.getCredentialsId(), fetchedCreds.getCredentialsId()); + + // delete device + UUID deviceToDeleteId = createdDevices.get(0).getId().getId(); + client.deleteDevice(deviceToDeleteId.toString()); + + // Verify the device is deleted + PageDataDevice devicesAfterDelete = client.getTenantDevices(100, 0, null, null, null, null); + assertEquals(initialSize, devicesAfterDelete.getData().size()); + + assertReturns404(() -> + client.getDeviceById(deviceToDeleteId.toString())); + + // assign device to customer + client.assignDeviceToCustomer(savedClientCustomer.getId().getId().toString(), savedDeviceWithCreds.getId().getId().toString()); + + // check customer devices + PageDataDevice pageDataDevice = client.getCustomerDevices(savedClientCustomer.getId().getId().toString(), 100, 0, null, null, null, null); + List data = pageDataDevice.getData(); + assertEquals(1, data.size()); + assertEquals(savedDeviceWithCreds.getName(), data.get(0).getName()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java new file mode 100644 index 0000000000..d8204cab41 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceConnectivityApiClientTest.java @@ -0,0 +1,53 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.thingsboard.client.model.Device; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.UUID; + +import static org.junit.Assert.assertEquals; + +@DaoSqlTest +public class DeviceConnectivityApiClientTest extends AbstractApiClientTest { + + @Test + public void testGetDevicePublishTelemetryCommands() throws Exception { + Device device = new Device(); + device.setName(TEST_PREFIX + System.currentTimeMillis()); + device.setType("default"); + + Device savedDevice = client.saveDevice(device, null, null, null, null); + String token = client.getDeviceCredentialsByDeviceId(savedDevice.getId().getId().toString()).getCredentialsId(); + + String deviceId = savedDevice.getId().getId().toString(); + + JsonNode commands = client.getDevicePublishTelemetryCommands(deviceId); + assertEquals("curl -v -X POST http://localhost:8080/api/v1/" + token + "/telemetry --header Content-Type:application/json --data \"{temperature:25}\"", commands.get("http").get("http").asText()); + assertEquals("mosquitto_pub -d -q 1 -h localhost -p 1883 -t v1/devices/me/telemetry -u \"" + token + "\" -m \"{temperature:25}\"", commands.get("mqtt").get("mqtt").asText()); + assertEquals("coap-client -v 6 -m POST -t \"application/json\" -e \"{temperature:25}\" coap://localhost:5683/api/v1/" + token + "/telemetry", commands.get("coap").get("coap").asText()); + } + + @Test + public void testGetDevicePublishTelemetryCommands_nonExistentDevice() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getDevicePublishTelemetryCommands(nonExistentId)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java new file mode 100644 index 0000000000..e8fcb4ffd1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DeviceProfileApiClientTest.java @@ -0,0 +1,157 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.DefaultDeviceProfileConfiguration; +import org.thingsboard.client.model.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.client.model.DeviceProfile; +import org.thingsboard.client.model.DeviceProfileData; +import org.thingsboard.client.model.DeviceProfileInfo; +import org.thingsboard.client.model.DeviceProfileType; +import org.thingsboard.client.model.DeviceTransportType; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataDeviceProfile; +import org.thingsboard.client.model.PageDataDeviceProfileInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class DeviceProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testDeviceProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // Get initial count (there should be a default profile) + PageDataDeviceProfile initialProfiles = client.getDeviceProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default device profile", initialSize >= 1); + + // Get default device profile info + DeviceProfileInfo defaultProfileInfo = client.getDefaultDeviceProfileInfo(); + assertNotNull(defaultProfileInfo); + assertNotNull(defaultProfileInfo.getName()); + + // Create multiple device profiles + for (int i = 0; i < 5; i++) { + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setName("Test Device Profile " + timestamp + "_" + i); + deviceProfile.setDescription("Test description " + i); + deviceProfile.setType(DeviceProfileType.DEFAULT); + deviceProfile.setTransportType(DeviceTransportType.DEFAULT); + + DeviceProfileData deviceProfileData = new DeviceProfileData(); + DefaultDeviceProfileConfiguration configuration = new DefaultDeviceProfileConfiguration(); + configuration.setType(DeviceProfileType.DEFAULT.getValue()); + deviceProfileData.setConfiguration(configuration); + DefaultDeviceProfileTransportConfiguration transportConf = new DefaultDeviceProfileTransportConfiguration(); + transportConf.setType(DeviceTransportType.DEFAULT.getValue()); + deviceProfileData.setTransportConfiguration(transportConf); + deviceProfile.setProfileData(deviceProfileData); + deviceProfile.setDefault(false); + deviceProfile.setDefaultRuleChainId(null); + + DeviceProfile created = client.saveDeviceProfile(deviceProfile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(deviceProfile.getName(), created.getName()); + assertEquals(deviceProfile.getDescription(), created.getDescription()); + assertEquals(DeviceProfileType.DEFAULT, created.getType()); + assertEquals(DeviceTransportType.DEFAULT, created.getTransportType()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // Find all, check count + PageDataDeviceProfile allProfiles = client.getDeviceProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // Find all with text search + PageDataDeviceProfile filteredProfiles = client.getDeviceProfiles(100, 0, "Test Device Profile " + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // Get by id + DeviceProfile searchProfile = createdProfiles.get(2); + DeviceProfile fetchedProfile = client.getDeviceProfileById(searchProfile.getId().getId().toString(), false); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // Update device profile + fetchedProfile.setDescription("Updated description"); + DeviceProfile updatedProfile = client.saveDeviceProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // Get device profile info by id + DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo(); + assertNotNull(profileInfo); + assertEquals(searchProfile.getType().getValue().toLowerCase(), profileInfo.getName()); + assertEquals(DeviceTransportType.DEFAULT, profileInfo.getTransportType()); + + // Get device profile infos (paginated) + PageDataDeviceProfileInfo profileInfos = client.getDeviceProfileInfos(100, 0, null, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // Set a profile as default + DeviceProfile profileToSetDefault = createdProfiles.get(1); + DeviceProfile newDefault = client.setDefaultDeviceProfile(profileToSetDefault.getId().getId().toString()); + assertNotNull(newDefault); + assertTrue(newDefault.getDefault()); + + // Verify default profile info now points to the new default + DeviceProfileInfo newDefaultInfo = client.getDefaultDeviceProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // Get device profile names + List profileNames = client.getDeviceProfileNames(false); + assertNotNull(profileNames); + assertEquals(createdProfiles.size() + 1, profileNames.size()); + + // Delete device profile (cannot delete the default one, so delete a non-default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteDeviceProfile(profileToDeleteId.toString()); + + // Verify the profile is deleted + assertReturns404(() -> + client.getDeviceProfileById(profileToDeleteId.toString(), false)); + + // Verify count after deletion + PageDataDeviceProfile profilesAfterDelete = client.getDeviceProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + + // Restore original default profile + DeviceProfile originalDefault = initialProfiles.getData().stream() + .filter(DeviceProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultDeviceProfile(originalDefault.getId().getId().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java new file mode 100644 index 0000000000..e9498a1c50 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/DomainApiClientTest.java @@ -0,0 +1,105 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.After; +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Domain; +import org.thingsboard.client.model.DomainInfo; +import org.thingsboard.client.model.PageDataDomainInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class DomainApiClientTest extends AbstractApiClientTest { + + List createdDomains = new ArrayList<>(); + + @After + public void afterDomainTest() { + createdDomains.forEach(domain -> { + try { + client.deleteDomain(domain.getId().getId()); + } catch (ApiException e) { + // ignore + } + }); + } + + @Test + public void testDomainLifecycle() throws Exception { + client.login("sysadmin@thingsboard.org", "sysadmin"); + + long timestamp = System.currentTimeMillis(); + + // create 5 domains + for (int i = 0; i < 5; i++) { + Domain domain = new Domain(); + domain.setName("domain." + i + ".com"); + domain.setOauth2Enabled(false); + domain.setPropagateToEdge(false); + + Domain created = client.saveDomain(domain, null); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(domain.getName(), created.getName()); + assertEquals(false, created.getOauth2Enabled()); + + createdDomains.add(created); + } + + // list tenant domains with text search + PageDataDomainInfo filteredDomains = client.getDomainInfos(100, 0, + "domain.", null, null); + assertNotNull(filteredDomains); + assertEquals(5, filteredDomains.getData().size()); + + // get domain info by id + Domain searchDomain = createdDomains.get(2); + DomainInfo fetchedInfo = client.getDomainInfoById(searchDomain.getId().getId()); + assertEquals(searchDomain.getName(), fetchedInfo.getName()); + assertEquals(searchDomain.getOauth2Enabled(), fetchedInfo.getOauth2Enabled()); + assertNotNull(fetchedInfo.getOauth2ClientInfos()); + + // update domain + Domain domainToUpdate = createdDomains.get(3); + domainToUpdate.setPropagateToEdge(true); + Domain updatedDomain = client.saveDomain(domainToUpdate, null); + assertEquals(true, updatedDomain.getPropagateToEdge()); + + // delete domain + UUID domainToDeleteId = createdDomains.get(0).getId().getId(); + createdDomains.remove(0); + client.deleteDomain(domainToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getDomainInfoById(domainToDeleteId) + ); + + PageDataDomainInfo domainsAfterDelete = client.getDomainInfos(100, 0, + "domain.", null, null); + assertEquals(4, domainsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java new file mode 100644 index 0000000000..6aeb7f0ef6 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EdgeApiClientTest.java @@ -0,0 +1,141 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Edge; +import org.thingsboard.client.model.EdgeInfo; +import org.thingsboard.client.model.PageDataEdge; +import org.thingsboard.client.model.PageDataEdgeInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class EdgeApiClientTest extends AbstractApiClientTest { + + @Test + public void testEdgeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdEdges = new ArrayList<>(); + + // create 5 edges + for (int i = 0; i < 5; i++) { + Edge edge = new Edge(); + edge.setName(TEST_PREFIX + "Edge_" + timestamp + "_" + i); + edge.setType("gateway"); + edge.setLabel("Test Edge " + i); + edge.setRoutingKey("routing_key_" + timestamp + "_" + i); + edge.setSecret("secret_key_" + timestamp + "_" + i); + + Edge created = client.saveEdge(edge); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(edge.getName(), created.getName()); + assertEquals("gateway", created.getType()); + assertNotNull(created.getRoutingKey()); + assertNotNull(created.getSecret()); + + createdEdges.add(created); + } + + // list tenant edges with text search + PageDataEdge filteredEdges = client.getTenantEdges(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertNotNull(filteredEdges); + assertEquals(5, filteredEdges.getData().size()); + + // list tenant edges with type filter + PageDataEdge typedEdges = client.getTenantEdges(100, 0, "gateway", + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(5, typedEdges.getData().size()); + + // get tenant edge infos + PageDataEdgeInfo edgeInfos = client.getTenantEdgeInfos(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(5, edgeInfos.getData().size()); + + // get edge by id + Edge searchEdge = createdEdges.get(2); + Edge fetchedEdge = client.getEdgeById(searchEdge.getId().getId().toString()); + assertEquals(searchEdge.getName(), fetchedEdge.getName()); + assertEquals(searchEdge.getType(), fetchedEdge.getType()); + assertEquals(searchEdge.getRoutingKey(), fetchedEdge.getRoutingKey()); + + // get edge by name + Edge fetchedByName = client.getTenantEdgeByName(searchEdge.getName()); + assertEquals(searchEdge.getId().getId(), fetchedByName.getId().getId()); + + // get edges by list of ids + List idsToFetch = List.of( + createdEdges.get(0).getId().getId().toString(), + createdEdges.get(1).getId().getId().toString() + ); + List edgeList = client.getEdgeList(idsToFetch); + assertEquals(2, edgeList.size()); + + // update edge + Edge edgeToUpdate = createdEdges.get(3); + edgeToUpdate.setLabel("Updated Label"); + Edge updatedEdge = client.saveEdge(edgeToUpdate); + assertEquals("Updated Label", updatedEdge.getLabel()); + + // assign edge to customer + String customerId = savedClientCustomer.getId().getId().toString(); + String edgeId = createdEdges.get(1).getId().getId().toString(); + Edge assignedEdge = client.assignEdgeToCustomer(customerId, edgeId); + assertNotNull(assignedEdge.getCustomerId()); + + // get customer edges + PageDataEdge customerEdges = client.getCustomerEdges(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(1, customerEdges.getData().size()); + + // get customer edge infos + PageDataEdgeInfo customerEdgeInfos = client.getCustomerEdgeInfos(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(1, customerEdgeInfos.getData().size()); + EdgeInfo edgeInfo = customerEdgeInfos.getData().get(0); + assertNotNull(edgeInfo.getCustomerTitle()); + + // unassign edge from customer + Edge unassignedEdge = client.unassignEdgeFromCustomer(edgeId); + assertNotNull(unassignedEdge); + + PageDataEdge customerEdgesAfter = client.getCustomerEdges(customerId, 100, 0, + null, TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(0, customerEdgesAfter.getData().size()); + + // delete edge + UUID edgeToDeleteId = createdEdges.get(0).getId().getId(); + client.deleteEdge(edgeToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getEdgeById(edgeToDeleteId.toString()) + ); + + PageDataEdge edgesAfterDelete = client.getTenantEdges(100, 0, null, + TEST_PREFIX + "Edge_" + timestamp, null, null); + assertEquals(4, edgesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java new file mode 100644 index 0000000000..2571306eb6 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityQueryApiClientTest.java @@ -0,0 +1,289 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AliasEntityId; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.AssetTypeFilter; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.DeviceTypeFilter; +import org.thingsboard.client.model.Direction; +import org.thingsboard.client.model.EntityData; +import org.thingsboard.client.model.EntityDataPageLink; +import org.thingsboard.client.model.EntityDataQuery; +import org.thingsboard.client.model.EntityDataSortOrder; +import org.thingsboard.client.model.EntityKey; +import org.thingsboard.client.model.EntityKeyType; +import org.thingsboard.client.model.EntityKeyValueType; +import org.thingsboard.client.model.EntityListFilter; +import org.thingsboard.client.model.EntityNameFilter; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.FilterPredicateValueString; +import org.thingsboard.client.model.KeyFilter; +import org.thingsboard.client.model.PageDataEntityData; +import org.thingsboard.client.model.SingleEntityFilter; +import org.thingsboard.client.model.StringFilterPredicate; +import org.thingsboard.client.model.StringOperation; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityQueryApiClientTest extends AbstractApiClientTest { + + private static final String QUERY_TEST_PREFIX = "QueryTest_"; + + private EntityDataPageLink pageLink(int pageSize) { + return new EntityDataPageLink() + .pageSize(pageSize) + .page(0) + .sortOrder(new EntityDataSortOrder() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .direction(Direction.ASC)); + } + + @Test + public void testFindByDeviceTypeFilter() throws Exception { + long ts = System.currentTimeMillis(); + String type1 = "temperatureSensor"; + String type2 = "humiditySensor"; + + for (int i = 0; i < 3; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "temp_" + ts + "_" + i); + d.setType(type1); + client.saveDevice(d, null, null, null, null); + } + for (int i = 0; i < 2; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "hum_" + ts + "_" + i); + d.setType(type2); + client.saveDevice(d, null, null, null, null); + } + + // filter by single device type + EntityDataQuery singleTypeQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(singleTypeQuery); + assertNotNull(result); + assertEquals(3, result.getTotalElements().intValue()); + for (EntityData entity : result.getData()) { + assertNotNull(entity.getEntityId()); + } + + // filter by multiple device types + EntityDataQuery multiTypeQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1, type2))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData multiResult = client.findEntityDataByQuery(multiTypeQuery); + assertNotNull(multiResult); + assertEquals(5, multiResult.getTotalElements().intValue()); + + // filter by device type + name filter + EntityDataQuery nameFilterQuery = new EntityDataQuery() + .entityFilter(new DeviceTypeFilter() + .deviceTypes(List.of(type1, type2)) + .deviceNameFilter(QUERY_TEST_PREFIX + "temp_" + ts)) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData nameResult = client.findEntityDataByQuery(nameFilterQuery); + assertNotNull(nameResult); + assertEquals(3, nameResult.getTotalElements().intValue()); + } + + @Test + public void testFindByEntityNameFilter() throws Exception { + long ts = System.currentTimeMillis(); + String prefix = QUERY_TEST_PREFIX + "named_" + ts; + + for (int i = 0; i < 4; i++) { + Device d = new Device(); + d.setName(prefix + "_" + i); + d.setType("default"); + client.saveDevice(d, null, null, null, null); + } + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(prefix)) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(4, result.getTotalElements().intValue()); + assertFalse(result.getHasNext()); + } + + @Test + public void testFindByEntityListFilter() throws Exception { + long ts = System.currentTimeMillis(); + + Device d1 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_1").type("default"), null, null, null, null); + Device d2 = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_2").type("default"), null, null, null, null); + client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "list_" + ts + "_3").type("default"), null, null, null, null); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityListFilter() + .entityType(EntityType.DEVICE) + .entityList(List.of( + d1.getId().getId().toString(), + d2.getId().getId().toString()))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(2, result.getTotalElements().intValue()); + + List returnedIds = result.getData().stream() + .map(e -> e.getEntityId().getId().toString()) + .collect(Collectors.toList()); + assertTrue(returnedIds.contains(d1.getId().getId().toString())); + assertTrue(returnedIds.contains(d2.getId().getId().toString())); + } + + @Test + public void testFindBySingleEntityFilter() throws Exception { + long ts = System.currentTimeMillis(); + Device device = client.saveDevice(new Device().name(QUERY_TEST_PREFIX + "single_" + ts).type("default"), null, null, null, null); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new SingleEntityFilter() + .singleEntity(new AliasEntityId() + .id(device.getId().getId()) + .entityType(EntityType.DEVICE))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(1, result.getTotalElements().intValue()); + assertEquals(device.getId().getId().toString(), + result.getData().get(0).getEntityId().getId().toString()); + } + + @Test + public void testFindByAssetTypeFilter() throws Exception { + long ts = System.currentTimeMillis(); + String assetType = "building"; + + for (int i = 0; i < 3; i++) { + Asset a = new Asset(); + a.setName(QUERY_TEST_PREFIX + "asset_" + ts + "_" + i); + a.setType(assetType); + client.saveAsset(a, null, null, null); + } + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new AssetTypeFilter() + .assetTypes(List.of(assetType))) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(3, result.getTotalElements().intValue()); + } + + @Test + public void testFindWithKeyFilter() throws Exception { + long ts = System.currentTimeMillis(); + String matchName = QUERY_TEST_PREFIX + "kf_match_" + ts; + String noMatchName = QUERY_TEST_PREFIX + "kf_other_" + ts; + + client.saveDevice(new Device().name(matchName).type("default"), null, null, null, null); + client.saveDevice(new Device().name(noMatchName).type("default"), null, null, null, null); + + KeyFilter nameKeyFilter = new KeyFilter() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .valueType(EntityKeyValueType.STRING) + .predicate(new StringFilterPredicate() + .operation(StringOperation.CONTAINS) + .value(new FilterPredicateValueString().defaultValue("kf_match")) + .ignoreCase(true)); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(QUERY_TEST_PREFIX + "kf_")) + .addKeyFiltersItem(nameKeyFilter) + .pageLink(pageLink(10)) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + PageDataEntityData result = client.findEntityDataByQuery(query); + assertNotNull(result); + assertEquals(1, result.getTotalElements().intValue()); + } + + @Test + public void testFindWithPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + Device d = new Device(); + d.setName(QUERY_TEST_PREFIX + "page_" + ts + "_" + i); + d.setType("default"); + client.saveDevice(d, null, null, null, null); + } + + EntityDataPageLink smallPage = new EntityDataPageLink() + .pageSize(2) + .page(0) + .sortOrder(new EntityDataSortOrder() + .key(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")) + .direction(Direction.ASC)); + + EntityDataQuery query = new EntityDataQuery() + .entityFilter(new EntityNameFilter() + .entityType(EntityType.DEVICE) + .entityNameFilter(QUERY_TEST_PREFIX + "page_" + ts)) + .pageLink(smallPage) + .addEntityFieldsItem(new EntityKey().type(EntityKeyType.ENTITY_FIELD).key("name")); + + // first page + PageDataEntityData page1 = client.findEntityDataByQuery(query); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + // last page + smallPage.setPage(2); + PageDataEntityData lastPage = client.findEntityDataByQuery(query); + assertNotNull(lastPage); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java new file mode 100644 index 0000000000..1792ba9ff1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityRelationApiClientTest.java @@ -0,0 +1,182 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Asset; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntityRelation; +import org.thingsboard.client.model.EntityRelationInfo; +import org.thingsboard.client.model.EntityRelationsQuery; +import org.thingsboard.client.model.EntitySearchDirection; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.RelationEntityTypeFilter; +import org.thingsboard.client.model.RelationTypeGroup; +import org.thingsboard.client.model.RelationsSearchParameters; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityRelationApiClientTest extends AbstractApiClientTest { + + @Test + public void testEntityRelationLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create assets and devices to relate + Asset building = new Asset(); + building.setName(TEST_PREFIX + "Building_" + timestamp); + building.setType("building"); + building = client.saveAsset(building, null, null, null); + + Asset floor = new Asset(); + floor.setName(TEST_PREFIX + "Floor_" + timestamp); + floor.setType("floor"); + floor = client.saveAsset(floor, null, null, null); + + Device device1 = new Device(); + device1.setName(TEST_PREFIX + "Sensor_" + timestamp + "_1"); + device1.setType("sensor"); + device1 = client.saveDevice(device1, null, null, null, null); + + Device device2 = new Device(); + device2.setName(TEST_PREFIX + "Sensor_" + timestamp + "_2"); + device2.setType("sensor"); + device2 = client.saveDevice(device2, null, null, null, null); + + Device device3 = new Device(); + device3.setName(TEST_PREFIX + "Sensor_" + timestamp + "_3"); + device3.setType("sensor"); + device3 = client.saveDevice(device3, null, null, null, null); + + // create relations: building -> Contains -> floor, floor -> Contains -> device1/device2/device3 + EntityRelation buildingToFloor = new EntityRelation(); + buildingToFloor.setFrom(building.getId()); + buildingToFloor.setTo(floor.getId()); + buildingToFloor.setType("Contains"); + buildingToFloor.setTypeGroup(RelationTypeGroup.COMMON); + EntityRelation savedRelation = client.saveRelation(buildingToFloor); + assertNotNull(savedRelation); + assertEquals("Contains", savedRelation.getType()); + + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device1.getId()) + .type("Contains") + .typeGroup(RelationTypeGroup.COMMON)); + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device2.getId()) + .type("Contains").typeGroup(RelationTypeGroup.COMMON)); + client.saveRelation(new EntityRelation() + .from(floor.getId()) + .to(device3.getId()) + .type("Manages") + .typeGroup(RelationTypeGroup.COMMON)); + + // get specific relation + EntityRelation fetched = client.getRelation( + building.getId().getId().toString(), "ASSET", + "Contains", + floor.getId().getId().toString(), "ASSET", + RelationTypeGroup.COMMON.getValue()); + assertNotNull(fetched); + assertEquals("Contains", fetched.getType()); + + // find all relations from floor + List fromFloor = client.findEntityRelationsByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(3, fromFloor.size()); + + // find relations from floor with type filter "Contains" + List containsFromFloor = client.findEntityRelationsByFromAndRelationType("ASSET", + floor.getId().getId().toString(), "Contains", RelationTypeGroup.COMMON.getValue()); + assertEquals(2, containsFromFloor.size()); + + // find relations to device1 + List toDevice1 = client.findEntityRelationsByTo("DEVICE", + device1.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(1, toDevice1.size()); + assertEquals("Contains", toDevice1.get(0).getType()); + + // find relations to device3 with type filter "Manages" + List managesToDevice3 = client.findEntityRelationsByToAndRelationType("DEVICE", + device3.getId().getId().toString(), "Manages", RelationTypeGroup.COMMON.getValue()); + assertEquals(1, managesToDevice3.size()); + + // find info by from (includes entity names) + List infoFromFloor = client.findEntityRelationInfosByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(3, infoFromFloor.size()); + Device finalDevice = device1; + assertTrue(infoFromFloor.stream().anyMatch(info -> + finalDevice.getName().equals(info.getToName()))); + + // find info by to + List infoToDevice2 = client.findEntityRelationInfosByTo("DEVICE", + device2.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(1, infoToDevice2.size()); + assertEquals(floor.getName(), infoToDevice2.get(0).getFromName()); + + // find by query - search from building, direction FROM, max 2 levels + RelationsSearchParameters params = new RelationsSearchParameters(); + params.setRootId(building.getId().getId()); + params.setRootType(EntityType.ASSET); + params.setDirection(EntitySearchDirection.FROM); + params.setRelationTypeGroup(RelationTypeGroup.COMMON); + params.setMaxLevel(2); + + RelationEntityTypeFilter filter = new RelationEntityTypeFilter(); + filter.setRelationType("Contains"); + filter.setEntityTypes(List.of(EntityType.ASSET, EntityType.DEVICE)); + + EntityRelationsQuery query = new EntityRelationsQuery(); + query.setParameters(params); + query.setFilters(List.of(filter)); + + List queryResult = client.findEntityRelationsByQuery(query); + assertTrue(queryResult.size() >= 3); + + // find info by query + List infoQueryResult = client.findEntityRelationInfosByQuery(query); + assertTrue(infoQueryResult.size() >= 3); + + // delete single relation + client.deleteRelation( + floor.getId().getId().toString(), "ASSET", + "Manages", + device3.getId().getId().toString(), "DEVICE", + RelationTypeGroup.COMMON.getValue()); + + // verify deletion + List afterDelete = client.findEntityRelationsByFrom("ASSET", + floor.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(2, afterDelete.size()); + + // delete all relations for building + client.deleteRelations(building.getId().getId().toString(), "ASSET"); + + List afterDeleteAll = client.findEntityRelationsByFrom("ASSET", + building.getId().getId().toString(), RelationTypeGroup.COMMON.getValue()); + assertEquals(0, afterDeleteAll.size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java new file mode 100644 index 0000000000..60c0e86662 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/EntityViewApiClientTest.java @@ -0,0 +1,267 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AttributesEntityView; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.EntitySubtype; +import org.thingsboard.client.model.EntityView; +import org.thingsboard.client.model.EntityViewInfo; +import org.thingsboard.client.model.PageDataEntityView; +import org.thingsboard.client.model.PageDataEntityViewInfo; +import org.thingsboard.client.model.TelemetryEntityView; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class EntityViewApiClientTest extends AbstractApiClientTest { + + private static final String EV_PREFIX = "EvTest_"; + + @Test + public void testSaveAndGetEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + EntityView ev = new EntityView(); + ev.setName(EV_PREFIX + "save_" + ts); + ev.setType("testType"); + ev.setEntityId(device.getId()); + ev.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature", "humidity")) + .attributes(new AttributesEntityView() + .cs(List.of("firmware")) + .ss(List.of("active")) + .sh(List.of()))); + ev.setStartTimeMs(1000L); + ev.setEndTimeMs(2000L); + + EntityView saved = client.saveEntityView(ev, null, null, null); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(ev.getName(), saved.getName()); + assertEquals("testType", saved.getType()); + assertEquals(device.getId().getId(), saved.getEntityId().getId()); + assertEquals(List.of("temperature", "humidity"), saved.getKeys().getTimeseries()); + assertEquals(1000L, saved.getStartTimeMs().longValue()); + assertEquals(2000L, saved.getEndTimeMs().longValue()); + + // get by id + String evId = saved.getId().getId().toString(); + EntityView fetched = client.getEntityViewById(evId); + assertNotNull(fetched); + assertEquals(saved.getName(), fetched.getName()); + assertEquals(saved.getType(), fetched.getType()); + assertEquals(saved.getEntityId().getId(), fetched.getEntityId().getId()); + } + + @Test + public void testGetEntityViewInfoById() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "info_" + ts, "infoType", device); + + EntityViewInfo info = client.getEntityViewInfoById(saved.getId().getId().toString()); + assertNotNull(info); + assertEquals(saved.getName(), info.getName()); + assertEquals("infoType", info.getType()); + assertNotNull(info.getEntityId()); + } + + @Test + public void testUpdateEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "update_" + ts, "default", device); + + saved.setName(EV_PREFIX + "updated_" + ts); + saved.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature", "pressure")) + .attributes(new AttributesEntityView() + .cs(List.of()) + .ss(List.of()) + .sh(List.of()))); + + EntityView updated = client.saveEntityView(saved, null, null, null); + assertEquals(EV_PREFIX + "updated_" + ts, updated.getName()); + assertEquals(List.of("temperature", "pressure"), updated.getKeys().getTimeseries()); + assertEquals(saved.getId().getId(), updated.getId().getId()); + } + + @Test + public void testDeleteEntityView() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "delete_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + client.getEntityViewById(evId); + + client.deleteEntityView(evId); + + assertReturns404(() -> client.getEntityViewById(evId)); + } + + @Test + public void testGetTenantEntityViews() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + for (int i = 0; i < 3; i++) { + createEntityView(EV_PREFIX + "tenant_" + ts + "_" + i, "tenantViewType", device); + } + + PageDataEntityView page = client.getTenantEntityViews(100, 0, null, EV_PREFIX + "tenant_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (EntityView ev : page.getData()) { + assertTrue(ev.getName().startsWith(EV_PREFIX + "tenant_" + ts)); + } + } + + @Test + public void testGetTenantEntityViewInfos() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + createEntityView(EV_PREFIX + "tinfo_" + ts, "default", device); + + PageDataEntityViewInfo page = client.getTenantEntityViewInfos(100, 0, null, EV_PREFIX + "tinfo_" + ts, null, null); + assertNotNull(page); + assertEquals(1, page.getTotalElements().intValue()); + assertEquals(EV_PREFIX + "tinfo_" + ts, page.getData().get(0).getName()); + } + + @Test + public void testAssignAndUnassignEntityViewToCustomer() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "assign_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + String customerId = savedClientCustomer.getId().getId().toString(); + + // assign to customer + EntityView assigned = client.assignEntityViewToCustomer(customerId, evId); + assertNotNull(assigned); + assertEquals(savedClientCustomer.getId().getId(), assigned.getCustomerId().getId()); + + // verify in customer entity views + PageDataEntityView customerViews = client.getCustomerEntityViews( + customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null); + assertEquals(1, customerViews.getTotalElements().intValue()); + assertEquals(saved.getName(), customerViews.getData().get(0).getName()); + + // unassign from customer + EntityView unassigned = client.unassignEntityViewFromCustomer(evId); + assertNotNull(unassigned); + + PageDataEntityView afterUnassign = client.getCustomerEntityViews( + customerId, 100, 0, null, EV_PREFIX + "assign_" + ts, null, null); + assertEquals(0, afterUnassign.getTotalElements().intValue()); + } + + @Test + public void testGetCustomerEntityViewInfos() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + EntityView saved = createEntityView(EV_PREFIX + "cinfo_" + ts, "default", device); + + String evId = saved.getId().getId().toString(); + String customerId = savedClientCustomer.getId().getId().toString(); + + client.assignEntityViewToCustomer(customerId, evId); + + PageDataEntityViewInfo infos = client.getCustomerEntityViewInfos( + customerId, 100, 0, null, EV_PREFIX + "cinfo_" + ts, null, null); + assertNotNull(infos); + assertEquals(1, infos.getTotalElements().intValue()); + assertEquals(saved.getName(), infos.getData().get(0).getName()); + } + + @Test + public void testGetEntityViewTypes() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + createEntityView(EV_PREFIX + "types_" + ts, "uniqueEvType_" + ts, device); + + List types = client.getEntityViewTypes(); + assertNotNull(types); + assertFalse(types.isEmpty()); + + List typeNames = types.stream() + .map(EntitySubtype::getType) + .collect(Collectors.toList()); + assertTrue(typeNames.contains("uniqueEvType_" + ts)); + } + + @Test + public void testGetEntityViewById_notFound() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getEntityViewById(nonExistentId)); + } + + @Test + public void testGetTenantEntityViewsPagination() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createTestDevice(String.valueOf(ts)); + + for (int i = 0; i < 5; i++) { + createEntityView(EV_PREFIX + "paged_" + ts + "_" + i, "default", device); + } + + PageDataEntityView page1 = client.getTenantEntityViews(2, 0, null, EV_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataEntityView lastPage = client.getTenantEntityViews(2, 2, null, EV_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + private Device createTestDevice(String suffix) throws Exception { + Device device = new Device(); + device.setName(EV_PREFIX + "device_" + suffix); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + + private EntityView createEntityView(String name, String type, Device device) throws Exception { + EntityView ev = new EntityView(); + ev.setName(name); + ev.setType(type); + ev.setEntityId(device.getId()); + ev.setKeys(new TelemetryEntityView() + .timeseries(List.of("temperature")) + .attributes(new AttributesEntityView() + .cs(List.of()) + .ss(List.of()) + .sh(List.of()))); + return client.saveEntityView(ev, null, null, null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java new file mode 100644 index 0000000000..fd448eaee1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/MobileAppApiClientTest.java @@ -0,0 +1,156 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.MobileApp; +import org.thingsboard.client.model.MobileAppBundle; +import org.thingsboard.client.model.MobileAppBundleInfo; +import org.thingsboard.client.model.MobileAppStatus; +import org.thingsboard.client.model.PageDataMobileApp; +import org.thingsboard.client.model.PageDataMobileAppBundleInfo; +import org.thingsboard.client.model.PlatformType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class MobileAppApiClientTest extends AbstractApiClientTest { + + @Test + public void testMobileAppLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdApps = new ArrayList<>(); + + // create 3 Android apps + for (int i = 0; i < 3; i++) { + MobileApp app = new MobileApp(); + app.setPkgName("com.test.android." + timestamp + "." + i); + app.setTitle(TEST_PREFIX + "AndroidApp_" + timestamp + "_" + i); + app.setAppSecret("secret_android_" + timestamp + "_" + i); + app.setPlatformType(PlatformType.ANDROID); + app.setStatus(MobileAppStatus.DRAFT); + + MobileApp created = client.saveMobileApp(app); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(app.getPkgName(), created.getPkgName()); + assertEquals(PlatformType.ANDROID, created.getPlatformType()); + assertEquals(MobileAppStatus.DRAFT, created.getStatus()); + + createdApps.add(created); + } + + // create 2 iOS apps + for (int i = 0; i < 2; i++) { + MobileApp app = new MobileApp(); + app.setPkgName("com.test.ios." + timestamp + "." + i); + app.setTitle(TEST_PREFIX + "IosApp_" + timestamp + "_" + i); + app.setAppSecret("secret_ios_" + timestamp + "_" + i); + app.setPlatformType(PlatformType.IOS); + app.setStatus(MobileAppStatus.DRAFT); + + MobileApp created = client.saveMobileApp(app); + assertNotNull(created); + createdApps.add(created); + } + + // list all tenant mobile apps + PageDataMobileApp allApps = client.getTenantMobileApps(100, 0, null, + null, null, null); + assertNotNull(allApps); + assertEquals(5, allApps.getData().size()); + + // list with platform type filter + PageDataMobileApp androidApps = client.getTenantMobileApps(100, 0, PlatformType.ANDROID, + null, null, null); + assertEquals(3, androidApps.getData().size()); + + PageDataMobileApp iosApps = client.getTenantMobileApps(100, 0, PlatformType.IOS, + null, null, null); + assertEquals(2, iosApps.getData().size()); + + // get mobile app by id + MobileApp searchApp = createdApps.get(1); + MobileApp fetchedApp = client.getMobileAppById(searchApp.getId().getId()); + assertEquals(searchApp.getPkgName(), fetchedApp.getPkgName()); + assertEquals(searchApp.getTitle(), fetchedApp.getTitle()); + assertEquals(searchApp.getPlatformType(), fetchedApp.getPlatformType()); + + // update mobile app + MobileApp appToUpdate = createdApps.get(2); + appToUpdate.setTitle(appToUpdate.getTitle() + "_updated"); + MobileApp updatedApp = client.saveMobileApp(appToUpdate); + assertEquals(appToUpdate.getTitle(), updatedApp.getTitle()); + + // create mobile app bundle with android and ios apps + MobileAppBundle bundle = new MobileAppBundle(); + bundle.setTitle(TEST_PREFIX + "Bundle_" + timestamp); + bundle.setDescription("Test bundle"); + bundle.setAndroidAppId(createdApps.get(0).getId()); + bundle.setIosAppId(createdApps.get(3).getId()); + bundle.setOauth2Enabled(false); + + MobileAppBundle savedBundle = client.saveMobileAppBundle(bundle, null); + assertNotNull(savedBundle); + assertNotNull(savedBundle.getId()); + assertEquals(bundle.getTitle(), savedBundle.getTitle()); + + // get bundle info by id + MobileAppBundleInfo bundleInfo = client.getMobileAppBundleInfoById(savedBundle.getId().getId()); + assertEquals(savedBundle.getTitle(), bundleInfo.getTitle()); + assertEquals("Test bundle", bundleInfo.getDescription()); + assertNotNull(bundleInfo.getAndroidPkgName()); + assertNotNull(bundleInfo.getIosPkgName()); + + // list tenant bundles + PageDataMobileAppBundleInfo bundles = client.getTenantMobileAppBundleInfos(100, 0, + TEST_PREFIX + "Bundle_" + timestamp, null, null); + assertEquals(1, bundles.getData().size()); + + // update bundle + savedBundle.setDescription("Updated description"); + MobileAppBundle updatedBundle = client.saveMobileAppBundle(savedBundle, null); + assertEquals("Updated description", updatedBundle.getDescription()); + + // delete bundle + client.deleteMobileAppBundle(savedBundle.getId().getId()); + + // verify bundle deletion + assertReturns404(() -> + client.getMobileAppBundleInfoById(savedBundle.getId().getId()) + ); + + // delete mobile app + UUID appToDeleteId = createdApps.get(0).getId().getId(); + client.deleteMobileApp(appToDeleteId); + + // verify app deletion + assertReturns404(() -> + client.getMobileAppById(appToDeleteId) + ); + + PageDataMobileApp appsAfterDelete = client.getTenantMobileApps(100, 0, null, + null, null, null); + assertEquals(4, appsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java new file mode 100644 index 0000000000..026b8784ef --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/NotificationApiClientTest.java @@ -0,0 +1,278 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.EntityActionNotificationRuleTriggerConfig; +import org.thingsboard.client.model.EntityActionRecipientsConfig; +import org.thingsboard.client.model.EntityType; +import org.thingsboard.client.model.NotificationDeliveryMethod; +import org.thingsboard.client.model.NotificationRequest; +import org.thingsboard.client.model.NotificationRequestInfo; +import org.thingsboard.client.model.NotificationRule; +import org.thingsboard.client.model.NotificationRuleInfo; +import org.thingsboard.client.model.NotificationRuleTriggerType; +import org.thingsboard.client.model.NotificationSettings; +import org.thingsboard.client.model.NotificationTarget; +import org.thingsboard.client.model.NotificationTemplate; +import org.thingsboard.client.model.NotificationTemplateConfig; +import org.thingsboard.client.model.NotificationType; +import org.thingsboard.client.model.PageDataNotification; +import org.thingsboard.client.model.PageDataNotificationRequestInfo; +import org.thingsboard.client.model.PageDataNotificationRuleInfo; +import org.thingsboard.client.model.PageDataNotificationTarget; +import org.thingsboard.client.model.PageDataNotificationTemplate; +import org.thingsboard.client.model.PlatformUsersNotificationTargetConfig; +import org.thingsboard.client.model.TenantAdministratorsFilter; +import org.thingsboard.client.model.WebDeliveryMethodNotificationTemplate; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class NotificationApiClientTest extends AbstractApiClientTest { + + @Test + public void testNotificationLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // === 1. Notification Target CRUD === + + // Create target + TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter(); + PlatformUsersNotificationTargetConfig targetConfig = + new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter); + NotificationTarget target = + new NotificationTarget() + .name("Test Target " + timestamp) + ._configuration(targetConfig); + + NotificationTarget savedTarget = client.saveNotificationTarget(target); + assertNotNull(savedTarget); + assertNotNull(savedTarget.getId()); + assertEquals("Test Target " + timestamp, savedTarget.getName()); + + // Get target by ID + NotificationTarget fetchedTarget = + client.getNotificationTargetById(savedTarget.getId().getId()); + assertEquals(savedTarget.getName(), fetchedTarget.getName()); + + // List targets + PageDataNotificationTarget targetsPage = + client.getNotificationTargets(100, 0, null, null, null); + assertNotNull(targetsPage); + assertNotNull(targetsPage.getData()); + assertTrue( + targetsPage.getData().stream() + .anyMatch(t -> t.getName().equals(savedTarget.getName()))); + + // Update target + savedTarget.setName("Updated Target " + timestamp); + NotificationTarget updatedTarget = client.saveNotificationTarget(savedTarget); + assertEquals("Updated Target " + timestamp, updatedTarget.getName()); + + // === 2. Notification Template CRUD === + + // Create template + WebDeliveryMethodNotificationTemplate webTemplate = + new WebDeliveryMethodNotificationTemplate() + .subject("Test Subject") + .body("Test notification body") + .enabled(true); + NotificationTemplateConfig templateConfig = + new NotificationTemplateConfig() + .putDeliveryMethodsTemplatesItem("WEB", webTemplate); + NotificationTemplate template = + new NotificationTemplate() + .name("Test Template " + timestamp) + .notificationType(NotificationType.GENERAL) + ._configuration(templateConfig); + + NotificationTemplate savedTemplate = client.saveNotificationTemplate(template); + assertNotNull(savedTemplate); + assertNotNull(savedTemplate.getId()); + assertEquals("Test Template " + timestamp, savedTemplate.getName()); + + // Get template by ID + NotificationTemplate fetchedTemplate = + client.getNotificationTemplateById(savedTemplate.getId().getId()); + assertEquals(savedTemplate.getName(), fetchedTemplate.getName()); + assertEquals(NotificationType.GENERAL, fetchedTemplate.getNotificationType()); + + // List templates + PageDataNotificationTemplate templatesPage = + client.getNotificationTemplates(100, 0, null, null, null, null); + assertNotNull(templatesPage); + assertTrue( + templatesPage.getData().stream() + .anyMatch(t -> t.getName().equals(savedTemplate.getName()))); + + // Update template + savedTemplate.setName("Updated Template " + timestamp); + NotificationTemplate updatedTemplate = client.saveNotificationTemplate(savedTemplate); + assertEquals("Updated Template " + timestamp, updatedTemplate.getName()); + + // === 3. Send notification & read notifications === + + // Send notification request + NotificationRequest request = + new NotificationRequest() + .targets(List.of(savedTarget.getId().getId())) + .templateId(savedTemplate.getId()); + NotificationRequest sentRequest = client.createNotificationRequest(request); + assertNotNull(sentRequest); + assertNotNull(sentRequest.getId()); + + // Get request by ID + NotificationRequestInfo fetchedRequest = + client.getNotificationRequestById(sentRequest.getId().getId()); + assertNotNull(fetchedRequest); + + // List requests + PageDataNotificationRequestInfo requestsPage = + client.getNotificationRequests(100, 0, null, null, null); + assertNotNull(requestsPage); + assertFalse(requestsPage.getData().isEmpty()); + + // Get notifications for current user + PageDataNotification notificationsPage = + client.getNotifications(100, 0, null, null, null, null, null); + assertNotNull(notificationsPage); + assertFalse(notificationsPage.getData().isEmpty()); + + // Get unread count + Integer unreadCount = client.getUnreadNotificationsCount("WEB"); + assertNotNull(unreadCount); + assertTrue("Expected at least one unread notification", unreadCount > 0); + + // Mark single notification as read + client.markNotificationAsRead( + notificationsPage.getData().get(0).getId().getId()); + + // Mark all as read + client.markAllNotificationsAsRead(null); + Integer unreadAfterMarkAll = client.getUnreadNotificationsCount(null); + assertEquals("Expected no unread notifications after marking all as read", 0, unreadAfterMarkAll.intValue()); + + // === 4. Notification Settings === + + NotificationSettings settings = client.getNotificationSettings(); + assertNotNull(settings); + + List deliveryMethods = client.getAvailableDeliveryMethods(); + assertNotNull(deliveryMethods); + assertTrue(deliveryMethods.contains(NotificationDeliveryMethod.WEB)); + + // === 5. Cleanup === + + // Delete notification request + client.deleteNotificationRequest(sentRequest.getId().getId()); + assertReturns404(() -> client.getNotificationRequestById(sentRequest.getId().getId())); + + // Delete template + client.deleteNotificationTemplateById(savedTemplate.getId().getId()); + assertReturns404(() -> client.getNotificationTemplateById(savedTemplate.getId().getId())); + + // Delete target + client.deleteNotificationTargetById(savedTarget.getId().getId()); + assertReturns404(() -> client.getNotificationTargetById(savedTarget.getId().getId())); + } + + @Test + public void testNotificationRuleLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // Create a target for the rule recipients + TenantAdministratorsFilter usersFilter = new TenantAdministratorsFilter(); + PlatformUsersNotificationTargetConfig targetConfig = + new PlatformUsersNotificationTargetConfig().usersFilter(usersFilter); + NotificationTarget target = + new NotificationTarget() + .name("Rule Test Target " + timestamp) + ._configuration(targetConfig); + NotificationTarget savedTarget = client.saveNotificationTarget(target); + + // Create a template of type ENTITY_ACTION + WebDeliveryMethodNotificationTemplate webTemplate = + new WebDeliveryMethodNotificationTemplate() + .subject("Entity action: ${entityType}") + .body("Entity ${entityName} was ${actionType}") + .enabled(true); + NotificationTemplateConfig templateConfig = + new NotificationTemplateConfig() + .putDeliveryMethodsTemplatesItem("WEB", webTemplate); + NotificationTemplate template = + new NotificationTemplate() + .name("Rule Test Template " + timestamp) + .notificationType(NotificationType.ENTITY_ACTION) + ._configuration(templateConfig); + NotificationTemplate savedTemplate = client.saveNotificationTemplate(template); + + // Build trigger config: fire on DEVICE create/update + EntityActionNotificationRuleTriggerConfig triggerConfig = + new EntityActionNotificationRuleTriggerConfig() + .addEntityTypesItem(EntityType.DEVICE) + .created(true) + .updated(true) + .deleted(false); + + // Build recipients config + EntityActionRecipientsConfig recipientsConfig = new EntityActionRecipientsConfig() + .addTargetsItem(savedTarget.getId().getId()); + + // saveNotificationRule - create + NotificationRule rule = new NotificationRule() + .name("Test Rule " + timestamp) + .enabled(true) + .templateId(savedTemplate.getId()) + .triggerType(NotificationRuleTriggerType.ENTITY_ACTION) + .triggerConfig(triggerConfig) + .recipientsConfig(recipientsConfig); + + NotificationRule savedRule = client.saveNotificationRule(rule); + assertNotNull(savedRule); + assertNotNull(savedRule.getId()); + assertEquals("Test Rule " + timestamp, savedRule.getName()); + assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, savedRule.getTriggerType()); + assertEquals(Boolean.TRUE, savedRule.getEnabled()); + + // getNotificationRuleById + NotificationRuleInfo fetchedRule = client.getNotificationRuleById(savedRule.getId().getId()); + assertNotNull(fetchedRule); + assertEquals(savedRule.getName(), fetchedRule.getName()); + assertEquals(NotificationRuleTriggerType.ENTITY_ACTION, fetchedRule.getTriggerType()); + + // getNotificationRules - verify it appears in the list + PageDataNotificationRuleInfo rulesPage = client.getNotificationRules(100, 0, null, null, null); + assertNotNull(rulesPage); + assertTrue(rulesPage.getData().stream() + .anyMatch(r -> r.getId().getId().equals(savedRule.getId().getId()))); + + // deleteNotificationRule + client.deleteNotificationRule(savedRule.getId().getId()); + assertReturns404(() -> client.getNotificationRuleById(savedRule.getId().getId())); + + // Cleanup + client.deleteNotificationTemplateById(savedTemplate.getId().getId()); + client.deleteNotificationTargetById(savedTarget.getId().getId()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java new file mode 100644 index 0000000000..2099f405f0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/Oauth2ApiClientTest.java @@ -0,0 +1,138 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.MapperType; +import org.thingsboard.client.model.OAuth2BasicMapperConfig; +import org.thingsboard.client.model.OAuth2Client; +import org.thingsboard.client.model.OAuth2ClientInfo; +import org.thingsboard.client.model.OAuth2MapperConfig; +import org.thingsboard.client.model.PageDataOAuth2ClientInfo; +import org.thingsboard.client.model.PlatformType; +import org.thingsboard.client.model.TenantNameStrategyType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class Oauth2ApiClientTest extends AbstractApiClientTest { + + private OAuth2Client createOAuth2Client(String title, String clientId, String clientSecret) { + OAuth2BasicMapperConfig basicConfig = new OAuth2BasicMapperConfig(); + basicConfig.setEmailAttributeKey("email"); + basicConfig.setFirstNameAttributeKey("given_name"); + basicConfig.setLastNameAttributeKey("family_name"); + basicConfig.setTenantNameStrategy(TenantNameStrategyType.DOMAIN); + + OAuth2MapperConfig mapperConfig = new OAuth2MapperConfig(); + mapperConfig.setType(MapperType.BASIC); + mapperConfig.setAllowUserCreation(true); + mapperConfig.setActivateUser(false); + mapperConfig.setBasic(basicConfig); + + OAuth2Client oAuth2Client = new OAuth2Client(); + oAuth2Client.setTitle(title); + oAuth2Client.setClientId(clientId); + oAuth2Client.setClientSecret(clientSecret); + oAuth2Client.setAuthorizationUri("https://accounts.google.com/o/oauth2/v2/auth"); + oAuth2Client.setAccessTokenUri("https://oauth2.googleapis.com/token"); + oAuth2Client.setScope(List.of("openid", "email", "profile")); + oAuth2Client.setUserInfoUri("https://openidconnect.googleapis.com/v1/userinfo"); + oAuth2Client.setUserNameAttributeName("email"); + oAuth2Client.setClientAuthenticationMethod("POST"); + oAuth2Client.setLoginButtonLabel(title); + oAuth2Client.setMapperConfig(mapperConfig); + oAuth2Client.setPlatforms(List.of(PlatformType.WEB)); + + return oAuth2Client; + } + + @Test + public void testOAuth2ClientLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdClients = new ArrayList<>(); + + // create 5 OAuth2 clients + for (int i = 0; i < 5; i++) { + String title = TEST_PREFIX + "OAuth2_" + timestamp + "_" + i; + OAuth2Client oAuth2Client = createOAuth2Client(title, + "client_id_" + timestamp + "_" + i, + "client_secret_" + timestamp + "_" + i); + + OAuth2Client created = client.saveOAuth2Client(oAuth2Client); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(title, created.getTitle()); + assertEquals("POST", created.getClientAuthenticationMethod()); + assertNotNull(created.getMapperConfig()); + assertEquals(MapperType.BASIC, created.getMapperConfig().getType()); + + createdClients.add(created); + } + + // list tenant OAuth2 client infos + PageDataOAuth2ClientInfo clientInfos = client.findOAuth2ClientInfos(100, 0, + TEST_PREFIX + "OAuth2_" + timestamp, null, null); + assertNotNull(clientInfos); + assertEquals(5, clientInfos.getData().size()); + + // get OAuth2 client by id + OAuth2Client searchClient = createdClients.get(2); + OAuth2Client fetchedClient = client.getOAuth2ClientById(searchClient.getId().getId()); + assertEquals(searchClient.getTitle(), fetchedClient.getTitle()); + assertEquals(searchClient.getClientId(), fetchedClient.getClientId()); + assertEquals(searchClient.getAuthorizationUri(), fetchedClient.getAuthorizationUri()); + assertEquals(3, fetchedClient.getScope().size()); + + // fetch client infos by ids + List idsToFetch = List.of( + createdClients.get(0).getId().getId().toString(), + createdClients.get(1).getId().getId().toString() + ); + List fetchedInfos = client.findTenantOAuth2ClientInfosByIds(idsToFetch); + assertEquals(2, fetchedInfos.size()); + + // update OAuth2 client + OAuth2Client clientToUpdate = client.getOAuth2ClientById(createdClients.get(3).getId().getId()); + clientToUpdate.setTitle(clientToUpdate.getTitle() + "_updated"); + clientToUpdate.setLoginButtonLabel("Updated Login"); + clientToUpdate.setPlatforms(List.of(PlatformType.WEB, PlatformType.ANDROID)); + OAuth2Client updatedClient = client.saveOAuth2Client(clientToUpdate); + assertEquals(clientToUpdate.getTitle(), updatedClient.getTitle()); + assertEquals("Updated Login", updatedClient.getLoginButtonLabel()); + assertEquals(2, updatedClient.getPlatforms().size()); + + // delete OAuth2 client + UUID clientToDeleteId = createdClients.get(0).getId().getId(); + client.deleteOauth2Client(clientToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getOAuth2ClientById(clientToDeleteId) + ); + + PageDataOAuth2ClientInfo clientsAfterDelete = client.findOAuth2ClientInfos(100, 0, + TEST_PREFIX + "OAuth2_" + timestamp, null, null); + assertEquals(4, clientsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java new file mode 100644 index 0000000000..c71f2cb2e3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/OtaPackageApiClientTest.java @@ -0,0 +1,261 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.ChecksumAlgorithm; +import org.thingsboard.client.model.DeviceProfileId; +import org.thingsboard.client.model.DeviceProfileInfo; +import org.thingsboard.client.model.OtaPackage; +import org.thingsboard.client.model.OtaPackageInfo; +import org.thingsboard.client.model.OtaPackageType; +import org.thingsboard.client.model.PageDataOtaPackageInfo; +import org.thingsboard.client.model.SaveOtaPackageInfoRequest; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.File; +import java.io.FileWriter; +import java.nio.file.Files; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class OtaPackageApiClientTest extends AbstractApiClientTest { + + private static final String OTA_PREFIX = "OtaTest_"; + + private DeviceProfileId getDefaultDeviceProfileId() throws Exception { + DeviceProfileInfo profileInfo = client.getDefaultDeviceProfileInfo(); + return (DeviceProfileId) profileInfo.getId(); + } + + private SaveOtaPackageInfoRequest buildOtaPackageInfoRequest( + String title, String version, OtaPackageType type, + DeviceProfileId deviceProfileId, boolean usesUrl, String url) { + SaveOtaPackageInfoRequest request = new SaveOtaPackageInfoRequest(); + request.setTitle(title); + request.setType(type); + request.setUrl(url); + request.setVersion(version); + request.setDeviceProfileId(deviceProfileId); + return request; + } + + private OtaPackageInfo createFirmwareInfo(String suffix) throws Exception { + DeviceProfileId profileId = getDefaultDeviceProfileId(); + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(), + OtaPackageType.FIRMWARE, profileId, false, null); + return client.saveOtaPackageInfo(request); + } + + private OtaPackageInfo createFirmwareWithUrl(String suffix) throws Exception { + DeviceProfileId profileId = getDefaultDeviceProfileId(); + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + OTA_PREFIX + suffix, "1.0." + System.currentTimeMillis(), + OtaPackageType.FIRMWARE, profileId, true, "https://example.com/firmware.bin"); + return client.saveOtaPackageInfo(request); + } + + @Test + public void testSaveAndGetOtaPackageInfo() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + String title = OTA_PREFIX + "save_" + ts; + String version = "1.0." + ts; + + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + title, version, OtaPackageType.FIRMWARE, profileId, true, "https://example.com/fw.bin"); + + OtaPackageInfo saved = client.saveOtaPackageInfo(request); + assertNotNull(saved); + assertNotNull(saved.getId()); + assertEquals(title, saved.getTitle()); + assertEquals(version, saved.getVersion()); + assertEquals(OtaPackageType.FIRMWARE, saved.getType()); + assertTrue(saved.getUrl().contains("example.com")); + + // get info by id + String pkgId = saved.getId().getId().toString(); + OtaPackageInfo fetched = client.getOtaPackageInfoById(pkgId); + assertNotNull(fetched); + assertEquals(title, fetched.getTitle()); + assertEquals(version, fetched.getVersion()); + } + + @Test + public void testGetOtaPackageById() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("getbyid_" + ts); + + OtaPackage fullPkg = client.getOtaPackageById(saved.getId().getId().toString()); + assertNotNull(fullPkg); + assertEquals(saved.getTitle(), fullPkg.getTitle()); + assertEquals(saved.getVersion(), fullPkg.getVersion()); + } + + @Test + public void testSaveOtaPackageInfoForSoftware() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + String title = OTA_PREFIX + "sw_" + ts; + + SaveOtaPackageInfoRequest request = buildOtaPackageInfoRequest( + title, "2.0." + ts, OtaPackageType.SOFTWARE, profileId, true, "https://example.com/sw.bin"); + + OtaPackageInfo saved = client.saveOtaPackageInfo(request); + assertNotNull(saved); + assertEquals(OtaPackageType.SOFTWARE, saved.getType()); + assertEquals(title, saved.getTitle()); + } + + @Test + public void testSaveOtaPackageData() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo info = createFirmwareInfo("data_" + ts); + + File tempFile = Files.createTempFile("ota_test_", ".bin").toFile(); + tempFile.deleteOnExit(); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write("test firmware content " + ts); + } + + OtaPackageInfo updated = client.saveOtaPackageData( + info.getId().getId().toString(), "MD5", tempFile, null); + assertNotNull(updated); + assertTrue(updated.getHasData()); + assertNotNull(updated.getFileName()); + assertNotNull(updated.getDataSize()); + assertTrue(updated.getDataSize() > 0); + assertEquals(ChecksumAlgorithm.MD5, updated.getChecksumAlgorithm()); + } + + @Test + public void testDownloadOtaPackage() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo info = createFirmwareInfo("download_" + ts); + + String content = "downloadable firmware " + ts; + File tempFile = Files.createTempFile("ota_dl_", ".bin").toFile(); + tempFile.deleteOnExit(); + try (FileWriter writer = new FileWriter(tempFile)) { + writer.write(content); + } + + client.saveOtaPackageData(info.getId().getId().toString(), "MD5", tempFile, null); + + File downloaded = client.downloadOtaPackage(info.getId().getId().toString()); + assertNotNull(downloaded); + assertTrue(downloaded.length() > 0); + String downloadedContent = Files.readString(downloaded.toPath()); + assertEquals(content, downloadedContent); + } + + @Test + public void testDeleteOtaPackage() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("delete_" + ts); + + String pkgId = saved.getId().getId().toString(); + client.getOtaPackageInfoById(pkgId); + + client.deleteOtaPackage(pkgId); + + assertReturns404(() -> client.getOtaPackageInfoById(pkgId)); + } + + @Test + public void testGetOtaPackages() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 3; i++) { + createFirmwareWithUrl("list_" + ts + "_" + i); + } + + PageDataOtaPackageInfo page = client.getOtaPackages(100, 0, OTA_PREFIX + "list_" + ts, null, null); + assertNotNull(page); + assertEquals(3, page.getTotalElements().intValue()); + for (OtaPackageInfo pkg : page.getData()) { + assertTrue(pkg.getTitle().startsWith(OTA_PREFIX + "list_" + ts)); + } + } + + @Test + public void testGetOtaPackagesByDeviceProfileAndType() throws Exception { + long ts = System.currentTimeMillis(); + DeviceProfileId profileId = getDefaultDeviceProfileId(); + + createFirmwareWithUrl("byprofile_" + ts + "_0"); + createFirmwareWithUrl("byprofile_" + ts + "_1"); + + PageDataOtaPackageInfo page = client.getOtaPackagesByDeviceProfileAndType( + profileId.getId().toString(), "FIRMWARE", 100, 0, + OTA_PREFIX + "byprofile_" + ts, null, null); + assertNotNull(page); + assertEquals(2, page.getTotalElements().intValue()); + } + + @Test + public void testGetOtaPackageInfoById_notFound() { + String nonExistentId = UUID.randomUUID().toString(); + assertReturns404(() -> client.getOtaPackageInfoById(nonExistentId)); + } + + @Test + public void testGetOtaPackagesPagination() throws Exception { + long ts = System.currentTimeMillis(); + + for (int i = 0; i < 5; i++) { + createFirmwareWithUrl("paged_" + ts + "_" + i); + } + + PageDataOtaPackageInfo page1 = client.getOtaPackages(2, 0, OTA_PREFIX + "paged_" + ts, null, null); + assertNotNull(page1); + assertEquals(5, page1.getTotalElements().intValue()); + assertEquals(3, page1.getTotalPages().intValue()); + assertEquals(2, page1.getData().size()); + assertTrue(page1.getHasNext()); + + PageDataOtaPackageInfo lastPage = client.getOtaPackages(2, 2, OTA_PREFIX + "paged_" + ts, null, null); + assertEquals(1, lastPage.getData().size()); + assertFalse(lastPage.getHasNext()); + } + + @Test + public void testUpdateOtaPackageInfo() throws Exception { + long ts = System.currentTimeMillis(); + OtaPackageInfo saved = createFirmwareWithUrl("update_" + ts); + + SaveOtaPackageInfoRequest updateReq = new SaveOtaPackageInfoRequest(); + updateReq.setId(saved.getId()); + updateReq.setTitle(saved.getTitle()); + updateReq.setType(saved.getType()); + updateReq.setVersion(saved.getVersion()); + updateReq.setDeviceProfileId(saved.getDeviceProfileId()); + updateReq.setUrl(saved.getUrl()); + updateReq.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("infoKey", "infoValue")); + + OtaPackageInfo updated = client.saveOtaPackageInfo(updateReq); + assertNotNull(updated); + assertEquals(saved.getId().getId(), updated.getId().getId()); + assertEquals("infoValue", updated.getAdditionalInfo().get("infoKey").asText()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java new file mode 100644 index 0000000000..80719052d1 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RpcV1ApiClientTest.java @@ -0,0 +1,72 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Device; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import static org.junit.Assert.assertEquals; + +@DaoSqlTest +public class RpcV1ApiClientTest extends AbstractApiClientTest { + + private static final String ONE_WAY_BODY = + "{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}"; + private static final String TWO_WAY_BODY = + "{\"method\":\"getGpio\",\"params\":{\"pin\":7},\"persistent\":true}"; + + @Test + public void testHandleOneWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleOneWayDeviceRPCRequestV1(deviceId, ONE_WAY_BODY); + } catch (ApiException e) { + assertEquals("handleOneWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testHandleTwoWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleTwoWayDeviceRPCRequestV1(deviceId, TWO_WAY_BODY); + } catch (ApiException e) { + assertEquals("handleTwoWayDeviceRPCRequest got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + private Device createNewDevice(String name) throws ApiException { + Device device = new Device(); + device.setName(name); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java new file mode 100644 index 0000000000..02b7e0f0ba --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RpcV2ApiClientTest.java @@ -0,0 +1,133 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.Rpc; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class RpcV2ApiClientTest extends AbstractApiClientTest { + + private static final String PERSISTENT_BODY = + "{\"method\":\"setGpio\",\"params\":{\"pin\":7,\"value\":1},\"persistent\":true}"; + + @Test + public void testHandleOneWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleOneWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY); + } catch (ApiException e) { + assertEquals("handleOneWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testHandleTwoWayDeviceRPCRequest() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + try { + client.handleTwoWayDeviceRPCRequestV2(deviceId, PERSISTENT_BODY); + } catch (ApiException e) { + assertEquals("handleTwoWayDeviceRPCRequest1 got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + @Test + public void testGetPersistedRpcAndDeleteRpc() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + String rpcId = postPersistentRpcAndGetId(deviceId); + assertNotNull(rpcId); + + Rpc rpc = client.getPersistedRpc(rpcId); + assertNotNull(rpc); + assertNotNull(rpc.getId()); + + client.deleteRpc(rpcId); + + assertReturns404(() -> client.getPersistedRpc(rpcId)); + + client.deleteDevice(deviceId); + } + + @Test + public void testGetPersistedRpcNotFound() { + assertReturns404(() -> client.getPersistedRpc(UUID.randomUUID().toString())); + } + + @Test + public void testGetPersistedRpcByDevice() throws Exception { + long ts = System.currentTimeMillis(); + Device device = createNewDevice(TEST_PREFIX + ts); + String deviceId = device.getId().getId().toString(); + + postPersistentRpcAndGetId(deviceId); + + try { + client.getPersistedRpcByDevice(deviceId, 100, 0, null, null, null, null); + } catch (ApiException e) { + assertEquals("getPersistedRpcByDevice got an unexpected HTTP error: " + e.getCode(), + 0, e.getCode()); + } + + client.deleteDevice(deviceId); + } + + private Device createNewDevice(String name) throws ApiException { + Device device = new Device(); + device.setName(name); + device.setType("default"); + return client.saveDevice(device, null, null, null, null); + } + + private String postPersistentRpcAndGetId(String deviceId) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(getBaseUrl() + "/api/plugins/rpc/oneway/" + deviceId)) + .header("Content-Type", "application/json") + .header("Authorization", "Bearer " + client.getToken()) + .POST(HttpRequest.BodyPublishers.ofString(PERSISTENT_BODY)) + .build(); + HttpResponse response = HttpClient.newHttpClient().send(request, HttpResponse.BodyHandlers.ofString()); + return OBJECT_MAPPER.readTree(response.body()).get("rpcId").asText(); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java new file mode 100644 index 0000000000..8adcecaf24 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/RuleChainApiClientTest.java @@ -0,0 +1,163 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.NodeConnectionInfo; +import org.thingsboard.client.model.PageDataRuleChain; +import org.thingsboard.client.model.RuleChain; +import org.thingsboard.client.model.RuleChainMetaData; +import org.thingsboard.client.model.RuleChainType; +import org.thingsboard.client.model.RuleNode; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class RuleChainApiClientTest extends AbstractApiClientTest { + + @Test + public void testRuleChainAndNodeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdChains = new ArrayList<>(); + + // create 5 rule chains + for (int i = 0; i < 5; i++) { + RuleChain ruleChain = new RuleChain(); + ruleChain.setName(TEST_PREFIX + "RuleChain_" + timestamp + "_" + i); + ruleChain.setType(RuleChainType.CORE); + ruleChain.setDebugMode(false); + + RuleChain created = client.saveRuleChain(ruleChain); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(ruleChain.getName(), created.getName()); + assertEquals(RuleChainType.CORE, created.getType()); + + createdChains.add(created); + } + + // list rule chains with text search + PageDataRuleChain filteredChains = client.getRuleChains(100, 0, null, + TEST_PREFIX + "RuleChain_" + timestamp, null, null); + assertNotNull(filteredChains); + assertEquals(5, filteredChains.getData().size()); + + // get rule chain by id + RuleChain searchChain = createdChains.get(2); + RuleChain fetchedChain = client.getRuleChainById(searchChain.getId().getId().toString()); + assertEquals(searchChain.getName(), fetchedChain.getName()); + assertEquals(searchChain.getType(), fetchedChain.getType()); + + // get metadata (initially has default node) + RuleChainMetaData metadata = client.getRuleChainMetaData(searchChain.getId().getId().toString()); + assertNotNull(metadata); + assertEquals(searchChain.getId().getId(), metadata.getRuleChainId().getId()); + + // save metadata with rule nodes and connections + RuleChainMetaData newMetadata = new RuleChainMetaData(metadata.getRuleChainId()); + newMetadata.setVersion(metadata.getVersion()); + newMetadata.setFirstNodeIndex(0); + + // node 0: message type switch + RuleNode switchNode = new RuleNode(); + switchNode.setName("Message Type Switch"); + switchNode.setType("org.thingsboard.rule.engine.filter.TbMsgTypeSwitchNode"); + switchNode.setConfiguration(OBJECT_MAPPER.createObjectNode().put("version", 0)); + switchNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 200).put("layoutY", 150)); + + // node 1: log node for telemetry + RuleNode logNode = new RuleNode(); + logNode.setName("Log Telemetry"); + logNode.setType("org.thingsboard.rule.engine.action.TbLogNode"); + logNode.setConfiguration(OBJECT_MAPPER.createObjectNode() + .put("scriptLang", "TBEL") + .put("jsScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);") + .put("tbelScript", "return '\\nIncoming message:\\n' + JSON.stringify(msg) + '\\nIncoming metadata:\\n' + JSON.stringify(metadata);")); + logNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 100)); + + // node 2: save timeseries + RuleNode saveNode = new RuleNode(); + saveNode.setName("Save Timeseries"); + saveNode.setType("org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode"); + saveNode.setConfiguration(OBJECT_MAPPER.createObjectNode() + .put("defaultTTL", 0) + .put("skipLatestPersistence", false) + .put("useServerTs", false)); + saveNode.setAdditionalInfo(OBJECT_MAPPER.createObjectNode().put("layoutX", 500).put("layoutY", 250)); + + newMetadata.setNodes(List.of(switchNode, logNode, saveNode)); + + // connection: switch -> log (on "Post telemetry") + NodeConnectionInfo conn1 = new NodeConnectionInfo(); + conn1.setFromIndex(0); + conn1.setToIndex(1); + conn1.setType("Post telemetry"); + + // connection: switch -> save timeseries (on "Post telemetry") + NodeConnectionInfo conn2 = new NodeConnectionInfo(); + conn2.setFromIndex(0); + conn2.setToIndex(2); + conn2.setType("Post telemetry"); + + newMetadata.setConnections(List.of(conn1, conn2)); + newMetadata.setRuleChainConnections(List.of()); + + RuleChainMetaData savedMetadata = client.saveRuleChainMetaData(newMetadata, false); + assertNotNull(savedMetadata); + assertEquals(3, savedMetadata.getNodes().size()); + assertEquals(2, savedMetadata.getConnections().size()); + + // verify saved nodes + RuleChainMetaData fetchedMetadata = client.getRuleChainMetaData(searchChain.getId().getId().toString()); + assertEquals(3, fetchedMetadata.getNodes().size()); + assertTrue(fetchedMetadata.getNodes().stream() + .anyMatch(node -> "Log Telemetry".equals(node.getName()))); + assertTrue(fetchedMetadata.getNodes().stream() + .anyMatch(node -> "Save Timeseries".equals(node.getName()))); + + // get output labels + client.getRuleChainOutputLabels(searchChain.getId().getId().toString()); + + // update rule chain + RuleChain chainToUpdate = createdChains.get(3); + chainToUpdate.setName(chainToUpdate.getName() + "_updated"); + chainToUpdate.setDebugMode(true); + RuleChain updatedChain = client.saveRuleChain(chainToUpdate); + assertEquals(chainToUpdate.getName(), updatedChain.getName()); + assertEquals(true, updatedChain.getDebugMode()); + + // delete rule chain + UUID chainToDeleteId = createdChains.get(0).getId().getId(); + client.deleteRuleChain(chainToDeleteId.toString()); + + // verify deletion + assertReturns404(() -> + client.getRuleChainById(chainToDeleteId.toString()) + ); + + PageDataRuleChain chainsAfterDelete = client.getRuleChains(100, 0, null, + TEST_PREFIX + "RuleChain_" + timestamp, null, null); + assertEquals(4, chainsAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java new file mode 100644 index 0000000000..2a06549554 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TbImageApiClientTest.java @@ -0,0 +1,151 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.PageDataTbResourceInfo; +import org.thingsboard.client.model.ResourceExportData; +import org.thingsboard.client.model.TbImageDeleteResult; +import org.thingsboard.client.model.TbResourceInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import javax.imageio.ImageIO; +import java.awt.Color; +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TbImageApiClientTest extends AbstractApiClientTest { + + private File createTempImage(String name, Color color) throws IOException { + BufferedImage img = new BufferedImage(100, 100, BufferedImage.TYPE_INT_RGB); + Graphics2D g = img.createGraphics(); + g.setColor(color); + g.fillRect(0, 0, 100, 100); + g.dispose(); + + File tempFile = File.createTempFile(name, ".png"); + tempFile.deleteOnExit(); + ImageIO.write(img, "png", tempFile); + return tempFile; + } + + @Test + public void testImageLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdImages = new ArrayList<>(); + Color[] colors = {Color.RED, Color.GREEN, Color.BLUE, Color.YELLOW, Color.CYAN}; + + // upload 5 images + for (int i = 0; i < 5; i++) { + String title = TEST_PREFIX + "Image_" + timestamp + "_" + i; + File imageFile = createTempImage("test_image_" + i, colors[i]); + + TbResourceInfo uploaded = client.uploadImage(imageFile, title, null); + assertNotNull(uploaded); + assertNotNull(uploaded.getResourceKey()); + assertEquals(title, uploaded.getTitle()); + assertNotNull(uploaded.getLink()); + + createdImages.add(uploaded); + } + + // list images with text search + PageDataTbResourceInfo filteredImages = client.getImages(100, 0, null, false, + TEST_PREFIX + "Image_" + timestamp, null, null); + assertNotNull(filteredImages); + assertEquals(5, filteredImages.getData().size()); + + // get image info by type and key + TbResourceInfo searchImage = createdImages.get(2); + TbResourceInfo fetchedInfo = client.getImageInfo("tenant", searchImage.getResourceKey()); + assertEquals(searchImage.getTitle(), fetchedInfo.getTitle()); + assertEquals(searchImage.getResourceKey(), fetchedInfo.getResourceKey()); + + // download image + File downloadedImage = client.downloadImage("tenant", searchImage.getResourceKey(), null, null); + assertNotNull(downloadedImage); + assertTrue(downloadedImage.exists()); + assertTrue(downloadedImage.length() > 0); + + // download image preview + File preview = client.downloadImagePreview("tenant", searchImage.getResourceKey(), null, null); + assertNotNull(preview); + assertTrue(preview.exists()); + assertTrue(preview.length() > 0); + + // update image file + File updatedImageFile = createTempImage("updated_image", Color.MAGENTA); + TbResourceInfo updatedImage = client.updateImage("tenant", searchImage.getResourceKey(), updatedImageFile); + assertNotNull(updatedImage); + assertEquals(searchImage.getResourceKey(), updatedImage.getResourceKey()); + + // update image info (title) + TbResourceInfo infoToUpdate = client.getImageInfo("tenant", createdImages.get(3).getResourceKey()); + infoToUpdate.setTitle(infoToUpdate.getTitle() + "_updated"); + TbResourceInfo updatedInfo = client.updateImageInfo("tenant", infoToUpdate.getResourceKey(), infoToUpdate); + assertEquals(infoToUpdate.getTitle(), updatedInfo.getTitle()); + + // make image public + TbResourceInfo publicImage = client.updateImagePublicStatus("tenant", + createdImages.get(1).getResourceKey(), true); + assertTrue(publicImage.getPublic()); + assertNotNull(publicImage.getPublicResourceKey()); + assertNotNull(publicImage.getPublicLink()); + + // download public image + File publicDownload = client.downloadPublicImage(publicImage.getPublicResourceKey(), null, null); + assertNotNull(publicDownload); + assertTrue(publicDownload.exists()); + assertTrue(publicDownload.length() > 0); + + // make image private again + TbResourceInfo privateImage = client.updateImagePublicStatus("tenant", + createdImages.get(1).getResourceKey(), false); + assertEquals(false, privateImage.getPublic()); + + // export image + ResourceExportData exportData = client.exportImage("tenant", createdImages.get(4).getResourceKey()); + assertNotNull(exportData); + assertNotNull(exportData.getData()); + assertEquals(createdImages.get(4).getTitle(), exportData.getTitle()); + assertEquals(createdImages.get(4).getResourceKey(), exportData.getResourceKey()); + + // delete image + String keyToDelete = createdImages.get(0).getResourceKey(); + TbImageDeleteResult deleteResult = client.deleteImage("tenant", keyToDelete, false); + assertNotNull(deleteResult); + assertTrue(deleteResult.getSuccess()); + + // verify deletion + assertReturns404(() -> + client.getImageInfo("tenant", keyToDelete) + ); + + PageDataTbResourceInfo imagesAfterDelete = client.getImages(100, 0, null, false, + TEST_PREFIX + "Image_" + timestamp, null, null); + assertEquals(4, imagesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java new file mode 100644 index 0000000000..0651ce6cc3 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TbResourceApiClientTest.java @@ -0,0 +1,128 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.PageDataTbResourceInfo; +import org.thingsboard.client.model.ResourceType; +import org.thingsboard.client.model.TbResource; +import org.thingsboard.client.model.TbResourceInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.io.File; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TbResourceApiClientTest extends AbstractApiClientTest { + + @Test + public void testResourceLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdResources = new ArrayList<>(); + + // create 5 JS_MODULE resources + for (int i = 0; i < 5; i++) { + TbResource resource = new TbResource(); + resource.setTitle(TEST_PREFIX + "Resource_" + timestamp + "_" + i); + resource.setResourceType(ResourceType.JS_MODULE); + resource.setResourceKey("test_module_" + timestamp + "_" + i + ".js"); + resource.setFileName("test_module_" + timestamp + "_" + i + ".js"); + + String jsContent = "export default function test" + i + "() { return " + i + "; }"; + resource.setData(Base64.getEncoder().encodeToString(jsContent.getBytes())); + + TbResourceInfo created = client.saveResource(resource); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(resource.getTitle(), created.getTitle()); + assertEquals(ResourceType.JS_MODULE, created.getResourceType()); + + createdResources.add(created); + } + + // get tenant resources, check count + PageDataTbResourceInfo tenantResources = client.getTenantResources(100, 0, null, null, null); + assertNotNull(tenantResources); + assertNotNull(tenantResources.getData()); + int initialSize = tenantResources.getData().size(); + assertTrue("Expected at least 5 resources, but got " + initialSize, initialSize >= 5); + + // find with text search + PageDataTbResourceInfo filteredResources = client.getTenantResources(100, 0, + TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(5, filteredResources.getData().size()); + + // get resources with type filter + PageDataTbResourceInfo jsResources = client.getResources(100, 0, + ResourceType.JS_MODULE.getValue(), null, TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(5, jsResources.getData().size()); + + // get resource info by id + TbResourceInfo searchResource = createdResources.get(2); + TbResourceInfo fetchedInfo = client.getResourceInfoById(searchResource.getId().getId().toString()); + assertEquals(searchResource.getTitle(), fetchedInfo.getTitle()); + assertEquals(searchResource.getResourceKey(), fetchedInfo.getResourceKey()); + + // get full resource by id (includes data) + TbResource fullResource = client.getResourceById(searchResource.getId().getId().toString()); + assertNotNull(fullResource); + assertEquals(searchResource.getTitle(), fullResource.getTitle()); + assertNotNull(fullResource.getData()); + + // download resource + File downloadedFile = client.downloadResource(searchResource.getId().getId().toString()); + assertNotNull(downloadedFile); + assertTrue(downloadedFile.exists()); + assertTrue(downloadedFile.length() > 0); + + // get resources by list of ids + List idsToFetch = List.of( + createdResources.get(0).getId().getId().toString(), + createdResources.get(1).getId().getId().toString() + ); + List resourceList = client.getSystemOrTenantResourcesByIds(idsToFetch); + assertEquals(2, resourceList.size()); + + // update resource + TbResource resourceToUpdate = client.getResourceById(createdResources.get(3).getId().getId().toString()); + resourceToUpdate.setTitle(resourceToUpdate.getTitle() + "_updated"); + String updatedContent = "export default function updated() { return 42; }"; + resourceToUpdate.setData(Base64.getEncoder().encodeToString(updatedContent.getBytes())); + TbResourceInfo updatedResource = client.saveResource(resourceToUpdate); + assertEquals(resourceToUpdate.getTitle(), updatedResource.getTitle()); + + // delete resource + UUID resourceToDeleteId = createdResources.get(0).getId().getId(); + client.deleteResource(resourceToDeleteId.toString(), false); + + // verify deletion + assertReturns404(() -> + client.getResourceInfoById(resourceToDeleteId.toString()) + ); + + PageDataTbResourceInfo resourcesAfterDelete = client.getTenantResources(100, 0, + TEST_PREFIX + "Resource_" + timestamp, null, null); + assertEquals(4, resourcesAfterDelete.getData().size()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java new file mode 100644 index 0000000000..a2dbe0eda4 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TelemetryApiClientTest.java @@ -0,0 +1,150 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AttributeData; +import org.thingsboard.client.model.Device; +import org.thingsboard.client.model.TsData; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; +import java.util.Map; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TelemetryApiClientTest extends AbstractApiClientTest { + + @Test + public void testTelemetryLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + + // create a device for telemetry operations + Device device = new Device(); + device.setName("TelemetryTestDevice_" + timestamp); + device.setType("default"); + Device createdDevice = client.saveDevice(device, null, null, null, null); + assertNotNull(createdDevice); + + String entityType = "DEVICE"; + String entityId = createdDevice.getId().getId().toString(); + + // save server-side attributes + String serverAttributes = "{\"serverAttr1\": \"value1\", \"serverAttr2\": 42}"; + client.saveEntityAttributesV2(entityType, entityId, "SERVER_SCOPE", serverAttributes); + + // save shared attributes + String sharedAttributes = "{\"sharedAttr1\": \"sharedValue1\", \"sharedAttr2\": true}"; + client.saveEntityAttributesV2(entityType, entityId, "SHARED_SCOPE", sharedAttributes); + + // get attribute keys + List allKeys = client.getAttributeKeys(entityType, entityId); + assertNotNull(allKeys); + assertTrue(allKeys.containsAll(List.of("serverAttr1", "serverAttr2", "sharedAttr1", "sharedAttr2"))); + + // get attribute keys by scope + List serverKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertEquals(2 + 1, serverKeys.size()); //active attribute is automatically added to server scope + assertTrue(serverKeys.containsAll(List.of("serverAttr1", "serverAttr2", "active"))); + + // get attributes by scope + List serverAttrs = client.getAttributesByScope(entityType, entityId, "SERVER_SCOPE", "serverAttr1,serverAttr2", null); + assertNotNull(serverAttrs); + assertEquals(2, serverAttrs.size()); + + // get all attributes + List allAttrs = client.getAttributes(entityType, entityId, "serverAttr1,sharedAttr1", null); + assertEquals(2, allAttrs.size()); + assertEquals("value1", allAttrs.stream().filter(attr -> attr.getKey().equals("serverAttr1")).findFirst().orElseThrow().getValue().toString()); + assertEquals("sharedValue1", allAttrs.stream().filter(attr -> attr.getKey().equals("sharedAttr1")).findFirst().orElseThrow().getValue().toString()); + + // save timeseries data + long ts1 = timestamp - 60000; + long ts2 = timestamp - 30000; + long ts3 = timestamp; + String telemetryBody = "{\"ts\":" + ts1 + ",\"values\":{\"temperature\":25.5,\"humidity\":60}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody); + + String telemetryBody2 = "{\"ts\":" + ts2 + ",\"values\":{\"temperature\":26.0,\"humidity\":58}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody2); + + String telemetryBody3 = "{\"ts\":" + ts3 + ",\"values\":{\"temperature\":27.1,\"humidity\":55}}"; + client.saveEntityTelemetry(entityType, entityId, "ANY", telemetryBody3); + + // get timeseries keys + List tsKeys = client.getTimeseriesKeys(entityType, entityId); + assertNotNull(tsKeys); + assertEquals(2, tsKeys.size()); + assertTrue(tsKeys.containsAll(List.of("humidity", "temperature"))); + + // get latest timeseries + Map> latestData = client.getLatestTimeseries(entityType, entityId, "temperature,humidity", false, null); + assertNotNull(latestData); + assertNotNull(latestData.get("temperature")); + assertFalse(latestData.get("temperature").isEmpty()); + assertEquals("27.1", latestData.get("temperature").get(0).getValue().toString()); + + // get timeseries history + Map> historyData = client.getTimeseriesHistory( + entityType, entityId, + ts1 - 1000, ts3 + 1000, "temperature", + null, null, null, null, "NONE", "ASC", false, null); + assertNotNull(historyData); + List tempHistory = historyData.get("temperature"); + assertNotNull(tempHistory); + assertEquals(3, tempHistory.size()); + assertEquals("25.5", tempHistory.get(0).getValue().toString()); + assertEquals("27.1", tempHistory.get(2).getValue().toString()); + + // delete timeseries + client.deleteEntityTimeseries(entityType, entityId, "humidity", true, null, null, true, false, null); + + List keysAfterDelete = client.getTimeseriesKeys(entityType, entityId); + assertFalse(keysAfterDelete.contains("humidity")); + + // delete attributes + client.deleteEntityAttributes(entityType, entityId, "SERVER_SCOPE", "serverAttr1", null); + + List serverKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertFalse(serverKeysAfterDelete.contains("serverAttr1")); + assertTrue(serverKeysAfterDelete.contains("serverAttr2")); + + // save device attributes using device-specific endpoint + client.saveDeviceAttributes(entityId, "SERVER_SCOPE", "{\"deviceSpecificAttr\": \"test\"}"); + + List deviceKeys = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertTrue(deviceKeys.contains("deviceSpecificAttr")); + + // delete device attributes + client.deleteDeviceAttributes(entityId, "SERVER_SCOPE", "deviceSpecificAttr", null); + + List deviceKeysAfterDelete = client.getAttributeKeysByScope(entityType, entityId, "SERVER_SCOPE"); + assertFalse(deviceKeysAfterDelete.contains("deviceSpecificAttr")); + + // save telemetry with TTL + String ttlTelemetry = "{\"ts\":" + timestamp + ",\"values\":{\"shortLived\":99}}"; + client.saveEntityTelemetryWithTTL(entityType, entityId, "ANY", 86400L, ttlTelemetry); + + Map> latestWithTtl = client.getLatestTimeseries(entityType, entityId, "shortLived", false, null); + assertNotNull(latestWithTtl.get("shortLived")); + assertEquals("99", latestWithTtl.get("shortLived").get(0).getValue().toString()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java new file mode 100644 index 0000000000..150486eb8c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TenantApiClientTest.java @@ -0,0 +1,119 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.PageDataTenant; +import org.thingsboard.client.model.PageDataUser; +import org.thingsboard.client.model.Tenant; +import org.thingsboard.client.model.User; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class TenantApiClientTest extends AbstractApiClientTest { + + @Test + public void testTenantLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdTenants = new ArrayList<>(); + + // authenticate as sysadmin for tenant management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // create 20 tenants + for (int i = 0; i < 20; i++) { + Tenant tenant = new Tenant(); + String tenantTitle = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i; + tenant.setTitle(tenantTitle); + tenant.setEmail("tenant_" + timestamp + "_" + i + "@test.com"); + tenant.setCountry("US"); + tenant.setCity("City" + i); + + Tenant createdTenant = client.saveTenant(tenant); + assertNotNull(createdTenant); + assertNotNull(createdTenant.getId()); + assertEquals(tenantTitle, createdTenant.getTitle()); + + createdTenants.add(createdTenant); + } + + try { + // find all with search text, check count + PageDataTenant filteredTenants = client.getTenants(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 tenants matching prefix", 10, filteredTenants.getData().size()); + + // find by id + Tenant searchTenant = createdTenants.get(10); + Tenant fetchedTenant = client.getTenantById(searchTenant.getId().getId().toString()); + assertEquals(searchTenant.getTitle(), fetchedTenant.getTitle()); + assertEquals(searchTenant.getEmail(), fetchedTenant.getEmail()); + + // update tenant + fetchedTenant.setCity("Updated City"); + fetchedTenant.setCountry("DE"); + Tenant updatedTenant = client.saveTenant(fetchedTenant); + assertEquals("Updated City", updatedTenant.getCity()); + assertEquals("DE", updatedTenant.getCountry()); + + // create a tenant admin for one of the tenants and verify listing + Tenant tenantForAdmin = createdTenants.get(0); + User adminUser = new User(); + adminUser.setEmail("tenanttest_admin_" + timestamp + "@test.com"); + adminUser.setAuthority(Authority.TENANT_ADMIN); + adminUser.setTenantId(tenantForAdmin.getId()); + adminUser.setFirstName("TestAdmin"); + User savedAdmin = client.saveUser(adminUser, "false"); + assertNotNull(savedAdmin); + + PageDataUser tenantAdmins = client.getTenantAdmins( + tenantForAdmin.getId().getId().toString(), 100, 0, null, null, null); + assertEquals(1, tenantAdmins.getData().size()); + assertEquals(savedAdmin.getEmail(), tenantAdmins.getData().get(0).getEmail()); + + // delete tenant + UUID tenantToDeleteId = createdTenants.get(0).getId().getId(); + client.deleteTenant(tenantToDeleteId.toString()); + createdTenants.remove(0); + + // verify deletion + PageDataTenant tenantsAfterDelete = client.getTenants(100, 0, TEST_PREFIX_2, null, null); + assertEquals(10, tenantsAfterDelete.getData().size()); + + assertReturns404(() -> + client.getTenantById(tenantToDeleteId.toString()) + ); + } finally { + // clean up all created tenants (deleting tenant cascades to users) + client.login("sysadmin@thingsboard.org", "sysadmin"); + for (Tenant tenant : createdTenants) { + try { + client.deleteTenant(tenant.getId().getId().toString()); + } catch (ApiException ignored) { + } + } + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java new file mode 100644 index 0000000000..68dd5f7c0e --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TenantProfileApiClientTest.java @@ -0,0 +1,179 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.ApiException; +import org.thingsboard.client.model.DefaultTenantProfileConfiguration; +import org.thingsboard.client.model.EntityInfo; +import org.thingsboard.client.model.PageDataEntityInfo; +import org.thingsboard.client.model.PageDataTenantProfile; +import org.thingsboard.client.model.TenantProfile; +import org.thingsboard.client.model.TenantProfileData; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TenantProfileApiClientTest extends AbstractApiClientTest { + + @Test + public void testTenantProfileLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdProfiles = new ArrayList<>(); + + // authenticate as sysadmin for tenant profile management + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // get initial count (there should be a default profile) + PageDataTenantProfile initialProfiles = client.getTenantProfiles(100, 0, null, null, null); + assertNotNull(initialProfiles); + int initialSize = initialProfiles.getData().size(); + assertTrue("Expected at least 1 default tenant profile", initialSize >= 1); + + // get default tenant profile info + EntityInfo defaultProfileInfo = client.getDefaultTenantProfileInfo(); + assertNotNull(defaultProfileInfo); + assertNotNull(defaultProfileInfo.getName()); + + try { + // create 5 tenant profiles + for (int i = 0; i < 5; i++) { + TenantProfile profile = new TenantProfile(); + profile.setName(TEST_PREFIX + "TenantProfile_" + timestamp + "_" + i); + profile.setDescription("Test tenant profile " + i); + profile.setIsolatedTbRuleEngine(false); + + TenantProfileData profileData = new TenantProfileData(); + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setMaxDevices(100L); + config.setMaxAssets(100L); + config.setMaxCustomers(50L); + config.setMaxUsers(50L); + config.setMaxDashboards(50L); + config.setMaxRuleChains(20L); + config.setMaxDataPointsPerRollingArg(20L); + config.setMaxRelatedEntitiesToReturnPerCfArgument(20); + config.setMaxRelationLevelPerCfArgument(20); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + profile.setDefault(false); + + TenantProfile created = client.saveTenantProfile(profile); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(profile.getName(), created.getName()); + assertEquals(profile.getDescription(), created.getDescription()); + assertFalse(created.getDefault()); + + createdProfiles.add(created); + } + + // find all, check count + PageDataTenantProfile allProfiles = client.getTenantProfiles(100, 0, null, null, null); + assertNotNull(allProfiles); + assertEquals(initialSize + 5, allProfiles.getData().size()); + + // find with text search + PageDataTenantProfile filteredProfiles = client.getTenantProfiles(100, 0, + TEST_PREFIX + "TenantProfile_" + timestamp, null, null); + assertEquals(5, filteredProfiles.getData().size()); + + // get by id + TenantProfile searchProfile = createdProfiles.get(2); + TenantProfile fetchedProfile = client.getTenantProfileById(searchProfile.getId().getId().toString()); + assertEquals(searchProfile.getName(), fetchedProfile.getName()); + assertEquals(searchProfile.getDescription(), fetchedProfile.getDescription()); + + // update tenant profile + fetchedProfile.setDescription("Updated description"); + TenantProfile updatedProfile = client.saveTenantProfile(fetchedProfile); + assertEquals("Updated description", updatedProfile.getDescription()); + assertEquals(fetchedProfile.getName(), updatedProfile.getName()); + + // get tenant profile infos (paginated) + PageDataEntityInfo profileInfos = client.getTenantProfileInfos(100, 0, null, null, null); + assertNotNull(profileInfos); + assertEquals(initialSize + 5, profileInfos.getData().size()); + + // get profiles by list of ids + List idsToFetch = List.of( + createdProfiles.get(0).getId().getId().toString(), + createdProfiles.get(1).getId().getId().toString() + ); + List profileList = client.getTenantProfileList(idsToFetch); + assertEquals(2, profileList.size()); + + // set a profile as default + TenantProfile profileToSetDefault = createdProfiles.get(1); + client.setDefaultTenantProfile(profileToSetDefault.getId().getId().toString()); + EntityInfo defaultTenantProfileInfo = client.getDefaultTenantProfileInfo(); + assertEquals(profileToSetDefault.getName(), defaultTenantProfileInfo.getName()); + + // verify default profile info now points to the new default + EntityInfo newDefaultInfo = client.getDefaultTenantProfileInfo(); + assertEquals(profileToSetDefault.getName(), newDefaultInfo.getName()); + + // restore original default profile + TenantProfile originalDefault = initialProfiles.getData().stream() + .filter(TenantProfile::getDefault) + .findFirst() + .orElseThrow(); + client.setDefaultTenantProfile(originalDefault.getId().getId().toString()); + + // delete tenant profile (cannot delete the default one) + UUID profileToDeleteId = createdProfiles.get(0).getId().getId(); + client.deleteTenantProfile(profileToDeleteId.toString()); + createdProfiles.remove(0); + + // verify deletion + assertReturns404(() -> + client.getTenantProfileById(profileToDeleteId.toString()) + ); + + PageDataTenantProfile profilesAfterDelete = client.getTenantProfiles(100, 0, null, null, null); + assertEquals(initialSize + 4, profilesAfterDelete.getData().size()); + } finally { + // clean up created profiles + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // ensure original default is restored before deleting test profiles + TenantProfile originalDefault = initialProfiles.getData().stream() + .filter(TenantProfile::getDefault) + .findFirst() + .orElseThrow(); + try { + client.setDefaultTenantProfile(originalDefault.getId().getId().toString()); + } catch (ApiException ignored) { + } + + for (TenantProfile profile : createdProfiles) { + try { + client.deleteTenantProfile(profile.getId().getId().toString()); + } catch (ApiException ignored) { + } + } + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java new file mode 100644 index 0000000000..5f371b5d82 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/TwoFactorAuthApiClientTest.java @@ -0,0 +1,78 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.AccountTwoFaSettings; +import org.thingsboard.client.model.PlatformTwoFaSettings; +import org.thingsboard.client.model.TotpTwoFaAccountConfig; +import org.thingsboard.client.model.TotpTwoFaProviderConfig; +import org.thingsboard.client.model.TwoFaAccountConfig; +import org.thingsboard.client.model.TwoFaProviderType; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.List; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +@DaoSqlTest +public class TwoFactorAuthApiClientTest extends AbstractApiClientTest { + + @Test + public void testTwoFactorAuthLifecycle() throws Exception { + // save original platform 2FA settings as sysadmin + client.login("sysadmin@thingsboard.org", "sysadmin"); + + // configure platform 2FA settings with TOTP provider + TotpTwoFaProviderConfig totpProviderConfig = new TotpTwoFaProviderConfig(); + totpProviderConfig.setIssuerName("TestThingsBoard"); + + PlatformTwoFaSettings newSettings = new PlatformTwoFaSettings(); + newSettings.setProviders(List.of(totpProviderConfig)); + newSettings.setMinVerificationCodeSendPeriod(30); + newSettings.setTotalAllowedTimeForVerification(300); + newSettings.setMaxVerificationFailuresBeforeUserLockout(5); + + PlatformTwoFaSettings savedSettings = client.savePlatformTwoFaSettings(newSettings); + assertNotNull(savedSettings); + assertNotNull(savedSettings.getProviders()); + assertFalse(savedSettings.getProviders().isEmpty()); + assertEquals(30, savedSettings.getMinVerificationCodeSendPeriod().intValue()); + assertEquals(300, savedSettings.getTotalAllowedTimeForVerification().intValue()); + + // get available 2FA providers (should include TOTP) + List providerTypes = client.getAvailableTwoFaProviderTypes(); + assertNotNull(providerTypes); + assertTrue(providerTypes.contains(TwoFaProviderType.TOTP)); + + // get account 2FA settings (should be empty initially) + AccountTwoFaSettings accountSettings = client.getAccountTwoFaSettings(); + assertNull(accountSettings); + + // generate TOTP account config + TwoFaAccountConfig generatedConfig = client.generateTwoFaAccountConfig(TwoFaProviderType.TOTP.getValue()); + assertNotNull(generatedConfig); + TotpTwoFaAccountConfig totpConfig = (TotpTwoFaAccountConfig) generatedConfig; + assertNotNull(totpConfig); + assertNotNull(totpConfig.getAuthUrl()); + assertTrue(totpConfig.getAuthUrl().startsWith("otpauth://totp/")); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java new file mode 100644 index 0000000000..154858405f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/UserApiClientTest.java @@ -0,0 +1,135 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import org.junit.Test; +import org.thingsboard.client.model.Authority; +import org.thingsboard.client.model.Customer; +import org.thingsboard.client.model.JwtPair; +import org.thingsboard.client.model.PageDataUser; +import org.thingsboard.client.model.User; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class UserApiClientTest extends AbstractApiClientTest { + + @Test + public void testUserLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdUsers = new ArrayList<>(); + + // create 20 tenant admin users + for (int i = 0; i < 20; i++) { + User user = new User(); + String email = ((i % 2 == 0) ? TEST_PREFIX : TEST_PREFIX_2) + timestamp + "_" + i + "@test.com"; + user.setEmail(email); + user.setAuthority(Authority.TENANT_ADMIN); + user.setTenantId(savedClientTenant.getId()); + user.setFirstName("First" + i); + user.setLastName("Last" + i); + + User createdUser = client.saveUser(user, "false"); + assertNotNull(createdUser); + assertNotNull(createdUser.getId()); + assertEquals(email, createdUser.getEmail()); + assertEquals(Authority.TENANT_ADMIN, createdUser.getAuthority()); + + createdUsers.add(createdUser); + } + + // find all tenant admins, check count (20 created + 1 from setup) + PageDataUser allUsers = client.getUsers(100, 0, null, null, null); + assertNotNull(allUsers); + assertNotNull(allUsers.getData()); + int initialSize = allUsers.getData().size(); + assertEquals("Expected 21 users (20 created + 2 from setup), but got " + initialSize, 22, initialSize); + + // find with search text, check count + PageDataUser filteredUsers = client.getUsers(100, 0, TEST_PREFIX_2, null, null); + assertEquals("Expected exactly 10 users matching prefix", 10, filteredUsers.getData().size()); + + // find by id + User searchUser = createdUsers.get(10); + User fetchedUser = client.getUserById(searchUser.getId().getId().toString()); + assertEquals(searchUser.getEmail(), fetchedUser.getEmail()); + assertEquals(searchUser.getFirstName(), fetchedUser.getFirstName()); + + // update user + fetchedUser.setFirstName("UpdatedFirst"); + fetchedUser.setLastName("UpdatedLast"); + User updatedUser = client.saveUser(fetchedUser, "false"); + assertEquals("UpdatedFirst", updatedUser.getFirstName()); + assertEquals("UpdatedLast", updatedUser.getLastName()); + + // activate user and get token + activateUser(createdUsers.get(0).getId(), "password123", false); + JwtPair userToken = client.getUserToken(createdUsers.get(0).getId().getId().toString()); + assertNotNull(userToken); + assertNotNull(userToken.getToken()); + + // disable user credentials + client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "false"); + + // re-enable user credentials + client.setUserCredentialsEnabled(createdUsers.get(0).getId().getId().toString(), "true"); + + // create customer users and verify listing + Customer customer2 = new Customer(); + customer2.setTitle("User test customer " + timestamp); + customer2.setEmail("usertest_" + timestamp + "@test.com"); + Customer savedCustomer2 = client.saveCustomer(customer2, null, null, null); + + List customerUsers = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + User customerUser = new User(); + customerUser.setEmail("custuser_" + timestamp + "_" + i + "@test.com"); + customerUser.setAuthority(Authority.CUSTOMER_USER); + customerUser.setTenantId(savedClientTenant.getId()); + customerUser.setCustomerId(savedCustomer2.getId()); + customerUser.setFirstName("CustFirst" + i); + customerUser.setLastName("CustLast" + i); + + User created = client.saveUser(customerUser, "false"); + assertNotNull(created); + customerUsers.add(created); + } + + // list customer users + PageDataUser customerUserPage = client.getCustomerUsers( + savedCustomer2.getId().getId().toString(), 100, 0, null, null, null); + assertEquals("Expected 5 customer users", 5, customerUserPage.getData().size()); + + // delete user + UUID userToDeleteId = createdUsers.get(0).getId().getId(); + client.deleteUser(userToDeleteId.toString()); + + // verify deletion + PageDataUser usersAfterDelete = client.getUsers(100, 0, null, null, null); + assertEquals(initialSize + 5 - 1, usersAfterDelete.getData().size()); + + assertReturns404(() -> + client.getUserById(userToDeleteId.toString()) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java b/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java new file mode 100644 index 0000000000..ee3984bb4b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/client/WidgetTypeApiClientTest.java @@ -0,0 +1,155 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.client; + +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.Test; +import org.thingsboard.client.model.PageDataWidgetTypeInfo; +import org.thingsboard.client.model.WidgetTypeDetails; +import org.thingsboard.client.model.WidgetTypeInfo; +import org.thingsboard.client.model.WidgetsBundle; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; + +@DaoSqlTest +public class WidgetTypeApiClientTest extends AbstractApiClientTest { + + private JsonNode createDescriptor(String type) { + return OBJECT_MAPPER.createObjectNode() + .put("type", type) + .put("sizeX", 7.5) + .put("sizeY", 5) + .put("resources", "[]") + .put("templateHtml", "
Test
") + .put("templateCss", ".test-widget { font-size: 14px; }") + .put("controllerScript", "self.onInit = function() {};") + .put("settingsSchema", "{}") + .put("dataKeySettingsSchema", "{}"); + } + + @Test + public void testWidgetTypeLifecycle() throws Exception { + long timestamp = System.currentTimeMillis(); + List createdWidgetTypes = new ArrayList<>(); + + // create a widgets bundle + WidgetsBundle bundle = new WidgetsBundle(null, null, null, + TEST_PREFIX + "Bundle_" + timestamp, null, false, + "Test bundle description", null, null); + WidgetsBundle savedBundle = client.saveWidgetsBundle(bundle); + assertNotNull(savedBundle); + assertNotNull(savedBundle.getId()); + assertEquals(bundle.getTitle(), savedBundle.getTitle()); + + // create 5 widget types + for (int i = 0; i < 5; i++) { + String name = TEST_PREFIX + "Widget_" + timestamp + "_" + i; + JsonNode descriptor = createDescriptor("latest"); + + WidgetTypeDetails widgetType = new WidgetTypeDetails(null, null, null, name, descriptor); + widgetType.setDescription("Test widget " + i); + widgetType.setDeprecated(false); + widgetType.setTags(List.of("test", "automated")); + + WidgetTypeDetails created = client.saveWidgetType(widgetType, false); + assertNotNull(created); + assertNotNull(created.getId()); + assertEquals(name, created.getName()); + assertNotNull(created.getFqn()); + + createdWidgetTypes.add(created); + } + + // list widget types with text search (tenant only) + PageDataWidgetTypeInfo filteredTypes = client.getWidgetTypes(100, 0, + TEST_PREFIX + "Widget_" + timestamp, null, null, + true, false, null, null, null); + assertNotNull(filteredTypes); + assertEquals(5, filteredTypes.getData().size()); + + // get widget type details by id + WidgetTypeDetails searchWidget = createdWidgetTypes.get(2); + WidgetTypeDetails fetchedDetails = client.getWidgetTypeById( + searchWidget.getId().getId().toString(), true); + assertEquals(searchWidget.getName(), fetchedDetails.getName()); + assertEquals(searchWidget.getFqn(), fetchedDetails.getFqn()); + assertEquals("Test widget 2", fetchedDetails.getDescription()); + + // get widget type info by id + WidgetTypeInfo fetchedInfo = client.getWidgetTypeInfoById( + searchWidget.getId().getId().toString()); + assertEquals(searchWidget.getName(), fetchedInfo.getName()); + + // add widget types to bundle + List widgetTypeIds = createdWidgetTypes.stream() + .map(wt -> wt.getId().getId().toString()) + .collect(Collectors.toList()); + client.updateWidgetsBundleWidgetTypes(savedBundle.getId().getId().toString(), widgetTypeIds); + + // get bundle widget type fqns + List bundleFqns = client.getBundleWidgetTypeFqns(savedBundle.getId().getId().toString()); + assertEquals(5, bundleFqns.size()); + + // get bundle widget types details + List bundleDetails = client.getBundleWidgetTypesDetails( + savedBundle.getId().getId().toString(), false); + assertEquals(5, bundleDetails.size()); + + // get bundle widget types infos (paginated) + PageDataWidgetTypeInfo bundleInfos = client.getBundleWidgetTypesInfos( + savedBundle.getId().getId().toString(), 100, 0, + null, null, null, null, null, null); + assertEquals(5, bundleInfos.getData().size()); + + // update widget type + WidgetTypeDetails widgetToUpdate = client.getWidgetTypeById( + createdWidgetTypes.get(3).getId().getId().toString(), true); + widgetToUpdate.setDescription("Updated description"); + widgetToUpdate.setDeprecated(true); + widgetToUpdate.setTags(List.of("test", "updated")); + WidgetTypeDetails updatedWidget = client.saveWidgetType(widgetToUpdate, false); + assertEquals("Updated description", updatedWidget.getDescription()); + assertEquals(true, updatedWidget.getDeprecated()); + + // delete widget type + String widgetToDeleteId = createdWidgetTypes.get(0).getId().getId().toString(); + client.deleteWidgetType(widgetToDeleteId); + + // verify deletion + assertReturns404(() -> + client.getWidgetTypeById(widgetToDeleteId, false) + ); + + PageDataWidgetTypeInfo typesAfterDelete = client.getWidgetTypes(100, 0, + TEST_PREFIX + "Widget_" + timestamp, null, null, + true, false, null, null, null); + assertEquals(4, typesAfterDelete.getData().size()); + + // delete widgets bundle + client.deleteWidgetsBundle(savedBundle.getId().getId().toString()); + + assertReturns404(() -> + client.getWidgetsBundleById(savedBundle.getId().getId().toString(), false) + ); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java index c25dafa5e6..996efb17be 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractControllerTest.java @@ -86,12 +86,12 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest { } @Before - public void beforeWsTest() throws Exception { + public void beforeWsTest() { // placeholder } @After - public void afterWsTest() throws Exception { + public void afterWsTest() { if (wsClient != null) { wsClient.close(); } @@ -113,4 +113,11 @@ public abstract class AbstractControllerTest extends AbstractNotifyEntityTest { return wsClient; } + protected TbTestWebSocketClient buildAndConnectWebSocketClientWithApiKey(String apiKey) throws URISyntaxException, InterruptedException { + TbTestWebSocketClient wsClient = new TbTestWebSocketClient(new URI(WS_URL + wsPort + "/api/ws")); + assertThat(wsClient.connectBlocking(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + wsClient.authenticateWithApiKey(apiKey); + return wsClient; + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java index fcbd60eb4d..62a266f26a 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractNotifyEntityTest.java @@ -18,6 +18,8 @@ package org.thingsboard.server.controller; import lombok.extern.slf4j.Slf4j; import org.mockito.ArgumentMatcher; import org.mockito.Mockito; +import org.springframework.boot.info.BuildProperties; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.server.cluster.TbClusterService; import org.thingsboard.server.common.data.EdgeUtils; @@ -59,6 +61,9 @@ public abstract class AbstractNotifyEntityTest extends AbstractWebTest { @SpyBean protected AuditLogService auditLogService; + @MockBean + BuildProperties buildProperties; + protected final String msgErrorPermission = "You don't have permission to perform this operation!"; protected final String msgErrorShouldBeSpecified = "should be specified"; protected final String msgErrorNotFound = "Requested item wasn't found!"; diff --git a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index 457f723644..055e1e19a0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -60,6 +60,7 @@ import org.springframework.test.web.servlet.ResultActions; import org.springframework.test.web.servlet.ResultMatcher; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.result.MockMvcResultHandlers; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.context.WebApplicationContext; @@ -93,6 +94,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @@ -122,6 +124,8 @@ import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.job.Job; import org.thingsboard.server.common.data.job.JobType; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.notification.Notification; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import org.thingsboard.server.common.data.notification.NotificationType; @@ -176,6 +180,7 @@ import java.nio.charset.StandardCharsets; import java.sql.SQLException; import java.util.ArrayList; import java.util.Arrays; +import java.util.Collection; import java.util.Collections; import java.util.Comparator; import java.util.HashMap; @@ -349,7 +354,14 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { if (this.mockMvc == null) { this.mockMvc = webAppContextSetup(webApplicationContext) - .apply(springSecurity()).build(); + .apply(springSecurity()) + // conditional printing of non 2xx responses + .alwaysDo(result -> { + if (result.getResponse().getStatus() >= 400) { + MockMvcResultHandlers.log().handle(result); + } + }) + .build(); } loginSysAdmin(); @@ -414,6 +426,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { public void teardownWebTest() throws Exception { log.debug("Executing web test teardown"); + // Drain any pending housekeeper work left by the test body (e.g., bulk tenant deletes) + // before proceeding with teardown deletions, to avoid 90s per-tenant wait timing out. + awaitHousekeeperDrained(); + loginSysAdmin(); deleteTenant(tenantId); deleteDifferentTenant(); @@ -422,8 +438,9 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { tenantProfileService.deleteTenantProfiles(TenantId.SYS_TENANT_ID); jdbcTemplate.execute("TRUNCATE TABLE notification"); + jdbcTemplate.execute("TRUNCATE TABLE audit_log"); - log.info("Executed web test teardown"); + log.debug("Executed web test teardown"); } private void verifyNoTenantsLeft() throws Exception { @@ -445,6 +462,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { .until(() -> storage.getLag("tb_housekeeper") == 0); } + protected void awaitHousekeeperDrained() { + Awaitility.await("housekeeper drained").atMost(5, TimeUnit.MINUTES).during(300, TimeUnit.MILLISECONDS) + .until(() -> storage.getLag("tb_housekeeper") == 0); + } + private List getAllTenants() throws Exception { List loadedTenants = new ArrayList<>(); PageLink pageLink = new PageLink(10); @@ -709,6 +731,10 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { return assetProfile; } + protected Device createDevice(String name) throws Exception { + return createDevice(name, "default", null, null); + } + protected Device createDevice(String name, String accessToken) throws Exception { return createDevice(name, "default", null, accessToken); } @@ -722,7 +748,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { deviceData.setTransportConfiguration(new DefaultDeviceTransportConfiguration()); deviceData.setConfiguration(new DefaultDeviceConfiguration()); device.setDeviceData(deviceData); - return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + if (accessToken != null) { + return doPost("/api/device?accessToken=" + accessToken, device, Device.class); + } else { + return doPost("/api/device", device, Device.class); + } } protected Device assignDeviceToCustomer(DeviceId deviceId, CustomerId customerId) { @@ -1133,7 +1163,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { assertThat(findRelationsByTo(entityTo)).hasSize(1); doDelete(urlDelete) - .andExpect(status().isInternalServerError()); + .andExpect(status().isBadRequest()); assertThat(findRelationsByTo(entityTo)).hasSize(1); } finally { @@ -1210,7 +1240,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { Awaitility.await("CF state for entity actor ready to refresh dynamic arguments").atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> { CalculatedFieldState calculatedFieldState = statesMap.get(cfId); boolean isReady = calculatedFieldState != null && ((GeofencingCalculatedFieldState) calculatedFieldState).getLastScheduledRefreshTs() < - System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); + System.currentTimeMillis() - TimeUnit.SECONDS.toMillis(scheduledUpdateInterval); log.warn("entityId {}, cfId {}, state ready to refresh == {}", entityId, cfId, isReady); return isReady; }); @@ -1260,7 +1290,8 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { TenantProfile oldTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); TenantProfile tenantProfile = JacksonUtil.clone(oldTenantProfile); updater.accept(tenantProfile); - tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, oldTenantProfile); + // user should be sysadmin as this operation allowed only for sysadmins. But for the simplification of the test - already existed variable provided. This affects only an audit log content + tbTenantProfileService.save(TenantId.SYS_TENANT_ID, tenantProfile, oldTenantProfile, tenantAdminUser); } protected OAuth2Client createOauth2Client(TenantId tenantId, String title) { @@ -1401,7 +1432,7 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected List findJobs(List types, List entities) throws Exception { return doGetTypedWithPageLink("/api/jobs?types=" + types.stream().map(Enum::name).collect(Collectors.joining(",")) + - "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", + "&entities=" + entities.stream().map(UUID::toString).collect(Collectors.joining(",")) + "&", new TypeReference>() {}, new PageLink(100, 0, null, new SortOrder("createdTime", SortOrder.Direction.DESC))).getData(); } @@ -1415,21 +1446,50 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { protected void postTelemetry(EntityId entityId, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/timeseries/" + DataConstants.SERVER_SCOPE, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postTelemetry(EntityId entityId, TsKvEntry entry) throws Exception { + var values = JacksonUtil.newObjectNode(); + JacksonUtil.addKvEntry(values, entry); + + var payload = JacksonUtil.newObjectNode() + .put("ts", entry.getTs()) + .set("values", values); + + var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/timeseries/any"; + doPostAsync(url, payload, 30_000L).andExpect(status().isOk()); } protected void postAttributes(EntityId entityId, AttributeScope scope, String payload) throws Exception { doPostAsync("/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + - "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + "/attributes/" + scope, JacksonUtil.toJsonNode(payload), 30_000L).andExpect(status().isOk()); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, KvEntry... attributes) throws Exception { + postAttributes(entityId, scope, Arrays.asList(attributes)); + } + + protected void postAttributes(EntityId entityId, AttributeScope scope, Collection attributes) throws Exception { + var url = "/api/plugins/telemetry/" + entityId.getEntityType() + "/" + entityId.getId() + "/attributes/" + scope; + var payload = JacksonUtil.newObjectNode(); + for (KvEntry entry : attributes) { + JacksonUtil.addKvEntry(payload, entry); + } + doPostAsync(url, payload, 30_000L).andExpect(status().isOk()); } protected CalculatedField saveCalculatedField(CalculatedField calculatedField) { return doPost("/api/calculatedField", calculatedField, CalculatedField.class); } + protected AlarmRuleDefinition saveAlarmRule(AlarmRuleDefinition alarmRule) { + return doPost("/api/alarm/rule", alarmRule, AlarmRuleDefinition.class); + } + protected PageData getEntityCalculatedFields(EntityId entityId, CalculatedFieldType type, PageLink pageLink) throws Exception { return doGetTypedWithPageLink("/api/" + entityId.getEntityType() + "/" + entityId.getId() + "/calculatedFields" + - (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); + (type != null ? "?type=" + type.name() + "&" : "?"), new TypeReference<>() {}, pageLink); } protected PageData getCalculatedFieldNames(CalculatedFieldType type, PageLink pageLink) throws Exception { @@ -1442,11 +1502,11 @@ public abstract class AbstractWebTest extends AbstractInMemoryStorageTest { List entities, List names) throws Exception { return doGetTypedWithPageLink("/api/calculatedFields?" + - (type != null ? "types=" + type + "&" : "") + - (entityType != null ? "entityType=" + entityType + "&" : "") + - (entities != null ? "entities=" + String.join(",", - entities.stream().map(UUID::toString).toList()) + "&" : "") + - (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), + (type != null ? "types=" + type + "&" : "") + + (entityType != null ? "entityType=" + entityType + "&" : "") + + (entities != null ? "entities=" + String.join(",", + entities.stream().map(UUID::toString).toList()) + "&" : "") + + (names != null ? names.stream().map(name -> "name=" + name + "&").collect(Collectors.joining("")) : ""), new TypeReference>() {}, new PageLink(10)).getData(); } diff --git a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java index 099ef05752..100f44d4b7 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AiModelControllerTest.java @@ -19,8 +19,13 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import org.junit.Test; import org.springframework.test.web.servlet.ResultActions; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.dto.TbChatRequest; +import org.thingsboard.server.common.data.ai.dto.TbChatResponse; +import org.thingsboard.server.common.data.ai.dto.TbContent; +import org.thingsboard.server.common.data.ai.dto.TbUserMessage; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.GoogleAiGeminiChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; @@ -34,6 +39,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.List; + import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; @@ -136,6 +143,68 @@ public class AiModelControllerTest extends AbstractControllerTest { assertThat(updatedModel.getExternalId()).isNull(); } + @Test + public void saveAiModel_whenBaseUrlIsPrivateIp_shouldReturnBadRequest() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:22/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + AiModel model = AiModel.builder() + .tenantId(tenantId) + .name("SSRF test model") + .configuration(modelConfig) + .build(); + + // WHEN + ResultActions result = doPost("/api/ai/model", model); + + // THEN + result.andExpect(status().isBadRequest()); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + + @Test + public void sendChatRequest_whenBaseUrlBlockedAtRuntime_shouldReturnFailureEnvelope() throws Exception { + // GIVEN + loginTenantAdmin(); + SsrfProtectionValidator.setEnabled(true); + + try { + var modelConfig = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://10.0.0.1:8080/") + .apiKey("test-api-key") + .build()) + .modelId("gpt-4o") + .build(); + + var chatRequest = new TbChatRequest( + null, + new TbUserMessage(List.of(new TbContent.TbTextContent("hi"))), + modelConfig); + + // WHEN + TbChatResponse response = doPostAsync("/api/ai/model/chat", chatRequest, TbChatResponse.class, status().isOk()); + + // THEN + assertThat(response).isInstanceOf(TbChatResponse.Failure.class); + assertThat(((TbChatResponse.Failure) response).errorDetails()).contains("URI is invalid"); + } finally { + SsrfProtectionValidator.setEnabled(false); + } + } + /* --- Get by ID API tests --- */ @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java index 17a30cac35..3bb5fd5647 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmCommentControllerTest.java @@ -44,7 +44,9 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import java.util.LinkedList; import java.util.List; +import java.util.Optional; +import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -226,6 +228,18 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { doDelete("/api/alarm/" + alarm.getId() + "/comment/" + alarmComment.getId()) .andExpect(status().isOk()); + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + assertThat(systemComment.getId()).isEqualTo(alarmComment.getId()); + assertThat(systemComment.getType()).isEqualTo(AlarmCommentType.SYSTEM); + assertThat(systemComment.getComment().get("text").asText()).isEqualTo(String.format("User %s deleted his comment", + TENANT_ADMIN_EMAIL)); + AlarmComment expectedAlarmComment = AlarmComment.builder() .alarmId(alarm.getId()) .type(AlarmCommentType.SYSTEM) @@ -361,6 +375,39 @@ public class AlarmCommentControllerTest extends AbstractControllerTest { Assert.assertTrue("Created alarm doesn't match the found one!", equals); } + @Test + public void testShouldNotCreateOrUpdateSystemAlarmComment() throws Exception { + loginTenantAdmin(); + + AlarmComment alarmComment = AlarmComment.builder() + .type(AlarmCommentType.SYSTEM) + .comment(JacksonUtil.newObjectNode().set("text", new TextNode("Acknowledged by tenant admin"))) + .build(); + AlarmComment created = doPost("/api/alarm/" + alarm.getId() + "/comment", alarmComment, AlarmComment.class); + assertThat(created.getType()).isEqualTo(AlarmCommentType.OTHER); + + // acknowledge alarm to create system comment + doPost("/api/alarm/" + alarm.getId() + "/ack").andExpect(status().isOk()); + + Optional systemCommentOpt = doGetTyped( + "/api/alarm/" + alarm.getId() + "/comment" + "?page=0&pageSize=10", new TypeReference>() { + } + ).getData().stream().filter(alarmCommentInfo -> alarmCommentInfo.getType().equals(AlarmCommentType.SYSTEM)).findFirst(); + assertThat(systemCommentOpt).isPresent(); + AlarmCommentInfo systemComment = systemCommentOpt.get(); + + // system comment can't be updated with other type + systemComment.setType(AlarmCommentType.OTHER); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); + + // system comment can't be updated with other text + systemComment.setType(AlarmCommentType.SYSTEM); + systemComment.setComment(JacksonUtil.newObjectNode().set("text", new TextNode("New system comment"))); + doPost("/api/alarm/" + alarm.getId() + "/comment", systemComment).andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString("System alarm comment can't be updated!"))); + } + private AlarmComment createAlarmComment(AlarmId alarmId, String text) { AlarmComment alarmComment = AlarmComment.builder() .comment(JacksonUtil.newObjectNode().set("text", new TextNode(text))) diff --git a/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java new file mode 100644 index 0000000000..7c64fd6525 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/AlarmRuleControllerTest.java @@ -0,0 +1,420 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.alarm.rule.AlarmRule; +import org.thingsboard.server.common.data.alarm.rule.condition.SimpleAlarmCondition; +import org.thingsboard.server.common.data.alarm.rule.condition.expression.TbelAlarmConditionExpression; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinition; +import org.thingsboard.server.common.data.cf.AlarmRuleDefinitionInfo; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.Argument; +import org.thingsboard.server.common.data.cf.configuration.ArgumentType; +import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; +import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.SortOrder; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@DaoSqlTest +public class AlarmRuleControllerTest extends AbstractControllerTest { + + private Tenant savedTenant; + + @Before + public void beforeTest() throws Exception { + loginSysAdmin(); + + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + savedTenant = saveTenant(tenant); + assertThat(savedTenant).isNotNull(); + + User tenantAdmin = new User(); + tenantAdmin.setAuthority(Authority.TENANT_ADMIN); + tenantAdmin.setTenantId(savedTenant.getId()); + tenantAdmin.setEmail("tenant2@thingsboard.org"); + tenantAdmin.setFirstName("Joe"); + tenantAdmin.setLastName("Downs"); + + createUserAndLogin(tenantAdmin, "testPassword1"); + } + + @After + public void afterTest() throws Exception { + loginSysAdmin(); + deleteTenant(savedTenant.getId()); + } + + @Test + public void testSaveAlarmRule() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "High Temperature"); + + AlarmRuleDefinition saved = saveAlarmRule(alarmRule); + + assertThat(saved).isNotNull(); + assertThat(saved.getId()).isNotNull(); + assertThat(saved.getCreatedTime()).isGreaterThan(0); + assertThat(saved.getTenantId()).isEqualTo(savedTenant.getId()); + assertThat(saved.getEntityId()).isEqualTo(testDevice.getId()); + assertThat(saved.getName()).isEqualTo("High Temperature"); + assertThat(saved.getConfiguration()).isNotNull(); + assertThat(saved.getConfiguration().getCreateRules()).containsKey(AlarmSeverity.CRITICAL); + + saved.setName("Updated Alarm Rule"); + AlarmRuleDefinition updated = saveAlarmRule(saved); + + assertThat(updated.getName()).isEqualTo("Updated Alarm Rule"); + assertThat(updated.getVersion()).isEqualTo(saved.getVersion() + 1); + + doDelete("/api/alarm/rule/" + saved.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testGetAlarmRuleById() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + AlarmRuleDefinition alarmRule = createTestAlarmRule(testDevice.getId(), "Test Alarm"); + + AlarmRuleDefinition saved = saveAlarmRule(alarmRule); + AlarmRuleDefinition fetched = doGet("/api/alarm/rule/" + saved.getId().getId(), AlarmRuleDefinition.class); + + assertThat(fetched).isNotNull(); + assertThat(fetched).isEqualTo(saved); + + doDelete("/api/alarm/rule/" + saved.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testGetAlarmRuleById_notFound() throws Exception { + doGet("/api/alarm/rule/" + UUID.randomUUID()) + .andExpect(status().isNotFound()); + } + + @Test + public void testGetAlarmRuleById_calculatedFieldNotAlarm() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField cf = createSimpleCalculatedField(testDevice.getId()); + CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class); + + doGet("/api/alarm/rule/" + savedCf.getId().getId()) + .andExpect(status().isNotFound()); + + doDelete("/api/calculatedField/" + savedCf.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testGetAlarmRulesByEntityId() throws Exception { + Device device1 = createDevice("Device 1", "1234567890"); + Device device2 = createDevice("Device 2", "0987654321"); + AlarmRuleDefinition rule1 = saveAlarmRule(createTestAlarmRule(device1.getId(), "Rule 1")); + saveAlarmRule(createTestAlarmRule(device2.getId(), "Rule 2")); + + PageData result = doGetTypedWithPageLink( + "/api/alarm/rules/" + EntityType.DEVICE + "/" + device1.getUuidId() + "?", + new TypeReference<>() {}, new PageLink(10)); + + assertThat(result.getData()).hasSize(1); + assertThat(result.getData().get(0).getId()).isEqualTo(rule1.getId()); + assertThat(result.getData().get(0).getName()).isEqualTo("Rule 1"); + } + + @Test + public void testGetAlarmRules() throws Exception { + Device device = createDevice("Device A", "1234567890"); + AlarmRuleDefinition deviceRule = saveAlarmRule(createTestAlarmRule(device.getId(), "Device Alarm")); + + DeviceProfile profile = doPost("/api/deviceProfile", createDeviceProfile("Profile A"), DeviceProfile.class); + AlarmRuleDefinition profileRule = saveAlarmRule(createTestAlarmRule(profile.getId(), "Profile Alarm")); + + // All alarm rules + List all = getAlarmRules(null, null); + assertThat(all).extracting(AlarmRuleDefinition::getName) + .contains("Device Alarm", "Profile Alarm"); + + // Filter by entity type: DEVICE + List deviceRules = getAlarmRules(EntityType.DEVICE, null); + assertThat(deviceRules).extracting(AlarmRuleDefinition::getName) + .containsOnly("Device Alarm"); + + // Filter by entity type: DEVICE_PROFILE + List profileRules = getAlarmRules(EntityType.DEVICE_PROFILE, null); + assertThat(profileRules).extracting(AlarmRuleDefinition::getName) + .containsOnly("Profile Alarm"); + + // Filter by specific entity IDs + List specificRules = getAlarmRules(EntityType.DEVICE, List.of(device.getUuidId())); + assertThat(specificRules).extracting(AlarmRuleDefinition::getName) + .containsOnly("Device Alarm"); + + // Verify entity names are populated + AlarmRuleDefinitionInfo deviceInfo = all.stream() + .filter(r -> r.getName().equals("Device Alarm")).findFirst().orElseThrow(); + assertThat(deviceInfo.getEntityName()).isEqualTo("Device A"); + + AlarmRuleDefinitionInfo profileInfo = all.stream() + .filter(r -> r.getName().equals("Profile Alarm")).findFirst().orElseThrow(); + assertThat(profileInfo.getEntityName()).isEqualTo("Profile A"); + } + + @Test + public void testGetAlarmRules_textSearch() throws Exception { + Device device = createDevice("Device A", "1234567890"); + saveAlarmRule(createTestAlarmRule(device.getId(), "Temperature Alarm")); + saveAlarmRule(createTestAlarmRule(device.getId(), "Humidity Alarm")); + + PageData result = doGetTypedWithPageLink( + "/api/alarm/rules?textSearch=Temp&", + new TypeReference<>() {}, new PageLink(10)); + + assertThat(result.getData()).hasSize(1); + assertThat(result.getData().get(0).getName()).isEqualTo("Temperature Alarm"); + } + + @Test + public void testGetAlarmRuleNames() throws Exception { + Device device = createDevice("Device A", "1234567890"); + saveAlarmRule(createTestAlarmRule(device.getId(), "Alpha Alarm")); + saveAlarmRule(createTestAlarmRule(device.getId(), "Beta Alarm")); + + PageData names = getAlarmRuleNames(new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.ASC))); + assertThat(names.getTotalElements()).isEqualTo(2); + assertThat(names.getData()).isSortedAccordingTo(Comparator.naturalOrder()); + assertThat(names.getData()).contains("Alpha Alarm", "Beta Alarm"); + + names = getAlarmRuleNames(new PageLink(10, 0, + null, new SortOrder("", SortOrder.Direction.DESC))); + assertThat(names.getData()).isSortedAccordingTo(Comparator.reverseOrder()); + + names = getAlarmRuleNames(new PageLink(10, 0, + "Alpha", new SortOrder("", SortOrder.Direction.ASC))); + assertThat(names.getTotalElements()).isEqualTo(1); + assertThat(names.getData()).containsOnly("Alpha Alarm"); + } + + @Test + public void testDeleteAlarmRule() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "To Delete")); + + assertThat(saved).isNotNull(); + + doDelete("/api/alarm/rule/" + saved.getId().getId()) + .andExpect(status().isOk()); + doGet("/api/alarm/rule/" + saved.getId().getId()) + .andExpect(status().isNotFound()); + } + + @Test + public void testDeleteAlarmRule_notFound() throws Exception { + doDelete("/api/alarm/rule/" + UUID.randomUUID()) + .andExpect(status().isNotFound()); + } + + @Test + public void testDeleteAlarmRule_calculatedFieldNotAlarm() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + CalculatedField cf = createSimpleCalculatedField(testDevice.getId()); + CalculatedField savedCf = doPost("/api/calculatedField", cf, CalculatedField.class); + + doDelete("/api/alarm/rule/" + savedCf.getId().getId()) + .andExpect(status().isNotFound()); + + doDelete("/api/calculatedField/" + savedCf.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testGetLatestAlarmRuleDebugEvent() throws Exception { + Device testDevice = createDevice("Test device", "1234567890"); + AlarmRuleDefinition saved = saveAlarmRule(createTestAlarmRule(testDevice.getId(), "Debug Test")); + + doGet("/api/alarm/rule/" + saved.getId().getId() + "/debug") + .andExpect(status().isOk()); + + doDelete("/api/alarm/rule/" + saved.getId().getId()) + .andExpect(status().isOk()); + } + + @Test + public void testGetLatestAlarmRuleDebugEvent_notFound() throws Exception { + doGet("/api/alarm/rule/" + UUID.randomUUID() + "/debug") + .andExpect(status().isNotFound()); + } + + @Test + public void testTestAlarmRuleScript() throws Exception { + JsonNode request = JacksonUtil.toJsonNode(""" + { + "expression": "return temperature > 50;", + "arguments": { + "temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 55 } + } + } + """); + + JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); + + assertThat(result).isNotNull(); + assertThat(result.has("output")).isTrue(); + assertThat(result.has("error")).isTrue(); + assertThat(result.get("error").asText()).isEmpty(); + assertThat(result.get("output").asText()).isEqualTo("true"); + } + + @Test + public void testTestAlarmRuleScript_returnsFalse() throws Exception { + JsonNode request = JacksonUtil.toJsonNode(""" + { + "expression": "return temperature > 50;", + "arguments": { + "temperature": { "type": "SINGLE_VALUE", "ts": 1739776478057, "value": 30 } + } + } + """); + + JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); + + assertThat(result).isNotNull(); + assertThat(result.get("error").asText()).isEmpty(); + assertThat(result.get("output").asText()).isEqualTo("false"); + } + + @Test + public void testTestAlarmRuleScript_missingExpression() throws Exception { + JsonNode request = JacksonUtil.toJsonNode(""" + { + "arguments": {} + } + """); + + doPost("/api/alarm/rule/testScript", request) + .andExpect(status().isBadRequest()); + } + + @Test + public void testTestAlarmRuleScript_invalidExpression() throws Exception { + JsonNode request = JacksonUtil.toJsonNode(""" + { + "expression": "invalid syntax {{{{", + "arguments": {} + } + """); + + JsonNode result = doPost("/api/alarm/rule/testScript", request, JsonNode.class); + + assertThat(result).isNotNull(); + assertThat(result.get("error").asText()).isNotEmpty(); + } + + // --- Helper methods --- + + private AlarmRuleDefinition createTestAlarmRule(EntityId entityId, String name) { + AlarmRuleDefinition alarmRule = new AlarmRuleDefinition(); + alarmRule.setEntityId(entityId); + alarmRule.setName(name); + alarmRule.setConfigurationVersion(1); + alarmRule.setAdditionalInfo(JacksonUtil.newObjectNode()); + + AlarmCalculatedFieldConfiguration configuration = new AlarmCalculatedFieldConfiguration(); + + Argument argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + argument.setDefaultValue("0"); + configuration.setArguments(Map.of("temperature", argument)); + + AlarmRule rule = new AlarmRule(); + TbelAlarmConditionExpression expression = new TbelAlarmConditionExpression(); + expression.setExpression("return temperature >= 50;"); + SimpleAlarmCondition condition = new SimpleAlarmCondition(); + condition.setExpression(expression); + rule.setCondition(condition); + configuration.setCreateRules(Map.of(AlarmSeverity.CRITICAL, rule)); + + alarmRule.setConfiguration(configuration); + return alarmRule; + } + + private CalculatedField createSimpleCalculatedField(EntityId entityId) { + CalculatedField cf = new CalculatedField(); + cf.setEntityId(entityId); + cf.setType(CalculatedFieldType.SIMPLE); + cf.setName("Simple CF"); + cf.setConfigurationVersion(1); + cf.setAdditionalInfo(JacksonUtil.newObjectNode()); + + SimpleCalculatedFieldConfiguration config = new SimpleCalculatedFieldConfiguration(); + Argument arg = new Argument(); + arg.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + config.setArguments(Map.of("T", arg)); + config.setExpression("T * 2"); + TimeSeriesOutput output = new TimeSeriesOutput(); + output.setName("result"); + config.setOutput(output); + cf.setConfiguration(config); + + return cf; + } + + private List getAlarmRules(EntityType entityType, List entities) throws Exception { + StringBuilder url = new StringBuilder("/api/alarm/rules?"); + if (entityType != null) { + url.append("entityType=").append(entityType).append("&"); + } + if (entities != null) { + url.append("entities=").append(String.join(",", + entities.stream().map(UUID::toString).toList())).append("&"); + } + return doGetTypedWithPageLink(url.toString(), + new TypeReference>() {}, new PageLink(10)).getData(); + } + + private PageData getAlarmRuleNames(PageLink pageLink) throws Exception { + return doGetTypedWithPageLink("/api/alarm/rules/names?", + new TypeReference>() {}, pageLink); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java index 9c7f7a898d..836d1cbfad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyControllerTest.java @@ -53,7 +53,14 @@ public class ApiKeyControllerTest extends AbstractControllerTest { Assert.assertEquals(tenantId, savedApiKey.getTenantId()); Assert.assertEquals(tenantAdminUser.getId(), savedApiKey.getUserId()); - doDelete("/api/apiKey/" + savedApiKey.getId()).andExpect(status().isOk()); + String newDescription = "Updated API Key Description"; + savedApiKey.setDescription(newDescription); + ApiKey updatedApiKey = doPost("/api/apiKey", savedApiKey, ApiKey.class); + Assert.assertNotNull(updatedApiKey); + Assert.assertEquals(newDescription, updatedApiKey.getDescription()); + Assert.assertNull("Verify we do not expose API key value on update", updatedApiKey.getValue()); + + doDelete("/api/apiKey/" + updatedApiKey.getId()).andExpect(status().isOk()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java new file mode 100644 index 0000000000..405eecec9b --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/controller/ApiKeyWebSocketApiTest.java @@ -0,0 +1,71 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.controller; + +import lombok.extern.slf4j.Slf4j; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.dao.service.DaoSqlTest; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@Slf4j +@DaoSqlTest +public class ApiKeyWebSocketApiTest extends WebSocketApiTest { + + private ApiKey apiKey; + + @Before + public void setUpApiKey() { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setDescription("WS test API key"); + apiKeyInfo.setEnabled(true); + apiKeyInfo.setUserId(tenantAdminUserId); + apiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + } + + @After + public void tearDownApiKey() throws Exception { + loginTenantAdmin(); + doDelete("/api/apiKey/" + apiKey.getId()).andExpect(status().isOk()); + } + + @Override + protected TbTestWebSocketClient buildAndConnectWebSocketClient() throws URISyntaxException, InterruptedException { + return buildAndConnectWebSocketClientWithApiKey(apiKey.getValue()); + } + + @Test + public void testInvalidApiKeyAuthCmd_connectionClosed() throws Exception { + TbTestWebSocketClient client = new TbTestWebSocketClient(new URI(WS_URL + wsPort + "/api/ws")); + assertThat(client.connectBlocking(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + try { + client.authenticateWithApiKey("invalid-key"); + assertThat(client.waitForClose()).isTrue(); + } finally { + client.close(); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java index a76da5f161..95582cf261 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AuditLogControllerTest.java @@ -19,6 +19,7 @@ import com.datastax.oss.driver.api.core.uuid.Uuids; import com.fasterxml.jackson.core.type.TypeReference; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -28,6 +29,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.test.mock.mockito.SpyBean; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; @@ -60,6 +62,9 @@ import static org.mockito.Mockito.verify; @DaoSqlTest public class AuditLogControllerTest extends AbstractControllerTest { + // Small enough to force multiple pages, verifying pagination loop correctness + private static final int SMALL_PAGE_SIZE = 5; + private Tenant savedTenant; private User tenantAdmin; @@ -111,48 +116,47 @@ public class AuditLogControllerTest extends AbstractControllerTest { doPost("/api/device", device, Device.class); } - List loadedAuditLogs = new ArrayList<>(); - TimePageLink pageLink = new TimePageLink(5); - PageData pageData; - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs?")).hasSize(11 + 1)); - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?")).hasSize(11 + 1)); - loadedAuditLogs = new ArrayList<>(); - pageLink = new TimePageLink(5); - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?")).hasSize(11 + 1)); + } - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + @Test + public void testAuditLogsSysAdmin() throws Exception { + loginSysAdmin(); + List loadedAuditLogsBefore = getAuditLogs(100, "/api/audit/logs?"); + + for (int i = 0; i < 3; i++) { + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Profile " + UUID.randomUUID()); + doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + } - loadedAuditLogs = new ArrayList<>(); - pageLink = new TimePageLink(5); + int expectedSize = loadedAuditLogsBefore.size() + 3; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + Assert.assertEquals("Have X audit log before this test + New tenant profiles in the test", + expectedSize, getAuditLogs(100, "/api/audit/logs?").size())); + } + + private List getAuditLogs(int pageSize, String urlTemplate) throws Exception { + List loadedAuditLogs = new ArrayList<>(); + TimePageLink pageLink = new TimePageLink(pageSize); + PageData pageData; do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?", - new TypeReference>() { + pageData = doGetTypedWithTimePageLink(urlTemplate, + new TypeReference<>() { }, pageLink); loadedAuditLogs.addAll(pageData.getData()); if (pageData.hasNext()) { pageLink = pageLink.nextPageLink(); } } while (pageData.hasNext()); - - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + return loadedAuditLogs; } @Test @@ -166,20 +170,31 @@ public class AuditLogControllerTest extends AbstractControllerTest { savedDevice = doPost("/api/device", savedDevice, Device.class); } - List loadedAuditLogs = new ArrayList<>(); - TimePageLink pageLink = new TimePageLink(5); - PageData pageData; - do { - pageData = doGetTypedWithTimePageLink("/api/audit/logs/entity/DEVICE/" + savedDevice.getId().getId() + "?", - new TypeReference>() { - }, pageLink); - loadedAuditLogs.addAll(pageData.getData()); - if (pageData.hasNext()) { - pageLink = pageLink.nextPageLink(); - } - } while (pageData.hasNext()); + Device finalSavedDevice = savedDevice; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/entity/DEVICE/" + finalSavedDevice.getId().getId() + "?")).hasSize(11 + 1)); + } + + @Test + public void testAuditLogs_byTenantIdAndEntityId_Sysadmin() throws Exception { + loginSysAdmin(); + + //created + TenantProfile tenantProfile = new TenantProfile(); + tenantProfile.setName("Profile " + UUID.randomUUID()); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + //updated + tenantProfile.setName(tenantProfile.getName() + "(old)"); + tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); + + TenantProfile finalTenantProfile = tenantProfile; + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/entity/" + finalTenantProfile.getId().getEntityType() + "/" + finalTenantProfile.getId().getId() + "?")) + .as("Audit logs count by Tenant Profile entity").hasSize(2)); - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + //cleanup + doDelete("/api/tenantProfile/" + tenantProfile.getId().getId().toString()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java index d9ec187e39..18e11faaf9 100644 --- a/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/CalculatedFieldControllerTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.controller; import org.junit.After; import org.junit.Before; import org.junit.Test; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; @@ -108,6 +109,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); assertThat(savedCalculatedField.getConfiguration()).isEqualTo(getSimpleCalculatedFieldConfig()); + assertThat(savedCalculatedField.getAdditionalInfo()).isEqualTo(calculatedField.getAdditionalInfo()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -115,6 +117,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { CalculatedField updatedCalculatedField = doPost("/api/calculatedField", savedCalculatedField, CalculatedField.class); assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getAdditionalInfo()).isEqualTo(savedCalculatedField.getAdditionalInfo()); assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); doDelete("/api/calculatedField/" + savedCalculatedField.getId().getId().toString()) @@ -322,6 +325,7 @@ public class CalculatedFieldControllerTest extends AbstractControllerTest { calculatedField.setType(cfType); calculatedField.setName("Test Calculated Field for " + entityId); calculatedField.setConfigurationVersion(1); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); if (customConfiguration != null) { calculatedField.setConfiguration(customConfiguration); } else switch (cfType) { diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java index 7332ee26c6..07b68210bb 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceControllerTest.java @@ -1732,11 +1732,4 @@ public class DeviceControllerTest extends AbstractControllerTest { assertThat(fifthDevice.getName()).isEqualTo("My unique device_2"); } - private Device createDevice(String name) { - Device device = new Device(); - device.setName(name); - device.setType("default"); - return doPost("/api/device", device, Device.class); - } - } diff --git a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java index 3966bbb97a..0ee1a65cd8 100644 --- a/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/DeviceProfileControllerTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; +import org.awaitility.Awaitility; import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -52,8 +53,8 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.dao.device.DeviceProfileDao; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.Arrays; @@ -62,6 +63,7 @@ import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -93,6 +95,7 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { public DeviceProfileDao deviceProfileDao(DeviceProfileDao deviceProfileDao) { return Mockito.mock(DeviceProfileDao.class, AdditionalAnswers.delegatesTo(deviceProfileDao)); } + } @Before @@ -640,364 +643,364 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { @Test public void testSaveProtoDeviceProfileWithInvalidProtoFile() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " required int32 parameter = 1;\n" + - "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] failed to parse attributes proto schema due to: Syntax error in :6:4: 'required' label forbidden in proto3 field declarations"); } @Test public void testSaveProtoDeviceProfileWithInvalidProtoSyntax() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto2\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " required int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " required int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid schema syntax: proto2 for attributes proto schema provided! Only proto3 allowed!"); } @Test public void testSaveProtoDeviceProfileOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "option java_package = \"com.test.schemavalidation\";\n" + - "option java_multiple_files = true;\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!"); + "\n" + + "option java_package = \"com.test.schemavalidation\";\n" + + "option java_multiple_files = true;\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema options don't support!"); } @Test public void testSaveProtoDeviceProfilePublicImportsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "import public \"oldschema.proto\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!"); + "\n" + + "import public \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema public imports don't support!"); } @Test public void testSaveProtoDeviceProfileImportsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "import \"oldschema.proto\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SchemaValidationTest {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!"); + "\n" + + "import \"oldschema.proto\";\n" + + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SchemaValidationTest {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema imports don't support!"); } @Test public void testSaveProtoDeviceProfileExtendDeclarationsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "extend google.protobuf.MethodOptions {\n" + - " MyMessage my_method_option = 50007;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "extend google.protobuf.MethodOptions {\n" + + " MyMessage my_method_option = 50007;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Schema extend declarations don't support!"); } @Test public void testSaveProtoDeviceProfileEnumOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "enum testEnum {\n" + - " option allow_alias = true;\n" + - " DEFAULT = 0;\n" + - " STARTED = 1;\n" + - " RUNNING = 2;\n" + - "}\n" + - "\n" + - "message testMessage {\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " option allow_alias = true;\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}\n" + + "\n" + + "message testMessage {\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Enum definitions options are not supported!"); } @Test public void testSaveProtoDeviceProfileNoOneMessageTypeExists() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "enum testEnum {\n" + - " DEFAULT = 0;\n" + - " STARTED = 1;\n" + - " RUNNING = 2;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "enum testEnum {\n" + + " DEFAULT = 0;\n" + + " STARTED = 1;\n" + + " RUNNING = 2;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! At least one Message definition should exists!"); } @Test public void testSaveProtoDeviceProfileMessageTypeOptionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message testMessage {\n" + - " option allow_alias = true;\n" + - " optional int32 parameter = 1;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message testMessage {\n" + + " option allow_alias = true;\n" + + " optional int32 parameter = 1;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition options don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeExtensionsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message TestMessage {\n" + - " extensions 100 to 199;\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " extensions 100 to 199;\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition extensions don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeReservedElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message Foo {\n" + - " reserved 2, 15, 9 to 11;\n" + - " reserved \"foo\", \"bar\";\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message Foo {\n" + + " reserved 2, 15, 9 to 11;\n" + + " reserved \"foo\", \"bar\";\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition reserved elements don't support!"); } @Test public void testSaveProtoDeviceProfileMessageTypeGroupsElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message TestMessage {\n" + - " repeated group Result = 1 {\n" + - " optional string url = 2;\n" + - " optional string title = 3;\n" + - " repeated string snippets = 4;\n" + - " }\n" + - "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message TestMessage {\n" + + " repeated group Result = 1 {\n" + + " optional string url = 2;\n" + + " optional string title = 3;\n" + + " repeated string snippets = 4;\n" + + " }\n" + + "}", "[Transport Configuration] invalid attributes proto schema provided! Message definition groups don't support!"); } @Test public void testSaveProtoDeviceProfileOneOfsGroupsElementsNotSupported() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax = \"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message SampleMessage {\n" + - " oneof test_oneof {\n" + - " string name = 1;\n" + - " group Result = 2 {\n" + - " \tstring url = 3;\n" + - " \tstring title = 4;\n" + - " \trepeated string snippets = 5;\n" + - " }\n" + - " }" + - "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message SampleMessage {\n" + + " oneof test_oneof {\n" + + " string name = 1;\n" + + " group Result = 2 {\n" + + " \tstring url = 3;\n" + + " \tstring title = 4;\n" + + " \trepeated string snippets = 5;\n" + + " }\n" + + " }" + + "}", "[Transport Configuration] invalid attributes proto schema provided! OneOf definition groups don't support!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsField() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " int64 ts = 1;\n" + - " Values values = 2;\n" + - " \n" + - " message Values {\n" + - " string key1 = 3;\n" + - " bool key2 = 4;\n" + - " double key3 = 5;\n" + - " int32 key4 = 6;\n" + - " JsonObject key5 = 7;\n" + - " }\n" + - " \n" + - " message JsonObject {\n" + - " optional int32 someNumber = 8;\n" + - " repeated int32 someArray = 9;\n" + - " NestedJsonObject someNestedObject = 10;\n" + - " message NestedJsonObject {\n" + - " optional string key = 11;\n" + - " }\n" + - " }\n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " int64 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " repeated int32 someArray = 9;\n" + + " NestedJsonObject someNestedObject = 10;\n" + + " message NestedJsonObject {\n" + + " optional string key = 11;\n" + + " }\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid label. Field 'ts' should have optional keyword!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaTsDateType() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " optional int32 ts = 1;\n" + - " Values values = 2;\n" + - " \n" + - " message Values {\n" + - " string key1 = 3;\n" + - " bool key2 = 4;\n" + - " double key3 = 5;\n" + - " int32 key4 = 6;\n" + - " JsonObject key5 = 7;\n" + - " }\n" + - " \n" + - " message JsonObject {\n" + - " optional int32 someNumber = 8;\n" + - " }\n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int32 ts = 1;\n" + + " Values values = 2;\n" + + " \n" + + " message Values {\n" + + " string key1 = 3;\n" + + " bool key2 = 4;\n" + + " double key3 = 5;\n" + + " int32 key4 = 6;\n" + + " JsonObject key5 = 7;\n" + + " }\n" + + " \n" + + " message JsonObject {\n" + + " optional int32 someNumber = 8;\n" + + " }\n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'ts' has invalid data type. Only int64 type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidTelemetrySchemaValuesDateType() throws Exception { testSaveDeviceProfileWithInvalidProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message PostTelemetry {\n" + - " optional int64 ts = 1;\n" + - " string values = 2;\n" + - " \n" + - "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message PostTelemetry {\n" + + " optional int64 ts = 1;\n" + + " string values = 2;\n" + + " \n" + + "}", "[Transport Configuration] invalid telemetry proto schema provided! Field 'values' has invalid data type. Only message type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodDateType() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional int32 method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid data type. Only string type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdDateType() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int64 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int64 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid data type. Only int32 type is supported!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaMethodLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " repeated string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " repeated string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'method' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaRequestIdLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " repeated int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " repeated int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'requestId' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaParamsLabel() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " repeated string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " repeated string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Field 'params' has invalid label!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldsCount() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! RpcRequestMsg message should always contains 3 fields: method, requestId and params!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldMethodIsNoSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string methodName = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string methodName = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: method!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldRequestIdIsNotSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestIdentifier = 2;\n" + - " optional string params = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestIdentifier = 2;\n" + + " optional string params = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: requestId!"); } @Test public void testSaveProtoDeviceProfileWithInvalidRpcRequestSchemaFieldParamsIsNotSet() throws Exception { testSaveDeviceProfileWithInvalidRpcRequestProtoSchema("syntax =\"proto3\";\n" + - "\n" + - "package schemavalidation;\n" + - "\n" + - "message RpcRequestMsg {\n" + - " optional string method = 1;\n" + - " optional int32 requestId = 2;\n" + - " optional string parameters = 3;\n" + - " \n" + - "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!"); + "\n" + + "package schemavalidation;\n" + + "\n" + + "message RpcRequestMsg {\n" + + " optional string method = 1;\n" + + " optional int32 requestId = 2;\n" + + " optional string parameters = 3;\n" + + " \n" + + "}", "[Transport Configuration] invalid rpc request proto schema provided! Failed to get field descriptor for field: params!"); } @Test @@ -1068,11 +1071,17 @@ public class DeviceProfileControllerTest extends AbstractControllerTest { MqttDeviceProfileTransportConfiguration mqttDeviceProfileTransportConfiguration = this.createMqttDeviceProfileTransportConfiguration(protoTransportPayloadConfiguration, false); DeviceProfile deviceProfile = this.createDeviceProfile("Device Profile", mqttDeviceProfileTransportConfiguration); - Mockito.reset(tbClusterService, auditLogService); - - doPost("/api/deviceProfile", deviceProfile) - .andExpect(status().isBadRequest()) - .andExpect(statusReason(containsString(errorMsg))); + // The request may hit a transient TenantNotFoundException right after the @Before tenant creation + // if the tenant profile cache is not yet warmed up for the newly created tenant. Retry until the + // request returns the expected 400 Bad Request for the invalid schema. Mockito.reset is inside the + // retry loop so the subsequent verify* assertions see only the invocations from the last attempt. + Awaitility.await().atMost(10, TimeUnit.SECONDS).pollInterval(500, TimeUnit.MILLISECONDS) + .ignoreExceptions().untilAsserted(() -> { + Mockito.reset(tbClusterService, auditLogService); + doPost("/api/deviceProfile", deviceProfile) + .andExpect(status().isBadRequest()) + .andExpect(statusReason(containsString(errorMsg))); + }); testNotifyEntityEqualsOneTimeServiceNeverError(deviceProfile, savedTenant.getId(), tenantAdmin.getId(), tenantAdmin.getEmail(), ActionType.ADDED, new DataValidationException(errorMsg)); diff --git a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java index 43f08f42f9..9d50e3a62c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdgeControllerTest.java @@ -36,7 +36,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.AdminSettings; @@ -99,6 +102,7 @@ import org.thingsboard.server.service.edge.instructions.EdgeUpgradeInstructionsS import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -121,7 +125,12 @@ import static org.thingsboard.server.edge.AbstractEdgeTest.CONNECT_MESSAGE_COUNT public class EdgeControllerTest extends AbstractControllerTest { public static final String EDGE_HOST = "localhost"; - public static final int EDGE_PORT = 7070; + public static final int EDGE_PORT = TestSocketUtils.findAvailableTcpPort(); + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.debug("edges.rpc.port = {}", EDGE_PORT); + registry.add("edges.rpc.port", () -> EDGE_PORT); + } private IdComparator idComparator = new IdComparator<>(); @@ -916,11 +925,11 @@ public class EdgeControllerTest extends AbstractControllerTest { verifyFetchersMsgs(edgeImitator, savedDevice); // verify queue msgs - Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); - Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgs(), savedDevice.getId())); - Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); + Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); + Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgsDeque(), savedDevice.getId())); + Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); printQueueMsgsIfNotEmpty(edgeImitator); // 17 connect messages @@ -1000,32 +1009,32 @@ public class EdgeControllerTest extends AbstractControllerTest { } private void verifyFetchersMsgs(EdgeImitator edgeImitator, Device savedDevice) { - Assert.assertTrue(popQueueMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Main")); - Assert.assertTrue(popRuleChainMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Edge Root Rule Chain")); - Assert.assertTrue(popRuleChainMetadataMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, getEdgeRootRuleChainId(edgeImitator))); - Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "general")); - Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "mail")); - Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "connectivity")); - Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgs(), "jwt")); - Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popUserCredentialsMsg(edgeImitator.getDownlinkMsgs(), currentUserId)); - Assert.assertTrue(popUserMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, TENANT_ADMIN_EMAIL, Authority.TENANT_ADMIN)); - Assert.assertTrue(popCustomerMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Public")); - Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); - Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgs(), savedDevice.getId())); - Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); - Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgs(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); - Assert.assertTrue(popTenantMsg(edgeImitator.getDownlinkMsgs(), tenantId)); - Assert.assertTrue(popTenantProfileMsg(edgeImitator.getDownlinkMsgs(), tenantProfileId)); - Assert.assertTrue(popSyncCompletedMsg(edgeImitator.getDownlinkMsgs())); + Assert.assertTrue(popQueueMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Main")); + Assert.assertTrue(popRuleChainMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Edge Root Rule Chain")); + Assert.assertTrue(popRuleChainMetadataMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, getEdgeRootRuleChainId(edgeImitator))); + Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgsDeque(), "general")); + Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgsDeque(), "mail")); + Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgsDeque(), "connectivity")); + Assert.assertTrue(popAdminSettingsMsg(edgeImitator.getDownlinkMsgsDeque(), "jwt")); + Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popUserCredentialsMsg(edgeImitator.getDownlinkMsgsDeque(), currentUserId)); + Assert.assertTrue(popUserMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, TENANT_ADMIN_EMAIL, Authority.TENANT_ADMIN)); + Assert.assertTrue(popCustomerMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Public")); + Assert.assertTrue(popDeviceProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popDeviceMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Device 1")); + Assert.assertTrue(popDeviceCredentialsMsg(edgeImitator.getDownlinkMsgsDeque(), savedDevice.getId())); + Assert.assertTrue(popAssetProfileMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "default")); + Assert.assertTrue(popAssetMsg(edgeImitator.getDownlinkMsgsDeque(), UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, "Test Sync Edge Asset 1")); + Assert.assertTrue(popTenantMsg(edgeImitator.getDownlinkMsgsDeque(), tenantId)); + Assert.assertTrue(popTenantProfileMsg(edgeImitator.getDownlinkMsgsDeque(), tenantProfileId)); + Assert.assertTrue(popSyncCompletedMsg(edgeImitator.getDownlinkMsgsDeque())); } - private boolean popQueueMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popQueueMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof QueueUpdateMsg queueUpdateMsg) { Queue queue = JacksonUtil.fromString(queueUpdateMsg.getEntity(), Queue.class, true); @@ -1039,7 +1048,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popRuleChainMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popRuleChainMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof RuleChainUpdateMsg ruleChainUpdateMsg) { RuleChain ruleChain = JacksonUtil.fromString(ruleChainUpdateMsg.getEntity(), RuleChain.class, true); @@ -1055,7 +1064,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popRuleChainMetadataMsg(List messages, UpdateMsgType msgType, RuleChainId ruleChainId) { + private boolean popRuleChainMetadataMsg(Deque messages, UpdateMsgType msgType, RuleChainId ruleChainId) { for (AbstractMessage message : messages) { if (message instanceof RuleChainMetadataUpdateMsg ruleChainMetadataUpdateMsg) { RuleChainMetaData ruleChainMetaData = JacksonUtil.fromString(ruleChainMetadataUpdateMsg.getEntity(), RuleChainMetaData.class, true); @@ -1070,7 +1079,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popAdminSettingsMsg(List messages, String key) { + private boolean popAdminSettingsMsg(Deque messages, String key) { for (AbstractMessage message : messages) { if (message instanceof AdminSettingsUpdateMsg adminSettingsUpdateMsg) { AdminSettings adminSettings = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); @@ -1084,7 +1093,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popDeviceProfileMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popDeviceProfileMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof DeviceProfileUpdateMsg deviceProfileUpdateMsg) { DeviceProfile deviceProfile = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); @@ -1099,7 +1108,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popDeviceMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popDeviceMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof DeviceUpdateMsg deviceUpdateMsg) { Device device = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); @@ -1114,7 +1123,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popDeviceCredentialsMsg(List messages, DeviceId deviceId) { + private boolean popDeviceCredentialsMsg(Deque messages, DeviceId deviceId) { for (AbstractMessage message : messages) { if (message instanceof DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg) { DeviceCredentials deviceCredentials = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); @@ -1128,7 +1137,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popAssetProfileMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popAssetProfileMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof AssetProfileUpdateMsg assetProfileUpdateMsg) { AssetProfile assetProfile = JacksonUtil.fromString(assetProfileUpdateMsg.getEntity(), AssetProfile.class, true); @@ -1143,7 +1152,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popAssetMsg(List messages, UpdateMsgType msgType, String name) { + private boolean popAssetMsg(Deque messages, UpdateMsgType msgType, String name) { for (AbstractMessage message : messages) { if (message instanceof AssetUpdateMsg assetUpdateMsg) { Asset asset = JacksonUtil.fromString(assetUpdateMsg.getEntity(), Asset.class, true); @@ -1158,7 +1167,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popUserCredentialsMsg(List messages, UserId userId) { + private boolean popUserCredentialsMsg(Deque messages, UserId userId) { for (AbstractMessage message : messages) { if (message instanceof UserCredentialsUpdateMsg userCredentialsUpdateMsg) { UserCredentials userCredentials = JacksonUtil.fromString(userCredentialsUpdateMsg.getEntity(), UserCredentials.class, true); @@ -1172,7 +1181,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popUserMsg(List messages, UpdateMsgType msgType, String email, Authority authority) { + private boolean popUserMsg(Deque messages, UpdateMsgType msgType, String email, Authority authority) { for (AbstractMessage message : messages) { if (message instanceof UserUpdateMsg userUpdateMsg) { User user = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); @@ -1188,7 +1197,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popCustomerMsg(List messages, UpdateMsgType msgType, String title) { + private boolean popCustomerMsg(Deque messages, UpdateMsgType msgType, String title) { for (AbstractMessage message : messages) { if (message instanceof CustomerUpdateMsg customerUpdateMsg) { Customer customer = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); @@ -1203,7 +1212,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popTenantMsg(List messages, TenantId tenantId1) { + private boolean popTenantMsg(Deque messages, TenantId tenantId1) { for (AbstractMessage message : messages) { if (message instanceof TenantUpdateMsg tenantUpdateMsg) { Tenant tenant = JacksonUtil.fromString(tenantUpdateMsg.getEntity(), Tenant.class, true); @@ -1218,7 +1227,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popTenantProfileMsg(List messages, TenantProfileId tenantProfileId) { + private boolean popTenantProfileMsg(Deque messages, TenantProfileId tenantProfileId) { for (AbstractMessage message : messages) { if (message instanceof TenantProfileUpdateMsg tenantProfileUpdateMsg) { TenantProfile tenantProfile = JacksonUtil.fromString(tenantProfileUpdateMsg.getEntity(), TenantProfile.class, true); @@ -1233,7 +1242,7 @@ public class EdgeControllerTest extends AbstractControllerTest { return false; } - private boolean popSyncCompletedMsg(List messages) { + private boolean popSyncCompletedMsg(Deque messages) { for (AbstractMessage message : messages) { if (message instanceof SyncCompletedMsg) { messages.remove(message); diff --git a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java index f15ab09ab2..6c1a63194c 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EdqsEntityQueryControllerTest.java @@ -23,6 +23,7 @@ import org.springframework.test.context.TestPropertySource; import org.thingsboard.server.common.data.edqs.EdqsState; import org.thingsboard.server.common.data.edqs.EdqsState.EdqsApiMode; import org.thingsboard.server.common.data.edqs.ToCoreEdqsRequest; +import org.awaitility.core.ThrowingRunnable; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; @@ -86,6 +87,11 @@ public class EdqsEntityQueryControllerTest extends EntityQueryControllerTest { result -> result == expectedResult); } + @Override + protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(assertion); + } + @Test public void testEdqsState() throws Exception { loginSysAdmin(); diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java index e1173ef9a7..5de4c9ffcf 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -17,13 +17,18 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.node.BooleanNode; +import com.fasterxml.jackson.databind.node.DoubleNode; +import com.fasterxml.jackson.databind.node.IntNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.awaitility.core.ThrowingRunnable; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.ResultActions; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; @@ -43,12 +48,23 @@ import org.thingsboard.server.common.data.alarm.AlarmSeverity; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataPageLink; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AliasEntityId; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2.KeyInfo; +import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -76,6 +92,7 @@ import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import org.thingsboard.server.common.data.security.Authority; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.dao.entity.BaseEntityService; import org.thingsboard.server.dao.queue.QueueStatsService; import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.edqs.util.EdqsRocksDb; @@ -84,6 +101,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.UUID; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.stream.Collectors; @@ -109,6 +127,8 @@ public class EntityQueryControllerTest extends AbstractControllerTest { @Autowired private QueueStatsService queueStatsService; + @Autowired + private BaseEntityService baseEntityService; @MockitoBean private EdqsRocksDb edqsRocksDb; @@ -1454,6 +1474,10 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return result; } + protected void verifyAvailableKeysByQueryV2(ThrowingRunnable assertion) throws Throwable { + assertion.run(); + } + private KeyFilter getEntityFieldEqualFilter(String keyName, String value) { return getEntityFieldKeyFilter(keyName, value, StringFilterPredicate.StringOperation.EQUAL); } @@ -1494,4 +1518,740 @@ public class EntityQueryControllerTest extends AbstractControllerTest { return nameFilter; } + // --- findAvailableEntityKeysV2 tests --- + + @Test + public void testFindAvailableKeysByQueryV2() throws Throwable { + // GIVEN — two devices matched by query; a third device should not be matched + var device1 = createDevice("Test device 1"); + var device2 = createDevice("Test device 2"); + var unmatchedDevice = createDevice("Unmatched device"); + + // unmatched device has unique keys that must NOT appear in the result + postTelemetry(unmatchedDevice.getId(), new BasicTsKvEntry(9000, new DoubleDataEntry("unmatchedTs", 999.0))); + postAttributes(unmatchedDevice.getId(), AttributeScope.SHARED_SCOPE, new StringDataEntry("unmatchedAttr", "nope")); + + // device1: timeseries1 (Double) with two data points, and timeseries2 older data point + postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new DoubleDataEntry("timeseries1", 10.0))); + postTelemetry(device1.getId(), new BasicTsKvEntry(2000, new DoubleDataEntry("timeseries1", 20.5))); + postTelemetry(device1.getId(), new BasicTsKvEntry(1000, new LongDataEntry("timeseries2", 100L))); + + // device2: timeseries2 (Long) with a newer data point, and timeseries3 only on this device + postTelemetry(device2.getId(), new BasicTsKvEntry(3000, new LongDataEntry("timeseries2", 300L))); + postTelemetry(device2.getId(), new BasicTsKvEntry(5000, new DoubleDataEntry("timeseries3", 99.9))); + + // device1: SHARED_SCOPE attributes + postAttributes(device1.getId(), AttributeScope.SHARED_SCOPE, + new BooleanDataEntry("sharedAttribute1", true), new DoubleDataEntry("sharedAttribute2", 3.14)); + + // device2: CLIENT_SCOPE attributes (saved via service to bypass API restriction) + attributesService.save(tenantId, device2.getId(), AttributeScope.CLIENT_SCOPE, List.of( + new BaseAttributeKvEntry(new JsonDataEntry("clientAttribute1", "{\"key\":\"val\"}"), System.currentTimeMillis()), + new BaseAttributeKvEntry(new BooleanDataEntry("clientAttribute2", false), System.currentTimeMillis()) + )).get(); + + // device1 also has SERVER_SCOPE attributes (should be omitted by scope filter) + postAttributes(device1.getId(), AttributeScope.SERVER_SCOPE, + new StringDataEntry("serverAttribute1", "sv1"), new LongDataEntry("serverAttribute2", 42L)); + + // WHEN — query matches both devices; request timeseries + only SHARED and CLIENT attribute scopes + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter("Test device"); + EntityDataPageLink pageLink = new EntityDataPageLink(100, 0, null, null); + EntityDataQuery query = new EntityDataQuery(filter, pageLink, List.of(), null, null); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query, + true, true, List.of(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE), true); + + assertThat(result.entityTypes()).containsExactly(EntityType.DEVICE); + + // timeseries: keys collected from both devices, samples contain the freshest data points + assertThat(result.timeseries()).extracting(KeyInfo::key) + .containsExactly("timeseries1", "timeseries2", "timeseries3"); + assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNotNull()); + assertKeySample(result.timeseries(), "timeseries1", new DoubleNode(20.5), 2000); // from device1 + assertKeySample(result.timeseries(), "timeseries2", new IntNode(300), 3000); // from device2 (newer) + assertKeySample(result.timeseries(), "timeseries3", new DoubleNode(99.9), 5000); // only on device2 + + // SERVER_SCOPE must be fully omitted from the response + assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SHARED_SCOPE, AttributeScope.CLIENT_SCOPE); + + // SHARED_SCOPE: from device1 (alphabetical order) + assertThat(result.attributes().get(AttributeScope.SHARED_SCOPE)) + .extracting(KeyInfo::key).containsExactly("sharedAttribute1", "sharedAttribute2"); + assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute1", BooleanNode.TRUE); + assertKeySample(result.attributes().get(AttributeScope.SHARED_SCOPE), "sharedAttribute2", new DoubleNode(3.14)); + + // CLIENT_SCOPE: from device2 (alphabetical order) + assertThat(result.attributes().get(AttributeScope.CLIENT_SCOPE)) + .extracting(KeyInfo::key).containsExactly("clientAttribute1", "clientAttribute2"); + assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute1", JacksonUtil.toJsonNode("{\"key\":\"val\"}")); + assertKeySample(result.attributes().get(AttributeScope.CLIENT_SCOPE), "clientAttribute2", BooleanNode.FALSE); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_withoutSamples() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), true, true, null, false); + + assertThat(result.timeseries()).allSatisfy(ki -> assertThat(ki.sample()).isNull()); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .allSatisfy(ki -> assertThat(ki.sample()).isNull()); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_timeseriesOnly() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), true, false, null, false); + + assertThat(result.timeseries()).extracting(KeyInfo::key).contains("temperature"); + assertThat(result.attributes()).isNull(); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_attributesOnly() throws Throwable { + // GIVEN + var device = createDevice("Test device"); + postTelemetry(device.getId(), new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry("temperature", 10.0))); + postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("firmware", "v1.0")); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("Test device"), false, true, null, false); + + assertThat(result.timeseries()).isNull(); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .extracting(KeyInfo::key).contains("firmware"); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_noMatchingEntities() throws Throwable { + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2( + buildDeviceQuery("NonExistentDevice_" + UUID.randomUUID()), true, true, null, true); + + assertThat(result.entityTypes()).isEmpty(); + assertThat(result.timeseries()).isEmpty(); + assertThat(result.attributes()).isEmpty(); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_assetUsesServerScopeOnly() throws Throwable { + // GIVEN + var asset = new Asset(); + asset.setName("Test asset"); + asset.setType("default"); + asset = doPost("/api/asset", asset, Asset.class); + postAttributes(asset.getId(), AttributeScope.SERVER_SCOPE, new StringDataEntry("location", "warehouse")); + + // WHEN + var filter = new SingleEntityFilter(); + filter.setSingleEntity(AliasEntityId.fromEntityId(asset.getId())); + var query = new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); + + // THEN + verifyAvailableKeysByQueryV2(() -> { + AvailableEntityKeysV2 result = findAvailableEntityKeysByQueryV2(query, false, true, null, false); + + assertThat(result.entityTypes()).containsExactly(EntityType.ASSET); + assertThat(result.attributes()).containsOnlyKeys(AttributeScope.SERVER_SCOPE); + assertThat(result.attributes().get(AttributeScope.SERVER_SCOPE)) + .extracting(KeyInfo::key).containsExactly("location"); + }); + } + + @Test + public void testFindAvailableKeysByQueryV2_rejectsWhenNoKeyTypeRequested() throws Exception { + // WHEN / THEN + EntityDataQuery query = buildDeviceQuery("NonExistent"); + + doPostAsync("/api/v2/entitiesQuery/find/keys?includeTimeseries=false&includeAttributes=false", + query, 30_000L).andExpect(status().isBadRequest()); + } + + protected AvailableEntityKeysV2 findAvailableEntityKeysByQueryV2(EntityDataQuery query, + boolean includeTimeseries, boolean includeAttributes, + List scopes, boolean includeSamples) throws Exception { + StringBuilder url = new StringBuilder("/api/v2/entitiesQuery/find/keys?") + .append("includeTimeseries=").append(includeTimeseries) + .append("&includeAttributes=").append(includeAttributes) + .append("&includeSamples=").append(includeSamples); + if (scopes != null) { + for (AttributeScope scope : scopes) { + url.append("&scopes=").append(scope); + } + } + return doPostAsyncWithTypedResponse(url.toString(), query, + new TypeReference<>() {}, status().isOk()); + } + + private static void assertKeySample(List keys, String expectedKey, JsonNode expectedValue, long expectedTs) { + KeyInfo keyInfo = findKeyInfo(keys, expectedKey); + assertThat(keyInfo.sample()).isNotNull(); + assertThat(keyInfo.sample().value()).isEqualTo(expectedValue); + assertThat(keyInfo.sample().ts()).isEqualTo(expectedTs); + } + + private static void assertKeySample(List keys, String expectedKey, JsonNode expectedValue) { + KeyInfo keyInfo = findKeyInfo(keys, expectedKey); + assertThat(keyInfo.sample()).isNotNull(); + assertThat(keyInfo.sample().value()).isEqualTo(expectedValue); + assertThat(keyInfo.sample().ts()).isGreaterThan(0); + } + + private static KeyInfo findKeyInfo(List keys, String key) { + return keys.stream() + .filter(ki -> ki.key().equals(key)).findFirst().orElseThrow(); + } + + private static EntityDataQuery buildDeviceQuery(String deviceName) { + var filter = new DeviceTypeFilter(); + filter.setDeviceTypes(Collections.singletonList("default")); + filter.setDeviceNameFilter(deviceName); + return new EntityDataQuery(filter, new EntityDataPageLink(1, 0, null, null), Collections.emptyList(), null, null); + } + + // --- OR conditions tests --- + + private Device createDeviceWithSharedAttributes(String name, String type, String sharedAttributesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + if (sharedAttributesPayload != null) { + doPost("/api/plugins/telemetry/" + device.getId() + "/" + DataConstants.SHARED_SCOPE, + sharedAttributesPayload, String.class, status().isOk()); + } + return device; + } + + private Device createDeviceWithTimeseries(String name, String type, String timeseriesPayload) throws Exception { + Device device = new Device(); + device.setName(name); + device.setType(type); + device = doPost("/api/device", device, Device.class); + JsonNode payload = JacksonUtil.toJsonNode(timeseriesPayload); + doPost("/api/plugins/telemetry/" + EntityType.DEVICE.name() + "/" + device.getUuidId() + "/timeseries/SERVER_SCOPE", payload) + .andExpect(status().isOk()); + return device; + } + + private Alarm createAlarm(DeviceId originator, String type, AlarmSeverity severity) throws Exception { + Alarm alarm = new Alarm(); + alarm.setOriginator(originator); + alarm.setType(type); + alarm.setSeverity(severity); + return doPost("/api/alarm", alarm, Alarm.class); + } + + private static DeviceTypeFilter deviceTypeFilter(String type) { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of(type)); + filter.setDeviceNameFilter(""); + return filter; + } + + private static KeyFilter numericKeyFilter(EntityKeyType keyType, String key, + NumericFilterPredicate.NumericOperation operation, double value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.NUMERIC); + NumericFilterPredicate predicate = new NumericFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromDouble(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter numericAttributeKeyFilter(String key, + NumericFilterPredicate.NumericOperation operation, double value) { + return numericKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static KeyFilter stringKeyFilter(EntityKeyType keyType, String key, + StringFilterPredicate.StringOperation operation, String value) { + KeyFilter keyFilter = new KeyFilter(); + keyFilter.setKey(new EntityKey(keyType, key)); + keyFilter.setValueType(EntityKeyValueType.STRING); + StringFilterPredicate predicate = new StringFilterPredicate(); + predicate.setValue(FilterPredicateValue.fromString(value)); + predicate.setOperation(operation); + keyFilter.setPredicate(predicate); + return keyFilter; + } + + private static KeyFilter stringAttributeKeyFilter(String key, + StringFilterPredicate.StringOperation operation, String value) { + return stringKeyFilter(EntityKeyType.ATTRIBUTE, key, operation, value); + } + + private static EntityDataPageLink pageLinkSortedByName(int pageSize, int page, String textSearch) { + EntityDataSortOrder sortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.ENTITY_FIELD, "name"), EntityDataSortOrder.Direction.ASC); + return new EntityDataPageLink(pageSize, page, textSearch, sortOrder); + } + + private static List nameEntityField() { + return Collections.singletonList(new EntityKey(EntityKeyType.ENTITY_FIELD, "name")); + } + + private static List extractNames(PageData result) { + return result.getData().stream() + .map(e -> e.getLatest().get(EntityKeyType.ENTITY_FIELD).get("name").getValue()) + .collect(Collectors.toList()); + } + + @Test + public void testCountEntitiesWithOrKeyFiltersOperation() throws Exception { + String type = "orTestType"; + createDeviceWithSharedAttributes("OrTestDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTestDeviceB", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTestDeviceC", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device has temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrKeyFiltersOperation() throws Exception { + String type = "orDataType"; + createDeviceWithSharedAttributes("OrDataDeviceX", type, "{\"status\":\"active\"}"); + createDeviceWithSharedAttributes("OrDataDeviceY", type, "{\"humidity\":80}"); + createDeviceWithSharedAttributes("OrDataDeviceZ", type, null); // no matching attribute + + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 70)); + + // OR: deviceX matches status=active, deviceY matches humidity>70 + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrDataDeviceX", "OrDataDeviceY"); + } + + @Test + public void testFindEntityDataWithOrDoesNotLeakFilterOnlyEntityFields() throws Exception { + // Regression test: under OR, an entity-field filter (e.g. label) that isn't declared in + // entityFields must not leak its value into EntityData.latest[ENTITY_FIELD]. + String type = "orLeakGuardType"; + Device d1 = createDeviceWithSharedAttributes("OrLeakDeviceA", type, "{\"status\":\"active\"}"); + d1.setLabel("leak-label-A"); + doPost("/api/device", d1, Device.class); + + Device d2 = createDeviceWithSharedAttributes("OrLeakDeviceB", type, null); + d2.setLabel("leak-label-B"); + doPost("/api/device", d2, Device.class); + + List keyFilters = List.of( + stringAttributeKeyFilter("status", StringFilterPredicate.StringOperation.EQUAL, "active"), + buildStringKeyFilter(EntityKeyType.ENTITY_FIELD, "label", StringFilterPredicate.StringOperation.EQUAL, "leak-label-B")); + + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrLeakDeviceA", "OrLeakDeviceB"); + for (EntityData entity : result.getData()) { + assertThat(entity.getLatest().get(EntityKeyType.ENTITY_FIELD)).containsOnlyKeys("name"); + } + } + + @Test + public void testFindEntityDataWithOrSameKeyFilters() throws Exception { + String type = "orSameKeyType"; + createDeviceWithSharedAttributes("OrSameKeyDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSameKeyDeviceB", type, "{\"temperature\":5}"); + + // Same key "temperature" with two different predicates exercises the OR-ungrouping path + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + EntityDataQuery orQuery = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery, 2)); + PageData result = findByQueryAndCheck(orQuery, 2); + assertThat(extractNames(result)).containsExactlyInAnyOrder("OrSameKeyDeviceA", "OrSameKeyDeviceB"); + } + + @Test + public void testCountEntitiesWithoutKeyFiltersOperation() throws Exception { + String type = "backCompatType"; + createDeviceWithSharedAttributes("BackCompatDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("BackCompatDeviceB", type, "{\"temperature\":5}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // Await attribute propagation via an OR query that should find 2 when propagated + EntityCountQuery orCheckQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orCheckQuery, 2)); + + // Query without keyFiltersOperation (null) -- should behave as AND + EntityCountQuery nullOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters); + Long nullResult = countByQueryAndCheck(nullOpQuery, 0); + + // Query with explicit AND -- should produce the same result + EntityCountQuery andOpQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andOpQuery, 0); + + Assert.assertEquals(nullResult, andResult); + } + + @Test + public void testAlarmDataQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + String type = "orAlarmType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmDeviceMid", type, "{\"temperature\":30}"); + + createAlarm(deviceHot.getId(), "highTemp", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + AlarmDataPageLink pageLink = new AlarmDataPageLink(); + pageLink.setPage(0); + pageLink.setPageSize(100); + pageLink.setSortOrder(new EntityDataSortOrder(new EntityKey(EntityKeyType.ALARM_FIELD, "createdTime"))); + List alarmFields = List.of(new EntityKey(EntityKeyType.ALARM_FIELD, "type")); + + // OR query: should return alarms for deviceHot (60>50) and deviceCold (5<10) = 2 alarms + AlarmDataQuery orAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findAlarmsByQueryAndCheck(orAlarmQuery, 2)); + PageData alarmResult = findAlarmsByQueryAndCheck(orAlarmQuery, 2); + List alarmTypes = alarmResult.getData().stream().map(AlarmData::getType).collect(Collectors.toList()); + assertThat(alarmTypes).containsExactlyInAnyOrder("highTemp", "lowTemp"); + + // AND query: no device has temp both >50 AND <10 => 0 alarms + AlarmDataQuery andAlarmQuery = new AlarmDataQuery(deviceTypeFilter(type), pageLink, null, null, keyFilters, alarmFields, ComplexOperation.AND); + findAlarmsByQueryAndCheck(andAlarmQuery, 0); + } + + @Test + public void testCountAlarmsByQueryWithOrKeyFiltersOperation() throws Exception { + loginTenantAdmin(); + + String type = "orAlarmCntType"; + Device deviceHot = createDeviceWithSharedAttributes("OrAlarmCntDeviceHot", type, "{\"temperature\":60}"); + Device deviceCold = createDeviceWithSharedAttributes("OrAlarmCntDeviceCold", type, "{\"temperature\":5}"); + Device deviceMid = createDeviceWithSharedAttributes("OrAlarmCntDeviceMid", type, "{\"temperature\":30}"); + + // 2 alarms for deviceHot, 1 for deviceCold, 1 for deviceMid + createAlarm(deviceHot.getId(), "highTemp1", AlarmSeverity.CRITICAL); + createAlarm(deviceHot.getId(), "highTemp2", AlarmSeverity.CRITICAL); + createAlarm(deviceCold.getId(), "lowTemp", AlarmSeverity.WARNING); + createAlarm(deviceMid.getId(), "normalTemp", AlarmSeverity.WARNING); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceHot (2 alarms) + deviceCold (1 alarm) match => 3 alarms total + AlarmCountQuery orQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countAlarmsByQueryAndCheck(orQuery, 3)); + + // AND: no device matches both filters => 0 + AlarmCountQuery andQuery = new AlarmCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countAlarmsByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrMixedEntityFieldAndAttribute() throws Exception { + // Exercises the entity field predicate relocation to middle-layer WHERE under OR + String type = "orMixedType"; + createDeviceWithSharedAttributes("OrMixedAlpha", type, "{\"temperature\":10}"); + createDeviceWithSharedAttributes("OrMixedBeta", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrMixedGamma", type, "{\"temperature\":10}"); + + List keyFilters = List.of( + stringKeyFilter(EntityKeyType.ENTITY_FIELD, "name", StringFilterPredicate.StringOperation.CONTAINS, "Alpha"), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); + + // OR: Alpha matches name contains "Alpha", Beta matches temp>50 => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: only Alpha has name "Alpha" AND temp is 10 (not >50) => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrStringAttributes() throws Exception { + String type = "orStrType"; + createDeviceWithSharedAttributes("OrStrDeviceA", type, "{\"color\":\"red\"}"); + createDeviceWithSharedAttributes("OrStrDeviceB", type, "{\"color\":\"blue\"}"); + createDeviceWithSharedAttributes("OrStrDeviceC", type, "{\"color\":\"green\"}"); + + List keyFilters = List.of( + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "red"), + stringAttributeKeyFilter("color", StringFilterPredicate.StringOperation.EQUAL, "blue")); + + // OR: red and blue match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device is both red AND blue => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrSingleFilter() throws Exception { + String type = "orSingleType"; + createDeviceWithSharedAttributes("OrSingleDeviceA", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrSingleDeviceB", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50)); + + // Single filter with OR should behave identically to AND + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 1)); + Long orResult = countByQueryAndCheck(orQuery, 1); + + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + Long andResult = countByQueryAndCheck(andQuery, 1); + + Assert.assertEquals(orResult, andResult); + } + + @Test + public void testCountEntitiesWithOrThreeFilters() throws Exception { + String type = "or3fType"; + createDeviceWithSharedAttributes("Or3fDeviceA", type, "{\"temperature\":60,\"humidity\":50,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceB", type, "{\"temperature\":20,\"humidity\":90,\"pressure\":1000}"); + createDeviceWithSharedAttributes("Or3fDeviceC", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1050}"); + createDeviceWithSharedAttributes("Or3fDeviceD", type, "{\"temperature\":20,\"humidity\":50,\"pressure\":1000}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80), + numericAttributeKeyFilter("pressure", NumericFilterPredicate.NumericOperation.GREATER, 1040)); + + // OR: A matches temp>50, B matches hum>80, C matches press>1040, D matches none => 3 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 3)); + + // AND: no device matches all three => 0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testFindEntityDataWithOrPagination() throws Exception { + // 5 devices, 4 match OR filters; explicit temperatures keep the setup easy to read. + String type = "orPageType"; + int[] temperatures = {61, 62, 2, 1, 25}; // devices 1,2: temp>50; 3,4: temp<10; 5: no match + for (int i = 0; i < temperatures.length; i++) { + createDeviceWithSharedAttributes( + String.format("OrPageDevice%02d", i + 1), type, + "{\"temperature\":" + temperatures[i] + "}"); + } + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // Page 1: pageSize=2, totalElements=4, data.size()=2 + EntityDataQuery orQuery1 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQuery1, 4)); + PageData page1 = findByQueryAndCheck(orQuery1, 4); + Assert.assertEquals(2, page1.getData().size()); + Assert.assertTrue(page1.hasNext()); + + // Page 2: remaining 2 of 4 + EntityDataQuery orQuery2 = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(2, 1, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + PageData page2 = findByQueryAndCheck(orQuery2, 4); + Assert.assertEquals(2, page2.getData().size()); + Assert.assertFalse(page2.hasNext()); + + // All names across both pages should be from the 4 matching devices (01..04) + List allNames = new ArrayList<>(extractNames(page1)); + allNames.addAll(extractNames(page2)); + assertThat(allNames).hasSize(4).doesNotContain("OrPageDevice05"); + } + + @Test + public void testCountEntitiesWithOrZeroMatches() throws Exception { + String type = "orZeroType"; + createDeviceWithSharedAttributes("OrZeroDeviceA", type, "{\"temperature\":30}"); + createDeviceWithSharedAttributes("OrZeroDeviceB", type, "{\"temperature\":40}"); + + // Await attribute propagation via a filter that actually matches both devices + EntityCountQuery propagationCheck = new EntityCountQuery(deviceTypeFilter(type), List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 20)), + ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(propagationCheck, 2)); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR with no matches: neither filter matches any device => count=0 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + countByQueryAndCheck(orQuery, 0); + } + + @Test + public void testOrKeyFiltersOperationRejectedWhenDisabled() throws Exception { + loginTenantAdmin(); + + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", false); + try { + DeviceTypeFilter filter = deviceTypeFilter("default"); + + // POST a query with OR operation -- should be rejected with 400 + EntityCountQuery orQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.OR); + String errorMessage = getErrorMessage( + doPost("/api/entitiesQuery/count", orQuery).andExpect(status().isBadRequest()) + ); + assertThat(errorMessage).contains("OR conditions between key filters are disabled"); + + // POST a query without keyFiltersOperation (null/AND) -- should still succeed + EntityCountQuery andQuery = new EntityCountQuery(filter, Collections.emptyList()); + doPost("/api/entitiesQuery/count", andQuery).andExpect(status().isOk()); + + // POST a query with explicit AND -- should also succeed + EntityCountQuery explicitAndQuery = new EntityCountQuery(filter, Collections.emptyList(), ComplexOperation.AND); + doPost("/api/entitiesQuery/count", explicitAndQuery).andExpect(status().isOk()); + } finally { + ReflectionTestUtils.setField(baseEntityService, "keyFiltersOrConditionsEnabled", true); + } + } + + @Test + public void testFindEntityDataWithOrAndTextSearch() throws Exception { + // 3 devices: 2 match OR filters, but only 1 also matches textSearch at a time + String type = "orTextType"; + createDeviceWithSharedAttributes("OrTextAlpha", type, "{\"temperature\":60}"); + createDeviceWithSharedAttributes("OrTextBeta", type, "{\"temperature\":5}"); + createDeviceWithSharedAttributes("OrTextGamma", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericAttributeKeyFilter("temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR without textSearch: Alpha and Beta match => 2 + EntityDataQuery orQueryNoText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, null), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> findByQueryAndCheck(orQueryNoText, 2)); + + // OR with textSearch="Alpha": only Alpha matches both OR filter AND text search + EntityDataQuery orQueryWithText = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Alpha"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryWithText, 1))).containsExactly("OrTextAlpha"); + + // OR with textSearch="Beta": only Beta matches both OR filter AND text search + EntityDataQuery orQueryBeta = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Beta"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + assertThat(extractNames(findByQueryAndCheck(orQueryBeta, 1))).containsExactly("OrTextBeta"); + + // OR with textSearch="Gamma": Gamma doesn't match any OR filter => 0 + EntityDataQuery orQueryGamma = new EntityDataQuery(deviceTypeFilter(type), pageLinkSortedByName(10, 0, "Gamma"), + nameEntityField(), null, keyFilters, ComplexOperation.OR); + findByQueryAndCheck(orQueryGamma, 0); + } + + @Test + public void testCountEntitiesWithOrTimeSeriesKeyFilters() throws Exception { + String type = "orTsType"; + createDeviceWithTimeseries("OrTsDeviceA", type, "{\"temperature\":60}"); + createDeviceWithTimeseries("OrTsDeviceB", type, "{\"temperature\":5}"); + createDeviceWithTimeseries("OrTsDeviceC", type, "{\"temperature\":30}"); + + List keyFilters = List.of( + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.GREATER, 50), + numericKeyFilter(EntityKeyType.TIME_SERIES, "temperature", NumericFilterPredicate.NumericOperation.LESS, 10)); + + // OR: deviceA (60>50) and deviceB (5<10) match => count=2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // AND: no device has ts temperature both >50 AND <10 => count=0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + + @Test + public void testCountEntitiesWithOrComplexFilterPredicate() throws Exception { + // Key-level ComplexFilterPredicate combined with query-level OR + String type = "orCplxType"; + createDeviceWithSharedAttributes("OrCplxDeviceA", type, "{\"temperature\":65,\"humidity\":50}"); + createDeviceWithSharedAttributes("OrCplxDeviceB", type, "{\"temperature\":25,\"humidity\":90}"); + createDeviceWithSharedAttributes("OrCplxDeviceC", type, "{\"temperature\":25,\"humidity\":50}"); + + // Key filter 1: temperature > 50 AND temperature < 70 (complex predicate within key filter) — matches A only + NumericFilterPredicate gt50 = new NumericFilterPredicate(); + gt50.setValue(FilterPredicateValue.fromDouble(50)); + gt50.setOperation(NumericFilterPredicate.NumericOperation.GREATER); + NumericFilterPredicate lt70 = new NumericFilterPredicate(); + lt70.setValue(FilterPredicateValue.fromDouble(70)); + lt70.setOperation(NumericFilterPredicate.NumericOperation.LESS); + ComplexFilterPredicate complexTempPred = new ComplexFilterPredicate(); + complexTempPred.setOperation(ComplexOperation.AND); + complexTempPred.setPredicates(List.of(gt50, lt70)); + KeyFilter tempComplexFilter = new KeyFilter(); + tempComplexFilter.setKey(new EntityKey(EntityKeyType.ATTRIBUTE, "temperature")); + tempComplexFilter.setValueType(EntityKeyValueType.NUMERIC); + tempComplexFilter.setPredicate(complexTempPred); + + // Key filter 2: humidity > 80 (simple predicate) — matches B only + List keyFilters = List.of( + tempComplexFilter, + numericAttributeKeyFilter("humidity", NumericFilterPredicate.NumericOperation.GREATER, 80)); + + // Query-level OR: A matches key filter 1, B matches key filter 2 => 2 + EntityCountQuery orQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.OR); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> countByQueryAndCheck(orQuery, 2)); + + // Query-level AND: no device matches both key filters => 0 + EntityCountQuery andQuery = new EntityCountQuery(deviceTypeFilter(type), keyFilters, ComplexOperation.AND); + countByQueryAndCheck(andQuery, 0); + } + } diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java index b0c76a16a8..b4ce7179d2 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityRelationControllerTest.java @@ -633,13 +633,6 @@ public class EntityRelationControllerTest extends AbstractControllerTest { deleteDifferentTenant(); } - private Device createDevice(String name) { - var device = new Device(); - device.setName(name); - device.setType("default"); - return doPost("/api/device", device, Device.class); - } - private ResultActions getRelation(EntityRelation relation) throws Exception { return doGet("/api/relation?" + "fromId=" + relation.getFrom().getId() + diff --git a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java index 6d095dc95f..277ec687f1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -38,7 +38,10 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Primary; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.springframework.test.web.servlet.ResultActions; import org.thingsboard.common.util.ThingsBoardExecutors; import org.thingsboard.server.common.data.Customer; @@ -97,6 +100,21 @@ import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; @ContextConfiguration(classes = {EntityViewControllerTest.Config.class}) @DaoSqlTest public class EntityViewControllerTest extends AbstractControllerTest { + // Must NOT be imported from AbstractMqttIntegrationTest. That field is a static final initialized + // once per JVM. Other test classes (e.g. MqttGatewayRateLimitsTest, DeviceEdgeTest) share the same + // constant but produce a different Spring context cache key, so Spring creates a separate + // ApplicationContext for each of them. Every context starts its own MqttTransportService and tries + // to bind the same port, causing BindException when tests run in the same Surefire JVM fork. + // Declaring the port here gives this context its own independently allocated port. + static final int MQTT_PORT = TestSocketUtils.findAvailableTcpPort(); + static final String MQTT_URL = "tcp://localhost:" + MQTT_PORT; + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); + registry.add("transport.mqtt.bind_port", () -> MQTT_PORT); + } + static final TypeReference> PAGE_DATA_ENTITY_VIEW_TYPE_REF = new TypeReference<>() { }; static final TypeReference> PAGE_DATA_ENTITY_VIEW_INFO_TYPE_REF = new TypeReference<>() { @@ -683,7 +701,7 @@ public class EntityViewControllerTest extends AbstractControllerTest { String viewDeviceId = testDevice.getId().getId().toString(); String clientId = MqttAsyncClient.generateClientId(); - MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId, new MemoryPersistence()); + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId, new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setUserName(accessToken); @@ -734,7 +752,7 @@ public class EntityViewControllerTest extends AbstractControllerTest { assertNotNull(accessToken); String clientId = MqttAsyncClient.generateClientId(); - MqttAsyncClient client = new MqttAsyncClient("tcp://localhost:1883", clientId, new MemoryPersistence()); + MqttAsyncClient client = new MqttAsyncClient(MQTT_URL, clientId, new MemoryPersistence()); MqttConnectOptions options = new MqttConnectOptions(); options.setUserName(accessToken); diff --git a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java index d53e69d721..b65e500082 100644 --- a/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/RuleChainControllerTest.java @@ -44,6 +44,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; +import org.thingsboard.server.common.data.rule.RuleChainNote; import org.thingsboard.server.common.data.rule.RuleChainType; import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.data.security.Authority; @@ -442,6 +443,201 @@ public class RuleChainControllerTest extends AbstractControllerTest { assertThat(ruleChain.getVersion()).isEqualTo(5); } + @Test + public void testSaveAndLoadRuleChainMetaDataWithNotes() throws Exception { + RuleChain ruleChain = createRuleChain("RuleChain with notes"); + + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setNodes(new ArrayList<>()); + + List notes = new ArrayList<>(); + RuleChainNote note1 = new RuleChainNote(); + note1.setId("note-1"); + note1.setX(100); + note1.setY(200); + note1.setWidth(300); + note1.setHeight(150); + note1.setContent("# Test Note\nSome markdown content"); + note1.setBackgroundColor("#FFF9C4"); + note1.setBorderColor("#E6C800"); + note1.setBorderWidth(2); + note1.setApplyDefaultMarkdownStyle(true); + notes.add(note1); + + RuleChainNote note2 = new RuleChainNote(); + note2.setId("note-2"); + note2.setX(500); + note2.setY(300); + note2.setWidth(200); + note2.setHeight(100); + note2.setContent("Simple note"); + note2.setBackgroundColor("#C8E6C9"); + notes.add(note2); + + ruleChainMetaData.setNotes(notes); + + RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class); + Assert.assertNotNull(savedMetaData); + Assert.assertNotNull(savedMetaData.getNotes()); + Assert.assertEquals(2, savedMetaData.getNotes().size()); + + RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class); + Assert.assertNotNull(loadedMetaData); + Assert.assertNotNull(loadedMetaData.getNotes()); + Assert.assertEquals(2, loadedMetaData.getNotes().size()); + + RuleChainNote loadedNote1 = loadedMetaData.getNotes().stream() + .filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null); + Assert.assertNotNull(loadedNote1); + Assert.assertEquals(100, loadedNote1.getX()); + Assert.assertEquals(200, loadedNote1.getY()); + Assert.assertEquals(300, loadedNote1.getWidth()); + Assert.assertEquals(150, loadedNote1.getHeight()); + Assert.assertEquals("# Test Note\nSome markdown content", loadedNote1.getContent()); + Assert.assertEquals("#FFF9C4", loadedNote1.getBackgroundColor()); + Assert.assertEquals("#E6C800", loadedNote1.getBorderColor()); + Assert.assertEquals(Integer.valueOf(2), loadedNote1.getBorderWidth()); + Assert.assertEquals(Boolean.TRUE, loadedNote1.getApplyDefaultMarkdownStyle()); + + RuleChainNote loadedNote2 = loadedMetaData.getNotes().stream() + .filter(n -> "note-2".equals(n.getId())).findFirst().orElse(null); + Assert.assertNotNull(loadedNote2); + Assert.assertEquals(500, loadedNote2.getX()); + Assert.assertEquals(300, loadedNote2.getY()); + Assert.assertEquals("Simple note", loadedNote2.getContent()); + Assert.assertEquals("#C8E6C9", loadedNote2.getBackgroundColor()); + } + + @Test + public void testUpdateRuleChainNotes() throws Exception { + RuleChain ruleChain = createRuleChain("RuleChain update notes"); + + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setNodes(new ArrayList<>()); + + RuleChainNote note = new RuleChainNote(); + note.setId("note-1"); + note.setX(10); + note.setY(20); + note.setWidth(100); + note.setHeight(50); + note.setContent("Original content"); + ruleChainMetaData.setNotes(List.of(note)); + + RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class); + + note.setContent("Updated content"); + note.setX(50); + RuleChainNote newNote = new RuleChainNote(); + newNote.setId("note-2"); + newNote.setX(200); + newNote.setY(300); + newNote.setWidth(150); + newNote.setHeight(75); + newNote.setContent("New note"); + savedMetaData.setNotes(List.of(note, newNote)); + + RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class); + Assert.assertEquals(2, updatedMetaData.getNotes().size()); + + RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class); + Assert.assertEquals(2, loadedMetaData.getNotes().size()); + + RuleChainNote updatedNote = loadedMetaData.getNotes().stream() + .filter(n -> "note-1".equals(n.getId())).findFirst().orElse(null); + Assert.assertNotNull(updatedNote); + Assert.assertEquals("Updated content", updatedNote.getContent()); + Assert.assertEquals(50, updatedNote.getX()); + } + + @Test + public void testSaveRuleChainDoesNotOverwriteNotes() throws Exception { + RuleChain ruleChain = createRuleChain("RuleChain preserve notes"); + + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setNodes(new ArrayList<>()); + + RuleChainNote note = new RuleChainNote(); + note.setId("note-1"); + note.setX(10); + note.setY(20); + note.setWidth(100); + note.setHeight(50); + note.setContent("Persistent note"); + ruleChainMetaData.setNotes(List.of(note)); + + doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class); + + // Save the RuleChain itself (not metadata) — e.g. rename + ruleChain = doGet("/api/ruleChain/" + ruleChain.getId().getId(), RuleChain.class); + ruleChain.setName("Renamed RuleChain"); + ruleChain = doPost("/api/ruleChain", ruleChain, RuleChain.class); + Assert.assertEquals("Renamed RuleChain", ruleChain.getName()); + + // Notes must still be present + RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class); + Assert.assertNotNull(loadedMetaData.getNotes()); + Assert.assertEquals(1, loadedMetaData.getNotes().size()); + Assert.assertEquals("Persistent note", loadedMetaData.getNotes().get(0).getContent()); + } + + @Test + public void testRemoveNotesByUpdatingMetadata() throws Exception { + RuleChain ruleChain = createRuleChain("RuleChain remove notes"); + + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setNodes(new ArrayList<>()); + + RuleChainNote note = new RuleChainNote(); + note.setId("note-1"); + note.setX(10); + note.setY(20); + note.setWidth(100); + note.setHeight(50); + note.setContent("Will be removed"); + ruleChainMetaData.setNotes(List.of(note)); + + RuleChainMetaData savedMetaData = doPost("/api/ruleChain/metadata", ruleChainMetaData, RuleChainMetaData.class); + Assert.assertEquals(1, savedMetaData.getNotes().size()); + + // Save metadata without notes — should clear them + savedMetaData.setNotes(null); + RuleChainMetaData updatedMetaData = doPost("/api/ruleChain/metadata", savedMetaData, RuleChainMetaData.class); + + RuleChainMetaData loadedMetaData = doGet("/api/ruleChain/" + ruleChain.getId().getId() + "/metadata", RuleChainMetaData.class); + Assert.assertTrue(loadedMetaData.getNotes() == null || loadedMetaData.getNotes().isEmpty()); + } + + @Test + public void testSaveRuleChainNotesExceedsSizeLimit() throws Exception { + RuleChain ruleChain = createRuleChain("RuleChain oversized notes"); + + RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); + ruleChainMetaData.setRuleChainId(ruleChain.getId()); + ruleChainMetaData.setNodes(new ArrayList<>()); + + List notes = new ArrayList<>(); + for (int i = 0; i < 20; i++) { + RuleChainNote note = new RuleChainNote(); + note.setId("note-" + i); + note.setX(i * 10); + note.setY(i * 10); + note.setWidth(300); + note.setHeight(150); + note.setContent(StringUtils.randomAlphabetic(60000)); + notes.add(note); + } + ruleChainMetaData.setNotes(notes); + + String error = getErrorMessage(doPost("/api/ruleChain/metadata", ruleChainMetaData) + .andExpect(status().isBadRequest())); + assertThat(error).contains("Rule chain notes data is too large"); + } + private RuleChain createRuleChain(String name) { RuleChain ruleChain = new RuleChain(); ruleChain.setName(name); diff --git a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java index 26705ddacb..671c936585 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java +++ b/application/src/test/java/org/thingsboard/server/controller/TbTestWebSocketClient.java @@ -39,7 +39,6 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityHistoryCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.LatestValueCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.TimeSeriesCmd; -import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.net.URI; import java.nio.channels.NotYetConnectedException; @@ -53,6 +52,8 @@ public class TbTestWebSocketClient extends WebSocketClient { private static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); + private final CountDownLatch closeLatch = new CountDownLatch(1); + @Getter private volatile String lastMsg; private volatile CountDownLatch reply; @@ -62,6 +63,7 @@ public class TbTestWebSocketClient extends WebSocketClient { super(serverUri); } + @Override public void onOpen(ServerHandshake serverHandshake) { @@ -69,7 +71,13 @@ public class TbTestWebSocketClient extends WebSocketClient { public void authenticate(String token) { WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper(); - cmdsWrapper.setAuthCmd(new AuthCmd(1, token)); + cmdsWrapper.setAuthCmd(new AuthCmd(1, token, null)); + send(JacksonUtil.toString(cmdsWrapper)); + } + + public void authenticateWithApiKey(String apiKey) { + WsCommandsWrapper cmdsWrapper = new WsCommandsWrapper(); + cmdsWrapper.setAuthCmd(new AuthCmd(1, null, apiKey)); send(JacksonUtil.toString(cmdsWrapper)); } @@ -88,6 +96,16 @@ public class TbTestWebSocketClient extends WebSocketClient { @Override public void onClose(int i, String s, boolean b) { log.info("CLOSED."); + closeLatch.countDown(); + } + + public boolean waitForClose() { + try { + return closeLatch.await(TIMEOUT, TimeUnit.MILLISECONDS); + } catch (InterruptedException e) { + log.warn("Failed to await close", e); + return false; + } } @Override @@ -275,7 +293,7 @@ public class TbTestWebSocketClient extends WebSocketClient { public JsonNode sendTimeseriesCmd(EntityId entityId, String scope) { log.warn("sendTimeseriesCmd entityId: {}, scope: {}", entityId, scope); - TimeseriesSubscriptionCmd cmd = new TimeseriesSubscriptionCmd(0, 0, 0, 10, null); + TimeseriesSubscriptionCmd cmd = new TimeseriesSubscriptionCmd(0, 0, 0, 10, null); cmd.setEntityId(entityId.getId().toString()); cmd.setEntityType(entityId.getEntityType().toString()); cmd.setCmdId(1); diff --git a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java index dd51cdd440..ce6d291546 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TelemetryControllerTest.java @@ -102,24 +102,29 @@ public class TelemetryControllerTest extends AbstractControllerTest { Assert.assertEquals(11L, thirdIntervalResult.get("value").asLong()); Assert.assertEquals(thirdIntervalTs, thirdIntervalResult.get("ts").asLong()); - result = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + + ObjectNode resultByMonth = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?keys=t&startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", ObjectNode.class, startTs, endTs, "SUM", "MONTH", "Europe/Kyiv"); - Assert.assertNotNull(result); - Assert.assertNotNull(result.get("t")); - Assert.assertEquals(1, result.get("t").size()); + Assert.assertNotNull(resultByMonth); + Assert.assertNotNull(resultByMonth.get("t")); + Assert.assertEquals(1, resultByMonth.get("t").size()); - var monthResult = result.get("t").get(0); + var monthResult = resultByMonth.get("t").get(0); Assert.assertEquals(22L, monthResult.get("value").asLong()); Assert.assertEquals(middleOfTheInterval, monthResult.get("ts").asLong()); - // get all latest (without keys parameter) - ObjectNode allLatest = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + + // check timeseries history with key parameter (instead of keys) has the same result as with keys parameter + ObjectNode resultByKey = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + + "/values/timeseries?key=t&startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", + ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv"); + Assert.assertEquals(result, resultByKey); + + // check timeseries history without keys and key results into empty object + ObjectNode resultWithoutKeyAndKeys = doGetAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/values/timeseries?startTs={startTs}&endTs={endTs}&agg={agg}&intervalType={intervalType}&timeZone={timeZone}", ObjectNode.class, startTs, endTs, "SUM", "WEEK_ISO", "Europe/Kyiv"); - Assert.assertNotNull(allLatest); - Assert.assertNotNull(allLatest.get("t")); + Assert.assertTrue(resultWithoutKeyAndKeys.isEmpty()); } @Test diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java index fb86ea7b43..aeca72c739 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantControllerTest.java @@ -43,6 +43,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.exception.TenantNotFoundException; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; @@ -936,6 +937,26 @@ public class TenantControllerTest extends AbstractControllerTest { Mockito.reset(tbClusterService); } + @Test + public void testSaveTenantWithNonExistentTenantProfileId() throws Exception { + loginSysAdmin(); + Tenant tenant = new Tenant(); + tenant.setTitle("My tenant"); + tenant.setTenantProfileId(new TenantProfileId(UUID.randomUUID())); + + String responseBody = doPost("/api/tenant", tenant) + .andExpect(status().isBadRequest()) + .andReturn().getResponse().getContentAsString(); + + // Verify sanitized message format + assertThat(responseBody).contains("Constraint violation: fk_tenant_profile"); + // Verify raw SQL details are not returned + assertThat(responseBody).doesNotContain("could not execute statement"); + assertThat(responseBody).doesNotContain("insert or update on table"); + assertThat(responseBody).doesNotContain("tenant_profile_id"); + assertThat(responseBody).doesNotContain("is not present in table"); + } + private void testBroadcastEntityStateChangeEventNeverTenant() { Mockito.verify(tbClusterService, never()).onTenantChange(Mockito.any(Tenant.class), Mockito.isNull()); diff --git a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java index 42a27eb6d8..a2e622c8b5 100644 --- a/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/TenantProfileControllerTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.controller; import com.fasterxml.jackson.core.type.TypeReference; +import org.awaitility.Awaitility; import org.junit.Assert; import org.junit.Test; import org.mockito.ArgumentMatcher; @@ -25,9 +26,12 @@ import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.audit.AuditLog; +import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.id.TenantProfileId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.page.TimePageLink; import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; import org.thingsboard.server.common.data.queue.ProcessingStrategy; import org.thingsboard.server.common.data.queue.ProcessingStrategyType; @@ -47,6 +51,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Collectors; @@ -81,12 +86,17 @@ public class TenantProfileControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.CREATED, 1); + awaitAuditLog("Wait for async audit log to be persisted (ADDED expected)", savedTenantProfile.getId(), ActionType.ADDED); + savedTenantProfile.setName("New tenant profile"); doPost("/api/tenantProfile", savedTenantProfile, TenantProfile.class); TenantProfile foundTenantProfile = doGet("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString(), TenantProfile.class); Assert.assertEquals(foundTenantProfile.getName(), savedTenantProfile.getName()); testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.UPDATED, 1); + + awaitAuditLog("Wait for async audit log to be persisted (UPDATED expected)", savedTenantProfile.getId(), ActionType.UPDATED); + } @Test @@ -180,6 +190,8 @@ public class TenantProfileControllerTest extends AbstractControllerTest { Assert.assertNotNull(foundDefaultTenantProfile); Assert.assertEquals(savedTenantProfile.getName(), foundDefaultTenantProfile.getName()); Assert.assertEquals(savedTenantProfile.getId(), foundDefaultTenantProfile.getId()); + + awaitAuditLog("Wait for async audit log to be persisted (UPDATED expected for NEW DEFAULT Tenant Profile)", savedTenantProfile.getId(), ActionType.UPDATED); } @Test @@ -245,6 +257,8 @@ public class TenantProfileControllerTest extends AbstractControllerTest { doDelete("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString()) .andExpect(status().isOk()); + awaitAuditLog("Wait for async audit log to be persisted for deleted tenant profile", savedTenantProfile.getId(), ActionType.DELETED); + testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(savedTenantProfile, ComponentLifecycleEvent.DELETED, 1); doGet("/api/tenantProfile/" + savedTenantProfile.getId().getId().toString()) @@ -393,6 +407,17 @@ public class TenantProfileControllerTest extends AbstractControllerTest { testBroadcastEntityStateChangeEventNeverTenantProfile(); } + private void awaitAuditLog(String awaitMessage, TenantProfileId tenantProfileId, ActionType expectedAction) { + Awaitility.await(awaitMessage) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .until(() -> doGetTypedWithTimePageLink( + "/api/audit/logs?", + new TypeReference>() {}, + new TimePageLink(100)).getData().stream() + .anyMatch(log -> log.getEntityId().equals(tenantProfileId) && log.getActionType() == expectedAction) + ); + } + private TenantProfile createTenantProfile(String name) { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName(name); @@ -429,7 +454,6 @@ public class TenantProfileControllerTest extends AbstractControllerTest { tenantProfile.setProfileData(profileData); } - private void testBroadcastEntityStateChangeEventTimeManyTimeTenantProfile(TenantProfile tenantProfile, ComponentLifecycleEvent event, int cntTime) { ArgumentMatcher matcherTenantProfile = cntTime == 1 ? argument -> argument.equals(tenantProfile) : argument -> argument.getClass().equals(TenantProfile.class); diff --git a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java b/application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java similarity index 78% rename from application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java rename to application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java index 87ba0ec3e8..2174b7425f 100644 --- a/application/src/test/java/org/thingsboard/server/controller/WebsocketApiTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/WebSocketApiTest.java @@ -19,13 +19,16 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FutureCallback; +import com.google.common.util.concurrent.Futures; import lombok.extern.slf4j.Slf4j; import org.checkerframework.checker.nullness.qual.Nullable; import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; @@ -60,7 +63,9 @@ import org.thingsboard.server.common.data.query.NumericFilterPredicate; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.service.subscription.SubscriptionErrorCode; import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; import org.thingsboard.server.service.telemetry.TelemetrySubscriptionService; @@ -71,13 +76,11 @@ import org.thingsboard.server.service.ws.telemetry.cmd.v2.AlarmStatusUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountCmd; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityCountUpdate; import org.thingsboard.server.service.ws.telemetry.cmd.v2.EntityDataUpdate; -import org.thingsboard.server.service.ws.telemetry.sub.TelemetrySubscriptionUpdate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; -import java.util.Map; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -91,10 +94,14 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "server.ws.alarms_per_alarm_status_subscription_cache_size=5", "server.ws.dynamic_page_link.refresh_interval=15" }) -public class WebsocketApiTest extends AbstractControllerTest { +public class WebSocketApiTest extends AbstractControllerTest { + @Autowired private TelemetrySubscriptionService tsService; + @SpyBean + private TimeseriesService timeseriesService; + Device device; DeviceTypeFilter dtf; @@ -128,8 +135,8 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); - Assert.assertEquals(0, pageData.getData().get(0).getTimeseries().get("temperature").length); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); + Assert.assertEquals(0, pageData.getData().getFirst().getTimeseries().get("temperature").length); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 42L)); @@ -144,8 +151,8 @@ public class WebsocketApiTest extends AbstractControllerTest { List dataList = update.getUpdate(); Assert.assertNotNull(dataList); Assert.assertEquals(1, dataList.size()); - Assert.assertEquals(device.getId(), dataList.get(0).getEntityId()); - TsValue[] tsArray = dataList.get(0).getTimeseries().get("temperature"); + Assert.assertEquals(device.getId(), dataList.getFirst().getEntityId()); + TsValue[] tsArray = dataList.getFirst().getTimeseries().get("temperature"); Assert.assertEquals(3, tsArray.length); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]); @@ -162,7 +169,7 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); TsKvEntry dataPoint2 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(2), new LongDataEntry("temperature", 43L)); @@ -176,8 +183,8 @@ public class WebsocketApiTest extends AbstractControllerTest { List listData = update.getUpdate(); Assert.assertNotNull(listData); Assert.assertEquals(1, listData.size()); - Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); - TsValue[] tsArray = listData.get(0).getTimeseries().get("temperature"); + Assert.assertEquals(device.getId(), listData.getFirst().getEntityId()); + TsValue[] tsArray = listData.getFirst().getTimeseries().get("temperature"); Assert.assertEquals(3, tsArray.length); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsArray[0]); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsArray[1]); @@ -186,7 +193,7 @@ public class WebsocketApiTest extends AbstractControllerTest { now = System.currentTimeMillis(); TsKvEntry dataPoint4 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 45L)); getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint4)); + sendTelemetry(device, List.of(dataPoint4)); String msg = getWsClient().waitForUpdate(); update = JacksonUtil.fromString(msg, EntityDataUpdate.class); @@ -194,9 +201,9 @@ public class WebsocketApiTest extends AbstractControllerTest { List eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getTimeseries()); - TsValue[] tsValues = eData.get(0).getTimeseries().get("temperature"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getTimeseries()); + TsValue[] tsValues = eData.getFirst().getTimeseries().get("temperature"); Assert.assertNotNull(tsValues); Assert.assertEquals(new TsValue(dataPoint4.getTs(), dataPoint4.getValueAsString()), tsValues[0]); } @@ -521,7 +528,7 @@ public class WebsocketApiTest extends AbstractControllerTest { String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); - // check device + // check a device AlarmStatusCmd deviceCmd = new AlarmStatusCmd(2, device.getId(), null, List.of(AlarmSeverity.CRITICAL)); getWsClient().send(deviceCmd); @@ -589,13 +596,13 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); - List tsData = Arrays.asList(dataPoint1); + List tsData = List.of(dataPoint1); sendTelemetry(device, tsData); update = getWsClient().subscribeLatestUpdate(keys); @@ -605,36 +612,36 @@ public class WebsocketApiTest extends AbstractControllerTest { List listData = update.getUpdate(); Assert.assertNotNull(listData); Assert.assertEquals(1, listData.size()); - Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); - Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); - TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); + Assert.assertEquals(device.getId(), listData.getFirst().getEntityId()); + Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES)); + TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue); now = System.currentTimeMillis(); TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L)); getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint2)); + sendTelemetry(device, List.of(dataPoint2)); update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); Assert.assertEquals(1, update.getCmdId()); List eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); - tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES)); + tsValue = eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue); //Sending update from the past, while latest value has new timestamp; getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint1)); + sendTelemetry(device, List.of(dataPoint1)); String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); //Sending duplicate update again getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint2)); + sendTelemetry(device, List.of(dataPoint2)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); } @@ -677,14 +684,14 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); getWsClient().registerWaitForUpdate(); TsKvEntry dataPoint1 = new BasicTsKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("temperature", 42L)); - List tsData = Arrays.asList(dataPoint1); + List tsData = List.of(dataPoint1); sendTelemetry(device, tsData); update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); @@ -694,38 +701,73 @@ public class WebsocketApiTest extends AbstractControllerTest { List listData = update.getUpdate(); Assert.assertNotNull(listData); Assert.assertEquals(1, listData.size()); - Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); - Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); - TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); + Assert.assertEquals(device.getId(), listData.getFirst().getEntityId()); + Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES)); + TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); Assert.assertEquals(new TsValue(dataPoint1.getTs(), dataPoint1.getValueAsString()), tsValue); now = System.currentTimeMillis(); TsKvEntry dataPoint2 = new BasicTsKvEntry(now, new LongDataEntry("temperature", 52L)); getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint2)); + sendTelemetry(device, List.of(dataPoint2)); update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); Assert.assertEquals(1, update.getCmdId()); List eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); - tsValue = eData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES)); + tsValue = eData.getFirst().getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); Assert.assertEquals(new TsValue(dataPoint2.getTs(), dataPoint2.getValueAsString()), tsValue); - //Sending update from the past, while latest value has new timestamp; + //Sending update from the past, while the latest value has new timestamp; getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint1)); + sendTelemetry(device, List.of(dataPoint1)); String msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); //Sending duplicate update again getWsClient().registerWaitForUpdate(); - sendTelemetry(device, Arrays.asList(dataPoint2)); + sendTelemetry(device, List.of(dataPoint2)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); } + @Test + public void testShouldSendWsUpdateMessageWhenTelemetryWasDeleted() throws Exception { + long now = System.currentTimeMillis() - 100; + TsKvEntry dataPoint = new BasicTsKvEntry(now, new LongDataEntry("temperature", 42L)); + List tsData = List.of(dataPoint); + sendTelemetry(device, tsData); + + List keys = List.of(new EntityKey(EntityKeyType.TIME_SERIES, "temperature")); + EntityDataUpdate update = getWsClient().subscribeLatestUpdate(keys, dtf); + + Assert.assertEquals(1, update.getCmdId()); + PageData pageData = update.getData(); + Assert.assertNotNull(pageData); + Assert.assertEquals(1, pageData.getData().size()); + Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); + Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature")); + Assert.assertEquals(now, pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getTs()); + Assert.assertEquals("42", pageData.getData().get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature").getValue()); + + // delete telemetry + getWsClient().registerWaitForUpdate(); + doDeleteAsync("/api/plugins/telemetry/DEVICE/" + device.getId() + "/timeseries/delete?keys=temperature&deleteAllDataForKeys=true", String.class); + update = getWsClient().parseDataReply(getWsClient().waitForUpdate()); + + Assert.assertEquals(1, update.getCmdId()); + + List listData = update.getUpdate(); + Assert.assertNotNull(listData); + Assert.assertEquals(1, listData.size()); + Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); + Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES)); + TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.TIME_SERIES).get("temperature"); + Assert.assertEquals(new TsValue(0, ""), tsValue); + } + @Test public void testEntityDataLatestAttrWsCmd() throws Exception { long now = System.currentTimeMillis(); @@ -736,21 +778,21 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); getWsClient().registerWaitForUpdate(); - // Pushing update with wrong scope and make sure it will not arrive. + // Pushing update with the wrong scope and make sure it will not arrive. AttributeKvEntry invalidDataPoint = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 55L)); - sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(invalidDataPoint)); + sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(invalidDataPoint)); Assert.assertNull(getWsClient().waitForUpdate(3000)); AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L)); - List tsData = Arrays.asList(dataPoint1); + List tsData = List.of(dataPoint1); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData); String msg = getWsClient().waitForUpdate(); @@ -761,16 +803,16 @@ public class WebsocketApiTest extends AbstractControllerTest { List listData = update.getUpdate(); Assert.assertNotNull(listData); Assert.assertEquals(1, listData.size()); - Assert.assertEquals(device.getId(), listData.get(0).getEntityId()); - Assert.assertNotNull(listData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); - TsValue tsValue = listData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); + Assert.assertEquals(device.getId(), listData.getFirst().getEntityId()); + Assert.assertNotNull(listData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); + TsValue tsValue = listData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), tsValue); now = System.currentTimeMillis(); AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("serverAttributeKey", 52L)); getWsClient().registerWaitForUpdate(); - sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint2)); + sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint2)); msg = getWsClient().waitForUpdate(); Assert.assertNotNull(msg); @@ -779,20 +821,20 @@ public class WebsocketApiTest extends AbstractControllerTest { List eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); - tsValue = eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); + tsValue = eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), tsValue); - //Sending update from the past, while latest value has new timestamp; + //Sending update from the past, while the latest value has new timestamp; getWsClient().registerWaitForUpdate(); - sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint1)); + sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); //Sending duplicate update again getWsClient().registerWaitForUpdate(); - sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint2)); + sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint2)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); } @@ -812,23 +854,23 @@ public class WebsocketApiTest extends AbstractControllerTest { PageData pageData = update.getData(); Assert.assertNotNull(pageData); Assert.assertEquals(1, pageData.getData().size()); - Assert.assertEquals(device.getId(), pageData.getData().get(0).getEntityId()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getValue()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getValue()); - Assert.assertNotNull(pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey")); - Assert.assertEquals(0, pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getTs()); - Assert.assertEquals("", pageData.getData().get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getValue()); + Assert.assertEquals(device.getId(), pageData.getData().getFirst().getEntityId()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey").getValue()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey").getValue()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey").getValue()); + Assert.assertNotNull(pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey")); + Assert.assertEquals(0, pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getTs()); + Assert.assertEquals("", pageData.getData().getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey").getValue()); getWsClient().registerWaitForUpdate(); AttributeKvEntry dataPoint1 = new BaseAttributeKvEntry(now - TimeUnit.MINUTES.toMillis(1), new LongDataEntry("serverAttributeKey", 42L)); - List tsData = Arrays.asList(dataPoint1); + List tsData = List.of(dataPoint1); sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, tsData); @@ -839,78 +881,78 @@ public class WebsocketApiTest extends AbstractControllerTest { List eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); - TsValue attrValue = eData.get(0).getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE)); + TsValue attrValue = eData.getFirst().getLatest().get(EntityKeyType.SERVER_ATTRIBUTE).get("serverAttributeKey"); Assert.assertEquals(new TsValue(dataPoint1.getLastUpdateTs(), dataPoint1.getValueAsString()), attrValue); - //Sending update from the past, while latest value has new timestamp; + //Sending update from the past, while the latest value has new timestamp; getWsClient().registerWaitForUpdate(); - sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, Arrays.asList(dataPoint1)); + sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, List.of(dataPoint1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); //Sending duplicate update again getWsClient().registerWaitForUpdate(); - sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint1)); + sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint1)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); Assert.assertNull(msg); //Sending update from the past, while latest value has new timestamp; getWsClient().registerWaitForUpdate(); AttributeKvEntry dataPoint2 = new BaseAttributeKvEntry(now, new LongDataEntry("sharedAttributeKey", 42L)); - sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, Arrays.asList(dataPoint2)); + sendAttributes(device, TbAttributeSubscriptionScope.SHARED_SCOPE, List.of(dataPoint2)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); update = JacksonUtil.fromString(msg, EntityDataUpdate.class); Assert.assertEquals(1, update.getCmdId()); eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE)); - attrValue = eData.get(0).getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE)); + attrValue = eData.getFirst().getLatest().get(EntityKeyType.SHARED_ATTRIBUTE).get("sharedAttributeKey"); Assert.assertEquals(new TsValue(dataPoint2.getLastUpdateTs(), dataPoint2.getValueAsString()), attrValue); getWsClient().registerWaitForUpdate(); AttributeKvEntry dataPoint3 = new BaseAttributeKvEntry(now, new LongDataEntry("clientAttributeKey", 42L)); - sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint3)); + sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint3)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); update = JacksonUtil.fromString(msg, EntityDataUpdate.class); Assert.assertEquals(1, update.getCmdId()); eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE)); - attrValue = eData.get(0).getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE)); + attrValue = eData.getFirst().getLatest().get(EntityKeyType.CLIENT_ATTRIBUTE).get("clientAttributeKey"); Assert.assertEquals(new TsValue(dataPoint3.getLastUpdateTs(), dataPoint3.getValueAsString()), attrValue); getWsClient().registerWaitForUpdate(); AttributeKvEntry dataPoint4 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 42L)); - sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, Arrays.asList(dataPoint4)); + sendAttributes(device, TbAttributeSubscriptionScope.CLIENT_SCOPE, List.of(dataPoint4)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); update = JacksonUtil.fromString(msg, EntityDataUpdate.class); Assert.assertEquals(1, update.getCmdId()); eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE)); - attrValue = eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE)); + attrValue = eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); Assert.assertEquals(new TsValue(dataPoint4.getLastUpdateTs(), dataPoint4.getValueAsString()), attrValue); getWsClient().registerWaitForUpdate(); AttributeKvEntry dataPoint5 = new BaseAttributeKvEntry(now, new LongDataEntry("anyAttributeKey", 43L)); - sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, Arrays.asList(dataPoint5)); + sendAttributes(device, TbAttributeSubscriptionScope.SERVER_SCOPE, List.of(dataPoint5)); msg = getWsClient().waitForUpdate(TimeUnit.SECONDS.toMillis(1)); update = JacksonUtil.fromString(msg, EntityDataUpdate.class); Assert.assertEquals(1, update.getCmdId()); eData = update.getUpdate(); Assert.assertNotNull(eData); Assert.assertEquals(1, eData.size()); - Assert.assertEquals(device.getId(), eData.get(0).getEntityId()); - Assert.assertNotNull(eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE)); - attrValue = eData.get(0).getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); + Assert.assertEquals(device.getId(), eData.getFirst().getEntityId()); + Assert.assertNotNull(eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE)); + attrValue = eData.getFirst().getLatest().get(EntityKeyType.ATTRIBUTE).get("anyAttributeKey"); Assert.assertEquals(new TsValue(dataPoint5.getLastUpdateTs(), dataPoint5.getValueAsString()), attrValue); } @@ -965,13 +1007,41 @@ public class WebsocketApiTest extends AbstractControllerTest { } + @Test + public void testHistoryCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception { + ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L); + Mockito.doReturn(Futures.immediateFailedFuture(exception)) + .when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any()); + + List keys = List.of("temperature"); + long now = System.currentTimeMillis(); + + EntityDataUpdate errorUpdate = getWsClient().sendHistoryCmd(keys, now, TimeUnit.HOURS.toMillis(1), dtf); + assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); + assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); + } + + @Test + public void testTimeSeriesCmdSendsWsErrorOnResultSetSizeLimitExceeded() throws Exception { + ResultSetSizeLimitExceededException exception = new ResultSetSizeLimitExceededException(100L, 200L); + Mockito.doReturn(Futures.immediateFailedFuture(exception)) + .when(timeseriesService).findAllByQueries(Mockito.any(), Mockito.any(), Mockito.any()); + + List keys = List.of("temperature"); + long now = System.currentTimeMillis(); + + EntityDataUpdate errorUpdate = getWsClient().subscribeTsUpdate(keys, now, TimeUnit.HOURS.toMillis(1), dtf); + assertThat(errorUpdate.getErrorCode()).isEqualTo(SubscriptionErrorCode.INTERNAL_ERROR.getCode()); + assertThat(errorUpdate.getErrorMsg()).isEqualTo(exception.getMessage()); + } + private void sendTelemetry(Device device, List tsData) throws InterruptedException { CountDownLatch latch = new CountDownLatch(1); tsService.saveTimeseries(TimeseriesSaveRequest.builder() .tenantId(device.getTenantId()) .entityId(device.getId()) .entries(tsData) - .callback(new FutureCallback() { + .callback(new FutureCallback<>() { @Override public void onSuccess(@Nullable Void result) { log.debug("sendTelemetry callback onSuccess"); diff --git a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java index 053cb6808f..970c244fad 100644 --- a/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/plugin/TbWebSocketHandlerTest.java @@ -25,16 +25,32 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketSession; import org.springframework.web.socket.adapter.NativeWebSocketSession; import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.security.auth.jwt.JwtAuthenticationProvider; +import org.thingsboard.server.service.security.auth.pat.ApiKeyAuthenticationProvider; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.ws.WebSocketService; import org.thingsboard.server.service.ws.WebSocketSessionRef; +import org.thingsboard.server.service.ws.WebSocketSessionType; import java.io.IOException; +import java.lang.reflect.Method; import java.util.Collection; import java.util.Deque; import java.util.List; import java.util.Random; +import java.util.UUID; import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; @@ -184,4 +200,167 @@ class TbWebSocketHandlerTest { assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100); } + // Regression test for the bug where publicUserSessionsMap was keyed by UserId(NULL_UUID), + // making maxWsSessionsPerPublicUser a global limit shared across all tenants. + // The limit is now scoped per-tenant. + @Test + void checkLimits_publicUserSessions_limitIsPerTenantNotGlobal() throws Exception { + TbTenantProfileCache tenantProfileCache = mock(TbTenantProfileCache.class); + ReflectionTestUtils.setField(wsHandler, "tenantProfileCache", tenantProfileCache); + + int maxPublicSessions = 2; + + TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile1 = new TenantProfile(); + profile1.createDefaultTenantProfileData(); + profile1.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile1).given(tenantProfileCache).get(tenant1); + + TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile2 = new TenantProfile(); + profile2.createDefaultTenantProfileData(); + profile2.getDefaultProfileConfiguration().setMaxWsSessionsPerPublicUser(maxPublicSessions); + willReturn(profile2).given(tenantProfileCache).get(tenant2); + + Method checkLimits = TbWebSocketHandler.class.getDeclaredMethod( + "checkLimits", WebSocketSession.class, WebSocketSessionRef.class); + checkLimits.setAccessible(true); + + // tenant1 fills up its limit + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t1-" + i), mockPublicSessionRef(tenant1))).isTrue(); + } + + // tenant2 must get its own independent quota — this was the bug: with NULL_UUID as key + // all tenants shared one global counter, so tenant2 would be blocked here + for (int i = 0; i < maxPublicSessions; i++) { + assertThat((boolean) checkLimits.invoke(wsHandler, mockWsSession("t2-" + i), mockPublicSessionRef(tenant2))) + .as("tenant2 session %d should not be affected by tenant1's sessions", i + 1) + .isTrue(); + } + + // tenant1's (maxPublicSessions + 1)-th session must be rejected + NativeWebSocketSession overLimit = mockWsSession("t1-over"); + assertThat((boolean) checkLimits.invoke(wsHandler, overLimit, mockPublicSessionRef(tenant1))).isFalse(); + verify(overLimit).close(CloseStatus.POLICY_VIOLATION.withReason("Max public user sessions limit reached")); + } + + private NativeWebSocketSession mockWsSession(String id) { + NativeWebSocketSession s = mock(NativeWebSocketSession.class); + willReturn(id).given(s).getId(); + return s; + } + + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId) { + CustomerId customerId = new CustomerId(UUID.randomUUID()); + SecurityUser securityUser = mock(SecurityUser.class); + willReturn(tenantId).given(securityUser).getTenantId(); + willReturn(customerId).given(securityUser).getCustomerId(); + willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId(); + willReturn(true).given(securityUser).isCustomerUser(); + willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal(); + + WebSocketSessionRef ref = mock(WebSocketSessionRef.class); + willReturn(securityUser).given(ref).getSecurityCtx(); + willReturn(UUID.randomUUID().toString()).given(ref).getSessionId(); + return ref; + } + + private AuthTestFixture createAuthTestFixture() throws IOException { + TbWebSocketHandler handler = spy(new TbWebSocketHandler()); + willDoNothing().given(handler).close(any(), any()); + + ApiKeyAuthenticationProvider apiKeyProvider = mock(ApiKeyAuthenticationProvider.class); + JwtAuthenticationProvider jwtProvider = mock(JwtAuthenticationProvider.class); + WebSocketService wsService = mock(WebSocketService.class); + + ReflectionTestUtils.setField(handler, "apiKeyAuthenticationProvider", apiKeyProvider); + ReflectionTestUtils.setField(handler, "authenticationProvider", jwtProvider); + ReflectionTestUtils.setField(handler, "webSocketService", wsService); + ReflectionTestUtils.setField(handler, "tenantProfileCache", mock(TbTenantProfileCache.class)); + ReflectionTestUtils.setField(handler, "authTimeoutMs", 10000); + ReflectionTestUtils.invokeMethod(handler, "init"); + + WebSocketSessionRef ref = WebSocketSessionRef.builder() + .sessionId(UUID.randomUUID().toString()) + .sessionType(WebSocketSessionType.GENERAL) + .build(); + + NativeWebSocketSession wsSession = mock(NativeWebSocketSession.class); + Session nativeSess = mock(Session.class); + willReturn(nativeSess).given(wsSession).getNativeSession(Session.class); + RemoteEndpoint.Async async = mock(RemoteEndpoint.Async.class); + willReturn(async).given(nativeSess).getAsyncRemote(); + willReturn("test-session-id").given(wsSession).getId(); + + TbWebSocketHandler.SessionMetaData sessionMd = handler.new SessionMetaData(wsSession, ref); + + return new AuthTestFixture(handler, apiKeyProvider, jwtProvider, ref, sessionMd); + } + + @Test + void processMsg_authenticatesWithApiKey() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.apiKeyProvider).authenticate("my-api-key"); + verify(f.jwtProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void processMsg_authenticatesWithJwtToken() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.jwtProvider).authenticate("my-jwt-token"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"token\":\"my-jwt-token\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.jwtProvider).authenticate("my-jwt-token"); + verify(f.apiKeyProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void processMsg_apiKeyTakesPrecedenceOverToken() throws Exception { + AuthTestFixture f = createAuthTestFixture(); + + SecurityUser securityUser = mock(SecurityUser.class, Mockito.RETURNS_DEEP_STUBS); + willReturn(securityUser).given(f.apiKeyProvider).authenticate("my-api-key"); + + String msg = "{\"authCmd\":{\"cmdId\":1,\"apiKey\":\"my-api-key\",\"token\":\"my-jwt-token\"},\"cmds\":[]}"; + f.handler.processMsg(f.sessionMd, msg); + + verify(f.apiKeyProvider).authenticate("my-api-key"); + verify(f.jwtProvider, never()).authenticate(anyString()); + assertThat(f.ref.getSecurityCtx()).isSameAs(securityUser); + } + + @Test + void extractQueryParam_parsesCorrectly() throws Exception { + TbWebSocketHandler handler = new TbWebSocketHandler(); + Method method = TbWebSocketHandler.class.getDeclaredMethod("extractQueryParam", String.class, String.class); + method.setAccessible(true); + + assertThat(method.invoke(handler, "token=jwt123", "token")).isEqualTo("jwt123"); + assertThat(method.invoke(handler, "token=jwt123&other=abc123", "token")).isEqualTo("jwt123"); + assertThat(method.invoke(handler, "other=value", "token")).isNull(); + assertThat(method.invoke(handler, "tokenExtra=value", "token")).isNull(); + } + + private record AuthTestFixture( + TbWebSocketHandler handler, + ApiKeyAuthenticationProvider apiKeyProvider, + JwtAuthenticationProvider jwtProvider, + WebSocketSessionRef ref, + TbWebSocketHandler.SessionMetaData sessionMd + ) {} + } diff --git a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java index 3629d7ff49..d75c7b8f59 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -27,7 +27,10 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.Customer; @@ -36,6 +39,7 @@ import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceInfo; import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.HasVersion; import org.thingsboard.server.common.data.OtaPackageInfo; import org.thingsboard.server.common.data.SaveOtaPackageInfoRequest; import org.thingsboard.server.common.data.StringUtils; @@ -118,6 +122,15 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) @Slf4j abstract public class AbstractEdgeTest extends AbstractControllerTest { + + public static final String EDGE_HOST = "localhost"; + public static final int EDGE_PORT = TestSocketUtils.findAvailableTcpPort(); + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.debug("edges.rpc.port = {}", EDGE_PORT); + registry.add("edges.rpc.port", () -> EDGE_PORT); + } + public static final Integer CONNECT_MESSAGE_COUNT = 17; public static final Integer INSTALLATION_MESSAGE_COUNT = 8; public static final Integer SYNC_MESSAGE_COUNT = CONNECT_MESSAGE_COUNT + INSTALLATION_MESSAGE_COUNT; @@ -144,7 +157,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { //8 installation messages installation(); - edgeImitator = new EdgeImitator("localhost", 7070, edge.getRoutingKey(), edge.getSecret()); + edgeImitator = new EdgeImitator(EDGE_HOST, EDGE_PORT, edge.getRoutingKey(), edge.getSecret()); // 17 connect messages + 8 installation messages edgeImitator.expectMessageAmount(SYNC_MESSAGE_COUNT); edgeImitator.ignoreType(OAuth2ClientUpdateMsg.class); @@ -593,7 +606,7 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); Assert.assertNotNull(deviceCredentialsMsg); Assert.assertEquals(savedDevice.getId(), deviceCredentialsMsg.getDeviceId()); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); return savedDevice; } @@ -763,4 +776,10 @@ abstract public class AbstractEdgeTest extends AbstractControllerTest { }); } + protected void compareHasVersionEntities(HasVersion entity1, HasVersion entity2) { + entity1.setVersion(null); + entity2.setVersion(null); + Assert.assertEquals(entity1, entity2); + } + } diff --git a/application/src/test/java/org/thingsboard/server/edge/AdminSettingsEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AdminSettingsEdgeTest.java new file mode 100644 index 0000000000..43350f7e8f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/AdminSettingsEdgeTest.java @@ -0,0 +1,79 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.google.protobuf.AbstractMessage; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.AdminSettings; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; + +@DaoSqlTest +public class AdminSettingsEdgeTest extends AbstractEdgeTest { + + @Autowired + private AdminSettingsService adminSettingsService; + + @Test + public void testAdminSettings() throws Exception { + loginSysAdmin(); + + // save + AdminSettings adminSettings = new AdminSettings(); + adminSettings.setKey("edgeTest"); + ObjectNode jsonValue = JacksonUtil.newObjectNode(); + jsonValue.put("key1", "value1"); + adminSettings.setJsonValue(jsonValue); + + edgeImitator.expectMessageAmount(1); + AdminSettings savedAdminSettings = doPost("/api/admin/settings", adminSettings, AdminSettings.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); + AdminSettingsUpdateMsg adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; + AdminSettings adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); + Assert.assertNotNull(adminSettingsMsg); + Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); + Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); + + // update + ObjectNode updatedJsonValue = (ObjectNode) savedAdminSettings.getJsonValue(); + updatedJsonValue.put("key2", "value2"); + savedAdminSettings.setJsonValue(updatedJsonValue); + + edgeImitator.expectMessageAmount(1); + doPost("/api/admin/settings", savedAdminSettings, AdminSettings.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof AdminSettingsUpdateMsg); + adminSettingsUpdateMsg = (AdminSettingsUpdateMsg) latestMessage; + adminSettingsMsg = JacksonUtil.fromString(adminSettingsUpdateMsg.getEntity(), AdminSettings.class, true); + Assert.assertNotNull(adminSettingsMsg); + Assert.assertEquals("edgeTest", adminSettingsMsg.getKey()); + Assert.assertEquals("value1", adminSettingsMsg.getJsonValue().get("key1").asText()); + Assert.assertEquals("value2", adminSettingsMsg.getJsonValue().get("key2").asText()); + + adminSettingsService.deleteAdminSettingsByTenantIdAndKey(savedAdminSettings.getTenantId(), "edgeTest"); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java new file mode 100644 index 0000000000..bd46cb5251 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/ApiKeyEdgeTest.java @@ -0,0 +1,238 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import com.datastax.oss.driver.api.core.uuid.Uuids; +import com.google.protobuf.AbstractMessage; +import com.google.protobuf.InvalidProtocolBufferException; +import org.junit.Assert; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.id.ApiKeyId; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; +import org.thingsboard.server.common.data.security.Authority; +import org.thingsboard.server.dao.pat.ApiKeyService; +import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.gen.edge.v1.UplinkMsg; +import org.thingsboard.server.gen.edge.v1.UplinkResponseMsg; +import org.thingsboard.server.gen.edge.v1.UserCredentialsUpdateMsg; +import org.thingsboard.server.gen.edge.v1.UserUpdateMsg; + +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.awaitility.Awaitility.await; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.thingsboard.server.gen.edge.v1.UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; + +@DaoSqlTest +public class ApiKeyEdgeTest extends AbstractEdgeTest { + + @Autowired + private ApiKeyService apiKeyService; + + private static final String DEFAULT_API_KEY_DESCRIPTION = "Edge Test ApiKey"; + private static final String UPDATED_API_KEY_DESCRIPTION = "Updated Edge Test ApiKey"; + + @Test + public void testApiKey_create_update_delete_fromCloud() throws Exception { + // create ApiKey + ApiKeyInfo apiKeyInfo = createSimpleApiKeyInfo(DEFAULT_API_KEY_DESCRIPTION); + + edgeImitator.expectMessageAmount(1); + ApiKey savedApiKey = doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + AbstractMessage latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + ApiKeyUpdateMsg apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); + Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); + ApiKey apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + Assert.assertNotNull(apiKeyFromMsg); + + Assert.assertEquals(DEFAULT_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); + Assert.assertEquals(savedApiKey.getTenantId(), apiKeyFromMsg.getTenantId()); + + // update ApiKey + edgeImitator.expectMessageAmount(1); + savedApiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); + savedApiKey = doPost("/api/apiKey", new ApiKeyInfo(savedApiKey), ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + apiKeyFromMsg = JacksonUtil.fromString(apiKeyUpdateMsg.getEntity(), ApiKey.class, true); + Assert.assertNotNull(apiKeyFromMsg); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(UPDATED_API_KEY_DESCRIPTION, apiKeyFromMsg.getDescription()); + + // delete ApiKey + edgeImitator.expectMessageAmount(1); + doDelete("/api/apiKey/" + savedApiKey.getUuidId()) + .andExpect(status().isOk()); + Assert.assertTrue(edgeImitator.waitForMessages()); + + latestMessage = edgeImitator.getLatestMessage(); + Assert.assertTrue(latestMessage instanceof ApiKeyUpdateMsg); + apiKeyUpdateMsg = (ApiKeyUpdateMsg) latestMessage; + Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + Assert.assertEquals(savedApiKey.getUuidId().getMostSignificantBits(), apiKeyUpdateMsg.getIdMSB()); + Assert.assertEquals(savedApiKey.getUuidId().getLeastSignificantBits(), apiKeyUpdateMsg.getIdLSB()); + } + + @Test + public void testApiKey_create_update_delete_toCloud() throws Exception { + // create + ApiKey apiKey = createSimpleApiKey(DEFAULT_API_KEY_DESCRIPTION); + UUID uuid = Uuids.timeBased(); + UplinkMsg uplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE); + + checkApiKeyOnCloud(uplinkMsg, uuid, apiKey.getDescription()); + + // update + apiKey.setDescription(UPDATED_API_KEY_DESCRIPTION); + UplinkMsg updatedUplinkMsg = getUplinkMsg(uuid, apiKey, UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE); + + checkApiKeyOnCloud(updatedUplinkMsg, uuid, apiKey.getDescription()); + + // delete + UplinkMsg deleteUplinkMsg = getDeleteUplinkMsg(uuid); + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(deleteUplinkMsg); + Assert.assertTrue(edgeImitator.waitForResponses()); + + ApiKeyId apiKeyId = new ApiKeyId(uuid); + await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> + Assert.assertNull(apiKeyService.findApiKeyById(tenantId, apiKeyId)) + ); + } + + @Test + public void testApiKey_pushedDuringUserSync() throws Exception { + // create tenant admin user - expect 3 messages: 1 UserUpdateMsg + 2 UserCredentialsUpdateMsg + User user = new User(); + user.setAuthority(Authority.TENANT_ADMIN); + user.setTenantId(tenantId); + user.setEmail("apiKeyTestUser@thingsboard.org"); + user.setFirstName("ApiKey"); + user.setLastName("TestUser"); + + edgeImitator.expectMessageAmount(3); + User savedUser = createUser(user, "tenant"); + Assert.assertTrue(edgeImitator.waitForMessages()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); + Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + + // create API key for this user - expect 1 ApiKeyUpdateMsg + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setTenantId(tenantId); + apiKeyInfo.setUserId(savedUser.getId()); + apiKeyInfo.setDescription("Test API Key for user sync"); + apiKeyInfo.setEnabled(true); + + edgeImitator.expectMessageAmount(1); + doPost("/api/apiKey", apiKeyInfo, ApiKey.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); + + // update user - expect 3 messages: UserUpdateMsg + UserCredentialsUpdateMsg + ApiKeyUpdateMsg + savedUser.setLastName("UpdatedLastName"); + edgeImitator.expectMessageAmount(3); + doPost("/api/user", savedUser, User.class); + Assert.assertTrue(edgeImitator.waitForMessages()); + + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + Assert.assertEquals(1, edgeImitator.findAllMessagesByType(ApiKeyUpdateMsg.class).size()); + + Optional apiKeyUpdateMsgOpt = edgeImitator.findMessageByType(ApiKeyUpdateMsg.class); + Assert.assertTrue(apiKeyUpdateMsgOpt.isPresent()); + ApiKeyUpdateMsg apiKeyUpdateMsg = apiKeyUpdateMsgOpt.get(); + Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, apiKeyUpdateMsg.getMsgType()); + } + + private ApiKeyInfo createSimpleApiKeyInfo(String description) { + ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); + apiKeyInfo.setTenantId(tenantId); + apiKeyInfo.setUserId(tenantAdminUserId); + apiKeyInfo.setDescription(description); + apiKeyInfo.setEnabled(true); + return apiKeyInfo; + } + + private ApiKey createSimpleApiKey(String description) { + ApiKey apiKey = new ApiKey(); + apiKey.setTenantId(tenantId); + apiKey.setUserId(tenantAdminUserId); + apiKey.setDescription(description); + apiKey.setEnabled(true); + apiKey.setValue("test-api-key-value-" + UUID.randomUUID()); + return apiKey; + } + + private UplinkMsg getDeleteUplinkMsg(UUID uuid) throws InvalidProtocolBufferException { + UplinkMsg.Builder upLinkMsgBuilder = UplinkMsg.newBuilder(); + ApiKeyUpdateMsg.Builder apiKeyDeleteMsgBuilder = ApiKeyUpdateMsg.newBuilder(); + apiKeyDeleteMsgBuilder.setMsgType(ENTITY_DELETED_RPC_MESSAGE); + apiKeyDeleteMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + apiKeyDeleteMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + testAutoGeneratedCodeByProtobuf(apiKeyDeleteMsgBuilder); + + upLinkMsgBuilder.addApiKeyUpdateMsg(apiKeyDeleteMsgBuilder.build()); + testAutoGeneratedCodeByProtobuf(upLinkMsgBuilder); + + return upLinkMsgBuilder.build(); + } + + private UplinkMsg getUplinkMsg(UUID uuid, ApiKey apiKey, UpdateMsgType updateMsgType) throws InvalidProtocolBufferException { + UplinkMsg.Builder uplinkMsgBuilder = UplinkMsg.newBuilder(); + ApiKeyUpdateMsg.Builder apiKeyUpdateMsgBuilder = ApiKeyUpdateMsg.newBuilder(); + apiKeyUpdateMsgBuilder.setIdMSB(uuid.getMostSignificantBits()); + apiKeyUpdateMsgBuilder.setIdLSB(uuid.getLeastSignificantBits()); + apiKeyUpdateMsgBuilder.setEntity(JacksonUtil.toString(apiKey)); + apiKeyUpdateMsgBuilder.setMsgType(updateMsgType); + testAutoGeneratedCodeByProtobuf(apiKeyUpdateMsgBuilder); + uplinkMsgBuilder.addApiKeyUpdateMsg(apiKeyUpdateMsgBuilder.build()); + + testAutoGeneratedCodeByProtobuf(uplinkMsgBuilder); + + return uplinkMsgBuilder.build(); + } + + private void checkApiKeyOnCloud(UplinkMsg uplinkMsg, UUID uuid, String description) throws Exception { + edgeImitator.expectResponsesAmount(1); + edgeImitator.sendUplinkMsg(uplinkMsg); + + Assert.assertTrue(edgeImitator.waitForResponses()); + + UplinkResponseMsg latestResponseMsg = edgeImitator.getLatestResponseMsg(); + Assert.assertTrue(latestResponseMsg.getSuccess()); + + ApiKey apiKey = apiKeyService.findApiKeyById(tenantId, new ApiKeyId(uuid)); + Assert.assertNotNull(apiKey); + Assert.assertEquals(description, apiKey.getDescription()); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java index 96c6dd42b7..843011c24f 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetEdgeTest.java @@ -58,7 +58,7 @@ public class AssetEdgeTest extends AbstractEdgeTest { Asset assetMsg = JacksonUtil.fromString(assetUpdateMsg.getEntity(), Asset.class, true); Assert.assertNotNull(assetMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); - Assert.assertEquals(savedAsset, assetMsg); + compareHasVersionEntities(savedAsset, assetMsg); Optional assetProfileUpdateMsgOpt = edgeImitator.findMessageByType(AssetProfileUpdateMsg.class); Assert.assertTrue(assetProfileUpdateMsgOpt.isPresent()); AssetProfileUpdateMsg assetProfileUpdateMsg = assetProfileUpdateMsgOpt.get(); @@ -109,7 +109,7 @@ public class AssetEdgeTest extends AbstractEdgeTest { assetMsg = JacksonUtil.fromString(assetUpdateMsg.getEntity(), Asset.class, true); Assert.assertNotNull(assetMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetUpdateMsg.getMsgType()); - Assert.assertEquals(savedAsset, assetMsg); + compareHasVersionEntities(savedAsset, assetMsg); assetProfileUpdateMsgOpt = edgeImitator.findMessageByType(AssetProfileUpdateMsg.class); Assert.assertTrue(assetProfileUpdateMsgOpt.isPresent()); assetProfileUpdateMsg = assetProfileUpdateMsgOpt.get(); diff --git a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java index 68548558f7..210b2799e0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AssetProfileEdgeTest.java @@ -53,7 +53,7 @@ public class AssetProfileEdgeTest extends AbstractEdgeTest { AssetProfileUpdateMsg assetProfileUpdateMsg = (AssetProfileUpdateMsg) latestMessage; AssetProfile assetProfileMsg = JacksonUtil.fromString(assetProfileUpdateMsg.getEntity(), AssetProfile.class, true); Assert.assertNotNull(assetProfileMsg); - Assert.assertEquals(assetProfile, assetProfileMsg); + compareHasVersionEntities(assetProfile, assetProfileMsg); Assert.assertEquals("Building", assetProfileMsg.getName()); Assert.assertEquals(buildingsRuleChainId, assetProfileMsg.getDefaultEdgeRuleChainId()); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, assetProfileUpdateMsg.getMsgType()); diff --git a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java index 6199657936..52a30f0e85 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CalculatedFieldEdgeTest.java @@ -73,6 +73,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { Assert.assertEquals(DEFAULT_CF_NAME, calculatedFieldFromMsg.getName()); Assert.assertEquals(savedDevice.getId(), calculatedFieldFromMsg.getEntityId()); Assert.assertEquals(config, calculatedFieldFromMsg.getConfiguration()); + Assert.assertEquals(calculatedField.getAdditionalInfo(), calculatedFieldFromMsg.getAdditionalInfo()); edgeImitator.expectMessageAmount(1); savedCalculatedField.setName(UPDATED_CF_NAME); @@ -147,7 +148,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = (CalculatedFieldUpdateMsg) latestMessage; CalculatedField calculatedFieldFromEdge = JacksonUtil.fromString(calculatedFieldUpdateMsg.getEntity(), CalculatedField.class, true); Assert.assertNotNull(calculatedFieldFromEdge); - Assert.assertEquals(savedCalculatedField, calculatedFieldFromEdge); + compareHasVersionEntities(savedCalculatedField, calculatedFieldFromEdge); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, calculatedFieldUpdateMsg.getMsgType()); } @@ -229,6 +230,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { config.setOutput(output); calculatedField.setConfiguration(config); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); return calculatedField; } @@ -260,6 +262,7 @@ public class CalculatedFieldEdgeTest extends AbstractEdgeTest { CalculatedField calculatedField = doGet("/api/calculatedField/" + uuid, CalculatedField.class); Assert.assertNotNull(calculatedField); Assert.assertEquals(resourceTitle, calculatedField.getName()); + Assert.assertEquals(JacksonUtil.newObjectNode(), calculatedField.getAdditionalInfo()); } } diff --git a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java index f3d12675a3..578175dc46 100644 --- a/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/CustomerEdgeTest.java @@ -60,7 +60,7 @@ public class CustomerEdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); testAutoGeneratedCodeByProtobuf(customerUpdateMsg); // update customer @@ -73,7 +73,7 @@ public class CustomerEdgeTest extends AbstractEdgeTest { customerUpdateMsg = (CustomerUpdateMsg) latestMessage; customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); // delete customer edgeImitator.expectMessageAmount(2); diff --git a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java index 9c70db267e..788dff02c0 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DashboardEdgeTest.java @@ -201,7 +201,7 @@ public class DashboardEdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); Dashboard dashboard = buildDashboardForUplinkMsg(savedCustomer); diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java index 9292d5bc06..369cda37aa 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -22,10 +22,13 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.JsonObject; import com.google.protobuf.AbstractMessage; import io.netty.handler.codec.mqtt.MqttQoS; +import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.adaptor.JsonConverter; @@ -84,12 +87,19 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.gen.edge.v1.UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; @TestPropertySource(properties = { "transport.mqtt.enabled=true" }) +@Slf4j @DaoSqlTest public class DeviceEdgeTest extends AbstractEdgeTest { + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); + registry.add("transport.mqtt.bind_port", () -> MQTT_PORT); + } private static final String DEFAULT_DEVICE_TYPE = "default"; @@ -131,7 +141,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device deviceFromMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceFromMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); - Assert.assertEquals(savedDevice, deviceFromMsg); + compareHasVersionEntities(savedDevice, deviceFromMsg); Assert.assertEquals(savedDevice.getId(), deviceFromMsg.getId()); Assert.assertEquals(savedDevice.getName(), deviceFromMsg.getName()); Assert.assertEquals(savedDevice.getType(), deviceFromMsg.getType()); @@ -212,7 +222,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Assert.assertTrue(latestMessage instanceof DeviceCredentialsUpdateMsg); DeviceCredentialsUpdateMsg deviceCredentialsUpdateMsg = (DeviceCredentialsUpdateMsg) latestMessage; DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); // update device credentials - X509_CERTIFICATE edgeImitator.expectMessageAmount(1); @@ -262,7 +272,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { Device deviceMsg = JacksonUtil.fromString(deviceUpdateMsg.getEntity(), Device.class, true); Assert.assertNotNull(deviceMsg); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, deviceUpdateMsg.getMsgType()); - Assert.assertEquals(savedDevice, deviceMsg); + compareHasVersionEntities(savedDevice, deviceMsg); Assert.assertEquals(firmwareOtaPackageInfo.getId(), deviceMsg.getFirmwareId()); Assert.assertEquals(softwareOtaPackageInfo.getId(), deviceMsg.getSoftwareId()); deviceData = deviceMsg.getDeviceData(); @@ -377,7 +387,7 @@ public class DeviceEdgeTest extends AbstractEdgeTest { DeviceCredentials deviceCredentialsMsg = JacksonUtil.fromString(deviceCredentialsUpdateMsg.getEntity(), DeviceCredentials.class, true); Assert.assertNotNull(deviceCredentialsMsg); Assert.assertEquals(device.getId(), deviceCredentialsMsg.getDeviceId()); - Assert.assertEquals(deviceCredentials, deviceCredentialsMsg); + compareHasVersionEntities(deviceCredentials, deviceCredentialsMsg); } @Test diff --git a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java index f44b4e295e..3ac7b3d648 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceProfileEdgeTest.java @@ -83,7 +83,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); // update device profile @@ -108,7 +108,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); // delete profile edgeImitator.expectMessageAmount(1); @@ -146,7 +146,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); // delete profile when edge is offline @@ -186,7 +186,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.SNMP, deviceProfileMsg.getTransportType()); @@ -224,7 +224,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.LWM2M, deviceProfileMsg.getTransportType()); @@ -273,7 +273,7 @@ public class DeviceProfileEdgeTest extends AbstractEdgeTest { DeviceProfileUpdateMsg deviceProfileUpdateMsg = (DeviceProfileUpdateMsg) latestMessage; DeviceProfile deviceProfileMsg = JacksonUtil.fromString(deviceProfileUpdateMsg.getEntity(), DeviceProfile.class, true); Assert.assertNotNull(deviceProfileMsg); - Assert.assertEquals(deviceProfile, deviceProfileMsg); + compareHasVersionEntities(deviceProfile, deviceProfileMsg); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, deviceProfileUpdateMsg.getMsgType()); Assert.assertEquals(DeviceTransportType.COAP, deviceProfileMsg.getTransportType()); diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeLatestVersionTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeLatestVersionTest.java new file mode 100644 index 0000000000..3852fe2ff0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeLatestVersionTest.java @@ -0,0 +1,41 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.edge; + +import org.junit.Assert; +import org.junit.Test; +import org.thingsboard.edge.rpc.EdgeVersionComparator; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; + +public class EdgeLatestVersionTest { + + @Test + public void edgeLatestVersionIsSynchronizedTest() { + EdgeVersion currentHighestEdgeVersion = EdgeVersionComparator.getNewestEdgeVersion(); + + String projectVersion = EdgeLatestVersionTest.class.getPackage().getImplementationVersion(); + if (projectVersion == null || projectVersion.isBlank()) { + projectVersion = System.getProperty("project.version", "UNKNOWN"); + } + + String projectVersionDigits = projectVersion.replaceAll("\\D", ""); + String currentHighestEdgeVersionDigits = currentHighestEdgeVersion.name().replaceAll("\\D", ""); + + String msg = "EdgeVersion enum in edge.proto is out of sync. Please add respective " + projectVersionDigits + " to EdgeVersion"; + Assert.assertEquals(msg, projectVersionDigits, currentHighestEdgeVersionDigits); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java index 334680f7a7..e9dbf6d443 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EdgeTest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.edge; +import com.fasterxml.jackson.databind.JsonNode; import org.junit.Assert; import org.junit.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.DataConstants; import org.thingsboard.server.common.data.edge.Edge; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -26,10 +28,14 @@ import org.thingsboard.server.dao.service.DaoSqlTest; import org.thingsboard.server.gen.edge.v1.CustomerUpdateMsg; import org.thingsboard.server.gen.edge.v1.EdgeConfiguration; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; +import org.thingsboard.server.service.subscription.TbAttributeSubscriptionScope; +import java.util.List; import java.util.Optional; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class EdgeTest extends AbstractEdgeTest { @@ -57,7 +63,7 @@ public class EdgeTest extends AbstractEdgeTest { CustomerUpdateMsg customerUpdateMsg = customerUpdateOpt.get(); Customer customerMsg = JacksonUtil.fromString(customerUpdateMsg.getEntity(), Customer.class, true); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, customerUpdateMsg.getMsgType()); - Assert.assertEquals(savedCustomer, customerMsg); + compareHasVersionEntities(savedCustomer, customerMsg); // unassign edge from customer edgeImitator.expectMessageAmount(2); @@ -76,4 +82,23 @@ public class EdgeTest extends AbstractEdgeTest { Assert.assertEquals(savedCustomer.getUuidId().getMostSignificantBits(), customerUpdateMsg.getIdMSB()); Assert.assertEquals(savedCustomer.getUuidId().getLeastSignificantBits(), customerUpdateMsg.getIdLSB()); } + + @Test + public void testSyncEdge_attributeUpdated() throws Exception { + getWsClient().subscribeForAttributes(edge.getId(), TbAttributeSubscriptionScope.SERVER_SCOPE.name(), List.of(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY)); + + doPost("/api/edge/sync/" + edge.getId()); + + // wait for sync to start + waitForEdgeSyncInProgressEqualsValue(true); + + // wait for sync to end + waitForEdgeSyncInProgressEqualsValue(false); + } + + private void waitForEdgeSyncInProgressEqualsValue(Boolean value) { + getWsClient().registerWaitForUpdate(); + JsonNode update = JacksonUtil.toJsonNode(getWsClient().waitForUpdate()); + assertThat(update.get("data").get(DataConstants.EDGE_SYNC_IN_PROGRESS_ATTR_KEY).get(0).get(1).asBoolean()).isEqualTo(value); + } } diff --git a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java index ce7b915457..640545c5d4 100644 --- a/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/EntityViewEdgeTest.java @@ -68,7 +68,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { EntityViewUpdateMsg entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; EntityView entityView = JacksonUtil.fromString(entityViewUpdateMsg.getEntity(), EntityView.class, true); Assert.assertNotNull(entityView); - Assert.assertEquals(savedEntityView, entityView); + compareHasVersionEntities(savedEntityView, entityView); Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); // request entity view(s) for device @@ -265,7 +265,7 @@ public class EntityViewEdgeTest extends AbstractEdgeTest { EntityViewUpdateMsg entityViewUpdateMsg = (EntityViewUpdateMsg) latestMessage; EntityView entityViewMsg = JacksonUtil.fromString(entityViewUpdateMsg.getEntity(), EntityView.class, true); Assert.assertNotNull(entityViewMsg); - Assert.assertEquals(entityView, entityViewMsg); + compareHasVersionEntities(entityView, entityViewMsg); Assert.assertEquals(device.getId(), entityViewMsg.getEntityId()); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, entityViewUpdateMsg.getMsgType()); testAutoGeneratedCodeByProtobuf(entityViewUpdateMsg); diff --git a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java index d5458ce793..193dc57c36 100644 --- a/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/NotificationEdgeTest.java @@ -266,7 +266,6 @@ public class NotificationEdgeTest extends AbstractEdgeTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); escalationTable.put(Integer.valueOf("1"), new ArrayList<>()); recipientsConfig.setEscalationTable(escalationTable); diff --git a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java index 054f67585e..cc23440deb 100644 --- a/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/RelationEdgeTest.java @@ -57,7 +57,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(relation, entityRelation); + compareHasVersionEntities(relation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); // delete relation @@ -76,7 +76,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { relationUpdateMsg = (RelationUpdateMsg) latestMessage; entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deletedRelation, entityRelation); + compareHasVersionEntities(deletedRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } @@ -155,7 +155,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deviceToAssetRelation, entityRelation); + compareHasVersionEntities(deviceToAssetRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } @@ -177,7 +177,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { RelationUpdateMsg relationUpdateMsg = (RelationUpdateMsg) latestMessage; EntityRelation entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(relation, entityRelation); + compareHasVersionEntities(relation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); // delete relation @@ -196,7 +196,7 @@ public class RelationEdgeTest extends AbstractEdgeTest { relationUpdateMsg = (RelationUpdateMsg) latestMessage; entityRelation = JacksonUtil.fromString(relationUpdateMsg.getEntity(), EntityRelation.class, true); Assert.assertNotNull(entityRelation); - Assert.assertEquals(deletedRelation, entityRelation); + compareHasVersionEntities(deletedRelation, entityRelation); Assert.assertEquals(UpdateMsgType.ENTITY_DELETED_RPC_MESSAGE, relationUpdateMsg.getMsgType()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java index 283e8bb66b..812357ac7d 100644 --- a/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/TenantEdgeTest.java @@ -50,7 +50,7 @@ public class TenantEdgeTest extends AbstractEdgeTest { TenantProfileUpdateMsg tenantProfileUpdateMsg = tenantProfileUpdateMsgOpt.get(); Tenant tenantMsg = JacksonUtil.fromString(tenantUpdateMsg.getEntity(), Tenant.class, true); Assert.assertNotNull(tenantMsg); - Assert.assertEquals(savedTenant, tenantMsg); + compareHasVersionEntities(savedTenant, tenantMsg); TenantProfile tenantProfileMsg = JacksonUtil.fromString(tenantProfileUpdateMsg.getEntity(), TenantProfile.class, true); Assert.assertNotNull(tenantProfileMsg); Assert.assertEquals(tenantMsg.getTenantProfileId(), tenantProfileMsg.getId()); @@ -75,7 +75,7 @@ public class TenantEdgeTest extends AbstractEdgeTest { Assert.assertNotNull(tenantProfileMsg); // tenant update Assert.assertEquals(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, tenantUpdateMsg.getMsgType()); - Assert.assertEquals(savedTenant, tenantMsg); + compareHasVersionEntities(savedTenant, tenantMsg); Assert.assertEquals(savedTenant.getTenantProfileId(), tenantProfileMsg.getId()); } diff --git a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java index f344335748..91dd0669b1 100644 --- a/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/UserEdgeTest.java @@ -20,7 +20,6 @@ import org.junit.Assert; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.test.web.servlet.ResultMatcher; import org.testcontainers.shaded.org.awaitility.Awaitility; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -224,7 +223,12 @@ public class UserEdgeTest extends AbstractEdgeTest { User savedUser = createUser(user, password); Assert.assertTrue(edgeImitator.waitForMessages()); Assert.assertEquals(1, edgeImitator.findAllMessagesByType(UserUpdateMsg.class).size()); - Assert.assertEquals(2, edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size()); + // The initial USER ADDED edge event may bundle a UserCredentialsUpdateMsg when + // user activation completes before the event is processed, in addition to the 2 + // messages from the CREDENTIALS_UPDATED events fired during activation. Accept 2 or 3. + int credMsgCount = edgeImitator.findAllMessagesByType(UserCredentialsUpdateMsg.class).size(); + Assert.assertTrue("Expected 2 or 3 UserCredentialsUpdateMsg (ADDED/activation race), got " + credMsgCount, + credMsgCount == 2 || credMsgCount == 3); UserUpdateMsg userUpdateMsg = getLatestUserUpdateMsg(); User userMsg = JacksonUtil.fromString(userUpdateMsg.getEntity(), User.class, true); diff --git a/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java index d6afa81262..ddaa867aee 100644 --- a/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/WidgetEdgeTest.java @@ -68,7 +68,7 @@ public class WidgetEdgeTest extends AbstractEdgeTest { WidgetType widgetsType = JacksonUtil.fromString(widgetTypeUpdateMsg.getEntity(), WidgetType.class, true); Assert.assertNotNull(widgetsType); Assert.assertEquals(UpdateMsgType.ENTITY_CREATED_RPC_MESSAGE, widgetTypeUpdateMsg.getMsgType()); - Assert.assertEquals(savedWidgetType, widgetsType); + compareHasVersionEntities(savedWidgetType, widgetsType); // update widget bundle edgeImitator.expectMessageAmount(1); diff --git a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java index 8b2cef2e8b..8208dc4fc9 100644 --- a/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java +++ b/application/src/test/java/org/thingsboard/server/edge/imitator/EdgeImitator.java @@ -30,6 +30,7 @@ import org.thingsboard.edge.rpc.EdgeRpcClient; import org.thingsboard.server.controller.AbstractWebTest; import org.thingsboard.server.gen.edge.v1.AdminSettingsUpdateMsg; import org.thingsboard.server.gen.edge.v1.AiModelUpdateMsg; +import org.thingsboard.server.gen.edge.v1.ApiKeyUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmCommentUpdateMsg; import org.thingsboard.server.gen.edge.v1.AlarmUpdateMsg; import org.thingsboard.server.gen.edge.v1.AssetProfileUpdateMsg; @@ -69,10 +70,12 @@ import org.thingsboard.server.gen.edge.v1.WidgetsBundleUpdateMsg; import java.lang.reflect.Field; import java.util.ArrayList; +import java.util.Deque; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentLinkedDeque; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; @@ -103,8 +106,17 @@ public class EdgeImitator { @Getter private EdgeConfiguration configuration; - @Getter - private final List downlinkMsgs; + private final ConcurrentLinkedDeque downlinkMsgs; + + //Returns collection copy as Unmodifiable list + //This addressing the issue: DeviceEdgeTest>AbstractEdgeTest.setupEdgeTest:212->AbstractEdgeTest.verifyEdgeConnectionAndInitialData:306->AbstractEdgeTest.validateMsgsCnt:387 » ConcurrentModification + public List getDownlinkMsgs() { + return downlinkMsgs.stream().toList(); + } + + public Deque getDownlinkMsgsDeque() { + return downlinkMsgs; + } @Getter private UplinkResponseMsg latestResponseMsg; @@ -113,7 +125,7 @@ public class EdgeImitator { edgeRpcClient = new EdgeGrpcClient(); messagesLatch = new CountDownLatch(0); responsesLatch = new CountDownLatch(0); - downlinkMsgs = new ArrayList<>(); + downlinkMsgs = new ConcurrentLinkedDeque<>(); ignoredTypes = new ArrayList<>(); this.routingKey = routingKey; this.routingSecret = routingSecret; @@ -364,6 +376,11 @@ public class EdgeImitator { result.add(saveDownlinkMsg(aiModelUpdateMsg)); } } + if (downlinkMsg.getApiKeyUpdateMsgCount() > 0) { + for (ApiKeyUpdateMsg apiKeyUpdateMsg : downlinkMsg.getApiKeyUpdateMsgList()) { + result.add(saveDownlinkMsg(apiKeyUpdateMsg)); + } + } if (downlinkMsg.hasEdgeConfiguration()) { result.add(saveDownlinkMsg(downlinkMsg.getEdgeConfiguration())); } @@ -458,7 +475,7 @@ public class EdgeImitator { } public AbstractMessage getLatestMessage() { - return downlinkMsgs.get(downlinkMsgs.size() - 1); + return downlinkMsgs.peekLast(); } public void ignoreType(Class type) { diff --git a/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java new file mode 100644 index 0000000000..fb9807a2a8 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ai/Langchain4jChatModelConfigurerImplTest.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ai; + +import com.google.cloud.vertexai.api.GenerationConfig; +import dev.langchain4j.model.chat.ChatModel; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.GoogleVertexAiGeminiChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OllamaChatModelConfig; +import org.thingsboard.server.common.data.ai.model.chat.OpenAiChatModelConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") +class Langchain4jChatModelConfigurerImplTest { + + private static final String TEST_SERVICE_ACCOUNT_KEY = """ + { + "type": "service_account", + "project_id": "test-project", + "private_key_id": "key-id", + "private_key": "-----BEGIN PRIVATE KEY-----\\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDNrHph/y7zyxIg\\ncmYYeOD8mFg9KraK71n84ffTQyVrl4HzlQgRIz5m4vM2rV5zjVLFi0xlAPT/iq/5\\nbh2zA4iXI0dEsR901nVjcL182t/GRYbKen53ZiuScBxBoCZPXW16Md+Yk8nMdNUb\\n4LoIRGZq64bjsJ+vh3Aa2gdGUHpDyebIXlXbF8ehWmEhgUsL7XjL0PkJ4lt2UMG+\\nx1j2Or25rqJmfc5M9kbxvINtdvSRTPiMOIXX00fCDZjQdd18RBVHOxraGxDgQpmv\\nk4qjFEPqGr0YTsa5dI8fz+4DqJpEi3rancRiTKM/KUwYLGnPSD07XqGfiDA8npVm\\nj4N62LhnAgMBAAECggEADbFfH87DRk7YQO8XgKdCOf7oglX+0NwjjmmlkVvwjgEI\\nZqT0ObPcz9u/MSjfV2vAEs/LK773ELD1NfLQqQjiBfHpfkIZOTLynhwKOYRBjqvf\\n+p38ynUzucGbV/vSC8meuW/AQPe3Nn9MFYQ4znEYrSNLbTWRRA3idvSEtHfffqDA\\nDHRBI1eIlxh1OTIR3L+HhcNYuus1LuoKnSlmwLGhAZLt7fjuWK3PkOiFT15e0M9M\\nUhp3WwhHcRC0o6bxT+BWRYKMVX3Vjlro4sF1fq4+jePThX1bpJcPfUmsC95tXPfX\\njfNGAxHlZ+MS1V/cLlIqyz7drXBcwCDJtbPmvavmNQKBgQD1ZR/ePHcjXUGM57U8\\nbxPatNOrcicvaP2AtTA6Y/JjfbcydkVXsenDGk0h6hykpIiMwrAaJuUTjGeM6QTI\\nOhK0k1QbGitcM71d9TSdLzWUdb3yvsyaPZlPR/6u2FBb6Bf9rWOQRYYyv1Lvu0+1\\nYLnR4sHBxiAur1NGxuHfA4ZUOwKBgQDWj+fcS/x/ifbCqexr7teWU+tUeyG3eLGA\\nMmB9eCkY7djl0/LHu/IHgqrGRVgra3IB1uI7Wr3jZYvlS7qGL3KpjeIPYj7LTQC5\\nznm0875NvJELPjQK/A4EM3mC057QRvb7y52KBNKJi7/JwHU7VHmudB78e7uGlW2K\\n5Ccl0PJFxQKBgCWv5yoJXT64JsYOG95xLLptBQkSmgQE+tHWgdal3Ob8urLsSRAD\\nyePl2Sy5OLbscfA0Qjlx+cJ70LdqXgqmKJNFASi8ZyZc59tTOkZdprvrLUXnmaKi\\njTYI14tgu06yIWUbSOwyUT7f9UvOF5rChSc/zQQGepDQ6lg3WR8X+nxbAoGBAKiu\\nfAcqSfjuuuuxcWgtXpoVoaZKI2i9Xza85DTf+ddabjHJXk3+iTm0VZQIwldoYjnl\\n+PfW0ABtPf1net2xgcChBf84Ksvj3tU06WQEWDF/NLyVC48zN8W/viDHREzT7app\\nGpJ+VhLCpmXzg3bAY+Vt70pp8DTPV05hLhHB4iZNAoGBAJW+bYh7jE61+58VpjvF\\nP36BK09jEEPWVucJdghb2mb62iA6JDy3ApU+8FzckXHDewt0sqvsW4VqukgwVZx3\\npSC7mR4B+Fm6znm0Z5mBWiG5bOOgTJ0mRZv4cYgC+JRRF/E3yYR58RyAKFAFIAFH\\nng8XYP1wQp64Fzv4+rUSwM49\\n-----END PRIVATE KEY-----\\n", + "client_email": "test@test-project.iam.gserviceaccount.com", + "client_id": "123456789", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://oauth2.googleapis.com/token" + } + """; + + private final Langchain4jChatModelConfigurerImpl configurer = new Langchain4jChatModelConfigurerImpl(); + + @BeforeEach + void enableSsrfProtection() { + SsrfProtectionValidator.setEnabled(true); + } + + @AfterEach + void disableSsrfProtection() { + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void configureChatModel_openAi_withPrivateIp_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://172.17.0.1:8080/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_openAi_withLocalhostUrl_shouldThrow() { + var config = OpenAiChatModelConfig.builder() + .providerConfig(OpenAiProviderConfig.builder() + .baseUrl("http://localhost:22/") + .apiKey("test") + .build()) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_azureOpenAi_withPrivateIp_shouldThrow() { + var config = AzureOpenAiChatModelConfig.builder() + .providerConfig(new AzureOpenAiProviderConfig( + "http://10.0.0.1:8080/", null, "test-key")) + .modelId("gpt-4o") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_ollama_withPrivateIp_shouldThrow() { + var config = OllamaChatModelConfig.builder() + .providerConfig(new OllamaProviderConfig( + "http://192.168.1.100:11434/", new OllamaProviderConfig.OllamaAuth.None())) + .modelId("llama3") + .build(); + + assertThatThrownBy(() -> configurer.configureChatModel(config)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void configureChatModel_vertexAi_setsFrequencyAndPresencePenaltyFromCorrectConfigFields() { + // GIVEN + var providerConfig = new GoogleVertexAiGeminiProviderConfig( + "test.json", "test-project", "us-central1", TEST_SERVICE_ACCOUNT_KEY + ); + var chatModelConfig = GoogleVertexAiGeminiChatModelConfig.builder() + .providerConfig(providerConfig) + .modelId("gemini-2.0-flash") + .frequencyPenalty(0.3) + .presencePenalty(0.7) + .build(); + + // WHEN + ChatModel chatModel = configurer.configureChatModel(chatModelConfig); + + // THEN + var generationConfig = (GenerationConfig) ReflectionTestUtils.getField(chatModel, "generationConfig"); + assertThat(generationConfig.getFrequencyPenalty()).isEqualTo(0.3f); + assertThat(generationConfig.getPresencePenalty()).isEqualTo(0.7f); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java index da73600671..4cd41c314e 100644 --- a/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java +++ b/application/src/test/java/org/thingsboard/server/service/apiusage/ApiUsageTest.java @@ -19,6 +19,8 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; +import org.thingsboard.server.common.data.ApiUsageRecordKey; +import org.thingsboard.server.common.data.ApiUsageState; import org.thingsboard.server.common.data.ApiUsageStateValue; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.SaveDeviceWithCredentialsRequest; @@ -30,6 +32,7 @@ import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.stats.TbApiUsageReportClient; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.controller.TbUrlConstants; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -46,6 +49,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=2", + "usage.stats.report.urgent_interval=1", "usage.stats.gauge_report_interval=1", }) public class ApiUsageTest extends AbstractControllerTest { @@ -54,9 +58,12 @@ public class ApiUsageTest extends AbstractControllerTest { private User tenantAdmin; private static final int MAX_DP_ENABLE_VALUE = 12; + private static final int MAX_SMS_ENABLE_VALUE = 10; private static final double WARN_THRESHOLD_VALUE = 0.5; @Autowired private ApiUsageStateService apiUsageStateService; + @Autowired + private TbApiUsageReportClient apiUsageReportClient; @Before public void beforeTest() throws Exception { @@ -82,7 +89,7 @@ public class ApiUsageTest extends AbstractControllerTest { } @Test - public void testTelemetryApiCall() throws Exception { + public void testDbStorageApiUsage() throws Exception { Device device = createDevice(); assertNotNull(device); String telemetryPayload = "{\"temperature\":25, \"humidity\":60}"; @@ -94,7 +101,8 @@ public class ApiUsageTest extends AbstractControllerTest { doPostAsync(url, telemetryPayload, String.class, status().isOk()); } - await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> assertEquals(ApiUsageStateValue.WARNING, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState())); + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.WARNING, getUsageState().getDbStorageState())); long VALUE_DISABLE = (long) (MAX_DP_ENABLE_VALUE - (MAX_DP_ENABLE_VALUE * WARN_THRESHOLD_VALUE)) / 2; @@ -104,10 +112,35 @@ public class ApiUsageTest extends AbstractControllerTest { await().atMost(TIMEOUT, TimeUnit.SECONDS) .untilAsserted(() -> { - assertEquals(ApiUsageStateValue.DISABLED, apiUsageStateService.findTenantApiUsageState(tenantId).getDbStorageState()); + assertEquals(ApiUsageStateValue.DISABLED, getUsageState().getDbStorageState()); }); } + @Test + public void testSmsApiUsage() { + long smsWarnThreshold = (long) (MAX_SMS_ENABLE_VALUE * WARN_THRESHOLD_VALUE); + + for (int i = 0; i < smsWarnThreshold; i++) { + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.SMS_EXEC_COUNT); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.WARNING, getUsageState().getSmsExecState())); + + long smsDisableCount = MAX_SMS_ENABLE_VALUE - smsWarnThreshold; + + for (int i = 0; i < smsDisableCount; i++) { + apiUsageReportClient.report(tenantId, null, ApiUsageRecordKey.SMS_EXEC_COUNT); + } + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertEquals(ApiUsageStateValue.DISABLED, getUsageState().getSmsExecState())); + } + + private ApiUsageState getUsageState() { + return apiUsageStateService.findTenantApiUsageState(tenantId); + } + private TenantProfile createTenantProfile() { TenantProfile tenantProfile = new TenantProfile(); tenantProfile.setName("Tenant Profile"); @@ -116,6 +149,8 @@ public class ApiUsageTest extends AbstractControllerTest { TenantProfileData tenantProfileData = new TenantProfileData(); DefaultTenantProfileConfiguration config = DefaultTenantProfileConfiguration.builder() .maxDPStorageDays(MAX_DP_ENABLE_VALUE) + .maxSms(MAX_SMS_ENABLE_VALUE) + .smsEnabled(true) .warnThreshold(WARN_THRESHOLD_VALUE) .build(); diff --git a/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java new file mode 100644 index 0000000000..3ee229e7a0 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/cf/DefaultCalculatedFieldCacheTest.java @@ -0,0 +1,517 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.cf; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldLink; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.TenantProfileId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.asset.AssetService; +import org.thingsboard.server.dao.cf.CalculatedFieldService; +import org.thingsboard.server.dao.customer.CustomerService; +import org.thingsboard.server.dao.device.DeviceService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.service.profile.TbAssetProfileCache; +import org.thingsboard.server.service.profile.TbDeviceProfileCache; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultCalculatedFieldCacheTest { + + @Mock + private CalculatedFieldService calculatedFieldService; + @Mock + private TbAssetProfileCache assetProfileCache; + @Mock + private TbDeviceProfileCache deviceProfileCache; + @Mock + private TbTenantProfileCache tenantProfileCache; + @Mock + private DeviceService deviceService; + @Mock + private AssetService assetService; + @Mock + private CustomerService customerService; + + private DefaultCalculatedFieldCache cache; + + @BeforeEach + public void setUp() { + // ActorSystemContext is only used in getCalculatedFieldCtx (not tested here), so null is safe + OwnerService ownerService = new OwnerService(deviceService, assetService, customerService); + cache = new DefaultCalculatedFieldCache(calculatedFieldService, assetProfileCache, + deviceProfileCache, tenantProfileCache, null, ownerService); + + } + + // --- Tenant deletion tests --- + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsAllTenantCfsFromAllMaps() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceId device1 = new DeviceId(UUID.randomUUID()); + DeviceId device2 = new DeviceId(UUID.randomUUID()); + + CalculatedField cf1 = addCfToCache(tenant1, device1); + CalculatedField cf2 = addCfToCache(tenant2, device2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(device1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(device2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsOwnerEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + stubDeviceOwner(tenant, device, tenant); + + cache.addOwnerEntity(tenant, device); + assertThat(cache.getDynamicEntities(tenant, tenant)).contains(device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After eviction, getDynamicEntities triggers a fresh load from ownerService (empty) + assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(device); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesLinksToLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId cfEntity = new DeviceId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + + CalculatedField cf = addCfToCache(tenant, cfEntity, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + } + + // --- Device/Asset deletion tests --- + + @Test + public void onComponentLifecycleEvent_deviceDeleted_evictsCfsForThatDevice() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + addCfToCache(tenant, device, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceDeleted_evictsDeviceFromOwnerEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + CustomerId customer = new CustomerId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + stubDeviceOwner(tenant, device, customer); + + cache.addOwnerEntity(tenant, device); + assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); + } + + @Test + public void onComponentLifecycleEvent_assetDeleted_evictsCfsForThatAsset() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetId asset = new AssetId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, asset); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, asset, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(asset)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceCreated_addsDeviceToOwnerEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + CustomerId customer = new CustomerId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + stubDeviceOwner(tenant, device, customer); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, device, ComponentLifecycleEvent.CREATED)); + + assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); + } + + // --- Customer deletion tests --- + + @Test + public void onComponentLifecycleEvent_customerDeleted_evictsCustomerOwnerEntries() { + TenantId tenant = new TenantId(UUID.randomUUID()); + CustomerId customer = new CustomerId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + stubDeviceOwner(tenant, device, customer); + + cache.addOwnerEntity(tenant, device); + assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED)); + + // The customer's owned-entities entry is evicted; fresh load returns empty + assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); + } + + // --- DeviceProfile/AssetProfile deletion tests --- + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId linkedDevice = new DeviceId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedDevice); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedDevice)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profile1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profile2 = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_deviceProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_evictsCfsForThatProfile() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_removesLinksForLinkedEntities() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId linkedAsset = new AssetId(UUID.randomUUID()); + addCfToCache(tenant, profileId, linkedAsset); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedFieldLinksByEntityId(linkedAsset)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_assetProfileDeleted_doesNotEvictOtherProfilesCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profile1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profile2 = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf1 = addCfToCache(tenant, profile1); + CalculatedField cf2 = addCfToCache(tenant, profile2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profile1, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf1.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(profile1)).isEmpty(); + assertThat(cache.getCalculatedField(cf2.getId())).isEqualTo(cf2); + assertThat(cache.getCalculatedFieldsByEntityId(profile2)).containsExactly(cf2); + } + + @Test + public void onComponentLifecycleEvent_assetProfileUpdated_doesNotEvictCfs() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(profileId)).containsExactly(cf); + } + + // --- CalculatedField lifecycle tests --- + + @Test + public void onComponentLifecycleEvent_calculatedFieldCreated_addsCfToCache() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); + CalculatedField cf = buildCalculatedField(cfId, tenant, device, simpleCfConfig()); + when(calculatedFieldService.findById(tenant, cfId)).thenReturn(cf); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cfId, ComponentLifecycleEvent.CREATED)); + + assertThat(cache.getCalculatedField(cfId)).isEqualTo(cf); + assertThat(cache.getCalculatedFieldsByEntityId(device)).containsExactly(cf); + } + + @Test + public void onComponentLifecycleEvent_calculatedFieldDeleted_evictsCfFromCache() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, device); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getCalculatedField(cf.getId())).isNull(); + assertThat(cache.getCalculatedFieldsByEntityId(device)).isEmpty(); + } + + @Test + public void onComponentLifecycleEvent_calculatedFieldUpdated_refreshesCfInCache() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + CalculatedField cf = addCfToCache(tenant, device); + + CalculatedField updatedCf = buildCalculatedField(cf.getId(), tenant, device, simpleCfConfig()); + updatedCf.setName("updated-name"); + when(calculatedFieldService.findById(tenant, cf.getId())).thenReturn(updatedCf); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, cf.getId(), ComponentLifecycleEvent.UPDATED)); + + assertThat(cache.getCalculatedField(cf.getId())).isEqualTo(updatedCf); + } + + // --- evictOwner recursive traversal tests --- + + @Test + public void evictOwner_customerDeleted_recursivelyEvictsDevicesOwnedByThatCustomer() { + TenantId tenant = new TenantId(UUID.randomUUID()); + CustomerId customer = new CustomerId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + + stubDeviceOwner(tenant, device, customer); + when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); + + // tenant owns customer (getOwner for CUSTOMER returns tenantId) + cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer} + cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device} + + assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer); + assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); + + // deleting the customer evicts the customer key and recursively cleans its owned set + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, customer, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); + } + + @Test + public void evictOwner_tenantDeleted_recursivelyEvictsCustomerAndItsOwnedDevices() { + TenantId tenant = new TenantId(UUID.randomUUID()); + CustomerId customer = new CustomerId(UUID.randomUUID()); + DeviceId device = new DeviceId(UUID.randomUUID()); + + stubDeviceOwner(tenant, device, customer); + when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); + + cache.addOwnerEntity(tenant, customer); // ownerEntities[tenant] = {customer} + cache.addOwnerEntity(tenant, device); // ownerEntities[customer] = {device} + + assertThat(cache.getDynamicEntities(tenant, tenant)).contains(customer); + assertThat(cache.getDynamicEntities(tenant, customer)).contains(device); + + // deleting the tenant: evictOwner(tenant) finds customer (CUSTOMER type) and recurses into it + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // both levels must be gone + assertThat(cache.getDynamicEntities(tenant, tenant)).doesNotContain(customer); + assertThat(cache.getDynamicEntities(tenant, customer)).doesNotContain(device); + } + + // --- TenantProfile lifecycle tests --- + + @Test + public void onComponentLifecycleEvent_tenantProfileUpdated_callsHandleTenantProfileUpdate() { + TenantId tenant = new TenantId(UUID.randomUUID()); + TenantProfileId profileId = new TenantProfileId(UUID.randomUUID()); + DefaultCalculatedFieldCache spyCache = spy(cache); + + spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.UPDATED)); + + verify(spyCache).handleTenantProfileUpdate(profileId); + } + + @Test + public void onComponentLifecycleEvent_tenantProfileDeleted_doesNotCallHandleTenantProfileUpdate() { + TenantId tenant = new TenantId(UUID.randomUUID()); + TenantProfileId profileId = new TenantProfileId(UUID.randomUUID()); + DefaultCalculatedFieldCache spyCache = spy(cache); + + spyCache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, profileId, ComponentLifecycleEvent.DELETED)); + + verify(spyCache, never()).handleTenantProfileUpdate(any()); + } + + // --- Helpers --- + + private void stubDeviceOwner(TenantId tenantId, DeviceId deviceId, EntityId ownerId) { + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + if (ownerId instanceof CustomerId customerId) { + device.setCustomerId(customerId); + } + // If ownerId is a TenantId, leaving customerId null means getOwnerId() returns tenantId + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + // Stubs for getOwnedEntities iteration (empty pages — device is added explicitly) + when(deviceService.findDeviceInfosByFilter(any(), any())).thenReturn(PageData.emptyPageData()); + when(assetService.findAssetsByTenantIdAndCustomerId(any(), any(), any())).thenReturn(PageData.emptyPageData()); + if (ownerId instanceof TenantId) { + when(customerService.findCustomersByTenantId(any(), any())).thenReturn(PageData.emptyPageData()); + } + } + + private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId) { + CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); + CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, simpleCfConfig()); + when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf); + cache.addCalculatedField(tenantId, cfId); + return cf; + } + + private CalculatedField addCfToCache(TenantId tenantId, EntityId entityId, EntityId linkedEntity) { + CalculatedFieldId cfId = new CalculatedFieldId(UUID.randomUUID()); + CalculatedFieldConfiguration config = linkedEntityCfConfig(tenantId, cfId, linkedEntity); + CalculatedField cf = buildCalculatedField(cfId, tenantId, entityId, config); + when(calculatedFieldService.findById(tenantId, cfId)).thenReturn(cf); + cache.addCalculatedField(tenantId, cfId); + return cf; + } + + private CalculatedField buildCalculatedField(CalculatedFieldId id, TenantId tenantId, EntityId entityId, CalculatedFieldConfiguration config) { + CalculatedField cf = new CalculatedField(); + cf.setId(id); + cf.setTenantId(tenantId); + cf.setEntityId(entityId); + cf.setType(CalculatedFieldType.SIMPLE); + cf.setName("test-cf-" + id.getId()); + cf.setConfiguration(config); + return cf; + } + + private CalculatedFieldConfiguration simpleCfConfig() { + CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class); + when(config.getReferencedEntities()).thenReturn(Collections.emptySet()); + when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(Collections.emptyList()); + return config; + } + + private CalculatedFieldConfiguration linkedEntityCfConfig(TenantId tenantId, CalculatedFieldId cfId, EntityId linkedEntity) { + CalculatedFieldConfiguration config = mock(CalculatedFieldConfiguration.class); + CalculatedFieldLink link = new CalculatedFieldLink(tenantId, linkedEntity, cfId); + when(config.getReferencedEntities()).thenReturn(Set.of(linkedEntity)); + when(config.buildCalculatedFieldLinks(any(), any(), any())).thenReturn(List.of(link)); + when(config.buildCalculatedFieldLink(any(), eq(linkedEntity), any())).thenReturn(link); + return config; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java index cbde708ff8..02b063a499 100644 --- a/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java +++ b/application/src/test/java/org/thingsboard/server/service/cf/ctx/state/RelatedEntitiesAggregationCalculatedFieldStateTest.java @@ -234,6 +234,8 @@ public class RelatedEntitiesAggregationCalculatedFieldStateTest { config.setUseLatestTs(true); + config.setScheduledUpdateInterval(10); + calculatedField.setConfiguration(config); calculatedField.setVersion(1L); return calculatedField; diff --git a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java index 4bbb51068c..3bf5fa2f25 100644 --- a/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/edge/EdgeMsgConstructorUtilsTest.java @@ -38,12 +38,30 @@ import org.thingsboard.rule.engine.rest.TbSendRestApiCallReplyNode; import org.thingsboard.rule.engine.telemetry.TbCalculatedFieldsNode; import org.thingsboard.rule.engine.telemetry.TbMsgAttributesNode; import org.thingsboard.rule.engine.telemetry.TbMsgTimeseriesNode; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityView; +import org.thingsboard.server.common.data.Tenant; +import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.edge.EdgeEvent; import org.thingsboard.server.common.data.edge.EdgeEventActionType; import org.thingsboard.server.common.data.edge.EdgeEventType; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.EntityViewId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.relation.EntityRelation; +import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.gen.edge.v1.EdgeVersion; import org.thingsboard.server.gen.edge.v1.UpdateMsgType; @@ -278,4 +296,167 @@ public class EdgeMsgConstructorUtilsTest { edgeEvent.setBody(body); return edgeEvent; } + + @Test + public void testConstructAssetUpdatedMsg_versionIsReset() { + Asset asset = new Asset(); + asset.setId(new AssetId(UUID.randomUUID())); + asset.setName("Test Asset"); + asset.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructAssetUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, asset).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Asset version should be null in serialized message"); + } + + @Test + public void testConstructAssetProfileUpdatedMsg_versionIsReset() { + AssetProfile assetProfile = new AssetProfile(); + assetProfile.setId(new AssetProfileId(UUID.randomUUID())); + assetProfile.setName("Test Asset Profile"); + assetProfile.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructAssetProfileUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, assetProfile).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "AssetProfile version should be null in serialized message"); + } + + @Test + public void testConstructCustomerUpdatedMsg_versionIsReset() { + Customer customer = new Customer(); + customer.setId(new CustomerId(UUID.randomUUID())); + customer.setTitle("Test Customer"); + customer.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructCustomerUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, customer).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Customer version should be null in serialized message"); + } + + @Test + public void testConstructDashboardUpdatedMsg_versionIsReset() { + Dashboard dashboard = new Dashboard(); + dashboard.setId(new DashboardId(UUID.randomUUID())); + dashboard.setTitle("Test Dashboard"); + dashboard.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDashboardUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, dashboard).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Dashboard version should be null in serialized message"); + } + + @Test + public void testConstructDeviceUpdatedMsg_versionIsReset() { + Device device = new Device(); + device.setId(new DeviceId(UUID.randomUUID())); + device.setName("Test Device"); + device.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDeviceUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, device).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Device version should be null in serialized message"); + } + + @Test + public void testConstructDeviceCredentialsUpdatedMsg_versionIsReset() { + DeviceCredentials credentials = new DeviceCredentials(); + credentials.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructDeviceCredentialsUpdatedMsg(credentials).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "DeviceCredentials version should be null in serialized message"); + } + + @Test + public void testConstructEntityViewUpdatedMsg_versionIsReset() { + EntityView entityView = new EntityView(); + entityView.setId(new EntityViewId(UUID.randomUUID())); + entityView.setName("Test EntityView"); + entityView.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructEntityViewUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, entityView).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "EntityView version should be null in serialized message"); + } + + @Test + public void testConstructRelationUpdatedMsg_versionIsReset() { + EntityRelation relation = new EntityRelation(); + relation.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRelationUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, relation).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "EntityRelation version should be null in serialized message"); + } + + @Test + public void testConstructRuleChainUpdatedMsg_versionIsReset() { + RuleChain ruleChain = new RuleChain(); + ruleChain.setId(new org.thingsboard.server.common.data.id.RuleChainId(UUID.randomUUID())); + ruleChain.setName("Test RuleChain"); + ruleChain.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRuleChainUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, ruleChain, false).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "RuleChain version should be null in serialized message"); + } + + @Test + public void testConstructTenantUpdateMsg_versionIsReset() { + Tenant tenant = new Tenant(); + tenant.setId(TenantId.fromUUID(UUID.randomUUID())); + tenant.setTitle("Test Tenant"); + tenant.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructTenantUpdateMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, tenant).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "Tenant version should be null in serialized message"); + } + + @Test + public void testConstructUserUpdatedMsg_versionIsReset() { + User user = new User(); + user.setId(new UserId(UUID.randomUUID())); + user.setEmail("test@test.com"); + user.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructUserUpdatedMsg(UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, user).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "User version should be null in serialized message"); + } + + @Test + public void testConstructRuleChainMetadataUpdatedMsg_versionIsReset() { + RuleChainMetaData metaData = new RuleChainMetaData(); + metaData.setVersion(42L); + + String entity = EdgeMsgConstructorUtils.constructRuleChainMetadataUpdatedMsg( + UpdateMsgType.ENTITY_UPDATED_RPC_MESSAGE, metaData, EdgeVersion.V_4_0_0).getEntity(); + JsonNode json = JacksonUtil.toJsonNode(entity); + + Assertions.assertTrue(json.get("version") == null || json.get("version").isNull(), + "RuleChainMetaData version should be null in serialized message"); + } } diff --git a/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java new file mode 100644 index 0000000000..7614bdd310 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/edge/rpc/EdgeGrpcSslTest.java @@ -0,0 +1,274 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.edge.rpc; + +import io.grpc.ManagedChannel; +import io.grpc.Server; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMWriter; +import org.bouncycastle.openssl.jcajce.JcePEMEncryptorBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.gen.edge.v1.EdgeRpcServiceGrpc; + +import java.io.ByteArrayInputStream; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.PrivateKey; +import java.security.Security; +import java.security.cert.X509Certificate; +import java.security.spec.ECGenParameterSpec; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; + +/** + * Tests for Edge gRPC SSL setup using the production {@link GrpcServer#setupSsl} method. + *

+ * Covers: + * 1. Separate cert and key PEM inputs + * 2. Combined PEM (cert + key in one file) + * 3. Encrypted private key with password + * 4. Missing key in combined PEM → error + *

+ * Each scenario is parameterized across key types: RSA-2048, RSA-4096, EC P-256, EC P-384. + */ +class EdgeGrpcSslTest { + + private static final int TIMEOUT = 30; + + static { + if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) { + Security.addProvider(new BouncyCastleProvider()); + } + } + + enum KeyType { + RSA_2048("RSA", 2048, null, "SHA256withRSA"), + RSA_4096("RSA", 4096, null, "SHA256withRSA"), + EC_P256("EC", 256, "secp256r1", "SHA256withECDSA"), + EC_P384("EC", 384, "secp384r1", "SHA384withECDSA"); + + final String algorithm; + final int size; + final String curve; + final String sigAlg; + + KeyType(String algorithm, int size, String curve, String sigAlg) { + this.algorithm = algorithm; + this.size = size; + this.curve = curve; + this.sigAlg = sigAlg; + } + + KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance(algorithm); + if (curve != null) { + kpg.initialize(new ECGenParameterSpec(curve)); + } else { + kpg.initialize(size); + } + return kpg.generateKeyPair(); + } + } + + private final List tempFiles = new ArrayList<>(); + private Server server; + private ManagedChannel channel; + + @AfterEach + void cleanup() throws Exception { + if (channel != null) { + channel.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + if (server != null) { + server.shutdownNow().awaitTermination(2, TimeUnit.SECONDS); + } + for (Path p : tempFiles) { + Files.deleteIfExists(p); + } + } + + @ParameterizedTest(name = "separateCertAndKey_{0}") + @EnumSource(KeyType.class) + void separateCertAndKey(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); + + Path certFile = writeTempPem("cert", cert); + Path keyFile = writeTempPem("key", kp.getPrivate()); + + server = startServer(certFile.toString(), keyFile.toString(), null); + assertTlsConnectivity(cert); + } + + @ParameterizedTest(name = "combinedPemWithCertAndKey_{0}") + @EnumSource(KeyType.class) + void combinedPemWithCertAndKey(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); + + Path combinedFile = writeTempPem("combined", cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", null); + assertTlsConnectivity(cert); + } + + // RSA-only: BouncyCastle writes encrypted EC keys in traditional PEM format (BEGIN EC PRIVATE KEY), + // which after decryption produces a PEMKeyPair without public key info — causing PemSslCredentials + // to fail with "Cannot invoke SubjectPublicKeyInfo.getEncoded() because getPublicKeyInfo() is null". + @ParameterizedTest(name = "encryptedPrivateKey_{0}") + @EnumSource(value = KeyType.class, names = {"RSA_2048", "RSA_4096"}) + void encryptedPrivateKey(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); + String password = "test-password"; + + Path combinedFile = writeTempPemEncrypted("enc-combined", password, cert, kp.getPrivate()); + + server = startServer(combinedFile.toString(), "", password); + assertTlsConnectivity(cert); + } + + @ParameterizedTest(name = "combinedPemWithCertOnly_throwsException_{0}") + @EnumSource(KeyType.class) + void combinedPemWithCertOnly_throwsException(KeyType keyType) throws Exception { + KeyPair kp = keyType.generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(kp, keyType.sigAlg); + + Path certOnlyFile = writeTempPem("cert-only", cert); + + assertThatThrownBy(() -> startServer(certOnlyFile.toString(), "", null)) + .isInstanceOf(RuntimeException.class) + .hasCauseInstanceOf(IllegalArgumentException.class); + } + + // --- Server startup using production EdgeGrpcService.setupSsl() --- + + private Server startServer(String certFileResource, String privateKeyResource, String keyPassword) throws Exception { + GrpcServer edgeGrpcService = new GrpcServer(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); + ReflectionTestUtils.setField(edgeGrpcService, "certFileResource", certFileResource); + ReflectionTestUtils.setField(edgeGrpcService, "privateKeyResource", privateKeyResource); + ReflectionTestUtils.setField(edgeGrpcService, "keyPassword", keyPassword != null ? keyPassword : ""); + + NettyServerBuilder builder = NettyServerBuilder.forPort(0) + .addService(new EdgeRpcServiceGrpc.EdgeRpcServiceImplBase() {}); + + edgeGrpcService.setupSsl(builder); + + return builder.build().start(); + } + + private void assertTlsConnectivity(X509Certificate trustedCert) throws Exception { + String certPem = toPem(trustedCert); + var clientSsl = GrpcSslContexts.forClient() + .trustManager(new ByteArrayInputStream(certPem.getBytes(StandardCharsets.UTF_8))) + .build(); + + channel = NettyChannelBuilder.forAddress("localhost", server.getPort()) + .sslContext(clientSsl) + .build(); + + channel.getState(true); // trigger connection attempt + await().atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(50, TimeUnit.MILLISECONDS) + .untilAsserted(() -> { + var state = channel.getState(false); + if (state == io.grpc.ConnectivityState.TRANSIENT_FAILURE) { + throw new AssertionError("TLS handshake failed: channel in TRANSIENT_FAILURE"); + } + assertThat(state).isEqualTo(io.grpc.ConnectivityState.READY); + }); + } + + // --- Cert/key generation --- + + private X509Certificate generateSelfSignedCert(KeyPair kp, String sigAlg) throws Exception { + X500Name subject = new X500Name("CN=localhost"); + Date now = new Date(); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.ONE, now, + new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)), + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder(sigAlg).build(kp.getPrivate()))); + } + + // --- PEM file helpers --- + + private String toPem(Object obj) throws Exception { + java.io.StringWriter sw = new java.io.StringWriter(); + try (JcaPEMWriter w = new JcaPEMWriter(sw)) { + w.writeObject(obj); + } + return sw.toString(); + } + + private Path writeTempPem(String prefix, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + w.writeObject(toPkcs8IfKey(o)); + } + } + return p; + } + + private Path writeTempPemEncrypted(String prefix, String password, Object... objects) throws Exception { + Path p = Files.createTempFile(prefix + "-", ".pem"); + tempFiles.add(p); + var encryptor = new JcePEMEncryptorBuilder("AES-256-CBC") + .setProvider(BouncyCastleProvider.PROVIDER_NAME) + .build(password.toCharArray()); + try (JcaPEMWriter w = new JcaPEMWriter(Files.newBufferedWriter(p))) { + for (Object o : objects) { + if (o instanceof PrivateKey) { + w.writeObject(o, encryptor); + } else { + w.writeObject(o); + } + } + } + return p; + } + + private Object toPkcs8IfKey(Object o) { + if (o instanceof PrivateKey pk) { + return new PemObject("PRIVATE KEY", pk.getEncoded()); + } + return o; + } +} diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java index adf664e7cf..eb672a790f 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EdqsEntityServiceTest.java @@ -16,19 +16,27 @@ package org.thingsboard.server.service.entitiy; import com.google.common.collect.Lists; +import com.google.common.util.concurrent.Futures; +import com.google.common.util.concurrent.ListenableFuture; import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.TestPropertySource; import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.asset.Asset; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.IdBased; +import org.thingsboard.server.common.data.kv.TimeseriesSaveResult; import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; +import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; +import org.thingsboard.server.common.data.query.EntityDataSortOrder; +import org.thingsboard.server.common.data.query.EntityKey; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; @@ -43,10 +51,13 @@ import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; +import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; @DaoSqlTest @TestPropertySource(properties = { @@ -103,6 +114,100 @@ public class EdqsEntityServiceTest extends EntityServiceTest { assetService.deleteAssetsByTenantId(tenantId); } + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "0", "0", "1")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("1", "0", "0", "", "")); + } + + // edqs has no nulls order strategies, always returns NULLs first for ASC and NULLs last for DESC + @Override + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).containsExactlyElementsOf(List.of("", "", "false", "false", "true")); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, devices.size()).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).containsExactlyElementsOf(List.of("true", "false", "false", "", "")); + } + @Override protected PageData findByQueryAndCheck(CustomerId customerId, EntityDataQuery query, long expectedResultSize) { return await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findByQuery(customerId, query), diff --git a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index c54ffe4a07..b3230b3d45 100644 --- a/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java @@ -26,6 +26,7 @@ import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.jdbc.core.ResultSetExtractor; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Customer; @@ -47,6 +48,7 @@ import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.AttributesSaveResult; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; @@ -79,7 +81,6 @@ import org.thingsboard.server.common.data.query.RelationsQueryFilter; import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; import org.thingsboard.server.common.data.query.StringFilterPredicate.StringOperation; -import org.thingsboard.server.common.data.query.TsValue; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; @@ -100,6 +101,7 @@ import org.thingsboard.server.dao.entityview.EntityViewDao; import org.thingsboard.server.dao.entityview.EntityViewService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.service.DaoSqlTest; +import org.thingsboard.server.dao.sql.query.DefaultEntityQueryRepository; import org.thingsboard.server.dao.sql.relation.RelationRepository; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.usagerecord.ApiUsageStateService; @@ -115,6 +117,8 @@ import java.util.Map; import java.util.Random; import java.util.UUID; import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -122,7 +126,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.thingsboard.server.common.data.AttributeScope.SERVER_SCOPE; import static org.thingsboard.server.common.data.query.EntityKeyType.ATTRIBUTE; import static org.thingsboard.server.common.data.query.EntityKeyType.ENTITY_FIELD; -import static org.thingsboard.server.common.data.query.EntityKeyType.SERVER_ATTRIBUTE; @Slf4j @DaoSqlTest @@ -152,6 +155,8 @@ public class EntityServiceTest extends AbstractControllerTest { @Autowired RelationRepository relationRepository; @Autowired + DefaultEntityQueryRepository entityQueryRepository; + @Autowired RelationService relationService; @Autowired TimeseriesService timeseriesService; @@ -1749,13 +1754,124 @@ public class EntityServiceTest extends AbstractControllerTest { } @Test - public void testFindTenantTelemetry() { + public void testSortByNumericTelemetryKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(1L, 0L, 0L); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("0", "0", "1", "", ""), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "0", "0", "1"), + List.of("", "", "1", "0", "0"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("0", "0", "1", "", ""), + List.of("1", "0", "0", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + @Test + public void testSortByBooleanKeyWithDifferentNullsOrderStrategy() throws ExecutionException, InterruptedException { + try { + List devices = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + Device device = new Device(); + device.setTenantId(tenantId); + device.setName("Device" + i); + device.setType("default"); + devices.add(deviceService.saveDevice(device)); + Thread.sleep(1); + } + + List values = List.of(true, false, false); + List> timeseriesFutures = new ArrayList<>(); + for (int i = 0; i < values.size(); i++) { + timeseriesFutures.add(saveTimeseries(devices.get(i).getId(), "test", values.get(i))); + } + Futures.allAsList(timeseriesFutures).get(); + + assertNullsOrdering("default", + List.of("false", "false", "true", "", ""), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_first", + List.of("", "", "false", "false", "true"), + List.of("", "", "true", "false", "false"), + devices.size()); + + assertNullsOrdering("nulls_last", + List.of("false", "false", "true", "", ""), + List.of("true", "false", "false", "", ""), + devices.size()); + } finally { + deviceService.deleteDevicesByTenantId(tenantId); + } + } + + private void assertNullsOrdering(String strategy, List expectedAsc, List expectedDesc, int deviceSize) { + String originalStrategy = entityQueryRepository.getNullsOrderStrategy(); + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", strategy); + try { + DeviceTypeFilter filter = new DeviceTypeFilter(); + filter.setDeviceTypes(List.of("default")); + filter.setDeviceNameFilter(""); + + List entityFields = Collections.singletonList(new EntityKey(ENTITY_FIELD, "name")); + List latestValues = Collections.singletonList(new EntityKey(EntityKeyType.TIME_SERIES, "test")); + + EntityDataSortOrder ascSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.ASC); + EntityDataQuery ascQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, ascSortOrder), entityFields, latestValues, null); + List ascTelemetry = loadAllData(ascQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(ascTelemetry).as("ASC with strategy '%s'", strategy).containsExactlyElementsOf(expectedAsc); + + EntityDataSortOrder descSortOrder = new EntityDataSortOrder( + new EntityKey(EntityKeyType.TIME_SERIES, "test"), EntityDataSortOrder.Direction.DESC); + EntityDataQuery descQuery = new EntityDataQuery(filter, + new EntityDataPageLink(10, 0, null, descSortOrder), entityFields, latestValues, null); + List descTelemetry = loadAllData(descQuery, deviceSize).stream() + .map(ed -> ed.getLatest().get(EntityKeyType.TIME_SERIES).get("test").getValue()) + .toList(); + assertThat(descTelemetry).as("DESC with strategy '%s'", strategy).containsExactlyElementsOf(expectedDesc); + } finally { + ReflectionTestUtils.setField(entityQueryRepository, "nullsOrderStrategy", originalStrategy); + } + } + + @Test + public void testFindTenantTelemetry() throws ExecutionException, InterruptedException, TimeoutException { // save timeseries by sys admin BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, new DoubleDataEntry("temperature", 45.5)); - timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries); + timeseriesService.save(TenantId.SYS_TENANT_ID, tenantId, timeseries).get(TIMEOUT, TimeUnit.SECONDS); AttributeKvEntry attr = new BaseAttributeKvEntry(new LongDataEntry("attr", 10L), 42L); - attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)); + attributesService.save(TenantId.SYS_TENANT_ID, tenantId, SERVER_SCOPE, List.of(attr)).get(TIMEOUT, TimeUnit.SECONDS); SingleEntityFilter singleEntityFilter = new SingleEntityFilter(); singleEntityFilter.setSingleEntity(AliasEntityId.fromEntityId(tenantId)); @@ -2319,12 +2435,18 @@ public class EntityServiceTest extends AbstractControllerTest { return timeseriesService.save(tenantId, entityId, timeseries); } - private ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Long value) { KvEntry telemetryValue = new LongDataEntry(key, value); BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); return timeseriesService.save(tenantId, entityId, timeseries); } + protected ListenableFuture saveTimeseries(EntityId entityId, String key, Boolean value) { + KvEntry telemetryValue = new BooleanDataEntry(key, value); + BasicTsKvEntry timeseries = new BasicTsKvEntry(42L, telemetryValue); + return timeseriesService.save(tenantId, entityId, timeseries); + } + protected void createMultiRootHierarchy(List buildings, List apartments, Map> entityNameByTypeMap, Map childParentRelationMap) throws InterruptedException { diff --git a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java index cf37afad5c..0c12767b60 100644 --- a/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/housekeeper/HousekeeperServiceTest.java @@ -23,8 +23,8 @@ import org.junit.Test; import org.mockito.ArgumentMatcher; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.metadata.TbGetAttributesNode; @@ -127,10 +127,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. }) public class HousekeeperServiceTest extends AbstractControllerTest { - @SpyBean + @MockitoSpyBean private HousekeeperService housekeeperService; - @SpyBean + @MockitoSpyBean private HousekeeperReprocessingService housekeeperReprocessingService; + @MockitoSpyBean + private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor; @Autowired private EventService eventService; @Autowired @@ -153,8 +155,6 @@ public class HousekeeperServiceTest extends AbstractControllerTest { private CustomerService customerService; @Autowired private DashboardService dashboardService; - @SpyBean - private TsHistoryDeletionTaskProcessor tsHistoryDeletionTaskProcessor; private TenantId tenantId; diff --git a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java index c17a9f6bfb..63fa4a7da4 100644 --- a/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/job/JobManagerTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.job; +import com.google.common.util.concurrent.SettableFuture; import lombok.SneakyThrows; import org.junit.After; import org.junit.Before; @@ -36,6 +37,7 @@ import org.thingsboard.server.common.data.job.JobType; import org.thingsboard.server.common.data.job.task.DummyTaskResult; import org.thingsboard.server.common.data.job.task.DummyTaskResult.DummyTaskFailure; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.msg.queue.TbCallback; import org.thingsboard.server.controller.AbstractControllerTest; import org.thingsboard.server.dao.job.JobDao; import org.thingsboard.server.dao.service.DaoSqlTest; @@ -44,10 +46,12 @@ import org.thingsboard.server.queue.task.JobStatsService; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; @@ -518,19 +522,114 @@ public class JobManagerTest extends AbstractControllerTest { }); } + @Test + public void testSubmitJob_finishCallback_success() { + SettableFuture future = SettableFuture.create(); + + int tasksCount = 3; + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(tasksCount) + .taskProcessingTimeMs(100) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThat(future.get()).isNull(); + }); + } + + @Test + public void testSubmitJob_finishCallback_taskFailure() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(1) + .failedTasksCount(2) + .errors(List.of("task error")) + .retries(0) + .taskProcessingTimeMs(100) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("task error; task error"); + }); + } + + @Test + public void testSubmitJob_finishCallback_generalError() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .generalError("Something went wrong") + .submittedTasksBeforeGeneralError(0) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("Something went wrong"); + }); + } + + @Test + public void testSubmitJob_finishCallback_cancelled() throws Exception { + SettableFuture future = SettableFuture.create(); + + JobId jobId = submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(200) + .taskProcessingTimeMs(50) + .build(), "test-job", TbCallback.wrap(future)).getId(); + + Thread.sleep(500); + cancelJob(jobId); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .cause() + .hasMessage("The task was cancelled"); + }); + } + + @Test + public void testSubmitJob_finishCallback_zeroTasks() { + SettableFuture future = SettableFuture.create(); + + submitJob(DummyJobConfiguration.builder() + .successfulTasksCount(0) + .build(), "test-job", TbCallback.wrap(future)); + + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { + assertThat(future.isDone()).isTrue(); + assertThat(future.get()).isNull(); + }); + } + private Job submitJob(DummyJobConfiguration configuration) { return submitJob(configuration, "test-job"); } @SneakyThrows private Job submitJob(DummyJobConfiguration configuration, String key) { + return submitJob(configuration, key, null); + } + + @SneakyThrows + private Job submitJob(DummyJobConfiguration configuration, String key, TbCallback callback) { return jobManager.submitJob(Job.builder() .tenantId(tenantId) .type(JobType.DUMMY) .key(key) .entityId(jobEntity.getId()) .configuration(configuration) - .build()).get(); + .build(), callback).get(); } private List getFailures(JobResult jobResult) { diff --git a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java index f5607415fa..cf99669777 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/AbstractNotificationApiTest.java @@ -188,8 +188,7 @@ public abstract class AbstractNotificationApiTest extends AbstractControllerTest rule.setTriggerType(triggerConfig.getTriggerType()); rule.setTriggerConfig(triggerConfig); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(triggerConfig.getTriggerType()); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(triggerConfig.getTriggerType()); recipientsConfig.setTargets(DaoUtil.toUUIDs(targets)); rule.setRecipientsConfig(recipientsConfig); diff --git a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java index 89f743aee4..94af26d3d7 100644 --- a/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java +++ b/application/src/test/java/org/thingsboard/server/service/notification/NotificationRuleApiTest.java @@ -211,7 +211,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); recipientsConfig.setEscalationTable(escalationTable); Map clients = new HashMap<>(); @@ -238,12 +237,12 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .set("createAlarm", BooleanNode.TRUE); postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); - await().atMost(10, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); Alarm alarm = alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType); long ts = System.currentTimeMillis(); - await().atMost(15, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> clients.values().stream().allMatch(client -> client.getLastDataUpdate() != null)); clients.forEach((expectedDelay, wsClient) -> { Notification notification = wsClient.getLastDataUpdate().getUpdate(); @@ -291,8 +290,8 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { alarm.setOriginator(device.getId()); alarm = doPost("/api/alarm", alarm, Alarm.class); - await().atMost(15, TimeUnit.SECONDS) - .pollDelay(2, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) + .pollDelay(1, TimeUnit.SECONDS) .untilAsserted(() -> { List notifications = getMyNotifications(false, 10); assertThat(notifications).singleElement().matches(notification -> { @@ -329,7 +328,6 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { notificationRule.setTriggerConfig(triggerConfig); EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ALARM); Map> escalationTable = new HashMap<>(); recipientsConfig.setEscalationTable(escalationTable); @@ -345,7 +343,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .set("createAlarm", BooleanNode.TRUE); postAttributes(device.getId(), AttributeScope.SERVER_SCOPE, attr.toString()); - await().atMost(10, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType) != null); Alarm alarm = alarmSubscriptionService.findLatestByOriginatorAndType(tenantId, device.getId(), alarmType); getWsClient().waitForUpdate(true); @@ -355,14 +353,14 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { assertThat(notification.getInfo()).asInstanceOf(type(AlarmNotificationInfo.class)) .extracting(AlarmNotificationInfo::getAlarmId).isEqualTo(alarm.getUuidId()); - await().atMost(10, TimeUnit.SECONDS).until(() -> findNotificationRequests(EntityType.ALARM).getTotalElements() == escalationTable.size()); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> findNotificationRequests(EntityType.ALARM).getTotalElements() == escalationTable.size()); NotificationRequestInfo scheduledNotificationRequest = findNotificationRequests(EntityType.ALARM).getData().stream() .filter(NotificationRequest::isScheduled) .findFirst().orElse(null); assertThat(scheduledNotificationRequest).extracting(NotificationRequest::getInfo).isEqualTo(notification.getInfo()); alarmSubscriptionService.clearAlarm(tenantId, alarm.getId(), System.currentTimeMillis(), null); - await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> { assertThat(findNotificationRequests(EntityType.ALARM).getData()).filteredOn(NotificationRequest::isScheduled).isEmpty(); }); } @@ -480,7 +478,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { } loginTenantAdmin(); - List notifications = await().atMost(15, TimeUnit.SECONDS) + List notifications = await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> getMyNotifications(true, 10).stream() .filter(notification -> notification.getType() == NotificationType.RATE_LIMITS) .collect(Collectors.toList()), list -> list.size() == 3); @@ -500,7 +498,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { }); loginSysAdmin(); - notifications = await().atMost(15, TimeUnit.SECONDS) + notifications = await().atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> getMyNotifications(true, 10).stream() .filter(notification -> notification.getType() == NotificationType.RATE_LIMITS) .collect(Collectors.toList()), list -> list.size() == 1); @@ -640,8 +638,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { EntityActionNotificationRuleTriggerConfig triggerConfig = new EntityActionNotificationRuleTriggerConfig(); rule.setTriggerConfig(triggerConfig); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); recipientsConfig.setTargets(List.of(createNotificationTarget(tenantAdminUserId).getUuidId())); rule.setRecipientsConfig(recipientsConfig); rule = saveNotificationRule(rule); @@ -671,8 +668,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { triggerConfig.setCreated(true); rule.setTriggerConfig(triggerConfig); NotificationTarget target = createNotificationTarget(tenantAdminUserId); - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(NotificationRuleTriggerType.ENTITY_ACTION); recipientsConfig.setTargets(List.of(target.getUuidId())); rule.setRecipientsConfig(recipientsConfig); rule = saveNotificationRule(rule); @@ -721,7 +717,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .updateInfo(new UpdateMessage(true, "CHANGED", "test", "test", "test", "test")) .build()); - await().atMost(5, TimeUnit.SECONDS) + await().atMost(TIMEOUT, TimeUnit.SECONDS) .untilAsserted(() -> { assertThat(getMyNotifications(false, 100)).size().isEqualTo(2); }); @@ -809,7 +805,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { method.invoke(systemInfoService); } - await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); Notification notification = getMyNotifications(false, 100).get(0); assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); assertThat(notification.getText()).isEqualTo("RAM shortage"); @@ -836,7 +832,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .build()); TimeUnit.MILLISECONDS.sleep(300); } - await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); Notification notification = getMyNotifications(false, 100).get(0); assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); assertThat(notification.getText()).isEqualTo("RAM shortage"); @@ -848,7 +844,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { .serviceType("serviceType") .serviceId("serviceId") .build()); - await("").atMost(5, TimeUnit.SECONDS).untilAsserted(() -> assertThat(getMyNotifications(false, 100)).size().isOne()); + await("").atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> assertThat(getMyNotifications(false, 100)).size().isOne()); } @Test @@ -887,7 +883,7 @@ public class NotificationRuleApiTest extends AbstractNotificationApiTest { method.invoke(systemInfoService); - await().atMost(10, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); + await().atMost(TIMEOUT, TimeUnit.SECONDS).until(() -> getMyNotifications(false, 100).size() == 1); Notification notification = getMyNotifications(false, 100).get(0); assertThat(notification.getSubject()).isEqualTo("Warning: RAM shortage"); assertThat(notification.getText()).isEqualTo("RAM shortage"); diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java new file mode 100644 index 0000000000..f9b8d428d7 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbAssetProfileCacheTest.java @@ -0,0 +1,159 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.id.AssetId; +import org.thingsboard.server.common.data.id.AssetProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.asset.AssetProfileService; +import org.thingsboard.server.dao.asset.AssetService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbAssetProfileCacheTest { + + @Mock + private AssetProfileService assetProfileService; + @Mock + private AssetService assetService; + + private DefaultTbAssetProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbAssetProfileCache(assetProfileService, assetService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsAssetProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(assetProfileService.findAssetProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + verify(assetProfileService, times(1)).findAssetProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsAssetMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + AssetId assetId = new AssetId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadAssetMappingIntoCache(tenant, assetId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, asset-to-profile mapping should be gone; get() should try to reload + when(assetService.findAssetById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, assetId)).isNull(); + verify(assetService, times(2)).findAssetById(tenant, assetId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new AssetId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + AssetProfileId profileId = new AssetProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(assetProfileService, times(1)).findAssetProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + AssetProfileId profileId1 = new AssetProfileId(UUID.randomUUID()); + AssetProfileId profileId2 = new AssetProfileId(UUID.randomUUID()); + + AssetProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(assetProfileService, times(1)).findAssetProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private AssetProfile loadProfileIntoCache(TenantId tenantId, AssetProfileId profileId) { + AssetProfile profile = new AssetProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(assetProfileService.findAssetProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadAssetMappingIntoCache(TenantId tenantId, AssetId assetId, AssetProfileId profileId) { + Asset asset = new Asset(); + asset.setId(assetId); + asset.setAssetProfileId(profileId); + when(assetService.findAssetById(tenantId, assetId)).thenReturn(asset); + cache.get(tenantId, assetId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java new file mode 100644 index 0000000000..a26413514c --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/profile/DefaultTbDeviceProfileCacheTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.profile; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.plugin.ComponentLifecycleEvent; +import org.thingsboard.server.common.msg.plugin.ComponentLifecycleMsg; +import org.thingsboard.server.dao.device.DeviceProfileService; +import org.thingsboard.server.dao.device.DeviceService; + +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class DefaultTbDeviceProfileCacheTest { + + @Mock + private DeviceProfileService deviceProfileService; + @Mock + private DeviceService deviceService; + + private DefaultTbDeviceProfileCache cache; + + @BeforeEach + public void setUp() { + cache = new DefaultTbDeviceProfileCache(deviceProfileService, deviceService); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceProfilesForThatTenant() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant1, tenant1, ComponentLifecycleEvent.DELETED)); + + // After deletion tenant1 profile should be reloaded from service on next get + when(deviceProfileService.findDeviceProfileById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant1, profileId1)).isNull(); + // tenant2 profile should still be served from cache (no extra service call) + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant2, profileId2); + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_evictsDeviceMappingsForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + loadProfileIntoCache(tenant, profileId); + loadDeviceMappingIntoCache(tenant, deviceId, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // After tenant deletion, device-to-profile mapping should be gone; get() should try to reload + when(deviceService.findDeviceById(any(), any())).thenReturn(null); + assertThat(cache.get(tenant, deviceId)).isNull(); + verify(deviceService, times(2)).findDeviceById(tenant, deviceId); // once on load, once after eviction + } + + @Test + public void onComponentLifecycleEvent_tenantDeleted_removesListenersForThatTenant() { + TenantId tenant = new TenantId(UUID.randomUUID()); + EntityId listenerId = new DeviceId(UUID.randomUUID()); + AtomicInteger callCount = new AtomicInteger(); + + cache.addListener(tenant, listenerId, profile -> callCount.incrementAndGet(), null); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.DELETED)); + + // Evicting a profile after tenant deletion should not trigger the removed listener + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + cache.evict(tenant, profileId); + + assertThat(callCount.get()).isZero(); + } + + @Test + public void onComponentLifecycleEvent_tenantUpdated_doesNotEvictProfiles() { + TenantId tenant = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId = new DeviceProfileId(UUID.randomUUID()); + loadProfileIntoCache(tenant, profileId); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant, tenant, ComponentLifecycleEvent.UPDATED)); + + // Profile should still be served from cache without hitting the service again + cache.get(tenant, profileId); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant, profileId); + } + + @Test + public void onComponentLifecycleEvent_differentTenantDeleted_keepsOtherTenantsProfiles() { + TenantId tenant1 = new TenantId(UUID.randomUUID()); + TenantId tenant2 = new TenantId(UUID.randomUUID()); + DeviceProfileId profileId1 = new DeviceProfileId(UUID.randomUUID()); + DeviceProfileId profileId2 = new DeviceProfileId(UUID.randomUUID()); + + DeviceProfile profile1 = loadProfileIntoCache(tenant1, profileId1); + loadProfileIntoCache(tenant2, profileId2); + + cache.onComponentLifecycleEvent(new ComponentLifecycleMsg(tenant2, tenant2, ComponentLifecycleEvent.DELETED)); + + assertThat(cache.get(tenant1, profileId1)).isEqualTo(profile1); + verify(deviceProfileService, times(1)).findDeviceProfileById(tenant1, profileId1); + } + + // --- Helpers --- + + private DeviceProfile loadProfileIntoCache(TenantId tenantId, DeviceProfileId profileId) { + DeviceProfile profile = new DeviceProfile(); + profile.setId(profileId); + profile.setTenantId(tenantId); + when(deviceProfileService.findDeviceProfileById(tenantId, profileId)).thenReturn(profile); + cache.get(tenantId, profileId); + return profile; + } + + private void loadDeviceMappingIntoCache(TenantId tenantId, DeviceId deviceId, DeviceProfileId profileId) { + Device device = new Device(); + device.setId(deviceId); + device.setDeviceProfileId(profileId); + when(deviceService.findDeviceById(tenantId, deviceId)).thenReturn(device); + cache.get(tenantId, deviceId); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java index 3ea827d330..8d349bb858 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/TbMsgPackProcessingContextTest.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.service.queue; -import lombok.extern.slf4j.Slf4j; -import org.junit.After; -import org.junit.Assert; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.mockito.junit.MockitoJUnitRunner; +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.DataConstants; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.msg.queue.RuleEngineException; import org.thingsboard.server.gen.transport.TransportProtos; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; @@ -35,30 +38,241 @@ import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; -import static org.junit.Assert.assertTrue; +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@Slf4j -@RunWith(MockitoJUnitRunner.class) -public class TbMsgPackProcessingContextTest { +@ExtendWith(MockitoExtension.class) +class TbMsgPackProcessingContextTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + + @Mock + TbRuleEngineSubmitStrategy submitStrategy; + @Mock + TbProtoQueueMsg mockMsg; + + ConcurrentMap> pendingMap; - public static final int TIMEOUT = 10; ExecutorService executorService; - @After - public void tearDown() { + @BeforeEach + void setup() { + pendingMap = new ConcurrentHashMap<>(); + lenient().when(submitStrategy.getPendingMap()).thenReturn(pendingMap); + } + + @AfterEach + void tearDown() { if (executorService != null) { - executorService.shutdownNow(); + MoreExecutors.shutdownAndAwaitTermination(executorService, 5, TimeUnit.SECONDS); } } @Test - public void testHighConcurrencyCase() throws InterruptedException { - //log.warn("preparing the test..."); + void testAwait_shouldReturnTrue_whenOnSuccessIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - onSuccess() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the message was moved to success map + assertThat(context.getSuccessMap()).containsKey(msgId); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + + + // Verify submit strategy was notified about successful message processing + then(submitStrategy).should().onSuccess(msgId); + } + + @Test + void testAwait_shouldReturnTrue_whenOnFailureIsCalledBeforeTimeout() throws InterruptedException { + // GIVEN - a context with one pending message + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - onFailure() is called in another thread before timeout + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onFailure(tenantId, msgId, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true (successful completion, even if message processing failed) + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when latch is counted down before timeout").isTrue(); + + // Verify the exception was added to exceptions map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testAwait_shouldReturnFalse_whenTimeoutOccurs() throws InterruptedException { + // GIVEN - a context with one pending message and no processing + UUID msgId = UUID.randomUUID(); + pendingMap.put(msgId, mockMsg); + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - await() is called with short timeout and no message processing happens + long startTime = System.nanoTime(); + boolean result = context.await(100, TimeUnit.MILLISECONDS); + long elapsedTime = System.nanoTime() - startTime; + + // THEN - await() should return false (timeout occurred) + assertThat(result).as("await() should return false when timeout occurs").isFalse(); + assertThat(elapsedTime).as("await() should wait for at least the timeout duration").isGreaterThanOrEqualTo(100L); + + // Message should still be in pending map + assertThat(context.getSuccessMap()).isEmpty(); + assertThat(context.getPendingMap()).containsKey(msgId); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMultiplePendingMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + UUID msgId3 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + pendingMap.put(msgId3, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - messages are processed one by one + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onSuccess(msgId2); + Thread.sleep(50); + context.onSuccess(msgId3); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should return true only after all messages are processed + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true after all messages are processed").isTrue(); + + // All messages should be in success map + assertThat(context.getSuccessMap()).containsKeys(msgId1, msgId2, msgId3); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldNotCountDownPrematurely_withMultipleMessages() throws InterruptedException { + // GIVEN - a context with multiple pending messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + // WHEN - only one message is processed + executorService.submit(() -> { + try { + Thread.sleep(100); + context.onSuccess(msgId1); + // msgId2 still in processing + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN: await should timeout because not all messages were processed + boolean result = context.await(2000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should timeout when not all messages are processed").isFalse(); + + // One message in success, one still pending + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).containsOnlyKeys(msgId2); + assertThat(context.getExceptionsMap()).isEmpty(); + } + + @Test + void testAwait_shouldHandleMixedSuccessAndFailure() throws InterruptedException { + // GIVEN - multiple messages + executorService = Executors.newSingleThreadExecutor(); + + UUID msgId1 = UUID.randomUUID(); + UUID msgId2 = UUID.randomUUID(); + + pendingMap.put(msgId1, mockMsg); + pendingMap.put(msgId2, mockMsg); + + var context = new TbMsgPackProcessingContext("test-queue", submitStrategy, false); + + var exception = new RuleEngineException("Test exception"); + + // WHEN - one succeeds, one fails + executorService.submit(() -> { + try { + Thread.sleep(50); + context.onSuccess(msgId1); + Thread.sleep(50); + context.onFailure(tenantId, msgId2, exception); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + }); + + // THEN - await() should complete successfully + boolean result = context.await(5000, TimeUnit.MILLISECONDS); + assertThat(result).as("await() should return true when all messages are processed").isTrue(); + + assertThat(context.getSuccessMap()).containsOnlyKeys(msgId1); + assertThat(context.getPendingMap()).isEmpty(); + assertThat(context.getExceptionsMap()).containsEntry(tenantId, exception); + } + + @Test + void testHighConcurrencyCase() throws InterruptedException { int msgCount = 1000; int parallelCount = 5; executorService = Executors.newFixedThreadPool(parallelCount, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope")); @@ -76,28 +290,24 @@ public class TbMsgPackProcessingContextTest { final CountDownLatch startLatch = new CountDownLatch(1); final CountDownLatch finishLatch = new CountDownLatch(parallelCount); for (int i = 0; i < parallelCount; i++) { - //final String taskName = "" + uuid + " " + i; executorService.submit(() -> { - //log.warn("ready {}", taskName); readyLatch.countDown(); try { startLatch.await(); } catch (InterruptedException e) { - Assert.fail("failed to await"); + fail("failed to await"); } - //log.warn("go {}", taskName); - context.onSuccess(uuid); - finishLatch.countDown(); }); } - assertTrue(readyLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(readyLatch.await(10, TimeUnit.SECONDS)); Thread.yield(); startLatch.countDown(); //run all-at-once submitted tasks - assertTrue(finishLatch.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(finishLatch.await(10, TimeUnit.SECONDS)); } - assertTrue(context.await(TIMEOUT, TimeUnit.SECONDS)); + assertTrue(context.await(10, TimeUnit.SECONDS)); verify(strategyMock, times(msgCount)).onSuccess(any(UUID.class)); } + } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java new file mode 100644 index 0000000000..20ea6fa881 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/RuleEngineConsumerLoopTest.java @@ -0,0 +1,281 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.queue.ruleengine; + +import com.google.common.util.concurrent.MoreExecutors; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.ThingsBoardExecutors; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.actors.ActorSystemContext; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.queue.ProcessingStrategy; +import org.thingsboard.server.common.data.queue.ProcessingStrategyType; +import org.thingsboard.server.common.data.queue.Queue; +import org.thingsboard.server.common.data.queue.SubmitStrategy; +import org.thingsboard.server.common.data.queue.SubmitStrategyType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.queue.ServiceType; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.common.stats.StatsFactory; +import org.thingsboard.server.gen.transport.TransportProtos; +import org.thingsboard.server.queue.TbQueueAdmin; +import org.thingsboard.server.queue.TbQueueConsumer; +import org.thingsboard.server.queue.TbQueueMsg; +import org.thingsboard.server.queue.common.DefaultTbQueueMsgHeaders; +import org.thingsboard.server.queue.common.TbProtoQueueMsg; +import org.thingsboard.server.queue.discovery.PartitionService; +import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.queue.memory.DefaultInMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryStorage; +import org.thingsboard.server.queue.memory.InMemoryTbQueueConsumer; +import org.thingsboard.server.queue.provider.TbQueueProducerProvider; +import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContext; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategy; +import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; +import org.thingsboard.server.service.stats.RuleEngineStatisticsService; + +import java.time.Duration; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RuleEngineConsumerLoopTest { + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + + InMemoryStorage storage; + + @Mock + ActorSystemContext actorContext; + @Mock + StatsFactory statsFactory; + @Mock + TbRuleEngineQueueFactory queueFactory; + @Mock + RuleEngineStatisticsService statisticsService; + @Mock + TbServiceInfoProvider serviceInfoProvider; + @Mock + PartitionService partitionService; + @Mock + TbQueueProducerProvider producerProvider; + @Mock + TbQueueAdmin queueAdmin; + @Mock + TbMsgPackProcessingContextFactory packProcessingContextFactory; + @Mock + TbMsgPackProcessingContext packCtx; + + Queue mainQueue; + + TbQueueConsumer> consumer; + + TbRuleEngineConsumerContext ruleEngineConsumerContext; + TbRuleEngineQueueConsumerManager consumerManager; + + ExecutorService consumersExecutor; + ScheduledExecutorService scheduler; + ExecutorService mgmtExecutor; + + @BeforeEach + void setup() throws InterruptedException { + consumersExecutor = Executors.newCachedThreadPool(ThingsBoardThreadFactory.forName("tb-rule-engine-consumer")); + scheduler = ThingsBoardExecutors.newSingleThreadScheduledExecutor("tb-rule-engine-consumer-scheduler"); + mgmtExecutor = ThingsBoardExecutors.newWorkStealingPool(1, "tb-rule-engine-mgmt"); + + mainQueue = new Queue(); + mainQueue.setTenantId(TenantId.SYS_TENANT_ID); + mainQueue.setName("Main"); + mainQueue.setTopic("tb_rule_engine.main"); + mainQueue.setPollInterval(25); + mainQueue.setPartitions(1); + mainQueue.setConsumerPerPartition(false); + mainQueue.setPackProcessingTimeout(2000L); + + var submitStrategy = new SubmitStrategy(); + submitStrategy.setType(SubmitStrategyType.BURST); + submitStrategy.setBatchSize(1000); + mainQueue.setSubmitStrategy(submitStrategy); + + var processingStrategy = new ProcessingStrategy(); + processingStrategy.setType(ProcessingStrategyType.SKIP_ALL_FAILURES); + processingStrategy.setRetries(3); + processingStrategy.setFailurePercentage(0.0); + processingStrategy.setPauseBetweenRetries(3); + processingStrategy.setMaxPauseBetweenRetries(3); + mainQueue.setProcessingStrategy(processingStrategy); + + storage = new DefaultInMemoryStorage(); + + consumer = spy(new InMemoryTbQueueConsumer<>(storage, mainQueue.getTopic())); + given(queueFactory.createToRuleEngineMsgConsumer(eq(mainQueue), isNull())).willReturn(consumer); + + ruleEngineConsumerContext = new TbRuleEngineConsumerContext( + actorContext, statsFactory, new TbRuleEngineSubmitStrategyFactory(), new TbRuleEngineProcessingStrategyFactory(), + queueFactory, statisticsService, serviceInfoProvider, partitionService, producerProvider, queueAdmin + ); + ruleEngineConsumerContext.setPollDuration(25); + ruleEngineConsumerContext.setPackProcessingTimeout(2000); + ruleEngineConsumerContext.setStatsEnabled(false); // true by default + ruleEngineConsumerContext.setPrometheusStatsEnabled(false); + ruleEngineConsumerContext.setTopicDeletionDelayInSec(15); + ruleEngineConsumerContext.setMgmtThreadPoolSize(12); + + // Tell the (mock) context factory to return (mock) message pack context + given(packProcessingContextFactory.create( + eq(mainQueue.getName()), + any(TbRuleEngineSubmitStrategy.class), + eq(false) + )).willAnswer(invocation -> { + TbRuleEngineSubmitStrategy realStrategy = invocation.getArgument(1); + when(packCtx.getPendingMap()).thenAnswer(i -> realStrategy.getPendingMap()); + when(packCtx.getFailedMap()).thenReturn(new ConcurrentHashMap<>()); + return packCtx; + }); + + // Tell the (mock) context's await() to return 'false' (always timeout) immediately + given(packCtx.await(anyLong(), any(TimeUnit.class))).willReturn(false); + + consumerManager = TbRuleEngineQueueConsumerManager.create() + .ctx(ruleEngineConsumerContext) + .queueKey(new QueueKey(ServiceType.TB_RULE_ENGINE, mainQueue)) + .consumerExecutor(consumersExecutor) + .scheduler(scheduler) + .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(packProcessingContextFactory) + .build(); + } + + @AfterEach + void destroy() { + MoreExecutors.shutdownAndAwaitTermination(scheduler, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(mgmtExecutor, Duration.ofSeconds(30)); + MoreExecutors.shutdownAndAwaitTermination(consumersExecutor, Duration.ofSeconds(30)); + } + + @Test + void consumerLoopTest_verifyOperationsOrder() throws InterruptedException { + // Create partition + var partition = TopicPartitionInfo.builder() + .tenantId(TenantId.SYS_TENANT_ID) + .topic(mainQueue.getTopic()) + .partition(0) + .myPartition(true) + .useInternalPartition(false) + .build(); + + // Put 10k messages to the queue + for (int i = 0; i < 10_000; i++) { + var tbMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(deviceId) + .data("{\"temperature\":123}") + .metaData(TbMsgMetaData.EMPTY) + .build(); + + var toRuleEngineMsg = TransportProtos.ToRuleEngineMsg.newBuilder() + .setTenantIdLSB(tenantId.getId().getLeastSignificantBits()) + .setTenantIdMSB(tenantId.getId().getMostSignificantBits()) + .setTbMsgProto(TbMsg.toProto(tbMsg)) + .addAllRelationTypes(Set.of("Success")) + .build(); + + storage.put(partition.getFullTopicName(), new TbProtoQueueMsg<>(UUID.randomUUID(), toRuleEngineMsg, new DefaultTbQueueMsgHeaders())); + } + + // Count how many polls were made + var totalPolls = new AtomicInteger(0); + var emptyPolls = new AtomicInteger(0); + doAnswer(invocation -> { + totalPolls.incrementAndGet(); + @SuppressWarnings("unchecked") + var messages = (List) invocation.callRealMethod(); + if (messages.isEmpty()) { + emptyPolls.incrementAndGet(); + } + return messages; + }).when(consumer).poll(mainQueue.getPollInterval()); + + // Count how many commits were made + var totalCommits = new AtomicInteger(0); + doAnswer(invocation -> { + totalCommits.incrementAndGet(); + return invocation.callRealMethod(); + }).when(consumer).commit(); + + // Initialize consumer + consumerManager.init(mainQueue); + + // Assign partition to the consumer + consumerManager.update(Set.of(partition)); + + // Give some time for the consumer to get all messages + await().atMost(Duration.ofSeconds(10L)).until(() -> storage.getLagTotal() == 0); + + // Stop consumer + consumerManager.stop(); + consumerManager.awaitStop(); + + // Determine number of non-empty consumer iterations made, since polling does not stop immediately after consuming all messages and may do a few empty polls + int nonEmptyPolls = totalPolls.get() - emptyPolls.get(); + + // Verify that there is 10 polls and 10 matching commits + // Each poll consumes 1k messages and queue has 10k total, so that means 10k total msgs / 1k msgs per poll = 10 polls + assertThat(nonEmptyPolls).isEqualTo(10).isEqualTo(totalCommits.get()); + + // Verify that poll-await-commit cycle happened in order with correct await timeout + InOrder inOrder = inOrder(consumer, packCtx); + for (int i = 0; i < nonEmptyPolls; i++) { + inOrder.verify(consumer).poll(mainQueue.getPollInterval()); + inOrder.verify(packCtx).await(mainQueue.getPackProcessingTimeout(), TimeUnit.MILLISECONDS); + inOrder.verify(consumer).commit(); + } + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java index 228b987cf0..e50698200b 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineQueueConsumerManagerTest.java @@ -59,6 +59,7 @@ import org.thingsboard.server.queue.provider.KafkaMonolithQueueFactory; import org.thingsboard.server.queue.provider.KafkaTbRuleEngineQueueFactory; import org.thingsboard.server.queue.provider.TbQueueProducerProvider; import org.thingsboard.server.queue.provider.TbRuleEngineQueueFactory; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; import org.thingsboard.server.service.stats.RuleEngineStatisticsService; @@ -194,6 +195,7 @@ public class TbRuleEngineQueueConsumerManagerTest { .consumerExecutor(consumersExecutor) .scheduler(scheduler) .taskExecutor(mgmtExecutor) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); } diff --git a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java index 3b33d91348..ca47af7b19 100644 --- a/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java +++ b/application/src/test/java/org/thingsboard/server/service/queue/ruleengine/TbRuleEngineStrategyTest.java @@ -45,6 +45,7 @@ import org.thingsboard.server.queue.TbQueueConsumer; import org.thingsboard.server.queue.common.TbProtoQueueMsg; import org.thingsboard.server.queue.common.consumer.TbQueueConsumerTask.ConsumerKey; import org.thingsboard.server.queue.discovery.QueueKey; +import org.thingsboard.server.service.queue.TbMsgPackProcessingContextFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineProcessingStrategyFactory; import org.thingsboard.server.service.queue.processing.TbRuleEngineSubmitStrategyFactory; @@ -196,6 +197,7 @@ public class TbRuleEngineStrategyTest { var consumerManager = TbRuleEngineQueueConsumerManager.create() .ctx(ruleEngineConsumerContext) .queueKey(queueKey) + .packProcessingContextFactory(new TbMsgPackProcessingContextFactory.DefaultTbMsgPackProcessingContextFactory()) .build(); consumerManager.init(queue); diff --git a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java index c227ec76aa..d33f1755d5 100644 --- a/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java +++ b/application/src/test/java/org/thingsboard/server/service/resource/DefaultResourceDataCacheTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.resource; +import org.awaitility.Awaitility; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; @@ -29,6 +30,8 @@ import org.thingsboard.server.dao.resource.ResourceService; import org.thingsboard.server.dao.resource.TbResourceDataCache; import org.thingsboard.server.dao.service.DaoSqlTest; +import java.util.concurrent.TimeUnit; + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.timeout; @@ -61,6 +64,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResourceInfo savedResource = tbResourceService.save(resource); verify(resourceDataCache, timeout(2000).times(1)).evictResourceData(tenantId, savedResource.getId()); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNotNull()); TbResourceDataInfo cachedData = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); assertThat(cachedData.getData()).isEqualTo(data); assertThat(JacksonUtil.treeToValue(cachedData.getDescriptor(), GeneralFileDescriptor.class)).isEqualTo(descriptor); @@ -76,8 +81,8 @@ public class DefaultResourceDataCacheTest extends AbstractControllerTest { TbResource resourceById = resourceService.findResourceById(tenantId, savedResource.getId()); tbResourceService.delete(resourceById, true, null); verify(resourceDataCache, timeout(2000).times(2)).evictResourceData(tenantId, savedResource.getId()); - TbResourceDataInfo cachedDataAfterDeletion = resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get(); - assertThat(cachedDataAfterDeletion).isEqualTo(null); + Awaitility.await().atMost(2, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(resourceDataCache.getResourceDataInfoAsync(tenantId, savedResource.getId()).get()).isNull()); } } diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java index 67e4ba8f32..a556593800 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeDocsIoTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.service.script; +import org.junit.jupiter.api.RepeatedTest; import org.junit.jupiter.api.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.script.api.tbel.TbDate; @@ -779,7 +780,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected.toString(), actual.toString()); } - @Test + @Test public void setsCreateNewSetFromCreateSetTbMethod_Test() throws ExecutionException, InterruptedException { msgStr = """ {"list": ["B", "A", "C", "A"]} @@ -800,7 +801,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected.toString(), actual.toString()); } - @Test + @Test public void setsForeachForLoop_Test() throws ExecutionException, InterruptedException { msgStr = """ {"list": ["A", "B", "C"]} @@ -975,13 +976,13 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { ArrayList listSortDesc = new ArrayList<>(List.of("hello", "C", "B", "A", 34567, 34)); Set expectedDesc = new LinkedHashSet<>(listSortDesc); Object actual = invokeScript(evalScript(decoderStr), msgStr); - assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1").toString()); - assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set1_asc").toString()); - assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set1_desc").toString()); - assertEquals(expected.toString(), ((LinkedHashMap)actual).get("set2").toString()); - assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3").toString()); - assertEquals(expectedAsc.toString(), ((LinkedHashMap)actual).get("set3_asc").toString()); - assertEquals(expectedDesc.toString(), ((LinkedHashMap)actual).get("set3_desc").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap) actual).get("set1").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap) actual).get("set1_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap) actual).get("set1_desc").toString()); + assertEquals(expected.toString(), ((LinkedHashMap) actual).get("set2").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap) actual).get("set3").toString()); + assertEquals(expectedAsc.toString(), ((LinkedHashMap) actual).get("set3_asc").toString()); + assertEquals(expectedDesc.toString(), ((LinkedHashMap) actual).get("set3_desc").toString()); } @Test @@ -1002,9 +1003,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { List listOrigin = new ArrayList<>(List.of("C", "B", "A", 34567, "B", "C", "hello", 34)); Set expectedSet = new LinkedHashSet<>(listOrigin); Object actual = invokeScript(evalScript(decoderStr), msgStr); - assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); - assertEquals(true, ((LinkedHashMap)actual).get("result1")); - assertEquals(false, ((LinkedHashMap)actual).get("result2")); + assertEquals(expectedSet.toString(), ((LinkedHashMap) actual).get("set1").toString()); + assertEquals(true, ((LinkedHashMap) actual).get("result1")); + assertEquals(false, ((LinkedHashMap) actual).get("result2")); } @Test @@ -1025,9 +1026,9 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { Set expectedSet = new LinkedHashSet<>(listOrigin); List expectedToList = new ArrayList<>(expectedSet); Object actual = invokeScript(evalScript(decoderStr), msgStr); - assertEquals(listOrigin.toString(), ((LinkedHashMap)actual).get("list").toString()); - assertEquals(expectedSet.toString(), ((LinkedHashMap)actual).get("set1").toString()); - assertEquals(expectedToList.toString(), ((LinkedHashMap)actual).get("tolist").toString()); + assertEquals(listOrigin.toString(), ((LinkedHashMap) actual).get("list").toString()); + assertEquals(expectedSet.toString(), ((LinkedHashMap) actual).get("set1").toString()); + assertEquals(expectedToList.toString(), ((LinkedHashMap) actual).get("tolist").toString()); } @Test @@ -1713,7 +1714,7 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { assertEquals(expected, actual); } - @Test + @RepeatedTest(value = 3, name = "{displayName} {currentRepetition}/{totalRepetitions}") public void parseBytes_Test() throws ExecutionException, InterruptedException { byte[] bytesExecutionArrayList = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD}; msgStr = "{}"; @@ -2355,7 +2356,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { expected.put("date_1", d1.toString()); TbDate d2 = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); expected.put("dLocal_2", d2.toLocaleString()); - expected.put("dLocal_2_us", "8/5/23, 10:04:05 PM"); + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + expected.put("dLocal_2_us", Runtime.version().feature() >= 21 ? "8/5/23, 10:04:05\u202FPM" : "8/5/23, 10:04:05 PM"); expected.put("dIso_2", d2.toISOString()); expected.put("date_2", d2.toString()); Object actual = invokeScript(evalScript(decoderStr), msgStr); @@ -2387,7 +2389,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { """, s2); LinkedHashMap expected = new LinkedHashMap<>(); TbDate d1 = new TbDate(2023, 8, 6, 4, 4, 5); - expected.put("dLocal_1_us", "8/6/23, 4:04:05 AM"); + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + expected.put("dLocal_1_us", Runtime.version().feature() >= 21 ? "8/6/23, 4:04:05\u202FAM" : "8/6/23, 4:04:05 AM"); expected.put("dIso_1", d1.toISOString()); expected.put("d1", d1.toString()); TbDate d2 = new TbDate(s2); @@ -2419,7 +2422,8 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { TbDate d = new TbDate(2023, 8, 6, 4, 4, 5, "Europe/Berlin"); expected.put("dIso", d.toISOString()); expected.put("dLocal_utc", d.toLocaleString("UTC")); - expected.put("dLocal_us", "8/5/23, 10:04:05 PM"); + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + expected.put("dLocal_us", Runtime.version().feature() >= 21 ? "8/5/23, 10:04:05\u202FPM" : "8/5/23, 10:04:05 PM"); expected.put("dLocal_de", "06.08.23, 04:04:05"); Object actual = invokeScript(evalScript(decoderStr), msgStr); assertEquals(expected, actual); @@ -2821,5 +2825,6 @@ class TbelInvokeDocsIoTest extends AbstractTbelInvokeTest { } return list; } + } diff --git a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java index 54e1a39769..8618e515c8 100644 --- a/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/script/TbelInvokeServiceTest.java @@ -217,6 +217,90 @@ class TbelInvokeServiceTest extends AbstractTbelInvokeTest { assertThat(compiledScriptsCache.getIfPresent(scriptIdToHash.get(scriptRemovedFromCache))).isNotNull(); } + @Test + void givenForbiddenSocketHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.logging.SocketHandler(\"127.0.0.1\", 9999)"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.SocketHandler"); + }); + } + + @Test + void givenForbiddenZipFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.zip.ZipFile(\"/tmp/test.zip\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.zip.ZipFile"); + }); + } + + @Test + void givenForbiddenFileHandler_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.logging.FileHandler(\"/tmp/test.log\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.logging.FileHandler"); + }); + } + + @Test + void givenForbiddenJarFile_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.jar.JarFile(\"/tmp/test.jar\")"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.jar.JarFile"); + }); + } + + @Test + void givenForbiddenPreferences_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("java.util.prefs.Preferences.userRoot()"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getMessage()).contains("unresolvable property or identifier: java"); + }); + } + + @Test + void givenForbiddenLocaleServiceProvider_whenInvoking_thenThrowsRuntimeError() throws ExecutionException, InterruptedException { + UUID scriptId = evalScript("new java.util.spi.LocaleServiceProvider()"); + assertThatThrownBy(() -> invokeScript(scriptId, "{\"temperature\":25}")) + .isInstanceOf(ExecutionException.class) + .cause() + .isInstanceOf(TbScriptException.class) + .asInstanceOf(type(TbScriptException.class)) + .satisfies(ex -> { + assertThat(ex.getErrorCode()).isEqualTo(TbScriptException.ErrorCode.RUNTIME); + assertThat(ex.getCause().getMessage()).contains("could not resolve class: java.util.spi.LocaleServiceProvider"); + }); + } + private void assertThatScriptIsBlocked(UUID scriptId) { assertThatThrownBy(() -> { invokeScriptResultString(scriptId, "{}"); diff --git a/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java new file mode 100644 index 0000000000..0933dfd942 --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/security/system/DefaultSystemSecurityServiceTest.java @@ -0,0 +1,204 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.security.system; + +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.junit.MockitoJUnitRunner; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.thingsboard.rule.engine.api.MailService; +import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.security.UserCredentials; +import org.thingsboard.server.common.data.security.model.SecuritySettings; +import org.thingsboard.server.common.data.security.model.UserPasswordPolicy; +import org.thingsboard.server.dao.audit.AuditLogService; +import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.dao.settings.SecuritySettingsService; +import org.thingsboard.server.dao.user.UserService; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@RunWith(MockitoJUnitRunner.class) +public class DefaultSystemSecurityServiceTest { + + @Mock + private AdminSettingsService adminSettingsService; + @Mock + private BCryptPasswordEncoder encoder; + @Mock + private UserService userService; + @Mock + private MailService mailService; + @Mock + private AuditLogService auditLogService; + @Mock + private SecuritySettingsService securitySettingsService; + + private DefaultSystemSecurityService systemSecurityService; + + private TenantId tenantId; + private UserId userId; + private UserCredentials userCredentials; + private SecuritySettings securitySettings; + private String username; + private String password; + private String encodedPassword; + + @Before + public void setUp() { + systemSecurityService = new DefaultSystemSecurityService(adminSettingsService, encoder, userService, mailService, auditLogService, securitySettingsService); + + tenantId = TenantId.fromUUID(UUID.randomUUID()); + userId = new UserId(UUID.randomUUID()); + username = "tenant@example.com"; + password = "correctPassword"; + encodedPassword = "$2a$10$encodedPasswordHash"; + + userCredentials = new UserCredentials(); + userCredentials.setUserId(userId); + userCredentials.setEnabled(true); + userCredentials.setPassword(encodedPassword); + userCredentials.setCreatedTime(System.currentTimeMillis()); + + securitySettings = new SecuritySettings(); + securitySettings.setMaxFailedLoginAttempts(5); + securitySettings.setPasswordPolicy(new UserPasswordPolicy()); + } + + @Test + public void testValidateUserCredentials_successfulLogin() { + when(encoder.matches(password, encodedPassword)).thenReturn(true); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password); + + verify(encoder).matches(password, encodedPassword); + verify(userService).resetFailedLoginAttempts(tenantId, userId); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_incrementsFailedAttempts() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(3); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_accountLocked() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class) + .hasMessageContaining("locked due to security policy"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + } + + @Test + public void testValidateUserCredentials_wrongPassword_exactlyAtThreshold_noLock() { + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(5); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class) + .hasMessageContaining("Authentication Failed"); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_correctPassword_disabledUser_throwsDisabledException() { + userCredentials.setEnabled(false); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(DisabledException.class) + .hasMessage("User is not active"); + + verify(encoder, never()).matches(any(), any()); + verify(userService, never()).increaseFailedLoginAttempts(any(), any()); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsDisabled() { + securitySettings.setMaxFailedLoginAttempts(null); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_maxAttemptsSetToZero() { + securitySettings.setMaxFailedLoginAttempts(0); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(100); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(BadCredentialsException.class); + + verify(userService).increaseFailedLoginAttempts(tenantId, userId); + verify(userService, never()).setUserCredentialsEnabled(any(), any(), eq(false)); + } + + @Test + public void testValidateUserCredentials_wrongPassword_withNotificationEmail() throws ThingsboardException { + String notificationEmail = "admin@example.com"; + securitySettings.setUserLockoutNotificationEmail(notificationEmail); + when(encoder.matches(password, encodedPassword)).thenReturn(false); + when(userService.increaseFailedLoginAttempts(tenantId, userId)).thenReturn(6); + when(securitySettingsService.getSecuritySettings()).thenReturn(securitySettings); + + assertThatThrownBy(() -> systemSecurityService.validateUserCredentials(tenantId, userCredentials, username, password)) + .isInstanceOf(LockedException.class); + + verify(userService).setUserCredentialsEnabled(TenantId.SYS_TENANT_ID, userId, false); + verify(mailService).sendAccountLockoutEmail(eq(username), eq(notificationEmail), eq(5)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java index 445be6363a..28ce4ea46a 100644 --- a/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sms/DefaultSmsServiceTest.java @@ -52,6 +52,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=1", + "usage.stats.report.urgent_interval=1" }) public class DefaultSmsServiceTest extends AbstractControllerTest { @SpyBean diff --git a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java index 31283a1165..e207c2325c 100644 --- a/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java +++ b/application/src/test/java/org/thingsboard/server/service/stats/DevicesStatisticsTest.java @@ -41,6 +41,7 @@ import static org.awaitility.Awaitility.await; @TestPropertySource(properties = { "usage.stats.report.enabled=true", "usage.stats.report.interval=2", + "usage.stats.report.urgent_interval=1", "usage.stats.gauge_report_interval=1", "usage.stats.devices.report_interval=3", "state.defaultStateCheckIntervalInSec=3", diff --git a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java index 0305d031b8..994f326627 100644 --- a/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java +++ b/application/src/test/java/org/thingsboard/server/service/sync/vc/VersionControlTest.java @@ -942,7 +942,6 @@ public class VersionControlTest extends AbstractControllerTest { } return result.getResult().stream().collect(Collectors.toMap(EntityTypeLoadResult::getEntityType, r -> r)); } - private List listVersions() throws Exception { PageData versions = doGetAsyncTyped("/api/entities/vc/version?branch=" + branch + "&pageSize=100&page=0&sortProperty=timestamp&sortOrder=DESC", new TypeReference>() {}); return versions.getData(); diff --git a/application/src/test/java/org/thingsboard/server/service/ttl/NotificationsCleanUpServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ttl/NotificationsCleanUpServiceTest.java new file mode 100644 index 0000000000..d74652325a --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ttl/NotificationsCleanUpServiceTest.java @@ -0,0 +1,144 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ttl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.notification.NotificationRequestDao; +import org.thingsboard.server.dao.sqlts.insert.sql.SqlPartitioningRepository; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.discovery.PartitionService; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class NotificationsCleanUpServiceTest { + + @Mock + private PartitionService partitionService; + @Mock + private SqlPartitioningRepository partitioningRepository; + @Mock + private NotificationRequestDao notificationRequestDao; + @Mock + private TenantService tenantService; + + private NotificationsCleanUpService cleanUpService; + + private static final int BATCH_SIZE = 3; + + @BeforeEach + public void setUp() { + cleanUpService = new NotificationsCleanUpService(partitionService, partitioningRepository, notificationRequestDao, tenantService); + ReflectionTestUtils.setField(cleanUpService, "ttlInSec", 2592000L); + ReflectionTestUtils.setField(cleanUpService, "partitionSizeInHours", 168); + ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); + } + + @Test + public void testBatchLoopCallsDaoMultipleTimes() { + TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); + when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); + when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) + .thenReturn(System.currentTimeMillis()); + + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + when(tenantService.findTenantsIds(any())) + .thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); + + // Sysadmin: returns 3 (full batch), then 1 (partial) -> 2 calls + when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) + .thenReturn(BATCH_SIZE) + .thenReturn(1); + // Tenant: returns 3, 3, 0 -> 3 calls + when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) + .thenReturn(BATCH_SIZE) + .thenReturn(BATCH_SIZE) + .thenReturn(0); + + cleanUpService.cleanUp(); + + verify(notificationRequestDao, times(2)) + .removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); + verify(notificationRequestDao, times(3)) + .removeByTenantIdAndCreatedTimeBeforeBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); + } + + @Test + public void testSkipsTenantNotOnMyPartition() { + TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); + TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); + when(partitionService.resolve(any(), eq(TenantId.SYS_TENANT_ID), eq(TenantId.SYS_TENANT_ID))) + .thenReturn(myPartition); + when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) + .thenReturn(System.currentTimeMillis()); + + // Sysadmin: no records + when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) + .thenReturn(0); + + TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); + TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); + when(tenantService.findTenantsIds(any())) + .thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); + when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); + when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); + + when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) + .thenReturn(0); + + cleanUpService.cleanUp(); + + verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); + verify(notificationRequestDao, never()).removeByTenantIdAndCreatedTimeBeforeBatch(eq(otherTenant), anyLong(), anyInt()); + } + + @Test + public void testNoPartitionsDropped_stillCleansUpRequests() { + TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); + when(partitionService.resolve(any(), any(), any())).thenReturn(myPartition); + when(partitioningRepository.dropPartitionsBefore(anyString(), anyLong(), anyLong())) + .thenReturn(0L); + + when(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE))) + .thenReturn(0); + when(tenantService.findTenantsIds(any())) + .thenReturn(new PageData<>(List.of(), 0, 0, false)); + + cleanUpService.cleanUp(); + + verify(notificationRequestDao).removeByTenantIdAndCreatedTimeBeforeBatch(eq(TenantId.SYS_TENANT_ID), anyLong(), eq(BATCH_SIZE)); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpServiceTest.java new file mode 100644 index 0000000000..22cc1be1dd --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ttl/rpc/RpcCleanUpServiceTest.java @@ -0,0 +1,136 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ttl.rpc; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; +import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.dao.rpc.RpcDao; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantService; +import org.thingsboard.server.queue.discovery.PartitionService; + +import java.util.List; +import java.util.UUID; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class RpcCleanUpServiceTest { + + @Mock + private PartitionService partitionService; + @Mock + private RpcDao rpcDao; + @Mock + private TenantService tenantService; + @Mock + private TbTenantProfileCache tenantProfileCache; + + private RpcCleanUpService cleanUpService; + + private static final int BATCH_SIZE = 3; + + @BeforeEach + public void setUp() { + cleanUpService = new RpcCleanUpService(tenantService, partitionService, tenantProfileCache, rpcDao); + ReflectionTestUtils.setField(cleanUpService, "removalBatchSize", BATCH_SIZE); + } + + @Test + public void testBatchLoopCallsDaoMultipleTimes() { + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + setupTenant(tenantId, 7); + + // Returns 3 (full batch), 3 (full batch), 1 (partial) -> 3 calls + when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE))) + .thenReturn(BATCH_SIZE) + .thenReturn(BATCH_SIZE) + .thenReturn(1); + + cleanUpService.cleanUp(); + + verify(rpcDao, times(3)).deleteOutdatedRpcByTenantIdBatch(eq(tenantId), anyLong(), eq(BATCH_SIZE)); + } + + @Test + public void testSkipsTenantNotOnMyPartition() { + TenantId myTenant = TenantId.fromUUID(UUID.randomUUID()); + TenantId otherTenant = TenantId.fromUUID(UUID.randomUUID()); + + TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); + TopicPartitionInfo notMyPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(false).build(); + + when(tenantService.findTenantsIds(any())) + .thenReturn(new PageData<>(List.of(myTenant, otherTenant), 2, 1, false)); + when(partitionService.resolve(any(), eq(myTenant), eq(myTenant))).thenReturn(myPartition); + when(partitionService.resolve(any(), eq(otherTenant), eq(otherTenant))).thenReturn(notMyPartition); + + setupTenantProfile(myTenant, 7); + when(rpcDao.deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE))) + .thenReturn(0); + + cleanUpService.cleanUp(); + + verify(rpcDao).deleteOutdatedRpcByTenantIdBatch(eq(myTenant), anyLong(), eq(BATCH_SIZE)); + verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(eq(otherTenant), anyLong(), anyInt()); + } + + @Test + public void testSkipsTenantWithZeroTtl() { + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + setupTenant(tenantId, 0); + + cleanUpService.cleanUp(); + + verify(rpcDao, never()).deleteOutdatedRpcByTenantIdBatch(any(), anyLong(), anyInt()); + } + + private void setupTenant(TenantId tenantId, int rpcTtlDays) { + TopicPartitionInfo myPartition = TopicPartitionInfo.builder().topic("tb_core").myPartition(true).build(); + when(partitionService.resolve(any(), eq(tenantId), eq(tenantId))).thenReturn(myPartition); + when(tenantService.findTenantsIds(any())) + .thenReturn(new PageData<>(List.of(tenantId), 1, 1, false)); + setupTenantProfile(tenantId, rpcTtlDays); + } + + private void setupTenantProfile(TenantId tenantId, int rpcTtlDays) { + TenantProfile profile = new TenantProfile(); + TenantProfileData profileData = new TenantProfileData(); + DefaultTenantProfileConfiguration config = new DefaultTenantProfileConfiguration(); + config.setRpcTtlDays(rpcTtlDays); + profileData.setConfiguration(config); + profile.setProfileData(profileData); + when(tenantProfileCache.get(tenantId)).thenReturn(profile); + } + +} diff --git a/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java new file mode 100644 index 0000000000..a533e0369f --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/service/ws/DefaultWebSocketServiceTest.java @@ -0,0 +1,277 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.service.ws; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.TenantProfile; +import org.thingsboard.server.common.data.id.CustomerId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.dao.attributes.AttributesService; +import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.timeseries.TimeseriesService; +import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; +import org.thingsboard.server.service.security.AccessValidator; +import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.service.security.model.UserPrincipal; +import org.thingsboard.server.service.subscription.TbEntityDataSubscriptionService; +import org.thingsboard.server.service.subscription.TbLocalSubscriptionService; +import org.thingsboard.server.service.ws.notification.NotificationCommandsHandler; +import org.thingsboard.server.service.ws.telemetry.cmd.v1.AttributesSubscriptionCmd; + +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentMap; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.willReturn; +import static org.mockito.Mockito.mock; + +class DefaultWebSocketServiceTest { + + DefaultWebSocketService service; + TbTenantProfileCache tenantProfileCache; + WebSocketMsgEndpoint msgEndpoint; + + @BeforeEach + void setUp() { + tenantProfileCache = mock(TbTenantProfileCache.class); + msgEndpoint = mock(WebSocketMsgEndpoint.class); + + service = new DefaultWebSocketService( + mock(TbLocalSubscriptionService.class), + mock(TbEntityDataSubscriptionService.class), + mock(NotificationCommandsHandler.class), + msgEndpoint, + mock(AccessValidator.class), + mock(AttributesService.class), + mock(TimeseriesService.class), + mock(TbServiceInfoProvider.class), + tenantProfileCache + ); + } + + // Regression test: publicUserSubscriptionsMap must be keyed by TenantId, not UserId(NULL_UUID). + // With the old UserId(NULL_UUID) key, all tenants shared one global subscription counter. + @Test + void processSubscription_publicUserSubscriptionsMap_isPerTenantNotGlobal() throws Exception { + int maxPublicSubscriptions = 2; + + TenantId tenant1 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile1 = new TenantProfile(); + profile1.createDefaultTenantProfileData(); + profile1.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile1).given(tenantProfileCache).get(tenant1); + + TenantId tenant2 = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile2 = new TenantProfile(); + profile2.createDefaultTenantProfileData(); + profile2.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile2).given(tenantProfileCache).get(tenant2); + + // tenant1 fills up its quota + for (int i = 0; i < maxPublicSubscriptions; i++) { + assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-" + i), subscriptionCmd(i))) + .as("tenant1 subscription %d should be accepted", i + 1) + .isTrue(); + } + + // tenant2 must have its own independent quota — this was the bug: + // with UserId(NULL_UUID) as key all tenants shared one counter, so tenant2 would be blocked here + for (int i = 0; i < maxPublicSubscriptions; i++) { + assertThat(service.processSubscription(mockPublicSessionRef(tenant2, "t2-session-" + i), subscriptionCmd(i))) + .as("tenant2 subscription %d should not be affected by tenant1's subscriptions", i + 1) + .isTrue(); + } + + // tenant1's (maxPublicSubscriptions + 1)-th subscription must be rejected + assertThat(service.processSubscription(mockPublicSessionRef(tenant1, "t1-session-over"), subscriptionCmd(99))) + .as("tenant1 should be rejected after exceeding its limit") + .isFalse(); + + // Verify that publicUserSubscriptionsMap has separate entries per tenant + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + + assertThat(publicUserSubscriptionsMap).as("map should contain tenant1").containsKey(tenant1); + assertThat(publicUserSubscriptionsMap).as("map should contain tenant2").containsKey(tenant2); + assertThat(publicUserSubscriptionsMap).as("map must not have a single NULL_UUID entry for all tenants") + .doesNotContainKey(new TenantId(EntityId.NULL_UUID)); + + assertThat(publicUserSubscriptionsMap.get(tenant1)) + .as("tenant1 should have exactly %d subscriptions", maxPublicSubscriptions) + .hasSize(maxPublicSubscriptions); + assertThat(publicUserSubscriptionsMap.get(tenant2)) + .as("tenant2 should have exactly %d subscriptions", maxPublicSubscriptions) + .hasSize(maxPublicSubscriptions); + } + + @Test + void processSubscription_publicUserSubscriptionsMap_subscriptionIdFormat() { + int maxPublicSubscriptions = 5; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "my-session-id"; + int cmdId = 42; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + service.processSubscription(sessionRef, subscriptionCmd(cmdId)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + + Set subs = publicUserSubscriptionsMap.get(tenantId); + assertThat(subs).hasSize(1); + assertThat(subs.iterator().next()).isEqualTo("[" + sessionId + "]:[" + cmdId + "]"); + } + + @Test + void processSubscription_unsubscribe_removesEntryFromPublicUserSubscriptionsMap() { + int maxPublicSubscriptions = 5; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "session-1"; + int cmdId = 1; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + + service.processSubscription(sessionRef, subscriptionCmd(cmdId)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(1); + + AttributesSubscriptionCmd unsubCmd = subscriptionCmd(cmdId); + unsubCmd.setUnsubscribe(true); + service.processSubscription(sessionRef, unsubCmd); + + assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); + } + + @Test + void processSubscription_unsubscribe_freesSlotForNewSubscription() { + int maxPublicSubscriptions = 1; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, "session-1"); + service.processSubscription(sessionRef, subscriptionCmd(1)); + + // slot is full — second subscription on same session should be rejected + assertThat(service.processSubscription(sessionRef, subscriptionCmd(2))).isFalse(); + + // unsubscribe cmd 1 to free the slot + AttributesSubscriptionCmd unsubCmd = subscriptionCmd(1); + unsubCmd.setUnsubscribe(true); + service.processSubscription(sessionRef, unsubCmd); + + // now a new subscription should succeed + assertThat(service.processSubscription(sessionRef, subscriptionCmd(3))) + .as("new subscription should succeed after unsubscribe freed the slot") + .isTrue(); + } + + @Test + void processSessionClose_removesAllSessionSubscriptionsFromPublicUserSubscriptionsMap() { + int maxPublicSubscriptions = 10; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + String sessionId = "closing-session"; + WebSocketSessionRef sessionRef = mockPublicSessionRef(tenantId, sessionId); + + service.processSubscription(sessionRef, subscriptionCmd(1)); + service.processSubscription(sessionRef, subscriptionCmd(2)); + service.processSubscription(sessionRef, subscriptionCmd(3)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); + + service.processSessionClose(sessionRef); + + assertThat(publicUserSubscriptionsMap.get(tenantId)).isEmpty(); + } + + @Test + void processSessionClose_onlyRemovesClosedSessionSubscriptions() { + int maxPublicSubscriptions = 10; + TenantId tenantId = TenantId.fromUUID(UUID.randomUUID()); + TenantProfile profile = new TenantProfile(); + profile.createDefaultTenantProfileData(); + profile.getDefaultProfileConfiguration().setMaxWsSubscriptionsPerPublicUser(maxPublicSubscriptions); + willReturn(profile).given(tenantProfileCache).get(tenantId); + + WebSocketSessionRef session1 = mockPublicSessionRef(tenantId, "session-1"); + WebSocketSessionRef session2 = mockPublicSessionRef(tenantId, "session-2"); + + service.processSubscription(session1, subscriptionCmd(1)); + service.processSubscription(session1, subscriptionCmd(2)); + service.processSubscription(session2, subscriptionCmd(1)); + + @SuppressWarnings("unchecked") + ConcurrentMap> publicUserSubscriptionsMap = + (ConcurrentMap>) ReflectionTestUtils.getField(service, "publicUserSubscriptionsMap"); + assertThat(publicUserSubscriptionsMap.get(tenantId)).hasSize(3); + + service.processSessionClose(session1); + + Set remaining = publicUserSubscriptionsMap.get(tenantId); + assertThat(remaining).hasSize(1); + assertThat(remaining).allMatch(subId -> subId.startsWith("[session-2]")); + } + + private WebSocketSessionRef mockPublicSessionRef(TenantId tenantId, String sessionId) { + CustomerId customerId = new CustomerId(UUID.randomUUID()); + SecurityUser securityUser = mock(SecurityUser.class); + willReturn(tenantId).given(securityUser).getTenantId(); + willReturn(customerId).given(securityUser).getCustomerId(); + willReturn(new UserId(EntityId.NULL_UUID)).given(securityUser).getId(); + willReturn(true).given(securityUser).isCustomerUser(); + willReturn(new UserPrincipal(UserPrincipal.Type.PUBLIC_ID, customerId.toString())).given(securityUser).getUserPrincipal(); + + WebSocketSessionRef ref = mock(WebSocketSessionRef.class); + willReturn(securityUser).given(ref).getSecurityCtx(); + willReturn(sessionId).given(ref).getSessionId(); + return ref; + } + + private AttributesSubscriptionCmd subscriptionCmd(int cmdId) { + AttributesSubscriptionCmd cmd = new AttributesSubscriptionCmd(); + cmd.setCmdId(cmdId); + return cmd; + } + +} diff --git a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java index 67e5e946c8..f2485dc7d5 100644 --- a/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java +++ b/application/src/test/java/org/thingsboard/server/system/SystemPatchApplierTest.java @@ -31,13 +31,21 @@ import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.WidgetTypeId; +import org.thingsboard.server.common.data.id.WidgetsBundleId; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.resource.ImageService; import org.thingsboard.server.dao.widget.WidgetTypeService; +import org.thingsboard.server.dao.widget.WidgetsBundleService; +import org.thingsboard.server.service.install.DatabaseSchemaSettingsService; import org.thingsboard.server.service.install.InstallScripts; import org.thingsboard.server.service.system.SystemPatchApplier; import java.nio.file.Files; import java.nio.file.Path; +import java.util.Collections; +import java.util.List; +import java.util.Set; import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -71,9 +79,18 @@ public class SystemPatchApplierTest { @Mock private InstallScripts installScripts; + @Mock + private DatabaseSchemaSettingsService schemaSettingsService; + @Mock private WidgetTypeService widgetTypeService; + @Mock + private WidgetsBundleService widgetsBundleService; + + @Mock + private ImageService imageService; + @InjectMocks private SystemPatchApplier reconciler; @@ -145,19 +162,72 @@ public class SystemPatchApplierTest { } @Test - void whenWidgetNotFound_thenThrowException() throws Exception { + void whenWidgetNotFound_thenCreateNewWidget() throws Exception { Path widgetTypesDir = tempDir.resolve("widget_types"); Files.createDirectories(widgetTypesDir); when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); - WidgetTypeDetails testWidget = createTestWidgetType("test_widget", "Test Widget"); - String json = JacksonUtil.toString(testWidget); + WidgetTypeDetails fileWidget = createTestWidgetType("new_widget", "New Widget"); + String json = JacksonUtil.toString(fileWidget); assertNotNull(json); - Files.writeString(widgetTypesDir.resolve("test_widget.json"), json); + Files.writeString(widgetTypesDir.resolve("new_widget.json"), json); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "new_widget")).thenReturn(null); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(0, stats.updated()); + verify(widgetTypeService).saveWidgetType(argThat(w -> "new_widget".equals(w.getFqn()))); + } + + @Test + void whenFqnIsBlank_thenThrowException() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); - when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")).thenReturn(null); + WidgetTypeDetails brokenWidget = createTestWidgetType("", "Broken Widget"); + String json = JacksonUtil.toString(brokenWidget); + assertNotNull(json); + Files.writeString(widgetTypesDir.resolve("broken.json"), json); assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes")); + verify(widgetTypeService, never()).saveWidgetType(any()); + } + + @Test + void whenMixOfCreatedAndUpdated_thenStatsAreCorrect() throws Exception { + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + + WidgetTypeDetails newFileWidget = createTestWidgetType("widget_new", "Widget New"); + Files.writeString(widgetTypesDir.resolve("widget_new.json"), JacksonUtil.toString(newFileWidget)); + + WidgetTypeDetails changedFileWidget = createTestWidgetType("widget_changed", "Widget Changed New Name"); + Files.writeString(widgetTypesDir.resolve("widget_changed.json"), JacksonUtil.toString(changedFileWidget)); + + WidgetTypeDetails sameFileWidget = createTestWidgetType("widget_same", "Widget Same"); + Files.writeString(widgetTypesDir.resolve("widget_same.json"), JacksonUtil.toString(sameFileWidget)); + + WidgetTypeDetails existingChanged = createTestWidgetType("widget_changed", "Widget Changed Old Name"); + existingChanged.setId(new WidgetTypeId(UUID.randomUUID())); + + WidgetTypeDetails existingSame = createTestWidgetType("widget_same", "Widget Same"); + existingSame.setId(new WidgetTypeId(UUID.randomUUID())); + + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_new")).thenReturn(null); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_changed")).thenReturn(existingChanged); + when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "widget_same")).thenReturn(existingSame); + + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + + assertNotNull(stats); + assertEquals(1, stats.created()); + assertEquals(1, stats.updated()); + verify(widgetTypeService, times(2)).saveWidgetType(any()); } @Test @@ -179,9 +249,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> w.getDescriptor().get("version").asInt() == 2 )); @@ -204,9 +276,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(1, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(1, stats.updated()); verify(widgetTypeService).saveWidgetType(argThat(w -> "New Name".equals(w.getName()))); } @@ -227,9 +301,11 @@ public class SystemPatchApplierTest { when(widgetTypeService.findWidgetTypeDetailsByTenantIdAndFqn(TenantId.SYS_TENANT_ID, "test_widget")) .thenReturn(existingWidget); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - assertEquals(0, updated); + assertNotNull(stats); + assertEquals(0, stats.created()); + assertEquals(0, stats.updated()); verify(widgetTypeService, never()).saveWidgetType(any()); } @@ -329,8 +405,8 @@ public class SystemPatchApplierTest { // Simulate work while holding lock Thread.sleep(100); - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - firstThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + firstThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -350,8 +426,8 @@ public class SystemPatchApplierTest { secondThreadAcquiredLock.set(Boolean.TRUE.equals(acquired)); if (secondThreadAcquiredLock.get()) { - Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); - secondThreadSavedWidget.set(updated != null && updated > 0); + SystemPatchApplier.WidgetTypeStats stats = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetTypes"); + secondThreadSavedWidget.set(stats != null && stats.updated() > 0); ReflectionTestUtils.invokeMethod(reconciler, "releaseAdvisoryLock"); } @@ -373,6 +449,249 @@ public class SystemPatchApplierTest { verify(widgetTypeService, times(1)).saveWidgetType(any()); } + // --- isVersionIncreased tests --- + + @ParameterizedTest(name = "isVersionIncreased: {0} (package={1}, db={2}) -> {3}") + @MethodSource("provideVersionComparisonTestCases") + void testIsVersionIncreased(String testName, SystemPatchApplier.VersionInfo packageVersion, + SystemPatchApplier.VersionInfo dbVersion, boolean expected) { + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionIncreased", packageVersion, dbVersion); + assertEquals(expected, result, testName); + } + + private static Stream provideVersionComparisonTestCases() { + return Stream.of( + // Maintenance digit increases within same LTS family + Arguments.of("maintenance increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("maintenance increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 3, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Patch digit increases within same maintenance + Arguments.of("patch increased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + Arguments.of("patch increased by more than one", + new SystemPatchApplier.VersionInfo(4, 3, 0, 5), + new SystemPatchApplier.VersionInfo(4, 3, 0, 2), true), + + // Both maintenance and patch increased + Arguments.of("maintenance and patch both increased", + new SystemPatchApplier.VersionInfo(4, 3, 1, 1), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), true), + + // Maintenance increased, patch value is lower (irrelevant — maintenance wins) + Arguments.of("maintenance increased, patch is lower", + new SystemPatchApplier.VersionInfo(4, 3, 2, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 5), true), + + // Same version — no increase + Arguments.of("same version", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("same version with non-zero parts", + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), + new SystemPatchApplier.VersionInfo(4, 3, 1, 2), false), + + // Decreased versions — no increase + Arguments.of("maintenance decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 1, 0), false), + Arguments.of("patch decreased", + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 1), false), + + // Different major — different family, skip + Arguments.of("different major", + new SystemPatchApplier.VersionInfo(5, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("major decreased", + new SystemPatchApplier.VersionInfo(3, 3, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + + // Different minor — different LTS family, skip + Arguments.of("minor increased (different LTS family)", + new SystemPatchApplier.VersionInfo(4, 4, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false), + Arguments.of("minor decreased", + new SystemPatchApplier.VersionInfo(4, 2, 0, 0), + new SystemPatchApplier.VersionInfo(4, 3, 0, 0), false) + ); + } + + // --- isVersionChanged tests --- + + @Test + void whenVersionIncreased_thenVersionChangedReturnsTrue() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertTrue(result); + } + + @Test + void whenVersionNotIncreased_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("invalid"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + @Test + void whenDbVersionUnparseable_thenVersionChangedReturnsFalse() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("bad"); + + Boolean result = ReflectionTestUtils.invokeMethod(reconciler, "isVersionChanged"); + + assertFalse(result); + } + + // --- updateLtsSqlSchema tests --- + + @Test + void whenLtsSqlFileExists_thenExecutesSql() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute("ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"); + } + + @Test + void whenLtsSqlFileDoesNotExist_thenSkips() { + Path dataDir = tempDir.resolve("data"); + // Don't create the file + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenLtsSqlFileHasMultipleStatements_thenExecutesAll() throws Exception { + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + String sql = "DO $$ BEGIN\n" + + " IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'test_type') THEN\n" + + " CREATE TYPE test_type AS ENUM ('A', 'B');\n" + + " END IF;\n" + + "END $$;\n" + + "ALTER TABLE device ADD COLUMN IF NOT EXISTS test_col VARCHAR(255);"; + Files.writeString(ltsDir.resolve("schema_update.sql"), sql); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + ReflectionTestUtils.invokeMethod(reconciler, "updateLtsSqlSchema"); + + verify(jdbcTemplate).execute(sql); + } + + // --- applyPatchIfNeeded flow tests --- + + @Test + void whenVersionIncreased_thenAppliesLtsSqlBeforeViewsAndWidgets() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + Path ltsDir = dataDir.resolve("upgrade").resolve("lts"); + Files.createDirectories(ltsDir); + Files.writeString(ltsDir.resolve("schema_update.sql"), "SELECT 1;"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // LTS SQL was executed + verify(jdbcTemplate).execute("SELECT 1;"); + // Schema version was updated + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenSkipsEverything() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + // No lock acquired + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + // No schema update + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + + @Test + void whenLockNotAcquired_thenSkipsPatchApplication() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(false); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService, never()).updateSchemaVersion(); + verify(jdbcTemplate, never()).execute(anyString()); + } + + @Test + void whenMaintenanceVersionIncreased_thenAppliesPatch() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.2.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.1.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenDifferentLtsFamily_thenSkipsPatch() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.4.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(jdbcTemplate, never()).queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong()); + verify(schemaSettingsService, never()).updateSchemaVersion(); + } + private static Stream provideDescriptorComparisonTestCases() { return Stream.of( Arguments.of("Both null", null, null, true), @@ -407,4 +726,398 @@ public class SystemPatchApplierTest { return widget; } + // --- createMissingSystemImages tests --- + + @Test + void whenImagesDirDoesNotExist_thenReturnsZeroAndDoesNotCallImageService() { + Path dataDir = tempDir.resolve("data"); + // Intentionally do not create resources/images dir + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenImagesDirIsEmpty_thenReturnsZeroAndDoesNotCallImageService() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenSystemImageDoesNotExistInDb_thenCreateIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{1, 2, 3, 4, 5}; + Files.write(imagesDir.resolve("gateway.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("gateway.png"), eq(imageBytes)); + } + + @Test + void whenSystemImageExistsInDb_thenSkipIt() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("gateway.png"), new byte[]{1, 2, 3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("gateway.png")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(0, created); + verify(imageService).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + @Test + void whenMixOfNewAndExistingImages_thenOnlyCreateMissingOnes() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] newImageBytes = new byte[]{9, 9, 9}; + byte[] existingImageBytes = new byte[]{1, 1, 1}; + Files.write(imagesDir.resolve("new.png"), newImageBytes); + Files.write(imagesDir.resolve("existing.svg"), existingImageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Set.of("existing.svg")); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("new.png"), eq(newImageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("existing.svg"), any(byte[].class)); + } + + @Test + void whenImagesDirContainsSubdirectory_thenSubdirectoryIsIgnored() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + Files.createDirectories(imagesDir.resolve("nested")); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + byte[] imageBytes = new byte[]{5, 6, 7}; + Files.write(imagesDir.resolve("logo.png"), imageBytes); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(1, created); + verify(imageService).createOrUpdateSystemImage(eq("logo.png"), eq(imageBytes)); + verify(imageService, never()).createOrUpdateSystemImage(eq("nested"), any(byte[].class)); + } + + @Test + void whenMultipleNewImages_thenCreatesAll() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("a.png"), new byte[]{1}); + Files.write(imagesDir.resolve("b.svg"), new byte[]{2}); + Files.write(imagesDir.resolve("c.jpg"), new byte[]{3}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + Integer created = ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages"); + + assertEquals(3, created); + verify(imageService, times(1)).getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID); + verify(imageService).createOrUpdateSystemImage(eq("a.png"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("b.svg"), any(byte[].class)); + verify(imageService).createOrUpdateSystemImage(eq("c.jpg"), any(byte[].class)); + } + + @Test + void whenImageServiceThrows_thenWrapsAndPropagates() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("broken.png"), new byte[]{1, 2}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + when(imageService.createOrUpdateSystemImage(eq("broken.png"), any(byte[].class))) + .thenThrow(new RuntimeException("DB error")); + + RuntimeException thrown = assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + assertTrue(thrown.getMessage().contains("broken.png")); + } + + @Test + void whenExistingKeysLookupFails_thenDoesNotCreateImage() throws Exception { + Path imagesDir = tempDir.resolve("data").resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + when(installScripts.getDataDir()).thenReturn(tempDir.resolve("data").toString()); + + Files.write(imagesDir.resolve("img.png"), new byte[]{1}); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)) + .thenThrow(new RuntimeException("lookup failed")); + + assertThrows(RuntimeException.class, + () -> ReflectionTestUtils.invokeMethod(reconciler, "createMissingSystemImages")); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + // --- applyPatchIfNeeded integration with createMissingSystemImages --- + + @Test + void whenApplyPatchIfNeededRuns_thenCreatesMissingImagesAfterWidgets() throws Exception { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.1.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + when(jdbcTemplate.queryForObject(contains("pg_try_advisory_lock"), eq(Boolean.class), anyLong())).thenReturn(true); + when(jdbcTemplate.queryForObject(contains("pg_advisory_unlock"), eq(Boolean.class), anyLong())).thenReturn(true); + + Path dataDir = tempDir.resolve("data"); + Path imagesDir = dataDir.resolve(InstallScripts.RESOURCES_DIR).resolve("images"); + Files.createDirectories(imagesDir); + byte[] imgBytes = new byte[]{7, 7, 7}; + Files.write(imagesDir.resolve("new_icon.svg"), imgBytes); + when(installScripts.getDataDir()).thenReturn(dataDir.toString()); + + Path widgetTypesDir = tempDir.resolve("widget_types"); + Files.createDirectories(widgetTypesDir); + when(installScripts.getWidgetTypesDir()).thenReturn(widgetTypesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("widget_bundles_missing")); + + when(imageService.getAllImageKeysByTenantId(TenantId.SYS_TENANT_ID)).thenReturn(Collections.emptySet()); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService).createOrUpdateSystemImage(eq("new_icon.svg"), eq(imgBytes)); + verify(schemaSettingsService).updateSchemaVersion(); + } + + @Test + void whenVersionNotIncreased_thenImagesAreNotTouched() { + when(schemaSettingsService.getPackageSchemaVersion()).thenReturn("4.3.0.0"); + when(schemaSettingsService.getDbSchemaVersion()).thenReturn("4.3.0.0"); + + ReflectionTestUtils.invokeMethod(reconciler, "applyPatchIfNeeded"); + + verify(imageService, never()).getAllImageKeysByTenantId(any()); + verify(imageService, never()).createOrUpdateSystemImage(anyString(), any(byte[].class)); + } + + // --- updateWidgetBundles tests --- + + @Test + void whenWidgetBundlesDirDoesNotExist_thenReturnsZero() { + when(installScripts.getWidgetBundlesDir()).thenReturn(tempDir.resolve("missing_bundles")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleNotInDb_thenSkipWithoutCreation() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(null); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleExistsAndHasNewFqn_thenMergeFqns() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\",\"new_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService).updateWidgetsBundleWidgetFqns( + eq(TenantId.SYS_TENANT_ID), + eq(existingBundle.getId()), + argThat(fqns -> fqns.size() == 3 + && fqns.get(0).equals("line_chart") + && fqns.get(1).equals("bar_chart") + && fqns.get(2).equals("new_chart")) + ); + } + + @Test + void whenBundleExistsAndAllFqnsAlreadyLinked_thenNoLinkUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypeFqns\":[\"line_chart\",\"bar_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart", "bar_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenOnlyBundleImageFormatDiffers_thenNoUpdate() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + // File carries a base64 data URI; DB has the resolved system-image URL — same content, different format. + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10," + + "\"image\":\"data:image/png;base64,iVBORw0KGgo\"}," + + "\"widgetTypeFqns\":[]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + existingBundle.setImage("tb-image;/api/images/system/charts.png"); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of()); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(0, updated); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleMetadataChanged_thenUpdateBundle() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"New Title\",\"description\":\"new\",\"order\":20}," + + "\"widgetTypeFqns\":[\"line_chart\"]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Old Title"); + existingBundle.setDescription("old"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + when(widgetTypeService.findWidgetFqnsByWidgetsBundleId(TenantId.SYS_TENANT_ID, existingBundle.getId())) + .thenReturn(List.of("line_chart")); + + Integer updated = ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles"); + + assertEquals(1, updated); + verify(widgetsBundleService).saveWidgetsBundle(argThat(b -> + "New Title".equals(b.getTitle()) && "new".equals(b.getDescription()) && b.getOrder() == 20 + )); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + } + + @Test + void whenBundleAliasIsBlank_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), + "{\"widgetsBundle\":{\"alias\":\"\",\"title\":\"Broken\"}}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleJsonMissingWidgetsBundleField_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("broken.json"), "{\"foo\":\"bar\"}"); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + @Test + void whenBundleHasInlineWidgetTypes_thenThrowException() throws Exception { + Path bundlesDir = tempDir.resolve("widget_bundles"); + Files.createDirectories(bundlesDir); + when(installScripts.getWidgetBundlesDir()).thenReturn(bundlesDir); + + Files.writeString(bundlesDir.resolve("charts.json"), + "{\"widgetsBundle\":{\"alias\":\"charts\",\"title\":\"Charts\",\"description\":\"d\",\"order\":10}," + + "\"widgetTypes\":[" + + "{\"fqn\":\"inline_chart\",\"name\":\"Inline\",\"descriptor\":{\"type\":\"latest\"}}" + + "]}"); + + WidgetsBundle existingBundle = createTestBundle("charts", "Charts"); + existingBundle.setDescription("d"); + existingBundle.setOrder(10); + when(widgetsBundleService.findWidgetsBundleByTenantIdAndAlias(TenantId.SYS_TENANT_ID, "charts")).thenReturn(existingBundle); + + assertThrows(RuntimeException.class, () -> ReflectionTestUtils.invokeMethod(reconciler, "updateWidgetBundles")); + verify(widgetTypeService, never()).saveWidgetType(any()); + verify(widgetTypeService, never()).updateWidgetsBundleWidgetFqns(any(), any(), any()); + verify(widgetsBundleService, never()).saveWidgetsBundle(any()); + } + + private WidgetsBundle createTestBundle(String alias, String title) { + WidgetsBundle bundle = new WidgetsBundle(); + bundle.setId(new WidgetsBundleId(UUID.randomUUID())); + bundle.setAlias(alias); + bundle.setTitle(title); + bundle.setTenantId(TenantId.SYS_TENANT_ID); + return bundle; + } + } diff --git a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java index 0badcd2656..c91fea282d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/AbstractTransportIntegrationTest.java @@ -28,10 +28,7 @@ import java.util.concurrent.atomic.AtomicInteger; @Slf4j public abstract class AbstractTransportIntegrationTest extends AbstractControllerTest { - protected static final int DEFAULT_WAIT_TIMEOUT_SECONDS = 30; - - protected static final String MQTT_URL = "tcp://localhost:1883"; - protected static final String COAP_BASE_URL = "coap://localhost:5683/api/v1/"; + public static final int DEFAULT_WAIT_TIMEOUT_SECONDS = 30; protected static final AtomicInteger atomicInteger = new AtomicInteger(2); diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java index 7240ab0afa..69d40f5409 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/AbstractCoapIntegrationTest.java @@ -16,7 +16,10 @@ package org.thingsboard.server.transport.coap; import lombok.extern.slf4j.Slf4j; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.thingsboard.server.common.data.CoapDeviceType; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -40,6 +43,7 @@ import org.thingsboard.server.common.data.device.profile.ProtoTransportPayloadCo import org.thingsboard.server.common.data.device.profile.TransportPayloadTypeConfiguration; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.transport.AbstractTransportIntegrationTest; +import org.thingsboard.server.utils.PortFinder; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -52,6 +56,16 @@ import static org.junit.Assert.assertNotNull; @Slf4j public abstract class AbstractCoapIntegrationTest extends AbstractTransportIntegrationTest { + public static final String COAP_HOST = "localhost"; + public static final int COAP_PORT = PortFinder.findAvailableUdpPort(); + public static final String COAP_BASE_URL = "coap://" + COAP_HOST + ":" + COAP_PORT + "/api/v1/"; + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("coap.bind_port = {}", COAP_PORT); + registry.add("coap.bind_port", () -> COAP_PORT); + } + protected final byte[] EMPTY_PAYLOAD = new byte[0]; protected CoapTestClient client; protected static final String PAYLOAD_VALUES_STR = "{\"key1\":\"value1\", \"key2\":true, \"key3\": 3.0, \"key4\": 4," + diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java index ce72d68fc6..df7e9babc9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/CoapTestClient.java @@ -28,9 +28,10 @@ import org.thingsboard.server.common.msg.session.FeatureType; import java.io.IOException; +import static org.thingsboard.server.transport.coap.AbstractCoapIntegrationTest.COAP_BASE_URL; + public class CoapTestClient { - private static final String COAP_BASE_URL = "coap://localhost:5683/api/v1/"; private static final long CLIENT_REQUEST_TIMEOUT = 60000L; private final CoapClient client; diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java index cfb449aa33..04434ff53d 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/security/AbstractCoapSecurityIntegrationTest.java @@ -21,6 +21,8 @@ import lombok.extern.slf4j.Slf4j; import org.eclipse.californium.core.CoapResponse; import org.eclipse.californium.core.coap.CoAP; import org.junit.Assert; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.CoapDeviceType; @@ -37,10 +39,10 @@ import org.thingsboard.server.transport.coap.AbstractCoapIntegrationTest; import org.thingsboard.server.transport.coap.x509.CertPrivateKey; import org.thingsboard.server.transport.coap.x509.CoapClientX509Test; import org.thingsboard.server.transport.coap.CoapTestConfigProperties; +import org.thingsboard.server.utils.PortFinder; import java.io.IOException; import java.io.InputStream; -import java.net.ServerSocket; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; @@ -51,9 +53,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Set; -import java.util.concurrent.TimeUnit; -import static org.awaitility.Awaitility.await; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertTrue; @@ -70,7 +70,17 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. "transport.coap.enabled=true", }) public abstract class AbstractCoapSecurityIntegrationTest extends AbstractCoapIntegrationTest { - private static final String COAPS_BASE_URL = "coaps://localhost:5684/api/v1/"; + + public static final String COAPS_HOST = "localhost"; + public static final int COAPS_PORT = PortFinder.findAvailableUdpPort(); + public static final String COAPS_BASE_URL = "coaps://" + COAPS_HOST + ":" + COAPS_PORT + "/api/v1/"; + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("coap.dtls.bind_port = {}", COAPS_PORT); + registry.add("coap.dtls.bind_port", () -> COAPS_PORT); + } + protected final String CREDENTIALS_PATH = "coap/credentials/"; protected final String CREDENTIALS_PATH_CLIENT = CREDENTIALS_PATH + "client/"; protected final String CREDENTIALS_PATH_CLIENT_CERT_PEM = CREDENTIALS_PATH_CLIENT + "cert.pem"; @@ -160,13 +170,10 @@ public abstract class AbstractCoapSecurityIntegrationTest extends AbstractCoapIn CertPrivateKey certPrivateKey = new CertPrivateKey(CREDENTIALS_PATH_CLIENT_CERT_PEM, CREDENTIALS_PATH_CLIENT_KEY_PEM); CertPrivateKey certPrivateKey_01 = new CertPrivateKey(CREDENTIALS_PATH_CLIENT + "cert_01.pem", CREDENTIALS_PATH_CLIENT + "key_01.pem"); - Integer fixedPort = getFreePort(); + int fixedPort = PortFinder.findAvailableUdpPort(); CoapClientX509Test clientX509 = clientX509UpdateTest(FeatureType.ATTRIBUTES, certPrivateKey, "CoapX509TrustNo_" + FeatureType.TELEMETRY.name(), deviceProfile.getId(), fixedPort); clientX509.disconnect(); - await("Need to make port " + fixedPort + " free") - .atMost(40, TimeUnit.SECONDS) - .until(() -> isPortAvailable(fixedPort)); CoapClientX509Test clientX509_01 = clientX509UpdateTest(FeatureType.ATTRIBUTES, certPrivateKey_01, "CoapX509TrustNo_" + FeatureType.TELEMETRY.name() + "_01", deviceProfile.getId(), fixedPort, PAYLOAD_VALUES_STR_01); @@ -274,19 +281,5 @@ public abstract class AbstractCoapSecurityIntegrationTest extends AbstractCoapIn } } - private static int getFreePort() throws IOException { - try (ServerSocket socket = new ServerSocket(0)) { - return socket.getLocalPort(); - } - } - - private static boolean isPortAvailable(int port) { - try (ServerSocket serverSocket = new ServerSocket(port)) { - serverSocket.setReuseAddress(true); - return true; - } catch (IOException e) { - return false; - } - } } diff --git a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java index 0f65298d86..ebbed25243 100644 --- a/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java +++ b/application/src/test/java/org/thingsboard/server/transport/coap/x509/CoapClientX509Test.java @@ -51,6 +51,7 @@ import java.util.List; import java.util.concurrent.TimeUnit; import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.awaitility.Awaitility.await; import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_AUTO_HANDSHAKE_TIMEOUT; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CIPHER_SUITES; @@ -71,6 +72,8 @@ import static org.eclipse.californium.scandium.config.DtlsConfig.MODULE; import static org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm.SHA256_WITH_ECDSA; import static org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm.SHA256_WITH_RSA; import static org.eclipse.californium.scandium.dtls.SignatureAndHashAlgorithm.SHA384_WITH_ECDSA; +import static org.thingsboard.server.transport.AbstractTransportIntegrationTest.DEFAULT_WAIT_TIMEOUT_SECONDS; +import static org.thingsboard.server.utils.PortFinder.isUDPPortAvailable; @Slf4j public class CoapClientX509Test { @@ -82,6 +85,7 @@ public class CoapClientX509Test { private final Configuration config; private final CertPrivateKey certPrivateKey; private final String coapsBaseUrl; + private final Integer fixedPort; @Getter private CoAP.Type type = CoAP.Type.CON; @@ -90,6 +94,7 @@ public class CoapClientX509Test { this.certPrivateKey = certPrivateKey; this.coapsBaseUrl = coapsBaseUrl; this.config = createConfiguration(); + this.fixedPort = fixedPort; this.dtlsConnector = createDTLSConnector(fixedPort); this.clientX509 = createClient(getFeatureTokenUrl(featureType)); } @@ -97,6 +102,15 @@ public class CoapClientX509Test { if (clientX509 != null) { clientX509.shutdown(); } + if (dtlsConnector != null) { + dtlsConnector.destroy(); + if (fixedPort != null) { + log.debug("Awaiting releasing UDP fixedPort {}", fixedPort); + await("Await client UDP port " + fixedPort + " to disconnect") + .atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .until(() -> isUDPPortAvailable(fixedPort)); + } + } } public CoapResponse postMethod(String requestBody) throws ConnectorException, IOException { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java index 096903f8df..f3a3212e67 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/AbstractLwM2MIntegrationTest.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.gson.JsonArray; import com.google.gson.JsonElement; +import jnr.ffi.annotations.In; import lombok.extern.slf4j.Slf4j; import org.apache.commons.io.IOUtils; import org.awaitility.core.ConditionTimeoutException; @@ -38,6 +39,8 @@ import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.SpyBean; import org.springframework.http.HttpStatus; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ThingsBoardExecutors; @@ -84,9 +87,8 @@ import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClientContext; import org.thingsboard.server.transport.lwm2m.server.client.ResourceUpdateResult; import org.thingsboard.server.transport.lwm2m.server.uplink.DefaultLwM2mUplinkMsgHandler; import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; +import org.thingsboard.server.utils.PortFinder; -import java.io.IOException; -import java.net.ServerSocket; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; @@ -141,21 +143,35 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte private LwM2mClientContext clientContextTest; // Lwm2m Server - public static final int port = 5685; - public static final int securityPort = 5686; - public static final int portBs = 5687; - public static final int securityPortBs = 5688; + public static final String LWM2M_HOST = "localhost"; + public static final int LWM2M_PORT = PortFinder.findAvailableUdpPort(); // 5685 + public static final int LWM2MS_PORT = PortFinder.findAvailableUdpPort(); //5686 + + public static final String LWM2M_BOOTSTRAP_HOST = "localhost"; + public static final int LWM2M_BOOTSTRAP_PORT = PortFinder.findAvailableUdpPort(); // 5687 + public static final int LWM2MS_BOOTSTRAP_PORT = PortFinder.findAvailableUdpPort(); // 5688 + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("transport.lwm2m.server.bind_port = {}", LWM2M_PORT); + registry.add("transport.lwm2m.server.bind_port", () -> LWM2M_PORT); + log.warn("transport.lwm2m.server.security.bind_port = {}", LWM2MS_PORT); + registry.add("transport.lwm2m.server.security.bind_port", () -> LWM2MS_PORT); + + log.warn("transport.lwm2m.bootstrap.bind_port = {}", LWM2M_BOOTSTRAP_PORT); + registry.add("transport.lwm2m.bootstrap.bind_port", () -> LWM2M_BOOTSTRAP_PORT); + log.warn("transport.lwm2m.bootstrap.security.bind_port = {}", LWM2MS_BOOTSTRAP_PORT); + registry.add("transport.lwm2m.bootstrap.security.bind_port", () -> LWM2MS_BOOTSTRAP_PORT); + } - public static final String host = "localhost"; - public static final String hostBs = "localhost"; public static final Integer shortServerId = 123; public static final String COAP = "coap://"; public static final String COAPS = "coaps://"; - public static final String URI = COAP + host + ":" + port; - public static final String SECURE_URI = COAPS + host + ":" + securityPort; - public static final String URI_BS = COAP + hostBs + ":" + portBs; - public static final String SECURE_URI_BS = COAPS + hostBs + ":" + securityPortBs; + public static final String URI = COAP + LWM2M_HOST + ":" + LWM2M_PORT; + public static final String SECURE_URI = COAPS + LWM2M_HOST + ":" + LWM2MS_PORT; + public static final String URI_BS = COAP + LWM2M_BOOTSTRAP_HOST + ":" + LWM2M_BOOTSTRAP_PORT; + public static final String SECURE_URI_BS = COAPS + LWM2M_BOOTSTRAP_HOST + ":" + LWM2MS_BOOTSTRAP_PORT; public static final Security SECURITY_NO_SEC = noSec(URI, shortServerId); protected final String OBSERVE_ATTRIBUTES_WITHOUT_PARAMS = @@ -570,12 +586,11 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte this.clientDestroy(); lwM2MTestClient = new LwM2MTestClient(this.executor, endpoint, resources); - try (ServerSocket socket = new ServerSocket(0)) { - int clientPort = socket.getLocalPort(); - lwM2MTestClient.init(security, securityBs, clientPort, isRpc, - this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, - clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR, value3_0_9); - } + int clientPort = PortFinder.findAvailableUdpPort(); + //automatic client port assignment + lwM2MTestClient.init(security, securityBs, 0 /* clientPort */, isRpc, + this.defaultLwM2mUplinkMsgHandlerTest, this.clientContextTest, + clientDtlsCidLength, queueMode, supportFormatOnly_SenMLJSON_SenMLCBOR, value3_0_9); lwM2MTestClient.setDeviceIdStr(deviceIdStr); } @@ -658,10 +673,9 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte try { if (lwM2MTestClient != null && lwM2MTestClient.getLeshanClient() != null) { boolean serverAlive = false; - for (int port = AbstractLwM2MIntegrationTest.port; port <= securityPortBs; port++) { - try (ServerSocket socket = new ServerSocket(port)) { - log.info("Port {} is free.", port); - } catch (IOException e) { + List ports = List.of(LWM2M_PORT, LWM2MS_PORT, LWM2MS_BOOTSTRAP_PORT, LWM2MS_BOOTSTRAP_PORT); + for (Integer port : ports) { + if (!PortFinder.isUDPPortAvailable(port)) { log.debug("Port {} is busy — CoAP server still active.", port); serverAlive = true; break; @@ -730,8 +744,8 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte bootstrapServerCredential.setServerPublicKey(""); bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); - bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); - bootstrapServerCredential.setPort(isBootstrap ? portBs : port); + bootstrapServerCredential.setHost(isBootstrap ? LWM2M_BOOTSTRAP_HOST : LWM2M_HOST); + bootstrapServerCredential.setPort(isBootstrap ? LWM2M_BOOTSTRAP_PORT : LWM2M_PORT); return bootstrapServerCredential; } @@ -746,11 +760,15 @@ public abstract class AbstractLwM2MIntegrationTest extends AbstractTransportInte return credentials; } - protected void awaitObserveReadAll(int cntObserve, String deviceIdStr) throws Exception { + awaitObserveReadAll(cntObserve, deviceIdStr, ""); + } + + protected void awaitObserveReadAll(int cntObserve, String deviceIdStr, String assertionAlias) throws Exception { try { await("ObserveReadAll: countObserve " + cntObserve) - .atMost(40, TimeUnit.SECONDS) + .alias(assertionAlias) + .atMost(TIMEOUT, TimeUnit.SECONDS) .until(() -> cntObserve == getCntObserveAll(deviceIdStr)); } catch (ConditionTimeoutException e) { int current = getCntObserveAll(deviceIdStr); diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java index e2d57077a3..35e12691c9 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java @@ -33,7 +33,6 @@ import java.util.List; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicInteger; import static org.thingsboard.server.dao.service.OtaPackageServiceTest.TARGET_FW_VERSION; import static org.thingsboard.server.dao.service.OtaPackageServiceTest.TITLE; @@ -137,48 +136,74 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { @Override public void destroy() { scheduler.shutdownNow(); + this.leshanClient = null; } private void startDownloading() { + long delay = 500; + // Step 1: state = 1 scheduler.schedule(() -> { - try { - state.set(1); - fireResourceChange(3); - Thread.sleep(100); - state.set(2); - fireResourceChange(3); - } catch (Exception e) { - } - }, 100, TimeUnit.MILLISECONDS); + state.set(1); // DOWNLOADING + fireResourceChange(3); + log.info("Downloading started: state=[{}]", state.get()); + }, delay, TimeUnit.MILLISECONDS); // 500 ms + + delay += 1000; // next step after 100 ms + + // Step 2: state = 2 + scheduler.schedule(() -> { + state.set(2); // DOWNLOADED + fireResourceChange(3); + log.info("Downloading in progress: state=[{}]", state.get()); + }, delay, TimeUnit.MILLISECONDS); // 1500 ms } + private void startUpdating(LwM2mServer identity) { scheduler.schedule(() -> { try { + // Update state + result state.set(3); fireResourceChange(3); - Thread.sleep(100); + updateResult.set(1); fireResourceChange(5); - this.pkgName = TITLE; - fireResourceChange(6); - this.pkgVersion = TARGET_FW_VERSION; - fireResourceChange(7); + if (this.leshanClient != null) { log.info("Stop/reboot LwM2M client {}", this.leshanClient.getEndpoint(identity)); - this.leshanClient.stop(false); + try { + this.leshanClient.stop(false); + } catch (Exception stopEx) { + // Leshan may throw NPE during CoAP observe-relation cleanup when the server + // reference is null (race condition in NotificationDataStore.toKey()). + // The client is still considered stopped at this point — proceed with restart. + log.warn("Exception during LwM2M client stop, proceeding with restart: {}", stopEx.getMessage()); + } + log.info("Start after update fw LwM2M client {}", this.leshanClient.getEndpoint(identity)); this.leshanClient.start(); - this.pkgName = this.pkgNameDef; - this.pkgVersion = this.pkgVersionDef; + + // Delayed reset pkgName/pkgVersion, after reboot + registration + // If a client stops, ThingsBoard can mark it as Offline. + // A new registration may take a few seconds. + scheduler.schedule(() -> { + this.pkgName = this.pkgNameDef; + fireResourceChange(6); + + this.pkgVersion = this.pkgVersionDef; + fireResourceChange(7); + + log.info("FW resources updating to new values: pkgName=[{}], pkgVersion=[{}]", + this.pkgName, this.pkgVersion); + }, 5, TimeUnit.SECONDS); // 5 sec — safe timing } } catch (Exception e) { + log.error("Error during firmware update", e); } - }, 100, TimeUnit.MILLISECONDS); + }, 1, TimeUnit.SECONDS); // delay 1 sec to allow CoAP Execute response to be delivered before client stops } protected void setLeshanClient(LeshanClient leshanClient) { this.leshanClient = leshanClient; } - } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java index bda2ef17e1..c9c39bc701 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2MTestClient.java @@ -33,7 +33,6 @@ import org.eclipse.leshan.client.object.Security; import org.eclipse.leshan.client.object.Server; import org.eclipse.leshan.client.observer.LwM2mClientObserver; import org.eclipse.leshan.client.resource.DummyInstanceEnabler; -import org.eclipse.leshan.client.resource.LwM2mInstanceEnabler; import org.eclipse.leshan.client.resource.LwM2mObjectEnabler; import org.eclipse.leshan.client.resource.ObjectsInitializer; import org.eclipse.leshan.client.resource.listener.ObjectsListenerAdapter; @@ -82,6 +81,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static org.awaitility.Awaitility.await; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY; import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; @@ -92,6 +92,7 @@ import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; import static org.eclipse.leshan.core.LwM2mId.SOFTWARE_MANAGEMENT; import static org.eclipse.leshan.core.node.codec.DefaultLwM2mEncoder.getDefaultPathEncoder; +import static org.thingsboard.server.transport.AbstractTransportIntegrationTest.DEFAULT_WAIT_TIMEOUT_SECONDS; import static org.thingsboard.server.transport.lwm2m.AbstractLwM2MIntegrationTest.shortServerId; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.LwM2MClientState; @@ -119,6 +120,7 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.OBJECT_INST import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.TEMPERATURE_SENSOR; import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.lwm2mClientResources; import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.setDtlsConnectorConfigCidLength; +import static org.thingsboard.server.utils.PortFinder.isUDPPortAvailable; @Slf4j @@ -139,14 +141,16 @@ public class LwM2MTestClient { private Map clientDtlsCid; private LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandlerTest; private LwM2mClientContext clientContext; - private LwM2mTemperatureSensor lwM2mTemperatureSensor12; + private LwM2mTemperatureSensor lwM2MTemperatureSensor12; private String deviceIdStr; + private int clientPort; - public void init(Security securityLwm2m, Security securityBs, int port, boolean isRpc, + public void init(Security securityLwm2m, Security securityBs, int clientPort, boolean isRpc, LwM2mUplinkMsgHandler defaultLwM2mUplinkMsgHandler, LwM2mClientContext clientContext, Integer cIdLength, boolean queueMode, boolean supportFormatOnly_SenMLJSON_SenMLCBOR, Integer value3_0_9) throws InvalidDDFFileException, IOException { Assert.assertNull("client already initialized", leshanClient); + this.clientPort = clientPort; this.defaultLwM2mUplinkMsgHandlerTest = defaultLwM2mUplinkMsgHandler; this.clientContext = clientContext; @@ -156,7 +160,7 @@ public class LwM2MTestClient { if (securityLwm2m != null && securityLwm2m.getId() != null) { forceNullSecurityId(securityLwm2m); } - if (securityBs!= null && securityBs.getId() != null) { + if (securityBs != null && securityBs.getId() != null) { forceNullSecurityId(securityBs); } if (securityBs != null && securityLwm2m != null) { @@ -165,7 +169,7 @@ public class LwM2MTestClient { } else if (securityBs != null) { log.warn("Security BS only: securityBs: [{}] ", securityBs.getId()); initializer.setInstancesForObject(SECURITY, securityBs); - } else if (securityLwm2m != null){ + } else if (securityLwm2m != null) { // SECURITY log.warn("Security Lwm2m only: security Lwm2m [{}]", securityLwm2m.getId()); initializer.setInstancesForObject(SECURITY, securityLwm2m); @@ -186,8 +190,8 @@ public class LwM2MTestClient { locationParams.getPos(); initializer.setInstancesForObject(LOCATION, new LwM2mLocation(locationParams.getLatitude(), locationParams.getLongitude(), locationParams.getScaleFactor(), executor, OBJECT_INSTANCE_ID_0)); LwM2mTemperatureSensor lwM2mTemperatureSensor0 = new LwM2mTemperatureSensor(executor, OBJECT_INSTANCE_ID_0); - lwM2mTemperatureSensor12 = new LwM2mTemperatureSensor(executor, OBJECT_INSTANCE_ID_12); - initializer.setInstancesForObject(TEMPERATURE_SENSOR, lwM2mTemperatureSensor0, lwM2mTemperatureSensor12); + lwM2MTemperatureSensor12 = new LwM2mTemperatureSensor(executor, OBJECT_INSTANCE_ID_12); + initializer.setInstancesForObject(TEMPERATURE_SENSOR, lwM2mTemperatureSensor0, lwM2MTemperatureSensor12); List enablers = initializer.createAll(); @@ -208,7 +212,9 @@ public class LwM2MTestClient { builder.setSessionListener(new DtlsSessionLogger(clientStates, clientDtlsCid)); return builder; - }; + } + + ; }; } }; @@ -245,7 +251,7 @@ public class LwM2MTestClient { // Set Californium Configuration endpointsBuilder.setConfiguration(clientCoapConfig); - endpointsBuilder.setClientAddress(new InetSocketAddress(port).getAddress()); + endpointsBuilder.setClientAddress(new InetSocketAddress(clientPort).getAddress()); // creates EndpointsProvider @@ -438,12 +444,14 @@ public class LwM2MTestClient { destroySafe(swLwM2MDevice); destroySafe(lwM2MBinaryAppDataContainer); destroySafe(lwM2MTemperatureSensor); + destroySafe(lwM2MTemperatureSensor12); lwM2MDevice = null; fwLwM2MDevice = null; swLwM2MDevice = null; lwM2MBinaryAppDataContainer = null; lwM2MTemperatureSensor = null; + lwM2MTemperatureSensor12 = null; } @@ -462,11 +470,17 @@ public class LwM2MTestClient { public void start(boolean isStartLw) { if (leshanClient != null) { + if (clientPort > 0) { + log.error("Await UDP clientPort {} to be available before leshanClient.start()", clientPort); + await("Await UDP clientPort " + clientPort + " to be available before leshanClient.start()") + .atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .until(() -> isUDPPortAvailable(clientPort)); + } leshanClient.start(); if (isStartLw) { this.awaitClientAfterStartConnectLw(); } - lwM2mTemperatureSensor12.setLeshanClient(leshanClient); + lwM2MTemperatureSensor12.setLeshanClient(leshanClient); fwLwM2MDevice.setLeshanClient(leshanClient); } } @@ -474,6 +488,12 @@ public class LwM2MTestClient { public void stop(boolean deregister) { if (leshanClient != null) { leshanClient.stop(deregister); + if (clientPort > 0) { + log.error("Await UDP clientPort {} to disconnect after leshanClient.stop(deregister)", clientPort); + await("Await client UDP port " + clientPort + " to disconnect after leshanClient.stop(deregister)") + .atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .until(() -> isUDPPortAvailable(clientPort)); + } } } @@ -535,5 +555,6 @@ public class LwM2MTestClient { log.error("[forceNullSecurityId] Failed to set id=null for {}", security.getClass(), e); } } + } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java index 64978af44d..bca551e989 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/LwM2mTemperatureSensor.java @@ -169,6 +169,7 @@ public class LwM2mTemperatureSensor extends BaseInstanceEnabler implements Destr @Override public void destroy() { + this.leshanClient = null; } private void sendCollected() { diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java index 76b9827f5a..6db2cf2e9b 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/SwLwM2MDevice.java @@ -33,6 +33,8 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; +import static org.thingsboard.server.controller.AbstractWebTest.TIMEOUT; + @Slf4j public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { @@ -85,10 +87,7 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { log.info("Write on Device resource /{}/{}/{}", getModel().id, getId(), resourceId); switch (resourceId) { - case 2: - startDownloading(); - return WriteResponse.success(); - case 3: + case 2, 3: startDownloading(); return WriteResponse.success(); default: @@ -123,25 +122,34 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { } private void startDownloading() { + long delay = 0; + + // Step 1: start downloading scheduler.schedule(() -> { - try { - state.set(1); - updateResult.set(1); - fireResourceChange(7); - fireResourceChange(9); - Thread.sleep(100); - state.set(2); - fireResourceChange(7); - Thread.sleep(100); - state.set(3); - fireResourceChange(7); - Thread.sleep(100); - updateResult.set(3); - fireResourceChange(9); - } catch (Exception e) { - - } - }, 100, TimeUnit.MILLISECONDS); + state.set(1); + updateResult.set(1); + fireResourceChange(7); + fireResourceChange(9); + }, delay, TimeUnit.MILLISECONDS); + + delay += 100; + + // Step 2: downloading in progress + scheduler.schedule(() -> { + state.set(2); + fireResourceChange(7); + }, delay, TimeUnit.MILLISECONDS); + + delay += 100; + + // Step 3: downloading finished + scheduler.schedule(() -> { + state.set(3); + fireResourceChange(7); + + updateResult.set(3); + fireResourceChange(9); + }, delay, TimeUnit.MILLISECONDS); } private void startUpdating() { @@ -150,7 +158,13 @@ public class SwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { updateResult.set(2); fireResourceChange(7); fireResourceChange(9); + + // Optional: delayed log about FW update + scheduler.schedule(() -> { + log.info("FW resources updating to new values: state=[{}], updateResult=[{}]", + state.get(), updateResult.get()); + }, 500, TimeUnit.MILLISECONDS); + }, 100, TimeUnit.MILLISECONDS); } - } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java index 1d49092074..7a32a48324 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/AbstractOtaLwM2MIntegrationTest.java @@ -196,12 +196,23 @@ public abstract class AbstractOtaLwM2MIntegrationTest extends AbstractLwM2MInteg } protected boolean predicateForStatuses(List ts) { - List statuses = ts.stream() + if (ts == null || ts.isEmpty()) return false; + + List statuses = ts.stream() .sorted(Comparator.comparingLong(TsKvEntry::getTs)) .map(KvEntry::getValueAsString) - .map(OtaPackageUpdateStatus::valueOf) .collect(Collectors.toList()); - log.warn("{}", statuses); - return statuses.containsAll(expectedStatuses); + + // If we haven't received UPDATED yet + if (!statuses.contains(OtaPackageUpdateStatus.UPDATED.name())) { + return false; + } + + log.warn("Captured statuses: {}", statuses); + + // Перевірка, що всі очікувані статуси хоча б раз промайнули + return expectedStatuses.stream() + .map(Enum::name) + .allMatch(statuses::contains); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java index 9cbd02f613..c041ba1277 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/ota/sql/Ota5LwM2MIntegrationTest.java @@ -103,8 +103,10 @@ public class Ota5LwM2MIntegrationTest extends AbstractOtaLwM2MIntegrationTest { expectedStatuses = Arrays.asList(QUEUED, INITIATED, DOWNLOADING, DOWNLOADED, UPDATING, UPDATED); List ts = await("await on timeseries for FW") - .atMost(TIMEOUT, TimeUnit.SECONDS) + .atMost(60, TimeUnit.SECONDS) // Increase the timeout to 60 sec + .pollInterval(1, TimeUnit.SECONDS) // Poll once per second .until(() -> getFwSwStateTelemetryFromAPI(device.getId().getId(), "fw_state"), this::predicateForStatuses); + log.warn("Object5: Got the ts: {}", ts); } } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java index 432fac6d1a..c61ca0b123 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/rpc/sql/RpcLwm2mIntegrationReadCollectedValueTest.java @@ -21,6 +21,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.extern.slf4j.Slf4j; import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper; import org.thingsboard.server.transport.lwm2m.rpc.AbstractRpcLwM2MIntegrationTest; import java.util.concurrent.atomic.AtomicReference; import static java.util.concurrent.TimeUnit.SECONDS; @@ -37,6 +38,12 @@ import static org.thingsboard.server.transport.lwm2m.Lwm2mTestHelper.RESOURCE_ID @Slf4j public class RpcLwm2mIntegrationReadCollectedValueTest extends AbstractRpcLwM2MIntegrationTest { + @Before + public void resetCollectedValueTimestamps() { + Lwm2mTestHelper.RESOURCE_ID_3303_12_5700_TS_0 = 0; + Lwm2mTestHelper.RESOURCE_ID_3303_12_5700_TS_1 = 0; + } + /** * Read {"id":"/3303/12/5700"} * Trigger a Send operation from the client with multiple values for the same resource as a payload diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java index 20af793052..52bef912f2 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/AbstractSecurityLwM2MIntegrationTest.java @@ -366,8 +366,8 @@ public abstract class AbstractSecurityLwM2MIntegrationTest extends AbstractLwM2M } bootstrapServerCredential.setShortServerId(isBootstrap ? null : shortServerId); bootstrapServerCredential.setBootstrapServerIs(isBootstrap); - bootstrapServerCredential.setHost(isBootstrap ? hostBs : host); - bootstrapServerCredential.setPort(isBootstrap ? securityPortBs : securityPort); + bootstrapServerCredential.setHost(isBootstrap ? LWM2M_BOOTSTRAP_HOST : LWM2M_HOST); + bootstrapServerCredential.setPort(isBootstrap ? LWM2MS_BOOTSTRAP_PORT : LWM2MS_PORT); return bootstrapServerCredential; } diff --git a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java index 899fdc06a0..a80e9c4643 100644 --- a/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java @@ -133,7 +133,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes ON_REGISTRATION_SUCCESS, true); - awaitObserveReadAll(1, lwm2mDevice.getId().getId().toString()); + awaitObserveReadAll(1, lwm2mDevice.getId().getId().toString(), "before client stops for the first time"); lwM2MTestClient.stop(true); DeviceProfile foundDeviceProfile = doGet("/api/deviceProfile/" + lwm2mDevice.getDeviceProfileId().getId().toString(), DeviceProfile.class); @@ -143,7 +143,7 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes Assert.assertNotNull(lwm2mDeviceProfileManyParams); lwM2MTestClient.start(true); - awaitObserveReadAll(1, lwm2mDevice.getId().getId().toString()); + awaitObserveReadAll(1, lwm2mDevice.getId().getId().toString(), "second after client restart"); awaitUpdateReg(3); } @@ -158,6 +158,20 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes clientCredentials.setKey(keyPsk); Lwm2mDeviceProfileTransportConfiguration transportConfiguration = getTransportConfiguration(OBSERVE_ATTRIBUTES_WITHOUT_PARAMS, getBootstrapServerCredentialsSecure(PSK, NONE)); DeviceProfile deviceProfile = createLwm2mDeviceProfile("profileFor" + clientEndpoint, transportConfiguration); + + // Wait for the profile to become available. This synchronizes asynchronous processes and prevents TenantNotFoundException during tearDown. + org.awaitility.Awaitility.await() + .atMost(10, java.util.concurrent.TimeUnit.SECONDS) + .pollInterval(200, java.util.concurrent.TimeUnit.MILLISECONDS) + .until(() -> { + try { + String url = "/api/deviceProfile/" + deviceProfile.getId().getId().toString(); + return doGet(url, DeviceProfile.class) != null; + } catch (Exception e) { + return false; + } + }); + LwM2MDeviceCredentials deviceCredentials = getDeviceCredentialsSecure(clientCredentials, null, null, PSK, false); MvcResult result = createDeviceWithMvcResult(deviceCredentials, clientEndpoint, deviceProfile.getId()); assertEquals(HttpServletResponse.SC_BAD_REQUEST, result.getResponse().getStatus()); @@ -165,7 +179,6 @@ public class PskLwm2mIntegrationTest extends AbstractSecurityLwM2MIntegrationTes assertTrue(result.getResponse().getContentAsString().contains(msgExpected)); } - // Bootstrap + Lwm2m @Test public void testWithPskConnectBsSuccess_UpdateTwoSectionsBootstrapAndLm2m_ConnectLwm2mSuccess() throws Exception { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java index 364b8fbe6a..962d85ec7c 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/AbstractMqttIntegrationTest.java @@ -19,7 +19,10 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import io.netty.handler.codec.mqtt.MqttQoS; import lombok.extern.slf4j.Slf4j; import org.eclipse.paho.client.mqttv3.MqttException; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.util.TestSocketUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; @@ -58,6 +61,16 @@ import static org.junit.Assert.assertNotNull; @Slf4j public abstract class AbstractMqttIntegrationTest extends AbstractTransportIntegrationTest { + public static final String MQTT_HOST = "localhost"; + public static final int MQTT_PORT = TestSocketUtils.findAvailableTcpPort(); + public static final String MQTT_URL = "tcp://" + MQTT_HOST + ":" + MQTT_PORT; + + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); + registry.add("transport.mqtt.bind_port", () -> MQTT_PORT); + } + protected Device savedGateway; protected String gatewayAccessToken; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java index c455ae45d4..541ca40072 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/MqttGatewayRateLimitsTest.java @@ -16,12 +16,15 @@ package org.thingsboard.server.transport.mqtt; import com.fasterxml.jackson.databind.node.ObjectNode; +import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.mockito.Mockito; import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; import org.springframework.test.context.TestPropertySource; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Device; @@ -43,13 +46,20 @@ import static org.junit.Assert.assertNotNull; import static org.mockito.ArgumentMatchers.eq; import static org.thingsboard.server.common.data.limit.LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY; import static org.thingsboard.server.common.data.limit.LimitedApi.TRANSPORT_MESSAGES_PER_GATEWAY_DEVICE; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; @DaoSqlTest @TestPropertySource(properties = { "service.integrations.supported=ALL", "transport.mqtt.enabled=true", }) +@Slf4j public class MqttGatewayRateLimitsTest extends AbstractControllerTest { + @DynamicPropertySource + static void props(DynamicPropertyRegistry registry) { + log.warn("transport.mqtt.bind_port = {}", MQTT_PORT); + registry.add("transport.mqtt.bind_port", () -> MQTT_PORT); + } private static final String GATEWAY_TOPIC = "v1/gateway/telemetry"; private static final String DEVICE_TOPIC = "v1/devices/me/telemetry"; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java index 7e5cc031a5..4c34d9bc81 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/MqttTestClient.java @@ -27,12 +27,16 @@ import org.thingsboard.server.common.data.StringUtils; import java.util.concurrent.TimeUnit; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_HOST; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; + public class MqttTestClient { - private static final String MQTT_URL = "tcp://localhost:1883"; private static final int TIMEOUT = 30; // seconds public static final long TIMEOUT_MS = TimeUnit.SECONDS.toMillis(TIMEOUT); + private String MQTT_URL = "tcp://" + MQTT_HOST + ":" + MQTT_PORT; + private final MqttAsyncClient client; public void setCallback(MqttTestCallback callback) { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java index a7307b2308..5f43aa7e00 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv3/attributes/AbstractMqttAttributesIntegrationTest.java @@ -420,6 +420,7 @@ public abstract class AbstractMqttAttributesIntegrationTest extends AbstractMqtt Awaitility.await() .atMost(10, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { List> attributes = doGetAsyncTyped(attributeValuesUrl, new TypeReference<>() { }); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java index 545fc148e2..558fa1a46a 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/MqttV5TestClient.java @@ -27,12 +27,16 @@ import org.thingsboard.server.common.data.StringUtils; import java.util.concurrent.TimeUnit; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_HOST; +import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; + public class MqttV5TestClient { // We should copy part of MqttV3TestClient, due to different package names in import - private static final String MQTT_URL = "tcp://localhost:1883"; private static final int TIMEOUT = 30; // seconds private static final long TIMEOUT_MS = TimeUnit.SECONDS.toMillis(TIMEOUT); + private String MQTT_URL = "tcp://" + MQTT_HOST + ":" + MQTT_PORT; + private final MqttAsyncClient client; public void setCallback(MqttCallback callback) { diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java index 23aa5133c5..1c9aa40a40 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/mqttv5/rpc/MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest.java @@ -42,6 +42,7 @@ import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.common.data.device.profile.MqttTopics.DEVICE_RPC_REQUESTS_SUB_TOPIC; @@ -97,7 +98,13 @@ public class MqttV5CloseTransportSessionOnRpcDeliveryTimeoutIntegrationTest exte callback.getDisconnectLatch().await(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertThat(callback.getReturnCode()).isEqualTo(MqttReturnCode.RETURN_CODE_ADMINISTRITIVE_ACTION); - Rpc persistedRpc = doGet("/api/rpc/persistent/" + response.get("rpcId").asText(), Rpc.class); + // The server re-queues the RPC asynchronously after closing the session. + // Poll until the status transitions from SENT to QUEUED. + String rpcId = response.get("rpcId").asText(); + Rpc persistedRpc = await("RPC re-queued after session close") + .atMost(DEFAULT_WAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .until(() -> doGet("/api/rpc/persistent/" + rpcId, Rpc.class), + rpc -> RpcStatus.QUEUED.equals(rpc.getStatus())); assertThat(persistedRpc).isNotNull(); assertThat(persistedRpc.getStatus()).isEqualTo(RpcStatus.QUEUED); assertThat(persistedRpc.getResponse()).isInstanceOf(NullNode.class); diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java index 6fb08f9eea..a9bdbb55db 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/AbstractMqttV5ClientSparkplugTest.java @@ -159,14 +159,17 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte protected List connectClientWithCorrectAccessTokenWithNDEATHCreatedDevices(int cntDevices, long ts) throws Exception { List devices = new ArrayList<>(); clientWithCorrectNodeAccessTokenWithNDEATH(); - MetricDataType metricDataType = Int32; - String key = "Node Metric int32"; + String keyInt = "Node Metric int32"; int valueDeviceInt32 = 1024; - SparkplugBProto.Payload.Metric metric = createMetric(valueDeviceInt32, ts, key, metricDataType, -1L); + SparkplugBProto.Payload.Metric metricInt = createMetric(valueDeviceInt32, ts, keyInt, Int32, -1L); + String keyStringEmpty = "Node Metric String Empty"; + String valueDeviceStringEmpty = ""; + SparkplugBProto.Payload.Metric metricStringEmpty = createMetric(valueDeviceStringEmpty, ts, keyStringEmpty, MetricDataType.String, -1L); SparkplugBProto.Payload.Builder payloadBirthNode = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getBdSeqNum()); - payloadBirthNode.addMetrics(metric); + payloadBirthNode.addMetrics(metricInt); + payloadBirthNode.addMetrics(metricStringEmpty); payloadBirthNode.setTimestamp(ts); if (client.isConnected()) { client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.NBIRTH.name() + "/" + edgeNode, @@ -174,20 +177,21 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte } valueDeviceInt32 = 4024; - metric = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); + metricInt = createMetric(valueDeviceInt32, ts, metricBirthName_Int32, metricBirthDataType_Int32, -1L); for (int i = 0; i < cntDevices; i++) { SparkplugBProto.Payload.Builder payloadBirthDevice = SparkplugBProto.Payload.newBuilder() .setTimestamp(ts) .setSeq(getSeqNum()); String deviceName = deviceId + "_" + i; - payloadBirthDevice.addMetrics(metric); + payloadBirthDevice.addMetrics(metricInt); if (client.isConnected()) { client.publish(TOPIC_ROOT_SPB_V_1_0 + "/" + groupId + "/" + SparkplugMessageType.DBIRTH.name() + "/" + edgeNode + "/" + deviceName, payloadBirthDevice.build().toByteArray(), 0, false); AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceName + "] after created") .atMost(200, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); return device.get() != null; @@ -233,6 +237,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceName + "] after created") .atMost(200, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); return device.get() != null; diff --git a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java index 756c8e603c..d42adfbcf0 100644 --- a/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java +++ b/application/src/test/java/org/thingsboard/server/transport/mqtt/sparkplug/attributes/AbstractMqttV5ClientSparkplugAttributesTest.java @@ -468,6 +468,7 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra AtomicReference> actualKeys = new AtomicReference<>(); await(alias + SparkplugMessageType.NBIRTH.name()) .atMost(40, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() { })); @@ -483,6 +484,7 @@ public abstract class AbstractMqttV5ClientSparkplugAttributesTest extends Abstra AtomicReference> actualKeys = new AtomicReference<>(); await(alias + SparkplugMessageType.DBIRTH.name()) .atMost(40, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { actualKeys.set(doGetAsyncTyped(urlTemplate, new TypeReference<>() { })); diff --git a/application/src/test/java/org/thingsboard/server/utils/PortFinder.java b/application/src/test/java/org/thingsboard/server/utils/PortFinder.java new file mode 100644 index 0000000000..21a1f1eb0d --- /dev/null +++ b/application/src/test/java/org/thingsboard/server/utils/PortFinder.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.utils; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.SocketException; + +@Slf4j +public class PortFinder { + public static int findAvailableUdpPort() { + try (DatagramSocket socket = new DatagramSocket(0)) { + return socket.getLocalPort(); + } catch (SocketException e) { + throw new IllegalStateException("No available UDP ports found", e); + } + } + + public static boolean isUDPPortAvailable(int port) { + try (DatagramSocket socket = new DatagramSocket(port)) { + return true; + } catch (IOException e) { + log.debug("Failed to open UDP port {}", port, e); + return false; + } + } +} diff --git a/application/src/test/resources/application-test.properties b/application/src/test/resources/application-test.properties index b8bdcf67e6..7f0ab964d6 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -1,3 +1,4 @@ +spring.main.banner-mode=off js.evaluator=mock transport.lwm2m.server.security.credentials.enabled=true transport.lwm2m.server.security.credentials.type=KEYSTORE @@ -43,6 +44,7 @@ queue.transport_api.response_poll_interval=5 queue.transport.poll_interval=5 queue.core.poll-interval=5 queue.core.partitions=2 +queue.core.housekeeper.task-reprocessing-delay-ms=0 queue.rule-engine.poll-interval=5 queue.rule-engine.stats.enabled=true diff --git a/application/src/test/resources/logback-test.xml b/application/src/test/resources/logback-test.xml index 13c93da411..9409eed7bd 100644 --- a/application/src/test/resources/logback-test.xml +++ b/application/src/test/resources/logback-test.xml @@ -18,9 +18,6 @@ - - - @@ -42,6 +39,12 @@ + + + + + + diff --git a/build.sh b/build.sh index d2a965f281..0342aa9cbf 100755 --- a/build.sh +++ b/build.sh @@ -31,7 +31,7 @@ java -version #echo "Cleaning ui-ngx/node_modules" && rm -rf ui-ngx/node_modules MAVEN_OPTS="-Xmx1024m" NODE_OPTIONS="--max_old_space_size=4096" DOCKER_CLI_EXPERIMENTAL=enabled DOCKER_BUILDKIT=0 \ -mvn -T2 license:format clean install -DskipTests \ +mvn -T6 license:format clean install -DskipTests -Dpkg.skip=true \ $PROJECTS --also-make # \ # -Dpush-docker-amd-arm-images diff --git a/common/actor/pom.xml b/common/actor/pom.xml index bd441e3a58..6326c45685 100644 --- a/common/actor/pom.xml +++ b/common/actor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/pom.xml b/common/cache/pom.xml index 7d22f5d3dc..774015fc33 100644 --- a/common/cache/pom.xml +++ b/common/cache/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java index bf5fb7e448..3e8aef83a1 100644 --- a/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java +++ b/common/cache/src/main/java/org/thingsboard/server/cache/TbJsonRedisSerializer.java @@ -18,6 +18,8 @@ package org.thingsboard.server.cache; import org.springframework.data.redis.serializer.SerializationException; import org.thingsboard.common.util.JacksonUtil; +import java.io.IOException; + public class TbJsonRedisSerializer implements TbRedisSerializer { private final Class clazz; @@ -33,6 +35,13 @@ public class TbJsonRedisSerializer implements TbRedisSerializer { @Override public V deserialize(K key, byte[] bytes) throws SerializationException { - return JacksonUtil.fromBytes(bytes, clazz); + if (bytes == null) { + return null; + } + try { + return JacksonUtil.IGNORE_UNKNOWN_PROPERTIES_JSON_MAPPER.readValue(bytes, clazz); + } catch (IOException e) { + throw new SerializationException("Failed to deserialize cached value", e); + } } } diff --git a/common/cache/src/test/resources/logback-test.xml b/common/cache/src/test/resources/logback-test.xml index 29dddea8df..9b4c5b62df 100644 --- a/common/cache/src/test/resources/logback-test.xml +++ b/common/cache/src/test/resources/logback-test.xml @@ -9,6 +9,9 @@ + + + diff --git a/common/cluster-api/pom.xml b/common/cluster-api/pom.xml index a7240c8b42..5983b96b95 100644 --- a/common/cluster-api/pom.xml +++ b/common/cluster-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/pom.xml b/common/coap-server/pom.xml index 3fba2211d5..32824826c3 100644 --- a/common/coap-server/pom.xml +++ b/common/coap-server/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java index 271866d23c..3b7248ee72 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/DefaultCoapServerService.java @@ -25,10 +25,12 @@ import org.eclipse.californium.core.server.resources.Resource; import org.eclipse.californium.elements.config.Configuration; import org.eclipse.californium.scandium.DTLSConnector; import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; import org.thingsboard.common.util.ThingsBoardExecutors; +import java.io.IOException; import java.net.InetAddress; import java.net.InetSocketAddress; import java.net.UnknownHostException; @@ -42,22 +44,41 @@ import static org.eclipse.californium.core.config.CoapConfig.DEFAULT_BLOCKWISE_S @Slf4j @Component @TbCoapServerComponent -public class DefaultCoapServerService implements CoapServerService { +public class DefaultCoapServerService implements CoapServerService, SmartInitializingSingleton { @Autowired private CoapServerContext coapServerContext; private CoapServer server; - private TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; + private volatile TbCoapDtlsCertificateVerifier tbDtlsCertificateVerifier; private ScheduledExecutorService dtlsSessionsExecutor; + private volatile DTLSConnector dtlsConnector; + + private volatile CoapEndpoint dtlsCoapEndpoint; + @PostConstruct public void init() throws UnknownHostException { createCoapServer(); } + @Override + public void afterSingletonsInstantiated() { + if (isDtlsEnabled()) { + coapServerContext.getDtlsSettings().registerReloadCallback(() -> { + try { + log.info("CoAP DTLS certificates reloaded. Recreating DTLS endpoint..."); + recreateDtlsEndpoint(); + log.info("CoAP DTLS endpoint recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate CoAP DTLS endpoint after certificate reload", e); + } + }); + } + } + @PreDestroy public void shutdown() { if (dtlsSessionsExecutor != null) { @@ -83,16 +104,7 @@ public class DefaultCoapServerService implements CoapServerService { } private CoapServer createCoapServer() throws UnknownHostException { - Configuration networkConfig = new Configuration(); - networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); - networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); - networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); - networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); - networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); - networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); - networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); - networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + Configuration networkConfig = createNetworkConfiguration(); server = new CoapServer(networkConfig); CoapEndpoint.Builder noSecCoapEndpointBuilder = new CoapEndpoint.Builder(); @@ -104,16 +116,7 @@ public class DefaultCoapServerService implements CoapServerService { CoapEndpoint noSecCoapEndpoint = noSecCoapEndpointBuilder.build(); server.addEndpoint(noSecCoapEndpoint); if (isDtlsEnabled()) { - CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); - TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); - DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); - networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); - dtlsCoapEndpointBuilder.setConfiguration(networkConfig); - DTLSConnector connector = new DTLSConnector(dtlsConnectorConfig); - dtlsCoapEndpointBuilder.setConnector(connector); - CoapEndpoint dtlsCoapEndpoint = dtlsCoapEndpointBuilder.build(); - server.addEndpoint(dtlsCoapEndpoint); - tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + createDtlsEndpoint(networkConfig); dtlsSessionsExecutor = ThingsBoardExecutors.newSingleThreadScheduledExecutor(getClass().getSimpleName()); dtlsSessionsExecutor.scheduleAtFixedRate(this::evictTimeoutSessions, new Random().nextInt((int) getDtlsSessionReportTimeout()), getDtlsSessionReportTimeout(), TimeUnit.MILLISECONDS); } @@ -137,4 +140,106 @@ public class DefaultCoapServerService implements CoapServerService { return tbDtlsCertificateVerifier.getDtlsSessionReportTimeout(); } + private Configuration createNetworkConfiguration() { + Configuration networkConfig = new Configuration(); + networkConfig.set(CoapConfig.BLOCKWISE_STRICT_BLOCK2_OPTION, true); + networkConfig.set(CoapConfig.BLOCKWISE_ENTITY_TOO_LARGE_AUTO_FAILOVER, true); + networkConfig.set(CoapConfig.BLOCKWISE_STATUS_LIFETIME, DEFAULT_BLOCKWISE_STATUS_LIFETIME_IN_SECONDS, TimeUnit.SECONDS); + networkConfig.set(CoapConfig.MAX_RESOURCE_BODY_SIZE, 256 * 1024 * 1024); + networkConfig.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + networkConfig.set(CoapConfig.PREFERRED_BLOCK_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_MESSAGE_SIZE, 1024); + networkConfig.set(CoapConfig.MAX_RETRANSMIT, 4); + networkConfig.set(CoapConfig.COAP_PORT, coapServerContext.getPort()); + return networkConfig; + } + + // Note: this method has a side effect — it sets COAP_SECURE_PORT on the provided networkConfig. + private DtlsConnectorConfig buildDtlsConnectorConfig(Configuration networkConfig) throws UnknownHostException { + TbCoapDtlsSettings dtlsSettings = coapServerContext.getDtlsSettings(); + DtlsConnectorConfig dtlsConnectorConfig = dtlsSettings.dtlsConnectorConfig(networkConfig); + networkConfig.set(CoapConfig.COAP_SECURE_PORT, dtlsConnectorConfig.getAddress().getPort()); + return dtlsConnectorConfig; + } + + private CoapEndpoint buildDtlsEndpoint(Configuration networkConfig, DTLSConnector connector) { + CoapEndpoint.Builder dtlsCoapEndpointBuilder = new CoapEndpoint.Builder(); + dtlsCoapEndpointBuilder.setConfiguration(networkConfig); + dtlsCoapEndpointBuilder.setConnector(connector); + return dtlsCoapEndpointBuilder.build(); + } + + private void createDtlsEndpoint(Configuration networkConfig) throws UnknownHostException { + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + server.addEndpoint(newEndpoint); + + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + } + + private DTLSConnector createDtlsConnector(DtlsConnectorConfig config) { + return new DTLSConnector(config); + } + + private synchronized void recreateDtlsEndpoint() throws IOException { + CoapEndpoint oldDtlsEndpoint = dtlsCoapEndpoint; + DTLSConnector oldDtlsConnector = dtlsConnector; + + Configuration networkConfig = createNetworkConfiguration(); + + log.info("Creating new DTLS endpoint with updated certificates..."); + + DtlsConnectorConfig dtlsConnectorConfig = buildDtlsConnectorConfig(networkConfig); + DTLSConnector newConnector = createDtlsConnector(dtlsConnectorConfig); + CoapEndpoint newEndpoint = buildDtlsEndpoint(networkConfig, newConnector); + + // We must stop the old endpoint before starting the new one so they don't compete for the same DTLS port. + // This creates a brief window where the port is unbound; + // if the new endpoint fails to start, we attempt to restore the old one (see rollback below). + if (oldDtlsEndpoint != null) { + log.info("Stopping old DTLS endpoint to release the port..."); + server.getEndpoints().remove(oldDtlsEndpoint); + oldDtlsEndpoint.stop(); + } + + server.addEndpoint(newEndpoint); + try { + newEndpoint.start(); + } catch (IOException e) { + log.error("Failed to start new DTLS endpoint, restoring old endpoint", e); + server.getEndpoints().remove(newEndpoint); + newEndpoint.destroy(); + newConnector.destroy(); + // Attempt to restore the old endpoint + if (oldDtlsEndpoint != null) { + try { + server.addEndpoint(oldDtlsEndpoint); + oldDtlsEndpoint.start(); + log.info("Old DTLS endpoint restored successfully."); + } catch (IOException restoreEx) { + log.error("Failed to restore old DTLS endpoint", restoreEx); + } + } + throw e; + } + log.info("New DTLS endpoint started successfully."); + + // Only swap instance fields after a successful start + dtlsConnector = newConnector; + dtlsCoapEndpoint = newEndpoint; + tbDtlsCertificateVerifier = (TbCoapDtlsCertificateVerifier) dtlsConnectorConfig.getAdvancedCertificateVerifier(); + + // Destroy old resources after a successful swap + if (oldDtlsEndpoint != null) { + if (oldDtlsConnector != null) { + oldDtlsConnector.destroy(); + } + oldDtlsEndpoint.destroy(); + log.info("Old DTLS endpoint destroyed."); + } + } + } diff --git a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java index af9dc2829f..7f63953fd3 100644 --- a/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java +++ b/common/coap-server/src/main/java/org/thingsboard/server/coapserver/TbCoapDtlsSettings.java @@ -100,6 +100,10 @@ public class TbCoapDtlsSettings { @Autowired(required = false) private TbServiceInfoProvider serviceInfoProvider; + public void registerReloadCallback(Runnable callback) { + coapDtlsCredentialsConfig.registerReloadCallback(callback); + } + public DtlsConnectorConfig dtlsConnectorConfig(Configuration configuration) throws UnknownHostException { DtlsConnectorConfig.Builder configBuilder = new DtlsConnectorConfig.Builder(configuration); configBuilder.setAddress(getInetSocketAddress()); @@ -154,5 +158,5 @@ public class TbCoapDtlsSettings { } return null; } -} +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..11e278b4f5 --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadIntegrationTest.java @@ -0,0 +1,349 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.eclipse.californium.core.CoapClient; +import org.eclipse.californium.core.CoapResource; +import org.eclipse.californium.core.CoapResponse; +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.coap.CoAP; +import org.eclipse.californium.core.config.CoapConfig; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.server.resources.CoapExchange; +import org.eclipse.californium.elements.config.Configuration; +import org.eclipse.californium.elements.util.SslContextUtil; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConfig; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.eclipse.californium.scandium.dtls.CertificateType; +import org.eclipse.californium.scandium.dtls.x509.SingleCertificateProvider; +import org.eclipse.californium.scandium.dtls.x509.StaticNewAdvancedCertificateVerifier; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsType; + +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.X509Certificate; +import java.util.Collections; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CLIENT_AUTHENTICATION_MODE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT; +import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_ROLE; +import static org.eclipse.californium.scandium.config.DtlsConfig.DtlsRole.SERVER_ONLY; + +public class CoapDtlsCertificateReloadIntegrationTest { + + private static final String TEST_RESOURCE_PATH = "test"; + private static final String TEST_PAYLOAD = "hello-dtls"; + + @TempDir + Path tempDir; + + private CoapServer coapServer; + + @AfterEach + public void teardown() { + if (coapServer != null) { + coapServer.destroy(); + } + } + + @Test + public void givenDtlsServer_whenCertFileChangedAndReloadTriggered_thenNewEndpointServesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + assertThat(responseA.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseA.getResponseText()).isEqualTo(TEST_PAYLOAD); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + assertThat(responseB.getResponseText()).isEqualTo(TEST_PAYLOAD); + } + + @Test + public void givenDtlsServer_whenCertReloaded_thenOldCertClientFails() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=ServerA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=ServerB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpointA = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointA); + coapServer.start(); + + CoapResponse responseA = doDtlsRequest(dtlsPort, certA); + assertThat(responseA).isNotNull(); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpointA); + endpointA.stop(); + CoapEndpoint endpointB = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpointB); + endpointB.start(); + endpointA.destroy(); + + CoapResponse failedResponse = doDtlsRequest(dtlsPort, certA); + assertThat(failedResponse).isNull(); + + CoapResponse responseB = doDtlsRequest(dtlsPort, certB); + assertThat(responseB).isNotNull(); + assertThat(responseB.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + @Test + public void givenDtlsServer_whenReloadWithSameCert_thenConnectionStillWorks() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=Server"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + + Configuration config = createServerConfig(); + coapServer = new CoapServer(config); + coapServer.add(new TestResource()); + + int dtlsPort = findAvailablePort(); + CoapEndpoint endpoint1 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint1); + coapServer.start(); + + CoapResponse response1 = doDtlsRequest(dtlsPort, cert); + assertThat(response1).isNotNull(); + assertThat(response1.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + + credentialsConfig.onCertificateFileChanged(); + + coapServer.getEndpoints().remove(endpoint1); + endpoint1.stop(); + CoapEndpoint endpoint2 = buildDtlsEndpointFromCredentials(config, credentialsConfig.getCredentials(), dtlsPort); + coapServer.addEndpoint(endpoint2); + endpoint2.start(); + endpoint1.destroy(); + + CoapResponse response2 = doDtlsRequest(dtlsPort, cert); + assertThat(response2).isNotNull(); + assertThat(response2.getCode()).isEqualTo(CoAP.ResponseCode.CONTENT); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("CoAP DTLS Test", false); + config.setEnabled(true); + config.setType(SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new KeystoreSslCredentials()); + config.init(); + return config; + } + + private CoapEndpoint buildDtlsEndpointFromCredentials(Configuration config, SslCredentials credentials, int port) { + DtlsConnectorConfig.Builder dtlsBuilder = new DtlsConnectorConfig.Builder(config); + dtlsBuilder.setAddress(new InetSocketAddress(InetAddress.getLoopbackAddress(), port)); + dtlsBuilder.set(DTLS_ROLE, SERVER_ONLY); + dtlsBuilder.set(DTLS_RETRANSMISSION_TIMEOUT, 3000, MILLISECONDS); + dtlsBuilder.set(DTLS_CLIENT_AUTHENTICATION_MODE, + org.eclipse.californium.elements.config.CertificateAuthenticationMode.WANTED); + + SslContextUtil.Credentials serverCreds = new SslContextUtil.Credentials( + credentials.getPrivateKey(), null, credentials.getCertificateChain()); + + dtlsBuilder.setCertificateIdentityProvider( + new SingleCertificateProvider(serverCreds.getPrivateKey(), serverCreds.getCertificateChain(), + Collections.singletonList(CertificateType.X_509))); + + dtlsBuilder.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustAllCertificates() + .build()); + + DTLSConnector connector = new DTLSConnector(dtlsBuilder.build()); + + CoapEndpoint.Builder endpointBuilder = new CoapEndpoint.Builder(); + endpointBuilder.setConfiguration(config); + endpointBuilder.setConnector(connector); + return endpointBuilder.build(); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("EC"); + kpg.initialize(256); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withECDSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private Configuration createServerConfig() { + Configuration config = new Configuration(); + config.set(CoapConfig.MAX_RETRANSMIT, 2); + config.set(CoapConfig.RESPONSE_MATCHING, CoapConfig.MatcherMode.RELAXED); + return config; + } + + private CoapResponse doDtlsRequest(int port, X509Certificate trustedCert) { + try { + Configuration clientConfig = new Configuration(); + clientConfig.set(CoapConfig.MAX_RETRANSMIT, 1); + clientConfig.set(DtlsConfig.DTLS_ROLE, DtlsConfig.DtlsRole.CLIENT_ONLY); + clientConfig.set(DtlsConfig.DTLS_RETRANSMISSION_TIMEOUT, 2000, MILLISECONDS); + clientConfig.set(DtlsConfig.DTLS_USE_HELLO_VERIFY_REQUEST, false); + clientConfig.set(DtlsConfig.DTLS_VERIFY_SERVER_CERTIFICATES_SUBJECT, false); + + DtlsConnectorConfig.Builder clientDtls = new DtlsConnectorConfig.Builder(clientConfig); + clientDtls.setAdvancedCertificateVerifier( + StaticNewAdvancedCertificateVerifier.builder() + .setTrustedCertificates(trustedCert) + .build()); + + DTLSConnector clientConnector = new DTLSConnector(clientDtls.build()); + CoapEndpoint clientEndpoint = new CoapEndpoint.Builder() + .setConfiguration(clientConfig) + .setConnector(clientConnector) + .build(); + + CoapClient client = new CoapClient("coaps://127.0.0.1:" + port + "/" + TEST_RESOURCE_PATH); + client.setEndpoint(clientEndpoint); + client.setTimeout((long) 5000); + + try { + clientEndpoint.start(); + return client.get(); + } finally { + client.shutdown(); + clientEndpoint.destroy(); + } + } catch (Exception e) { + return null; + } + } + + private int findAvailablePort() throws Exception { + try (java.net.DatagramSocket socket = new java.net.DatagramSocket(0)) { + return socket.getLocalPort(); + } + } + + private static class TestResource extends CoapResource { + TestResource() { + super(TEST_RESOURCE_PATH); + } + + @Override + public void handleGET(CoapExchange exchange) { + exchange.respond(CoAP.ResponseCode.CONTENT, TEST_PAYLOAD); + } + + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java new file mode 100644 index 0000000000..f22aaf5c7e --- /dev/null +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/CoapDtlsCertificateReloadTest.java @@ -0,0 +1,246 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.coapserver; + +import org.eclipse.californium.core.CoapServer; +import org.eclipse.californium.core.network.CoapEndpoint; +import org.eclipse.californium.core.network.Endpoint; +import org.eclipse.californium.scandium.DTLSConnector; +import org.eclipse.californium.scandium.config.DtlsConnectorConfig; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedConstruction; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockConstruction; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class CoapDtlsCertificateReloadTest { + + @Mock + private CoapServerContext mockCoapServerContext; + + @Mock + private TbCoapDtlsSettings mockDtlsSettings; + + @Mock + private CoapServer mockCoapServer; + + @Mock + private CoapEndpoint mockDtlsEndpoint; + + @Mock + private DTLSConnector mockDtlsConnector; + + private DefaultCoapServerService coapServerService; + + @BeforeEach + public void setup() { + coapServerService = new DefaultCoapServerService(); + ReflectionTestUtils.setField(coapServerService, "coapServerContext", mockCoapServerContext); + + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + doAnswer(invocation -> { + invocation.getArgument(0); + return null; + }).when(mockDtlsSettings).registerReloadCallback(any()); + } + + @Test + public void givenDtlsEnabled_whenRegisterCertificateReloadCallback_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenDtlsNotEnabled_whenRegisterCertificateReloadCallback_thenShouldNotRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(null); + + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings, never()).registerReloadCallback(any()); + } + + @Test + public void givenReloadCallbackInvoked_whenNewEndpointCreationFails_thenOldEndpointIsPreserved() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + // dtlsSettings.dtlsConnectorConfig() isn't mocked, so the callback will throw. + // The old endpoint should not be stopped/destroyed when creation of the new one fails. + reloadCallback.run(); + + verify(mockDtlsEndpoint, never()).stop(); + verify(mockDtlsConnector, never()).destroy(); + } + + @Test + public void givenDtlsEnabled_whenInit_thenShouldRegisterCallback() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + when(mockCoapServerContext.getHost()).thenReturn("localhost"); + when(mockCoapServerContext.getPort()).thenReturn(5683); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + + verify(mockDtlsSettings).registerReloadCallback(any(Runnable.class)); + } + + @Test + public void givenReloadCallback_whenInvokedMultipleTimes_thenShouldRegisterOnce() { + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + ReflectionTestUtils.invokeMethod(coapServerService, "afterSingletonsInstantiated"); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + assertThat(reloadCallback).isNotNull(); + } + + @Test + public void givenReloadCallback_whenSuccessful_thenOldEndpointRemovedFromServer() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + TbCoapDtlsCertificateVerifier mockNewVerifier = mock(TbCoapDtlsCertificateVerifier.class); + when(mockDtlsConfig.getAdvancedCertificateVerifier()).thenReturn(mockNewVerifier); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + ReflectionTestUtils.invokeMethod(coapServerService, "recreateDtlsEndpoint"); + + // THEN + assertThat(endpointsList).doesNotContain(mockDtlsEndpoint); + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).destroy(); + verify(mockDtlsConnector).destroy(); + verify(mockCoapServer).addEndpoint(mockNewEndpoint); + verify(mockNewEndpoint).start(); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockNewEndpoint); + } + } + + @Test + public void givenReloadCallback_whenStartFails_thenNewResourcesCleanedAndOldRestored() throws Exception { + // GIVEN + when(mockCoapServerContext.getDtlsSettings()).thenReturn(mockDtlsSettings); + + DtlsConnectorConfig mockDtlsConfig = mock(DtlsConnectorConfig.class); + when(mockDtlsConfig.getAddress()).thenReturn(new InetSocketAddress("localhost", 5684)); + when(mockDtlsSettings.dtlsConnectorConfig(any())).thenReturn(mockDtlsConfig); + + ReflectionTestUtils.setField(coapServerService, "server", mockCoapServer); + ReflectionTestUtils.setField(coapServerService, "dtlsCoapEndpoint", mockDtlsEndpoint); + ReflectionTestUtils.setField(coapServerService, "dtlsConnector", mockDtlsConnector); + + List endpointsList = new CopyOnWriteArrayList<>(); + endpointsList.add(mockDtlsEndpoint); + when(mockCoapServer.getEndpoints()).thenReturn(endpointsList); + + CoapEndpoint mockNewEndpoint = mock(CoapEndpoint.class); + doThrow(new IOException("start failed")).when(mockNewEndpoint).start(); + + try (MockedConstruction dtlsMock = mockConstruction(DTLSConnector.class); + MockedConstruction builderMock = mockConstruction(CoapEndpoint.Builder.class, + (builder, context) -> { + when(builder.build()).thenReturn(mockNewEndpoint); + when(builder.setConfiguration(any())).thenReturn(builder); + when(builder.setConnector(any(DTLSConnector.class))).thenReturn(builder); + })) { + + // WHEN + coapServerService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockDtlsSettings).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + reloadCallback.run(); + + // THEN - new resources cleaned up + DTLSConnector constructedConnector = dtlsMock.constructed().get(0); + verify(mockNewEndpoint).destroy(); + verify(constructedConnector).destroy(); + assertThat(endpointsList).doesNotContain(mockNewEndpoint); + // Old endpoint was stopped to release port, then restored after new one failed + verify(mockDtlsEndpoint).stop(); + verify(mockDtlsEndpoint).start(); + // Old fields preserved + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsCoapEndpoint")).isSameAs(mockDtlsEndpoint); + assertThat(ReflectionTestUtils.getField(coapServerService, "dtlsConnector")).isSameAs(mockDtlsConnector); + } + } + +} diff --git a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java index c75348d97e..9538670e25 100644 --- a/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java +++ b/common/coap-server/src/test/java/org/thingsboard/server/coapserver/TbCoapDtlsSettingsTest.java @@ -18,8 +18,8 @@ package org.thingsboard.server.coapserver; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import org.thingsboard.server.queue.discovery.TbServiceInfoProvider; @@ -41,11 +41,11 @@ class TbCoapDtlsSettingsTest { @Autowired TbCoapDtlsSettings coapDtlsSettings; - @MockBean + @MockitoBean SslCredentialsConfig sslCredentialsConfig; - @MockBean + @MockitoBean private TransportService transportService; - @MockBean + @MockitoBean private TbServiceInfoProvider serviceInfoProvider; @Test diff --git a/common/dao-api/pom.xml b/common/dao-api/pom.xml index bcab0249ef..12533cd813 100644 --- a/common/dao-api/pom.xml +++ b/common/dao-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java index 3166c64835..c5e359e2d5 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/attributes/AttributesService.java @@ -27,9 +27,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; -/** - * @author Andrew Shvayka - */ public interface AttributesService { ListenableFuture> find(TenantId tenantId, EntityId entityId, AttributeScope scope, String attributeKey); @@ -48,7 +45,13 @@ public interface AttributesService { List findAllKeysByEntityIds(TenantId tenantId, List entityIds); - List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope); + List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + + List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); int removeAllByEntityId(TenantId tenantId, EntityId entityId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java index df5833576a..07ea2243dc 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/audit/AuditLogService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.audit; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.validation.constraints.NotNull; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; @@ -43,7 +44,7 @@ public interface AuditLogService { CustomerId customerId, UserId userId, String userName, - I entityId, + @NotNull I entityId, E entity, ActionType actionType, Exception e, Object... additionalInfo); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java index 3a67c31b57..fdd722a266 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/entity/EntityService.java @@ -16,6 +16,7 @@ package org.thingsboard.server.dao.entity; import com.google.common.util.concurrent.FluentFuture; +import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.id.CustomerId; import org.thingsboard.server.common.data.id.EntityId; @@ -51,4 +52,6 @@ public interface EntityService { PageData findEntityDataByQuery(TenantId tenantId, CustomerId customerId, EntityDataQuery query); + ListenableFuture> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java new file mode 100644 index 0000000000..f20a8ebde0 --- /dev/null +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/ResultSetSizeLimitExceededException.java @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import lombok.Getter; + +@Getter +public class ResultSetSizeLimitExceededException extends IllegalArgumentException { + + private final long limitBytes; + private final long actualBytes; + + public ResultSetSizeLimitExceededException(long limitBytes, long actualBytes) { + super("Result set size exceeds the maximum allowed limit. Please narrow your query"); + this.limitBytes = limitBytes; + this.actualBytes = actualBytes; + } + +} diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java index 9dd5c6f4b1..a7a4aa5fa8 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/nosql/TbResultSet.java @@ -34,6 +34,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CompletionStage; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.Function; public class TbResultSet implements AsyncResultSet { @@ -89,9 +90,14 @@ public class TbResultSet implements AsyncResultSet { } public ListenableFuture> allRows(Executor executor) { + return allRows(executor, 0); + } + + public ListenableFuture> allRows(Executor executor, long maxResultSetSizeBytes) { List allRows = new ArrayList<>(); SettableFuture> resultFuture = SettableFuture.create(); - this.processRows(originalStatement, delegate, allRows, resultFuture, executor); + AtomicLong accumulatedBytes = new AtomicLong(0); + this.processRows(originalStatement, delegate, allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes); return resultFuture; } @@ -99,7 +105,19 @@ public class TbResultSet implements AsyncResultSet { AsyncResultSet resultSet, List allRows, SettableFuture> resultFuture, - Executor executor) { + Executor executor, + long maxResultSetSizeBytes, + AtomicLong accumulatedBytes) { + if (maxResultSetSizeBytes > 0) { + int pageSizeInBytes = resultSet.getExecutionInfo().getResponseSizeInBytes(); + if (pageSizeInBytes > 0) { + accumulatedBytes.addAndGet(pageSizeInBytes); + } + if (accumulatedBytes.get() > maxResultSetSizeBytes) { + resultFuture.setException(new ResultSetSizeLimitExceededException(maxResultSetSizeBytes, accumulatedBytes.get())); + return; + } + } allRows.addAll(loadRows(resultSet)); if (resultSet.hasMorePages()) { ByteBuffer nextPagingState = resultSet.getExecutionInfo().getPagingState(); @@ -110,7 +128,7 @@ public class TbResultSet implements AsyncResultSet { @Override public void onSuccess(@Nullable TbResultSet result) { processRows(nextStatement, result, - allRows, resultFuture, executor); + allRows, resultFuture, executor, maxResultSetSizeBytes, accumulatedBytes); } @Override diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java index 798549cf85..cdb8c23c41 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/pat/ApiKeyService.java @@ -24,10 +24,14 @@ import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.dao.entity.EntityDaoService; +import java.util.List; + public interface ApiKeyService extends EntityDaoService { ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKey); + ApiKey saveApiKey(TenantId tenantId, ApiKeyInfo apiKeyInfo, String value, boolean doValidate); + void deleteApiKey(TenantId tenantId, ApiKey apiKey, boolean force); void deleteByUserId(TenantId tenantId, UserId userId); @@ -38,4 +42,8 @@ public interface ApiKeyService extends EntityDaoService { PageData findApiKeysByUserId(TenantId tenantId, UserId userId, PageLink pageLink); + List findApiKeysByUserId(TenantId tenantId, UserId userId); + + PageData findApiKeysByTenantId(TenantId tenantId, PageLink pageLink); + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java index f412c5488a..406263aaed 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/resource/ImageService.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import java.util.Collection; +import java.util.Set; public interface ImageService { @@ -38,6 +39,8 @@ public interface ImageService { TbResourceInfo getImageInfoByTenantIdAndKey(TenantId tenantId, String key); + Set getAllImageKeysByTenantId(TenantId tenantId); + TbResourceInfo getPublicImageInfoByKey(String publicResourceKey); PageData getImagesByTenantId(TenantId tenantId, ResourceSubType imageSubType, PageLink pageLink); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java index dc7af6275d..ce243853e8 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsService.java @@ -18,6 +18,8 @@ package org.thingsboard.server.dao.settings; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.entity.EntityDaoService; public interface AdminSettingsService extends EntityDaoService { @@ -28,6 +30,8 @@ public interface AdminSettingsService extends EntityDaoService { AdminSettings findAdminSettingsByTenantIdAndKey(TenantId tenantId, String key); + PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); + AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings); boolean deleteAdminSettingsByTenantIdAndKey(TenantId tenantId, String key); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java index ccac3cc5c1..f8f308c8f4 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/tenant/TenantProfileService.java @@ -46,7 +46,7 @@ public interface TenantProfileService extends EntityDaoService { EntityInfo findDefaultTenantProfileInfo(TenantId tenantId); - boolean setDefaultTenantProfile(TenantId tenantId, TenantProfileId tenantProfileId); + TenantProfile setDefaultTenantProfile(TenantId tenantId, TenantProfileId tenantProfileId); void deleteTenantProfiles(TenantId tenantId); diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java index 5bf2c63176..6d5727474a 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesService.java @@ -30,9 +30,6 @@ import java.util.Collection; import java.util.List; import java.util.Optional; -/** - * @author Andrew Shvayka - */ public interface TimeseriesService { ListenableFuture> findAllByQueries(TenantId tenantId, EntityId entityId, List queries); @@ -65,6 +62,10 @@ public interface TimeseriesService { ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + List findLatestByEntityIds(TenantId tenantId, List entityIds); + + ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds); + void cleanup(long systemTtl); } diff --git a/common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java b/common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java new file mode 100644 index 0000000000..01c1c2c506 --- /dev/null +++ b/common/dao-api/src/test/java/org/thingsboard/server/dao/nosql/TbResultSetTest.java @@ -0,0 +1,140 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.nosql; + +import com.datastax.oss.driver.api.core.cql.AsyncResultSet; +import com.datastax.oss.driver.api.core.cql.ColumnDefinitions; +import com.datastax.oss.driver.api.core.cql.ExecutionInfo; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.cql.Statement; +import com.google.common.util.concurrent.ListenableFuture; +import com.google.common.util.concurrent.MoreExecutors; +import com.google.common.util.concurrent.SettableFuture; +import org.junit.jupiter.api.Test; + +import java.nio.ByteBuffer; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class TbResultSetTest { + + @Test + void allRows_withinLimit_returnsAllRows() throws Exception { + Row row = mock(Row.class); + AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 1000); + Statement statement = mock(Statement.class); + + TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); + ListenableFuture> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); + + List result = future.get(); + assertThat(result).hasSize(1); + assertThat(result.get(0)).isSameAs(row); + } + + @Test + void allRows_exceedsLimitOnFirstPage_failsWithException() { + Row row = mock(Row.class); + AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 6000); + Statement statement = mock(Statement.class); + + TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); + ListenableFuture> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); + } + + @Test + void allRows_exceedsLimitOnSecondPage_failsAfterSecondPage() { + Row row1 = mock(Row.class); + Row row2 = mock(Row.class); + Statement statement = mock(Statement.class); + doReturn(statement).when(statement).setPagingState((ByteBuffer) null); + + AsyncResultSet page2 = createMockResultSet(List.of(row2), false, 3000); + TbResultSet tbResultSetPage2 = new TbResultSet(statement, page2, s -> null); + SettableFuture page2Future = SettableFuture.create(); + page2Future.set(tbResultSetPage2); + TbResultSetFuture tbPage2Future = new TbResultSetFuture(page2Future); + + ExecutionInfo page1ExecInfo = mock(ExecutionInfo.class); + when(page1ExecInfo.getResponseSizeInBytes()).thenReturn(3000); + when(page1ExecInfo.getPagingState()).thenReturn(null); + + AsyncResultSet page1 = createMockResultSet(List.of(row1), true, 3000); + when(page1.getExecutionInfo()).thenReturn(page1ExecInfo); + + Function executeAsync = s -> tbPage2Future; + TbResultSet tbResultSet = new TbResultSet(statement, page1, executeAsync); + ListenableFuture> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 5000); + + assertThatThrownBy(future::get) + .isInstanceOf(ExecutionException.class) + .hasCauseInstanceOf(ResultSetSizeLimitExceededException.class); + } + + @Test + void allRows_unlimitedWithZero_returnsAllRowsRegardlessOfSize() throws Exception { + Row row = mock(Row.class); + AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); + Statement statement = mock(Statement.class); + + TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); + ListenableFuture> future = tbResultSet.allRows(MoreExecutors.directExecutor(), 0); + + List result = future.get(); + assertThat(result).hasSize(1); + } + + @Test + void allRows_noLimitOverload_returnsAllRows() throws Exception { + Row row = mock(Row.class); + AsyncResultSet asyncResultSet = createMockResultSet(List.of(row), false, 999999); + Statement statement = mock(Statement.class); + + TbResultSet tbResultSet = new TbResultSet(statement, asyncResultSet, s -> null); + ListenableFuture> future = tbResultSet.allRows(MoreExecutors.directExecutor()); + + List result = future.get(); + assertThat(result).hasSize(1); + } + + private AsyncResultSet createMockResultSet(List rows, boolean hasMorePages, int responseSizeInBytes) { + AsyncResultSet resultSet = mock(AsyncResultSet.class); + ExecutionInfo executionInfo = mock(ExecutionInfo.class); + ColumnDefinitions columnDefs = mock(ColumnDefinitions.class); + + when(executionInfo.getResponseSizeInBytes()).thenReturn(responseSizeInBytes); + when(executionInfo.getPagingState()).thenReturn(null); + when(resultSet.getExecutionInfo()).thenReturn(executionInfo); + when(resultSet.getColumnDefinitions()).thenReturn(columnDefs); + when(resultSet.currentPage()).thenReturn(rows); + when(resultSet.hasMorePages()).thenReturn(hasMorePages); + when(resultSet.remaining()).thenReturn(rows.size()); + + return resultSet; + } + +} diff --git a/common/data/pom.xml b/common/data/pom.xml index 43f3a5a38e..0647e706eb 100644 --- a/common/data/pom.xml +++ b/common/data/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java index 5a679ce2cf..ceb657374b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ApiUsageRecordKey.java @@ -25,8 +25,8 @@ public enum ApiUsageRecordKey { RE_EXEC_COUNT(ApiFeature.RE, "ruleEngineExecutionCount", "ruleEngineExecutionLimit", "Rule Engine execution"), JS_EXEC_COUNT(ApiFeature.JS, "jsExecutionCount", "jsExecutionLimit", "JavaScript execution"), TBEL_EXEC_COUNT(ApiFeature.TBEL, "tbelExecutionCount", "tbelExecutionLimit", "Tbel execution"), - EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit", "email message"), - SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit", "SMS message"), + EMAIL_EXEC_COUNT(ApiFeature.EMAIL, "emailCount", "emailLimit", "email message", true, true), + SMS_EXEC_COUNT(ApiFeature.SMS, "smsCount", "smsLimit", "SMS message", true, true), CREATED_ALARMS_COUNT(ApiFeature.ALARM, "createdAlarmsCount", "createdAlarmsLimit", "alarm"), ACTIVE_DEVICES("activeDevicesCount"), INACTIVE_DEVICES("inactiveDevicesCount"); @@ -50,21 +50,24 @@ public enum ApiUsageRecordKey { private final String unitLabel; @Getter private final boolean counter; + @Getter + private final boolean urgent; // urgent keys are reported at a shorter interval for quicker usage state updates ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel) { - this(apiFeature, apiCountKey, apiLimitKey, unitLabel, true); + this(apiFeature, apiCountKey, apiLimitKey, unitLabel, true, false); } ApiUsageRecordKey(String apiCountKey) { - this(null, apiCountKey, null, null, false); + this(null, apiCountKey, null, null, false, false); } - ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel, boolean counter) { + ApiUsageRecordKey(ApiFeature apiFeature, String apiCountKey, String apiLimitKey, String unitLabel, boolean counter, boolean urgent) { this.apiFeature = apiFeature; this.apiCountKey = apiCountKey; this.apiLimitKey = apiLimitKey; this.unitLabel = unitLabel; this.counter = counter; + this.urgent = urgent; } public static ApiUsageRecordKey[] getKeys(ApiFeature feature) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java index da72fb67d7..d854fa3eaf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/BaseDataWithAdditionalInfo.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.id.UUIDBased; import org.thingsboard.server.common.data.validation.NoXss; @@ -35,9 +36,11 @@ import java.util.function.Supplier; * Created by ashvayka on 19.02.18. */ @Slf4j +@Schema public abstract class BaseDataWithAdditionalInfo extends BaseData implements HasAdditionalInfo { @NoXss + @Schema private transient JsonNode additionalInfo; @JsonIgnore private byte[] additionalInfoBytes; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java index 61620a77be..956cf054dd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Customer.java @@ -134,13 +134,18 @@ public class Customer extends ContactBased implements HasTenantId, E return super.getPhone(); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Email", example = "example@company.com") + @Schema(description = "Email", example = "example@company.com") @Override public String getEmail() { return super.getEmail(); } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the customer. " + + "May include: 'description' (string), 'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean, whether to hide the dashboard toolbar), " + + "'isPublic' (boolean, whether this is a public customer).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Regional customer\",\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":false,\"isPublic\":false}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java index 168158d427..2c65a3dfc8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Dashboard.java @@ -33,7 +33,7 @@ import java.util.Optional; import java.util.stream.Collectors; @EqualsAndHashCode(callSuper = true) -@JsonPropertyOrder({"title", "image", "mobileHide", "mobileOrder", "configuration", "name", "resources"}) +@JsonPropertyOrder({"id", "createdTime", "tenantId", "title", "name", "image", "mobileHide", "mobileOrder", "assignedCustomers", "configuration", "resources", "version"}) public class Dashboard extends DashboardInfo implements ExportableEntity { private static final long serialVersionUID = 872682138346187503L; @@ -70,7 +70,7 @@ public class Dashboard extends DashboardInfo implements ExportableEntity implements HasL this.label = label; } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Device Profile Id.") + @Schema(description = "JSON object with Device Profile Id. If not provided, the type will be used to determine the profile. If neither deviceProfileId nor type is specified, the default device profile will be used.") public DeviceProfileId getDeviceProfileId() { return deviceProfileId; } @@ -231,7 +231,12 @@ public class Device extends BaseDataWithAdditionalInfo implements HasL this.softwareId = softwareId; } - @Schema(description = "Additional parameters of the device",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the device. " + + "May include: 'gateway' (boolean, whether the device is a gateway), " + + "'description' (string), " + + "'lastConnectedGateway' (string, UUID of the last gateway that connected this device).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"gateway\":false,\"description\":\"Temperature sensor\",\"lastConnectedGateway\":\"784f394c-42b6-435a-983c-b7beff2784f9\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java index 8bf65873a3..e21b8d3cd0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/DeviceProfileInfo.java @@ -22,6 +22,7 @@ import lombok.EqualsAndHashCode; import lombok.ToString; import lombok.Value; import org.thingsboard.server.common.data.id.DashboardId; +import org.thingsboard.server.common.data.id.DeviceProfileId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; import org.thingsboard.server.common.data.id.TenantId; @@ -75,4 +76,10 @@ public class DeviceProfileInfo extends EntityInfo { profile.getType(), profile.getTransportType()); } + @Override + @Schema(implementation = DeviceProfileId.class, description = "JSON object with the Device Profile Id.") + public EntityId getId() { + return super.getId(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java index 9ad0ebf9e0..5da4f2cd91 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/EntityView.java @@ -116,7 +116,10 @@ public class EntityView extends BaseDataWithAdditionalInfo return super.getCreatedTime(); } - @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the entity view. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Temperature readings view\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java index 2a3b95ba0c..92a446fcb6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/FeaturesInfo.java @@ -15,13 +15,29 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@JsonPropertyOrder({ + "emailEnabled", + "smsEnabled", + "notificationEnabled", + "oauthEnabled", + "twoFaEnabled" +}) +@Schema @Data public class FeaturesInfo { + @JsonProperty("emailEnabled") boolean isEmailEnabled; + @JsonProperty("smsEnabled") boolean isSmsEnabled; + @JsonProperty("notificationEnabled") boolean isNotificationEnabled; + @JsonProperty("oauthEnabled") boolean isOauthEnabled; + @JsonProperty("twoFaEnabled") boolean isTwoFaEnabled; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java index 246fba56c5..647b584666 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/OtaPackageInfo.java @@ -43,17 +43,17 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp @Schema(description = "JSON object with Tenant Id. Tenant Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; - @Schema(description = "JSON object with Device Profile Id. Device Profile Id of the ota package can't be changed.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with Device Profile Id. Device Profile Id of the ota package can't be changed.") private DeviceProfileId deviceProfileId; - @Schema(description = "OTA Package type.", example = "FIRMWARE", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package type.", example = "FIRMWARE") private OtaPackageType type; @Length(fieldName = "title") @NoXss - @Schema(description = "OTA Package title.", example = "fw", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package title.", example = "fw") private String title; @Length(fieldName = "version") @NoXss - @Schema(description = "OTA Package version.", example = "1.0", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package version.", example = "1.0") private String version; @Length(fieldName = "tag") @NoXss @@ -61,7 +61,7 @@ public class OtaPackageInfo extends BaseDataWithAdditionalInfo imp private String tag; @Length(fieldName = "url") @NoXss - @Schema(description = "OTA Package url.", example = "http://thingsboard.org/fw/1", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "OTA Package url.", example = "http://thingsboard.org/fw/1") private String url; @Schema(description = "Indicates OTA Package 'has data'. Field is returned from DB ('true' if data exists or url is set). If OTA Package 'has data' is 'false' we can not assign the OTA Package to the Device or Device Profile.", example = "true", accessMode = Schema.AccessMode.READ_ONLY) private boolean hasData; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java index 725aa7921c..13c75feed0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ResourceUtils.java @@ -51,11 +51,9 @@ public class ResourceUtils { return true; } else { try { - URL url = Resources.getResource(path); - if (url != null) { - return true; - } - } catch (IllegalArgumentException e) {} + Resources.getResource(path); + return true; + } catch (IllegalArgumentException ignored) {} } return false; } @@ -93,9 +91,9 @@ public class ResourceUtils { } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } } throw new RuntimeException("Unable to find resource: " + filePath); @@ -113,15 +111,19 @@ public class ResourceUtils { return resourceFile.getAbsolutePath(); } else { URL url = classLoader.getResource(filePath); + if (url == null) { + throw new RuntimeException("Unable to find resource: " + filePath); + } return url.toURI().toString(); } } catch (Exception e) { if (e instanceof NullPointerException) { - log.warn("Unable to find resource: " + filePath); + log.warn("Unable to find resource: {}", filePath); } else { - log.warn("Unable to find resource: " + filePath, e); + log.warn("Unable to find resource: {}", filePath, e); } throw new RuntimeException("Unable to find resource: " + filePath); } } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java index 930e4dddca..f1de65f1f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ShortCustomerInfo.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Getter; @@ -39,9 +40,15 @@ public class ShortCustomerInfo { @NoXss private String title; + @JsonProperty("public") @Schema(description = "Indicates special 'Public' customer used to embed dashboards on public websites.") - @Getter @Setter - private boolean isPublic; + private boolean publicCustomer; + + @JsonProperty("public") + public boolean isPublic() { return publicCustomer; } + + @JsonProperty("public") + public void setPublic(boolean publicCustomer) { this.publicCustomer = publicCustomer; } @Override public boolean equals(Object o) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java index 73bd3cddc4..3fd86d1163 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemInfo.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -23,6 +24,7 @@ import java.util.List; @Data public class SystemInfo { @Schema(description = "Is monolith.") + @JsonProperty("monolith") private boolean isMonolith; @Schema(description = "System data.") private List systemData; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java index 3cd2ada38f..d3b2e8b73a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/SystemParams.java @@ -45,4 +45,5 @@ public class SystemParams { long minAllowedAggregationIntervalInSecForCF; long intermediateAggregationIntervalInSecForCF; TrendzSettings trendzSettings; + boolean allowKeyFiltersOrConditions; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java index 4365357d2d..4343205b74 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResource.java @@ -28,6 +28,7 @@ import java.io.Serial; import java.util.Base64; import java.util.Optional; +@Schema @Slf4j @Data @EqualsAndHashCode(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java index f1cca4e80c..16e95d79ea 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/TbResourceInfo.java @@ -46,15 +46,17 @@ public class TbResourceInfo extends BaseData implements HasName, H @Length(fieldName = "title") @Schema(description = "Resource title.", example = "BinaryAppDataContainer id=19 v1.0") private String title; - @Schema(description = "Resource type.", example = "LWM2M_MODEL", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource type.", example = "LWM2M_MODEL") private ResourceType resourceType; - @Schema(description = "Resource sub type.", example = "IOT_SVG", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource sub type.", example = "IOT_SVG") private ResourceSubType resourceSubType; @NoXss @Length(fieldName = "resourceKey") - @Schema(description = "Resource key.", example = "19_1.0", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource key.", example = "19_1.0") private String resourceKey; + @Schema(description = "Whether the resource is public.", example = "false") private boolean isPublic; + @Schema(description = "Public resource key.") private String publicResourceKey; @Schema(description = "Resource search text.", example = "19_1.0:binaryappdatacontainer", accessMode = Schema.AccessMode.READ_ONLY) private String searchText; @@ -63,10 +65,12 @@ public class TbResourceInfo extends BaseData implements HasName, H private String etag; @NoXss @Length(fieldName = "file name") - @Schema(description = "Resource file name.", example = "19.xml", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Resource file name.", example = "19.xml") private String fileName; + @Schema(description = "Resource descriptor.") private JsonNode descriptor; + @Schema(description = "External resource Id used for import/export.") private TbResourceId externalId; public TbResourceInfo() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java index 005e3204cd..374e6b526a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/Tenant.java @@ -164,7 +164,11 @@ public class Tenant extends ContactBased implements HasTenantId, HasTi return super.getEmail(); } - @Schema(description = "Additional parameters of the device", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the tenant. " + + "May include: 'description' (string), 'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean, whether to hide the dashboard toolbar).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Main tenant\",\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":true}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/User.java b/common/data/src/main/java/org/thingsboard/server/common/data/User.java index 69ba115af1..e1b40b48f8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/User.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/User.java @@ -91,7 +91,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, return super.getCreatedTime(); } - @Schema(description = "JSON object with the Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with the Tenant Id.") public TenantId getTenantId() { return tenantId; } @@ -100,7 +100,7 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.tenantId = tenantId; } - @Schema(description = "JSON object with the Customer Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with the Customer Id.") public CustomerId getCustomerId() { return customerId; } @@ -161,7 +161,16 @@ public class User extends BaseDataWithAdditionalInfo implements HasName, this.phone = phone; } - @Schema(description = "Additional parameters of the user", implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the user. " + + "May include: 'defaultDashboardId' (string, UUID of the default dashboard), " + + "'defaultDashboardFullscreen' (boolean), " + + "'homeDashboardId' (string, UUID of the home dashboard), " + + "'homeDashboardHideToolbar' (boolean), " + + "'lang' (string, user locale, e.g. 'en_US'), " + + "'authProviderName' (string, name of the authentication provider).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"defaultDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"defaultDashboardFullscreen\":false," + + "\"homeDashboardId\":\"784f394c-42b6-435a-983c-b7beff2784f9\",\"homeDashboardHideToolbar\":true,\"lang\":\"en_US\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java index ffca023b23..4d4fcfa58e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiModel.java @@ -46,7 +46,6 @@ public final class AiModel extends BaseData implements HasTenantId, H private static final long serialVersionUID = 9017108678716011604L; @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "JSON object representing the ID of the tenant associated with this AI model", example = "e3c4b7d2-5678-4a9b-0c1d-2e3f4a5b6c7d" @@ -54,7 +53,6 @@ public final class AiModel extends BaseData implements HasTenantId, H private TenantId tenantId; @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "Version of the AI model record; increments automatically whenever the record is changed", example = "7", diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java index a553d66bf0..f1004790d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbChatResponse.java @@ -17,8 +17,17 @@ package org.thingsboard.server.common.data.ai.dto; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + description = "Response from chat API", + discriminatorProperty = "status", + discriminatorMapping = { + @DiscriminatorMapping(value = "SUCCESS", schema = TbChatResponse.Success.class), + @DiscriminatorMapping(value = "FAILURE", schema = TbChatResponse.Failure.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, property = "status", @@ -33,7 +42,8 @@ public sealed interface TbChatResponse permits TbChatResponse.Success, TbChatRes @Schema( description = "Indicates whether the request was successful or not", - example = "SUCCESS" + example = "SUCCESS", + requiredMode = Schema.RequiredMode.REQUIRED ) String getStatus(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java index 55f35a3960..4edf49045b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbContent.java @@ -19,11 +19,18 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import dev.langchain4j.data.message.Content; import dev.langchain4j.data.message.TextContent; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import static org.thingsboard.server.common.data.ai.dto.TbContent.TbTextContent; +@Schema( + discriminatorProperty = "contentType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TEXT", schema = TbTextContent.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, @@ -46,7 +53,8 @@ public sealed interface TbContent permits TbTextContent { } @Schema( - description = "Text-based content part of a user's prompt" + description = "Text-based content part of a user's prompt", + allOf = TbContent.class ) record TbTextContent( @NotBlank diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java index 2d060dbf4a..e1f0b84a4d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/dto/TbUserMessage.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.ai.dto; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; @@ -24,9 +25,12 @@ import java.util.List; public record TbUserMessage( @NotEmpty @Valid - @Schema( - requiredMode = Schema.RequiredMode.REQUIRED, - description = "A list of content parts that make up the complete user prompt" + @ArraySchema( + arraySchema = @Schema( + requiredMode = Schema.RequiredMode.REQUIRED, + description = "A list of content parts that make up the complete user prompt" + ), + schema = @Schema(ref = "#/components/schemas/TbContent") ) List contents ) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java index adcc176edc..d036969254 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/AiModelConfig.java @@ -17,6 +17,8 @@ package org.thingsboard.server.common.data.ai.model; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.ai.model.chat.AmazonBedrockChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AnthropicChatModelConfig; import org.thingsboard.server.common.data.ai.model.chat.AzureOpenAiChatModelConfig; @@ -55,10 +57,31 @@ import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; @JsonSubTypes.Type(value = GitHubModelsChatModelConfig.class, name = "GITHUB_MODELS"), @JsonSubTypes.Type(value = OllamaChatModelConfig.class, name = "OLLAMA") }) +@Schema( + name = "AiModelConfig", + description = "Root configuration for AI models", + discriminatorProperty = "provider", + discriminatorMapping = { + @DiscriminatorMapping(value = "OPENAI", schema = OpenAiChatModelConfig.class), + @DiscriminatorMapping(value = "AZURE_OPENAI", schema = AzureOpenAiChatModelConfig.class), + @DiscriminatorMapping(value = "GOOGLE_AI_GEMINI", schema = GoogleAiGeminiChatModelConfig.class), + @DiscriminatorMapping(value = "GOOGLE_VERTEX_AI_GEMINI", schema = GoogleVertexAiGeminiChatModelConfig.class), + @DiscriminatorMapping(value = "MISTRAL_AI", schema = MistralAiChatModelConfig.class), + @DiscriminatorMapping(value = "ANTHROPIC", schema = AnthropicChatModelConfig.class), + @DiscriminatorMapping(value = "AMAZON_BEDROCK", schema = AmazonBedrockChatModelConfig.class), + @DiscriminatorMapping(value = "GITHUB_MODELS", schema = GitHubModelsChatModelConfig.class), + @DiscriminatorMapping(value = "OLLAMA", schema = OllamaChatModelConfig.class) + } +) public interface AiModelConfig { + @Schema( + description = "AI Provider", + requiredMode = Schema.RequiredMode.REQUIRED + ) AiProvider provider(); + @Schema(hidden = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.EXTERNAL_PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java index ee80230fee..490ae67eb2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AmazonBedrockChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AmazonBedrockProviderConfig; +@Schema @Builder public record AmazonBedrockChatModelConfig( + @Schema(ref = "#/components/schemas/AmazonBedrockProviderConfig") @NotNull @Valid AmazonBedrockProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java index 44b55c4c22..77352b06c3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AnthropicChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AnthropicProviderConfig; +@Schema @Builder public record AnthropicChatModelConfig( + @Schema(ref = "#/components/schemas/AnthropicProviderConfig") @NotNull @Valid AnthropicProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java index 1e0d6d0c18..d4df82d237 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/AzureOpenAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +@Schema @Builder public record AzureOpenAiChatModelConfig( + @Schema(ref = "#/components/schemas/AzureOpenAiProviderConfig") @NotNull @Valid AzureOpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java index 606f30599b..264683f7b4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GitHubModelsChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GitHubModelsProviderConfig; +@Schema @Builder public record GitHubModelsChatModelConfig( + @Schema(ref = "#/components/schemas/GitHubModelsProviderConfig") @NotNull @Valid GitHubModelsProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java index 422c92ba97..82ba3a0295 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleAiGeminiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleAiGeminiProviderConfig; +@Schema @Builder public record GoogleAiGeminiChatModelConfig( + @Schema(ref = "#/components/schemas/GoogleAiGeminiProviderConfig") @NotNull @Valid GoogleAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java index 3e298e52e2..4d20a240e6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/GoogleVertexAiGeminiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.GoogleVertexAiGeminiProviderConfig; +@Schema @Builder public record GoogleVertexAiGeminiChatModelConfig( + @Schema(ref = "#/components/schemas/GoogleVertexAiGeminiProviderConfig") @NotNull @Valid GoogleVertexAiGeminiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java index 5713217fe9..60c208c181 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/MistralAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.MistralAiProviderConfig; +@Schema @Builder public record MistralAiChatModelConfig( + @Schema(ref = "#/components/schemas/MistralAiProviderConfig") @NotNull @Valid MistralAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java index 3f1856f630..5f7776a0a6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OllamaChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +@Schema @Builder public record OllamaChatModelConfig( + @Schema(ref = "#/components/schemas/OllamaProviderConfig") @NotNull @Valid OllamaProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java index f74292cd76..3845aaa543 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/model/chat/OpenAiChatModelConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.ai.model.chat; import dev.langchain4j.model.chat.ChatModel; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.NotBlank; @@ -27,8 +28,10 @@ import lombok.With; import org.thingsboard.server.common.data.ai.provider.AiProvider; import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; +@Schema @Builder public record OpenAiChatModelConfig( + @Schema(ref = "#/components/schemas/OpenAiProviderConfig") @NotNull @Valid OpenAiProviderConfig providerConfig, @NotBlank String modelId, @PositiveOrZero Double temperature, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java index dc38440a66..17e886e946 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AmazonBedrockProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AmazonBedrockProviderConfig( @NotNull String region, @NotNull String accessKeyId, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java index 6e7abeee84..4108a72439 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AnthropicProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AnthropicProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java index 05d9e99569..6687f8a08e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/AzureOpenAiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record AzureOpenAiProviderConfig( @NotNull String endpoint, String serviceVersion, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java index 56409c81ad..529eb2b989 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GitHubModelsProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record GitHubModelsProviderConfig( @NotNull String personalAccessToken ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java index 3492a1096d..5b20ed3d96 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleAiGeminiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record GoogleAiGeminiProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java index 0e0928e038..72c4b5e940 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/GoogleVertexAiGeminiProviderConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +@Schema public record GoogleVertexAiGeminiProviderConfig( @NotBlank String fileName, // not used on BE, but needed for UI @NotNull String projectId, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java index 46165f9843..b0ca42de16 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/MistralAiProviderConfig.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.ai.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; +@Schema public record MistralAiProviderConfig( @NotNull String apiKey ) implements AiProviderConfig {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java index 0a4da6c66c..606cc75b9d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OllamaProviderConfig.java @@ -17,14 +17,26 @@ package org.thingsboard.server.common.data.ai.provider; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; +@Schema public record OllamaProviderConfig( @NotNull String baseUrl, @NotNull @Valid OllamaAuth auth ) implements AiProviderConfig { + @Schema( + description = "Ollama authentication schemes", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "NONE", schema = OllamaAuth.None.class), + @DiscriminatorMapping(value = "BASIC", schema = OllamaAuth.Basic.class), + @DiscriminatorMapping(value = "TOKEN", schema = OllamaAuth.Token.class) + } + ) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java index 59eabc58a6..900eb6734c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/provider/OpenAiProviderConfig.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.ai.provider; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.AssertTrue; import lombok.Builder; import org.apache.commons.lang3.StringUtils; import java.util.Objects; +@Schema @Builder public record OpenAiProviderConfig( String baseUrl, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java index abd1515ff2..9f9bdec49d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/Alarm.java @@ -84,6 +84,7 @@ public class Alarm extends BaseData implements HasName, HasTenantId, Ha @Schema(description = "Timestamp of the alarm assignment, in milliseconds", example = "1634115928465") private long assignTs; @Schema(description = "JSON object with alarm details") + @JsonProperty private transient JsonNode details; @Schema(description = "Propagation flag to specify if alarm should be propagated to parent entities of alarm originator", example = "true") private boolean propagate; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java index 0a011c287f..ef7499fa33 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmComment.java @@ -45,7 +45,7 @@ public class AlarmComment extends BaseData implements HasName { private AlarmId alarmId; @Schema(description = "JSON object with User id.", accessMode = Schema.AccessMode.READ_ONLY) private UserId userId; - @Schema(description = "Defines origination of comment. System type means comment was created by TB. OTHER type means comment was created by user.", example = "SYSTEM/OTHER", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "Defines origination of comment. System type means comment was created by TB. OTHER type means comment was created by user.", example = "SYSTEM/OTHER") private AlarmCommentType type; @Schema(description = "JSON object with text of comment.") @NoXss @@ -56,7 +56,7 @@ public class AlarmComment extends BaseData implements HasName { @Schema(description = "JSON object with the alarm comment Id. " + "Specify this field to update the alarm comment. " + "Referencing non-existing alarm Id will cause error. " + - "Omit this field to create new alarm.", accessMode = Schema.AccessMode.READ_ONLY) + "Omit this field to create new alarm.") @Override public AlarmCommentId getId() { return super.getId(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java index c456323120..65e943c574 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/AlarmCreateOrUpdateActiveRequest.java @@ -39,6 +39,7 @@ public class AlarmCreateOrUpdateActiveRequest implements AlarmModificationReques private TenantId tenantId; @Schema(description = "JSON object with Customer Id", accessMode = Schema.AccessMode.READ_ONLY) private CustomerId customerId; + @NoXss @NotNull @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "representing type of the Alarm", example = "High Temperature Alarm") @Length(fieldName = "type") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java index 93370c0c30..3ab3f0a526 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java @@ -22,8 +22,10 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmCondition; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.DashboardId; +@Schema @Data @AllArgsConstructor @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java index 1d50f3343c..87bd44ce1f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/AlarmCondition.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotNull; @@ -29,6 +31,14 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.AlarmC import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AlarmSchedule; import org.thingsboard.server.common.data.alarm.rule.condition.schedule.AnyTimeSchedule; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmCondition.class), + @DiscriminatorMapping(value = "DURATION", schema = DurationAlarmCondition.class), + @DiscriminatorMapping(value = "REPEATING", schema = RepeatingAlarmCondition.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java index 0a4a995ab0..3599ee5c3c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/DurationAlarmCondition.java @@ -21,8 +21,11 @@ import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import io.swagger.v3.oas.annotations.media.Schema; + import java.util.concurrent.TimeUnit; +@Schema @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java index 28472d0556..42cba04ec0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/RepeatingAlarmCondition.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +@Schema @Data @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java index 73dfac9e60..f91ff1f3f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/SimpleAlarmCondition.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema public class SimpleAlarmCondition extends AlarmCondition { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java index e02d438c0d..5251f385df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionExpression.java @@ -20,7 +20,16 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmConditionExpression.class), + @DiscriminatorMapping(value = "TBEL", schema = TbelAlarmConditionExpression.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java index 448d4586ff..578b7909c0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/AlarmConditionFilter.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression; +import io.swagger.v3.oas.annotations.media.ArraySchema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; @@ -23,11 +24,15 @@ import lombok.Data; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.ComplexFilterPredicate; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyValueType; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; import java.util.List; +@Schema @Data public class AlarmConditionFilter implements Serializable { @@ -36,6 +41,7 @@ public class AlarmConditionFilter implements Serializable { @NotNull private EntityKeyValueType valueType; private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmRuleKeyFilterPredicate")) @Valid @NotEmpty private List predicates; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java index ec5407b19d..a581fa3a41 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/SimpleAlarmConditionExpression.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.FilterPredicateType; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java index 15731ae1fb..64a73d2f6d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/BooleanFilterPredicate.java @@ -18,8 +18,10 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleBooleanFilterPredicate") @Data public class BooleanFilterPredicate implements SimpleKeyFilterPredicate { @@ -29,11 +31,13 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate @NotNull private AlarmConditionValue value; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.BOOLEAN; } + @Schema(name = "AlarmRuleBooleanOperation") public enum BooleanOperation { EQUAL, NOT_EQUAL diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java index 5fa7c17107..34f128d3e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/ComplexFilterPredicate.java @@ -15,17 +15,22 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; -import org.thingsboard.server.common.data.alarm.rule.condition.expression.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import java.util.List; +@Schema(name = "AlarmRuleComplexFilterPredicate") @Data public class ComplexFilterPredicate implements KeyFilterPredicate { private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmRuleKeyFilterPredicate")) private List predicates; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.COMPLEX; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java index 3b5fa638d8..de05017130 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/FilterPredicateType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AlarmRuleFilterPredicateType") public enum FilterPredicateType { STRING, NUMERIC, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java index 24f7a6acf6..d6409d5d67 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/KeyFilterPredicate.java @@ -19,9 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + name = "AlarmRuleKeyFilterPredicate", + description = "Filter predicate for alarm rule key-based filtering", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "STRING", schema = StringFilterPredicate.class), + @DiscriminatorMapping(value = "NUMERIC", schema = NumericFilterPredicate.class), + @DiscriminatorMapping(value = "BOOLEAN", schema = BooleanFilterPredicate.class), + @DiscriminatorMapping(value = "NO_DATA", schema = NoDataFilterPredicate.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexFilterPredicate.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(value = StringFilterPredicate.class, name = "STRING"), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java index d6f733d2c1..1f719cb80c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NoDataFilterPredicate.java @@ -20,10 +20,12 @@ import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; import java.util.concurrent.TimeUnit; +@Schema @Data @AllArgsConstructor @NoArgsConstructor @@ -35,6 +37,7 @@ public class NoDataFilterPredicate implements KeyFilterPredicate { @NotNull private AlarmConditionValue duration; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.NO_DATA; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java index 5622836c93..5bddbc4387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/NumericFilterPredicate.java @@ -20,8 +20,10 @@ import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleNumericFilterPredicate") @Data @AllArgsConstructor @NoArgsConstructor @@ -33,11 +35,13 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate @NotNull private AlarmConditionValue value; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.NUMERIC; } + @Schema(name = "AlarmRuleNumericOperation") public enum NumericOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java index 952a5c2d46..1d2a3785db 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/SimpleKeyFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleSimpleKeyFilterPredicate") public interface SimpleKeyFilterPredicate extends KeyFilterPredicate { AlarmConditionValue getValue(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java index 1053f21271..602730b7e0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/predicate/StringFilterPredicate.java @@ -18,8 +18,10 @@ package org.thingsboard.server.common.data.alarm.rule.condition.expression.predi import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.alarm.rule.condition.AlarmConditionValue; +@Schema(name = "AlarmRuleStringFilterPredicate") @Data public class StringFilterPredicate implements SimpleKeyFilterPredicate { @@ -30,11 +32,13 @@ public class StringFilterPredicate implements SimpleKeyFilterPredicate { private AlarmConditionValue value; private boolean ignoreCase; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, ref = "#/components/schemas/AlarmRuleFilterPredicateType") @Override public FilterPredicateType getType() { return FilterPredicateType.STRING; } + @Schema(name= "AlarmRuleStringOperation") public enum StringOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java index e764a9fb46..127487b471 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmSchedule.java @@ -21,8 +21,19 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "ANY_TIME", schema = AnyTimeSchedule.class), + @DiscriminatorMapping(value = "SPECIFIC_TIME", schema = SpecificTimeSchedule.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomTimeSchedule.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java index 73e9e80159..28093fd845 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AlarmScheduleType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema public enum AlarmScheduleType { ANY_TIME, SPECIFIC_TIME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java index 2d0454c16e..873e1f882d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/AnyTimeSchedule.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema public class AnyTimeSchedule implements AlarmSchedule { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java index bbb1fd0390..aea9db5be4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeSchedule.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; +@Schema @Data public class CustomTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java index 47d60e732b..d0756a3195 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/CustomTimeScheduleItem.java @@ -17,8 +17,11 @@ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; import lombok.Data; +import io.swagger.v3.oas.annotations.media.Schema; + import java.io.Serializable; +@Schema @Data public class CustomTimeScheduleItem implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java index 733081b81f..a23dbe48a7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/schedule/SpecificTimeSchedule.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.alarm.rule.condition.schedule; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Set; +@Schema @Data public class SpecificTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java index ad53f070f6..000f99f303 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/Asset.java @@ -169,7 +169,10 @@ public class Asset extends BaseDataWithAdditionalInfo implements HasLab this.assetProfileId = assetProfileId; } - @Schema(description = "Additional parameters of the asset",implementation = com.fasterxml.jackson.databind.JsonNode.class) + @Schema(description = "Additional parameters of the asset. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Building A asset\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java index e5d0855989..a2d879ff0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/asset/AssetProfileInfo.java @@ -23,6 +23,7 @@ import lombok.ToString; import lombok.Value; import org.thingsboard.server.common.data.EntityInfo; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.id.AssetProfileId; import org.thingsboard.server.common.data.id.DashboardId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.EntityIdFactory; @@ -66,4 +67,10 @@ public class AssetProfileInfo extends EntityInfo { this(profile.getId(), profile.getTenantId(), profile.getName(), profile.getImage(), profile.getDefaultDashboardId()); } + @Override + @Schema(implementation = AssetProfileId.class, description = "JSON object with the Asset Profile Id.") + public EntityId getId() { + return super.getId(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java index fbb5e9cac7..27502eb70e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/audit/ActionType.java @@ -22,42 +22,203 @@ import java.util.Optional; public enum ActionType { - ADDED(TbMsgType.ENTITY_CREATED), // log entity - DELETED(TbMsgType.ENTITY_DELETED), // log string id - UPDATED(TbMsgType.ENTITY_UPDATED), // log entity - ATTRIBUTES_UPDATED(TbMsgType.ATTRIBUTES_UPDATED), // log attributes/values - ATTRIBUTES_DELETED(TbMsgType.ATTRIBUTES_DELETED), // log attributes - TIMESERIES_UPDATED(TbMsgType.TIMESERIES_UPDATED), // log timeseries update - TIMESERIES_DELETED(TbMsgType.TIMESERIES_DELETED), // log timeseries - RPC_CALL, // log method and params - CREDENTIALS_UPDATED, // log new credentials - ASSIGNED_TO_CUSTOMER(TbMsgType.ENTITY_ASSIGNED), // log customer name - UNASSIGNED_FROM_CUSTOMER(TbMsgType.ENTITY_UNASSIGNED), // log customer name - ACTIVATED, // log string id - SUSPENDED, // log string id - CREDENTIALS_READ(true), // log device id - ATTRIBUTES_READ(true), // log attributes + /** + * Entity created. Pushes {@link TbMsgType#ENTITY_CREATED} to rule engine. + * Audit log payload: full entity JSON. + */ + ADDED(TbMsgType.ENTITY_CREATED), + /** + * Entity deleted. Pushes {@link TbMsgType#ENTITY_DELETED} to rule engine. + * Audit log payload: entity string id. + */ + DELETED(TbMsgType.ENTITY_DELETED), + /** + * Entity updated. Pushes {@link TbMsgType#ENTITY_UPDATED} to rule engine. + * Audit log payload: full entity JSON. + */ + UPDATED(TbMsgType.ENTITY_UPDATED), + /** + * Server-side or shared attributes updated via API. + * Pushes {@link TbMsgType#ATTRIBUTES_UPDATED} to rule engine. + * Rule engine msg metadata includes {@code scope} ({@code SERVER_SCOPE} or {@code SHARED_SCOPE}). + * Rule engine msg data: key-value pairs of the updated attributes. + * Audit log payload: updated attributes and their values. + */ + ATTRIBUTES_UPDATED(TbMsgType.ATTRIBUTES_UPDATED), + /** + * Attributes deleted via API. + * Pushes {@link TbMsgType#ATTRIBUTES_DELETED} to rule engine. + * Rule engine msg metadata includes {@code scope} ({@code SERVER_SCOPE} or {@code SHARED_SCOPE}). + * Rule engine msg data: {@code {"attributes": ["key1", "key2"]}}. + * Audit log payload: list of deleted attribute keys. + */ + ATTRIBUTES_DELETED(TbMsgType.ATTRIBUTES_DELETED), + /** + * Timeseries data saved via API (not from device transport). + * Pushes {@link TbMsgType#TIMESERIES_UPDATED} to rule engine. + * Rule engine msg data: {@code {"timeseries": [{"ts": ..., "values": {...}}, ...]}}. + * Audit log payload: timeseries entries. + */ + TIMESERIES_UPDATED(TbMsgType.TIMESERIES_UPDATED), + /** + * Timeseries data deleted via API. + * Pushes {@link TbMsgType#TIMESERIES_DELETED} to rule engine. + * Rule engine msg data: {@code {"timeseries": ["key1", ...], "startTs": ..., "endTs": ...}}. + * Audit log payload: deleted timeseries keys. + */ + TIMESERIES_DELETED(TbMsgType.TIMESERIES_DELETED), + /** + * RPC call to device. Does not push to rule engine (RPC has its own lifecycle messages). + * Audit log payload: RPC method and params. + */ + RPC_CALL, + /** + * Device credentials updated. Does not push to rule engine. + * Audit log payload: new credentials value. + */ + CREDENTIALS_UPDATED, + /** + * Entity assigned to a customer. Pushes {@link TbMsgType#ENTITY_ASSIGNED} to rule engine. + * Rule engine msg metadata includes {@code assignedCustomerId} and {@code assignedCustomerName}. + * Audit log payload: customer name. + */ + ASSIGNED_TO_CUSTOMER(TbMsgType.ENTITY_ASSIGNED), + /** + * Entity unassigned from a customer. Pushes {@link TbMsgType#ENTITY_UNASSIGNED} to rule engine. + * Rule engine msg metadata includes {@code unassignedCustomerId} and {@code unassignedCustomerName}. + * Audit log payload: customer name. + */ + UNASSIGNED_FROM_CUSTOMER(TbMsgType.ENTITY_UNASSIGNED), + /** + * User account or integration activated. Does not push to rule engine. + * Audit log payload: entity string id. + */ + ACTIVATED, + /** + * User account or integration suspended. Does not push to rule engine. + * Audit log payload: entity string id. + */ + SUSPENDED, + /** + * Device credentials read. Read-only action. Does not push to rule engine. + * Audit log payload: device id. + */ + CREDENTIALS_READ(true), + /** + * Attributes read. Read-only action. Does not push to rule engine. + * Audit log payload: attribute keys read. + */ + ATTRIBUTES_READ(true), + /** + * Relation created or updated. Pushes {@link TbMsgType#RELATION_ADD_OR_UPDATE} to rule engine. + * Rule engine msg data: relation JSON ({@code from}, {@code to}, {@code type}, {@code typeGroup}). + */ RELATION_ADD_OR_UPDATE(TbMsgType.RELATION_ADD_OR_UPDATE), + /** + * Relation deleted. Pushes {@link TbMsgType#RELATION_DELETED} to rule engine. + * Rule engine msg data: relation JSON ({@code from}, {@code to}, {@code type}, {@code typeGroup}). + */ RELATION_DELETED(TbMsgType.RELATION_DELETED), + /** + * All relations for an entity deleted. Pushes {@link TbMsgType#RELATIONS_DELETED} to rule engine. + * Rule engine msg data: empty JSON object. + */ RELATIONS_DELETED(TbMsgType.RELATIONS_DELETED), - REST_API_RULE_ENGINE_CALL, // log call to rule engine from REST API + /** + * REST API call to rule engine. Does not push to rule engine directly + * (the REST controller creates a {@link TbMsgType#REST_API_REQUEST} message itself). + * Audit log payload: call details. + */ + REST_API_RULE_ENGINE_CALL, + /** + * Alarm acknowledged by a user. Pushes {@link TbMsgType#ALARM_ACK} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_ACK(TbMsgType.ALARM_ACK, true), + /** + * Alarm cleared by a user. Pushes {@link TbMsgType#ALARM_CLEAR} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_CLEAR(TbMsgType.ALARM_CLEAR, true), + /** + * Alarm deleted by a user. Pushes {@link TbMsgType#ALARM_DELETE} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_DELETE(TbMsgType.ALARM_DELETE, true), + /** + * Alarm assigned to a user. Pushes {@link TbMsgType#ALARM_ASSIGNED} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_ASSIGNED(TbMsgType.ALARM_ASSIGNED, true), + /** + * Alarm unassigned from a user. Pushes {@link TbMsgType#ALARM_UNASSIGNED} to rule engine. + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ALARM_UNASSIGNED(TbMsgType.ALARM_UNASSIGNED, true), + /** + * User logged in. Does not push to rule engine. + */ LOGIN, + /** + * User logged out. Does not push to rule engine. + */ LOGOUT, + /** + * User account locked out due to too many failed login attempts. Does not push to rule engine. + */ LOCKOUT, + /** + * Entity assigned from another tenant (incoming side of cross-tenant transfer). + * Pushes {@link TbMsgType#ENTITY_ASSIGNED_FROM_TENANT} to rule engine. + * Rule engine msg metadata includes {@code assignedFromTenantId} and {@code assignedFromTenantName}. + */ ASSIGNED_FROM_TENANT(TbMsgType.ENTITY_ASSIGNED_FROM_TENANT), + /** + * Entity assigned to another tenant (outgoing side of cross-tenant transfer). + * Pushes {@link TbMsgType#ENTITY_ASSIGNED_TO_TENANT} to rule engine. + * Rule engine msg metadata includes {@code assignedToTenantId} and {@code assignedToTenantName}. + */ ASSIGNED_TO_TENANT(TbMsgType.ENTITY_ASSIGNED_TO_TENANT), + /** + * Device provisioned successfully. Pushes {@link TbMsgType#PROVISION_SUCCESS} to rule engine. + * Rule engine msg data: full device JSON. + */ PROVISION_SUCCESS(TbMsgType.PROVISION_SUCCESS), + /** + * Device provisioning failed. Pushes {@link TbMsgType#PROVISION_FAILURE} to rule engine. + * Rule engine msg data: full device JSON. + */ PROVISION_FAILURE(TbMsgType.PROVISION_FAILURE), - ASSIGNED_TO_EDGE(TbMsgType.ENTITY_ASSIGNED_TO_EDGE), // log edge name + /** + * Entity assigned to an Edge instance. Pushes {@link TbMsgType#ENTITY_ASSIGNED_TO_EDGE} to rule engine. + * Rule engine msg metadata includes {@code assignedEdgeId} and {@code assignedEdgeName}. + * Audit log payload: edge name. + */ + ASSIGNED_TO_EDGE(TbMsgType.ENTITY_ASSIGNED_TO_EDGE), + /** + * Entity unassigned from an Edge instance. Pushes {@link TbMsgType#ENTITY_UNASSIGNED_FROM_EDGE} to rule engine. + * Rule engine msg metadata includes {@code unassignedEdgeId} and {@code unassignedEdgeName}. + */ UNASSIGNED_FROM_EDGE(TbMsgType.ENTITY_UNASSIGNED_FROM_EDGE), + /** + * Comment added to an alarm. Pushes {@link TbMsgType#COMMENT_CREATED} to rule engine. + * Rule engine msg metadata includes {@code comment} (JSON string of the AlarmComment object). + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ ADDED_COMMENT(TbMsgType.COMMENT_CREATED), + /** + * Alarm comment updated. Pushes {@link TbMsgType#COMMENT_UPDATED} to rule engine. + * Rule engine msg metadata includes {@code comment} (JSON string of the AlarmComment object). + * Rule engine msg data: full alarm JSON. Originator: alarm id. + */ UPDATED_COMMENT(TbMsgType.COMMENT_UPDATED), + /** + * Alarm comment deleted. Does not push to rule engine. + */ DELETED_COMMENT, + /** + * SMS sent. Does not push to rule engine. + */ SMS_SENT; @Getter diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java new file mode 100644 index 0000000000..bd0c76bc05 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinition.java @@ -0,0 +1,166 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.BaseData; +import org.thingsboard.server.common.data.HasAdditionalInfo; +import org.thingsboard.server.common.data.HasDebugSettings; +import org.thingsboard.server.common.data.HasName; +import org.thingsboard.server.common.data.HasTenantId; +import org.thingsboard.server.common.data.HasVersion; +import org.thingsboard.server.common.data.cf.configuration.AlarmCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.debug.DebugSettings; +import org.thingsboard.server.common.data.id.CalculatedFieldId; +import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.validation.Length; +import org.thingsboard.server.common.data.validation.NoXss; + +@Schema +@Data +@EqualsAndHashCode(callSuper = true) +public class AlarmRuleDefinition extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings, HasAdditionalInfo { + + private TenantId tenantId; + private EntityId entityId; + + @NoXss + @Length(fieldName = "name") + @Schema(description = "User defined name of the alarm rule.") + private String name; + @Deprecated + @Schema(description = "Enable/disable debug. ", example = "false", deprecated = true) + private boolean debugMode; + @Schema(description = "Debug settings object.") + private DebugSettings debugSettings; + @Schema(description = "Version of alarm rule configuration.", example = "0") + private int configurationVersion; + @Schema(implementation = AlarmCalculatedFieldConfiguration.class) + @Valid + @NotNull + private AlarmCalculatedFieldConfiguration configuration; + private Long version; + @NoXss + @Schema(description = "Additional parameters of the alarm rule. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"High temperature alarm rule\"}") + private JsonNode additionalInfo; + + public AlarmRuleDefinition() {} + + public AlarmRuleDefinition(CalculatedFieldId id) { + super(id); + } + + public AlarmRuleDefinition(AlarmRuleDefinition alarmRuleDefinition) { + super(alarmRuleDefinition); + this.tenantId = alarmRuleDefinition.tenantId; + this.entityId = alarmRuleDefinition.entityId; + this.name = alarmRuleDefinition.name; + this.debugMode = alarmRuleDefinition.debugMode; + this.debugSettings = alarmRuleDefinition.debugSettings; + this.configurationVersion = alarmRuleDefinition.configurationVersion; + this.configuration = alarmRuleDefinition.configuration; + this.version = alarmRuleDefinition.version; + this.additionalInfo = alarmRuleDefinition.additionalInfo; + } + + @Schema(description = "JSON object with the Alarm Rule Id. Referencing non-existing Alarm Rule Id will cause error.") + @Override + public CalculatedFieldId getId() { + return super.getId(); + } + + @Schema(description = "Timestamp of the alarm rule creation, in milliseconds", example = "1609459200000", accessMode = Schema.AccessMode.READ_ONLY) + @Override + public long getCreatedTime() { + return super.getCreatedTime(); + } + + // Getter is ignored for serialization + @JsonIgnore + public boolean isDebugMode() { + return debugMode; + } + + // Setter is annotated for deserialization + @JsonSetter + public void setDebugMode(boolean debugMode) { + this.debugMode = debugMode; + } + + public CalculatedField toCalculatedField() { + CalculatedField cf = new CalculatedField(); + cf.setId(this.id); + cf.setCreatedTime(this.createdTime); + cf.setTenantId(this.tenantId); + cf.setEntityId(this.entityId); + cf.setType(CalculatedFieldType.ALARM); + cf.setName(this.name); + cf.setDebugMode(this.debugMode); + cf.setDebugSettings(this.debugSettings); + cf.setConfigurationVersion(this.configurationVersion); + cf.setConfiguration(this.configuration); + cf.setVersion(this.version); + cf.setAdditionalInfo(this.additionalInfo); + return cf; + } + + public static AlarmRuleDefinition fromCalculatedField(CalculatedField cf) { + AlarmRuleDefinition def = new AlarmRuleDefinition(); + def.setId(cf.getId()); + def.setCreatedTime(cf.getCreatedTime()); + def.setTenantId(cf.getTenantId()); + def.setEntityId(cf.getEntityId()); + def.setName(cf.getName()); + def.setDebugMode(cf.isDebugMode()); + def.setDebugSettings(cf.getDebugSettings()); + def.setConfigurationVersion(cf.getConfigurationVersion()); + if (!(cf.getConfiguration() instanceof AlarmCalculatedFieldConfiguration config)) { + throw new IllegalArgumentException("Expected ALARM calculated field, got " + cf.getType()); + } + def.setConfiguration(config); + def.setVersion(cf.getVersion()); + def.setAdditionalInfo(cf.getAdditionalInfo()); + return def; + } + + @Override + public String toString() { + return new StringBuilder() + .append("AlarmRuleDefinition[") + .append("tenantId=").append(tenantId) + .append(", entityId=").append(entityId) + .append(", name='").append(name) + .append(", configurationVersion=").append(configurationVersion) + .append(", configuration=").append(configuration) + .append(", additionalInfo=").append(additionalInfo) + .append(", version=").append(version) + .append(", createdTime=").append(createdTime) + .append(", id=").append(id).append(']') + .toString(); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java new file mode 100644 index 0000000000..145dfc4081 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/AlarmRuleDefinitionInfo.java @@ -0,0 +1,39 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.cf; + +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.NoArgsConstructor; + +@Data +@EqualsAndHashCode(callSuper = true) +@NoArgsConstructor +public class AlarmRuleDefinitionInfo extends AlarmRuleDefinition { + + private String entityName; + + public AlarmRuleDefinitionInfo(AlarmRuleDefinition alarmRuleDefinition, String entityName) { + super(alarmRuleDefinition); + this.entityName = entityName; + } + + public static AlarmRuleDefinitionInfo fromCalculatedFieldInfo(CalculatedFieldInfo cfi) { + AlarmRuleDefinition def = AlarmRuleDefinition.fromCalculatedField(cfi); + return new AlarmRuleDefinitionInfo(def, cfi.getEntityName()); + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java index 36fd677299..2d3928ee88 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/CalculatedField.java @@ -17,15 +17,15 @@ package org.thingsboard.server.common.data.cf; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSetter; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.EqualsAndHashCode; -import lombok.Getter; -import lombok.Setter; import org.thingsboard.server.common.data.BaseData; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.HasAdditionalInfo; import org.thingsboard.server.common.data.HasDebugSettings; import org.thingsboard.server.common.data.HasName; import org.thingsboard.server.common.data.HasTenantId; @@ -39,7 +39,6 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; -import java.io.Serial; import java.util.Collections; import java.util.EnumSet; import java.util.Map; @@ -48,10 +47,7 @@ import java.util.Set; @Schema @Data @EqualsAndHashCode(callSuper = true) -public class CalculatedField extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings { - - @Serial - private static final long serialVersionUID = 4491966747773381420L; +public class CalculatedField extends BaseData implements HasName, HasTenantId, HasVersion, HasDebugSettings, HasAdditionalInfo { public static final Map> SUPPORTED_ENTITIES = Map.of( EntityType.DEVICE, CalculatedFieldType.all, @@ -86,32 +82,21 @@ public class CalculatedField extends BaseData implements HasN private DebugSettings debugSettings; @Schema(description = "Version of calculated field configuration.", example = "0") private int configurationVersion; - @Schema(implementation = SimpleCalculatedFieldConfiguration.class) + @Schema(implementation = CalculatedFieldConfiguration.class) @Valid @NotNull private CalculatedFieldConfiguration configuration; - @Getter - @Setter private Long version; + @NoXss + @Schema(description = "Additional parameters of the calculated field") + private JsonNode additionalInfo; - public CalculatedField() { - super(); - } + public CalculatedField() {} public CalculatedField(CalculatedFieldId id) { super(id); } - public CalculatedField(TenantId tenantId, EntityId entityId, CalculatedFieldType type, String name, int configurationVersion, CalculatedFieldConfiguration configuration, Long version) { - this.tenantId = tenantId; - this.entityId = entityId; - this.type = type; - this.name = name; - this.configurationVersion = configurationVersion; - this.configuration = configuration; - this.version = version; - } - public CalculatedField(CalculatedField calculatedField) { super(calculatedField); this.tenantId = calculatedField.tenantId; @@ -123,6 +108,7 @@ public class CalculatedField extends BaseData implements HasN this.configurationVersion = calculatedField.configurationVersion; this.configuration = calculatedField.configuration; this.version = calculatedField.version; + this.additionalInfo = calculatedField.additionalInfo; } @Schema(description = "JSON object with the Calculated Field Id. Referencing non-existing Calculated Field Id will cause error.") @@ -159,6 +145,7 @@ public class CalculatedField extends BaseData implements HasN .append(", name='").append(name) .append(", configurationVersion=").append(configurationVersion) .append(", configuration=").append(configuration) + .append(", additionalInfo=").append(additionalInfo) .append(", version=").append(version) .append(", createdTime=").append(createdTime) .append(", id=").append(id).append(']') diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java index ef3869fccb..d22666723b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AlarmCalculatedFieldConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.Data; @@ -34,6 +35,7 @@ import java.util.stream.Stream; import static java.util.Map.Entry.comparingByKey; +@Schema @Data public class AlarmCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java index 9c5f84f75b..b41e296cba 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Argument.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.springframework.lang.Nullable; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class Argument { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java index 1dd87a3974..852eefc4a4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ArgumentsBasedCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import org.thingsboard.server.common.data.id.EntityId; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java index 2a17232f0f..4fbac188bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesImmediateOutputStrategy.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @AllArgsConstructor @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java index 02b453b20a..22e566e39d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesOutputStrategy.java @@ -18,7 +18,16 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "IMMEDIATE", schema = AttributesImmediateOutputStrategy.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = AttributesRuleChainOutputStrategy.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java index 5d0b4a3abe..935d97fabc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/AttributesRuleChainOutputStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @NoArgsConstructor public class AttributesRuleChainOutputStrategy implements AttributesOutputStrategy { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java index d2c6ebb78c..0ba9db43cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/BaseCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.Map; +@Schema @Data public abstract class BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java index 18213b0b7b..73d130c16f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CalculatedFieldConfiguration.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.cf.CalculatedFieldLink; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.aggregation.single.EntityAggregationCalculatedFieldConfiguration; @@ -34,6 +36,18 @@ import java.util.List; import java.util.Set; import java.util.stream.Collectors; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "SCRIPT", schema = ScriptCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "GEOFENCING", schema = GeofencingCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "ALARM", schema = AlarmCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "PROPAGATION", schema = PropagationCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "RELATED_ENTITIES_AGGREGATION", schema = RelatedEntitiesAggregationCalculatedFieldConfiguration.class), + @DiscriminatorMapping(value = "ENTITY_AGGREGATION", schema = EntityAggregationCalculatedFieldConfiguration.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java index 90c4b3fa39..72b7f88bf8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/CfArgumentDynamicSourceConfiguration.java @@ -19,7 +19,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "RELATION_PATH_QUERY", schema = RelationPathQueryDynamicSourceConfiguration.class), + @DiscriminatorMapping(value = "CURRENT_OWNER", schema = CurrentOwnerDynamicSourceConfiguration.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java index 46df6cbad7..2ee6901e9c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/Output.java @@ -20,10 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.AttributeScope; import java.util.Objects; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "TIME_SERIES", schema = TimeSeriesOutput.class), + @DiscriminatorMapping(value = "ATTRIBUTES", schema = AttributesOutput.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java index d0ae8789b9..7390424b3d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/PropagationCalculatedFieldConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -26,6 +27,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import java.util.List; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class PropagationCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements HasRelationPathLevel, HasUseLatestTsConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java index 59627751d0..fd36d1bd61 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ReferencedEntityKey.java @@ -16,10 +16,12 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import org.thingsboard.server.common.data.AttributeScope; +@Schema @Data @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java index 208bb3cc80..b59f69afc1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/RelationPathQueryDynamicSourceConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntityRelation; @@ -28,6 +30,7 @@ import java.util.List; @Data public class RelationPathQueryDynamicSourceConfiguration implements CfArgumentDynamicSourceConfiguration { + @ArraySchema(schema = @Schema(implementation = RelationPathLevel.class)) private List levels; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java index f5b45c6d5c..f78ff72af0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfiguration.java @@ -22,14 +22,14 @@ public interface ScheduledUpdateSupportedCalculatedFieldConfiguration extends Ca boolean isScheduledUpdateEnabled(); @PositiveOrZero - int getScheduledUpdateInterval(); + Integer getScheduledUpdateInterval(); - void setScheduledUpdateInterval(int interval); + void setScheduledUpdateInterval(Integer interval); default void validate(long minAllowedScheduledUpdateInterval) { if (getScheduledUpdateInterval() < minAllowedScheduledUpdateInterval) { - throw new IllegalArgumentException("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval); + throw new IllegalArgumentException("Scheduled update interval (" + getScheduledUpdateInterval() + + " seconds) is less than minimum allowed interval in tenant profile: " + minAllowedScheduledUpdateInterval + " seconds"); } } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java index 464f7f54e4..40c28967b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/ScriptCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class ScriptCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java index 53210d1cf8..e3ca07b5be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/SimpleCalculatedFieldConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.cf.CalculatedFieldType; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class SimpleCalculatedFieldConfiguration extends BaseCalculatedFieldConfiguration implements ExpressionBasedCalculatedFieldConfiguration, HasUseLatestTsConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java index a16b9cab89..942a734908 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutput.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class TimeSeriesOutput implements Output { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java index 327cad3d5f..aca968f422 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesOutputStrategy.java @@ -17,7 +17,16 @@ package org.thingsboard.server.common.data.cf.configuration; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "IMMEDIATE", schema = TimeSeriesImmediateOutputStrategy.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = TimeSeriesRuleChainOutputStrategy.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java index d4ebb231a2..1d68d3e8ee 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/TimeSeriesRuleChainOutputStrategy.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.cf.configuration; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; +@Schema @Data @NoArgsConstructor public class TimeSeriesRuleChainOutputStrategy implements TimeSeriesOutputStrategy { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java index 32db324db9..4c9c0915f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/AggInput.java @@ -19,7 +19,16 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "key", schema = AggKeyInput.class), + @DiscriminatorMapping(value = "function", schema = AggFunctionInput.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java index d905248aa5..7cb833ab3a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/RelatedEntitiesAggregationCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -22,6 +23,7 @@ import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.HasRelationPathLevel; import org.thingsboard.server.common.data.cf.configuration.HasUseLatestTsConfig; import org.thingsboard.server.common.data.cf.configuration.Output; @@ -30,6 +32,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import java.util.Map; +@Schema @Data public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration, HasRelationPathLevel, HasUseLatestTsConfig { @@ -45,7 +48,7 @@ public class RelatedEntitiesAggregationCalculatedFieldConfiguration implements A private Output output; private boolean useLatestTs; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @Override public CalculatedFieldType getType() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java index 53235136bb..70be733928 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/EntityAggregationCalculatedFieldConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentType; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; @@ -31,6 +33,7 @@ import org.thingsboard.server.common.data.cf.configuration.aggregation.single.in import java.util.Map; +@Schema @Data public class EntityAggregationCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java index 2eada7f165..55ffb0243b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/AggInterval.java @@ -19,10 +19,25 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.time.ZoneId; import java.time.ZonedDateTime; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "HOUR", schema = HourInterval.class), + @DiscriminatorMapping(value = "DAY", schema = DayInterval.class), + @DiscriminatorMapping(value = "WEEK", schema = WeekInterval.class), + @DiscriminatorMapping(value = "WEEK_SUN_SAT", schema = WeekSunSatInterval.class), + @DiscriminatorMapping(value = "MONTH", schema = MonthInterval.class), + @DiscriminatorMapping(value = "QUARTER", schema = QuarterInterval.class), + @DiscriminatorMapping(value = "YEAR", schema = YearInterval.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomInterval.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java index 3b72f309d8..933f2569fb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/CustomInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -24,6 +25,7 @@ import lombok.NoArgsConstructor; import java.time.Duration; import java.time.ZonedDateTime; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java index 1ff5ed25c2..23cb648de2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/DayInterval.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @Data @NoArgsConstructor public class DayInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java index f20387db82..7a95f2d387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/HourInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java index 3e2dee2eaf..d49ff9b335 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/MonthInterval.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; +@Schema @Data @NoArgsConstructor public class MonthInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java index 6a9123f76f..a6f29f53f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/QuarterInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZonedDateTime; +@Schema @Data @NoArgsConstructor public class QuarterInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java index 311a5254ef..fad2f072d3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -23,6 +24,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; +@Schema @Data @NoArgsConstructor public class WeekInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java index 732b82446a..a3d5876b0e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/WeekSunSatInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -23,6 +24,7 @@ import java.time.ZonedDateTime; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalAdjusters; +@Schema @Data @NoArgsConstructor public class WeekSunSatInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java index 9f1015f01e..882aaa1e56 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/aggregation/single/interval/YearInterval.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.cf.configuration.aggregation.single.interval; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -22,6 +23,7 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.ZonedDateTime; +@Schema @Data @NoArgsConstructor public class YearInterval extends BaseAggInterval { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java index cb408c5dbf..62ece3d2dc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfiguration.java @@ -16,12 +16,14 @@ package org.thingsboard.server.common.data.cf.configuration.geofencing; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; import org.thingsboard.server.common.data.cf.configuration.ArgumentsBasedCalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.HasUseLatestTsConfig; import org.thingsboard.server.common.data.cf.configuration.Output; import org.thingsboard.server.common.data.cf.configuration.OutputType; @@ -36,6 +38,7 @@ import java.util.Set; import static java.util.stream.Collectors.toSet; +@Schema @Data public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCalculatedFieldConfiguration, ScheduledUpdateSupportedCalculatedFieldConfiguration, HasUseLatestTsConfig { @@ -48,7 +51,7 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal private Map zoneGroups; private boolean scheduledUpdateEnabled; - private int scheduledUpdateInterval; + private Integer scheduledUpdateInterval; @NotNull private Output output; @@ -88,6 +91,9 @@ public class GeofencingCalculatedFieldConfiguration implements ArgumentsBasedCal @Override public void validate() { + if (scheduledUpdateEnabled && scheduledUpdateInterval == null) { + throw new IllegalArgumentException("Refresh interval is required when periodic zone group refresh is enabled."); + } zoneGroups.forEach((key, value) -> value.validate(key)); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java index 3f23e4fe14..86a61e31b6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/CoapDeviceTransportConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; @@ -25,6 +26,7 @@ import java.util.HashMap; import java.util.Map; @Data +@Schema public class CoapDeviceTransportConfiguration extends PowerSavingConfiguration implements DeviceTransportConfiguration { private static final long serialVersionUID = 6061442236008925609L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java index 85c1d83744..a0040adb4b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceConfiguration.java @@ -19,7 +19,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileType; -@Schema +@Schema(description = "Default device configuration") @Data public class DefaultDeviceConfiguration implements DeviceConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java index ae7b2e10ab..022a0ca528 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DefaultDeviceTransportConfiguration.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.device.data; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; +@Schema @Data public class DefaultDeviceTransportConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java index 9fa449fdfa..6fe689d17c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceConfiguration.java @@ -19,12 +19,19 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.DeviceProfileType; import java.io.Serializable; -@Schema +@Schema( + description = "Device configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -34,7 +41,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = DefaultDeviceConfiguration.class, name = "DEFAULT")}) public interface DeviceConfiguration extends Serializable { - @JsonIgnore + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Device profile type") DeviceProfileType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java index 84c8e3b463..fa7df3392d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/DeviceTransportConfiguration.java @@ -19,12 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.DeviceTransportType; import java.io.Serializable; -@Schema +@Schema( + description = "Configuration for device transport", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "MQTT", schema = MqttDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "COAP", schema = CoapDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "LWM2M", schema = Lwm2mDeviceTransportConfiguration.class), + @DiscriminatorMapping(value = "SNMP", schema = SnmpDeviceTransportConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -37,6 +48,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = Lwm2mDeviceTransportConfiguration.class, name = "LWM2M"), @JsonSubTypes.Type(value = SnmpDeviceTransportConfiguration.class, name = "SNMP")}) public interface DeviceTransportConfiguration extends Serializable { + @JsonIgnore DeviceTransportType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java index 079ceffada..656262ab45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/Lwm2mDeviceTransportConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import java.util.HashMap; import java.util.Map; +@Schema @Data public class Lwm2mDeviceTransportConfiguration extends PowerSavingConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java index 0adb842ec6..bf83fd31fc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/MqttDeviceTransportConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonAnyGetter; import com.fasterxml.jackson.annotation.JsonAnySetter; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import java.util.HashMap; import java.util.Map; +@Schema @Data public class MqttDeviceTransportConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java index 5509845855..21f2b619d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/PowerSavingConfiguration.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.data; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; @Data +@Schema public class PowerSavingConfiguration implements Serializable { private static final long serialVersionUID = 2905389805488525362L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java index 8357b65f21..5c732b9e62 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/data/SnmpDeviceTransportConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.data; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.ToString; import org.thingsboard.server.common.data.DeviceTransportType; @@ -24,6 +25,7 @@ import org.thingsboard.server.common.data.transport.snmp.AuthenticationProtocol; import org.thingsboard.server.common.data.transport.snmp.PrivacyProtocol; import org.thingsboard.server.common.data.transport.snmp.SnmpProtocolVersion; +@Schema @Data @ToString(of = {"host", "port", "protocolVersion"}) public class SnmpDeviceTransportConfiguration implements DeviceTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java index 838239b091..120df6f3b2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmCondition.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @@ -23,14 +24,14 @@ import lombok.Data; import java.io.Serializable; import java.util.List; -@Schema +@Schema(hidden = true) @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class AlarmCondition implements Serializable { @Valid - @Schema(description = "JSON array of alarm condition filters") + @ArraySchema(schema = @Schema(ref = "#/components/schemas/AlarmConditionFilter")) private List condition; @Schema(description = "JSON object representing alarm condition type") private AlarmConditionSpec spec; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java index b2a3083f6f..96dfee9703 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionFilter.java @@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; -@Schema +@Schema(hidden = true) @Data @Deprecated public class AlarmConditionFilter implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java index d3066ec8cc..fd6a12d223 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmConditionSpec.java @@ -19,9 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + description = "Specification for alarm conditions", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SIMPLE", schema = SimpleAlarmConditionSpec.class), + @DiscriminatorMapping(value = "DURATION", schema = DurationAlarmConditionSpec.class), + @DiscriminatorMapping(value = "REPEATING", schema = RepeatingAlarmConditionSpec.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java index 9ceea43677..7a9be0f302 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmRule.java @@ -24,7 +24,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serial; import java.io.Serializable; -@Schema +@Schema(hidden = true) @Data @Deprecated public class AlarmRule implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java index d46fedf87b..884d9f13c5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java @@ -18,10 +18,14 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.query.DynamicValue; import java.io.Serializable; +@Schema( + hidden = true +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java index 7f78dbc46d..1183f9ae63 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmScheduleType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(hidden = true) @Deprecated public enum AlarmScheduleType { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java index b33b56ba82..d1672af07e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AllowCreateNewDevicesDeviceProfileProvisionConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class AllowCreateNewDevicesDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java index e7acb04cd3..70463dfbe7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AnyTimeSchedule.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.query.DynamicValue; +@Schema(hidden = true) @Deprecated public class AnyTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java index 7a1d6d1a95..935da4cbbe 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java index b12d0bdf40..66d7f15fc4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceProfileTransportConfiguration.java @@ -15,14 +15,18 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +@Schema @Data public class CoapDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { + @Schema(implementation = CoapDeviceTypeConfiguration.class) private CoapDeviceTypeConfiguration coapDeviceTypeConfiguration; + @Schema private PowerSavingConfiguration clientSettings; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java index 66907288e9..12efa4a14c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CoapDeviceTypeConfiguration.java @@ -19,10 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.CoapDeviceType; import java.io.Serializable; +@Schema( + description = "CoAP device type configuration", + discriminatorProperty = "coapDeviceType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultCoapDeviceTypeConfiguration.class), + @DiscriminatorMapping(value = "EFENTO", schema = EfentoCoapDeviceTypeConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java index 878a85360b..cea1d76d36 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeSchedule.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.DynamicValue; import java.util.List; +@Schema(hidden = true) @Data @Deprecated public class CustomTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java index 0d4f5cf018..d91c978ac5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/CustomTimeScheduleItem.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serializable; +@Schema(hidden = true) @Data @Deprecated public class CustomTimeScheduleItem implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java index 716f733a0a..0455f9f370 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultCoapDeviceTypeConfiguration.java @@ -15,14 +15,17 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.CoapDeviceType; @Data +@Schema public class DefaultCoapDeviceTypeConfiguration implements CoapDeviceTypeConfiguration { private static final long serialVersionUID = -4287100699186773773L; + @Schema private TransportPayloadTypeConfiguration transportPayloadTypeConfiguration; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java index 5058024680..13cd6b4a58 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileConfiguration.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileType; +@Schema(description = "Default device profile configuration") @Data public class DefaultDeviceProfileConfiguration implements DeviceProfileConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java index 012b09c8e1..f00922119a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DefaultDeviceProfileTransportConfiguration.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; +@Schema @Data public class DefaultDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java index 98546180e2..69c788539b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileConfiguration.java @@ -19,10 +19,19 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.DeviceProfileType; import java.io.Serializable; +@Schema( + description = "Device profile configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceProfileConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java index 636f546386..da7282f48a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileData.java @@ -36,7 +36,7 @@ public class DeviceProfileData implements Serializable { @Schema(description = "JSON object of provisioning strategy type per device profile") private DeviceProfileProvisionConfiguration provisionConfiguration; @Valid - @Schema(description = "JSON array of alarm rules configuration per device profile") + @Schema(hidden = true) private List alarms; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java index 45ac5207f5..b94c2c9a28 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileProvisionConfiguration.java @@ -19,10 +19,22 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.DeviceProfileProvisionType; import java.io.Serializable; +@Schema( + description = "Device profile provision configuration", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DISABLED", schema = DisabledDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "ALLOW_CREATE_NEW_DEVICES", schema = AllowCreateNewDevicesDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "CHECK_PRE_PROVISIONED_DEVICES", schema = CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration.class), + @DiscriminatorMapping(value = "X509_CERTIFICATE_CHAIN", schema = X509CertificateChainProvisionConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -35,6 +47,7 @@ import java.io.Serializable; @JsonSubTypes.Type(value = X509CertificateChainProvisionConfiguration.class, name = "X509_CERTIFICATE_CHAIN")}) public interface DeviceProfileProvisionConfiguration extends Serializable { + @Schema(description = "Provision device secret", example = "secret123") String getProvisionDeviceSecret(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java index 17627ecc6b..ea035248ac 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DeviceProfileTransportConfiguration.java @@ -19,10 +19,23 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.DeviceTransportType; import java.io.Serializable; +@Schema( + description = "Configuration for device profile transport", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "MQTT", schema = MqttDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "LWM2M", schema = Lwm2mDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "COAP", schema = CoapDeviceProfileTransportConfiguration.class), + @DiscriminatorMapping(value = "SNMP", schema = SnmpDeviceProfileTransportConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java index 7492c077c8..df5883230a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DisabledDeviceProfileProvisionConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceProfileProvisionType; @Data +@Schema public class DisabledDeviceProfileProvisionConfiguration implements DeviceProfileProvisionConfiguration { private final String provisionDeviceSecret; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java index 202c11019c..b40b8f2c82 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/DurationAlarmConditionSpec.java @@ -16,17 +16,21 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.FilterPredicateValue; import java.util.concurrent.TimeUnit; +@Schema(description = "Duration Alarm Condition Specification") @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class DurationAlarmConditionSpec implements AlarmConditionSpec { + @Schema(description = "Duration time unit") private TimeUnit unit; + @Schema(description = "Duration predicate") private FilterPredicateValue predicate; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java index b5d6bc36f4..8d6831ebd9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/EfentoCoapDeviceTypeConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.CoapDeviceType; @Data +@Schema public class EfentoCoapDeviceTypeConfiguration implements CoapDeviceTypeConfiguration { private static final long serialVersionUID = -8523081152598707064L; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java index 16d5ca7686..3103aff8c4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/JsonTransportPayloadConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.TransportPayloadType; @Data +@Schema public class JsonTransportPayloadConfiguration implements TransportPayloadTypeConfiguration { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java index d73e4bf1fe..300ede57c9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/Lwm2mDeviceProfileTransportConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.device.data.PowerMode; @@ -22,20 +24,25 @@ import org.thingsboard.server.common.data.device.profile.lwm2m.OtherConfiguratio import org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryMappingConfiguration; import org.thingsboard.server.common.data.device.profile.lwm2m.bootstrap.LwM2MBootstrapServerCredential; -import static org.eclipse.leshan.core.LwM2m.Version.V1_0; -import static org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryObserveStrategy.SINGLE; - import java.util.Collections; import java.util.List; +import static org.eclipse.leshan.core.LwM2m.Version.V1_0; +import static org.thingsboard.server.common.data.device.profile.lwm2m.TelemetryObserveStrategy.SINGLE; + @Data +@Schema public class Lwm2mDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { private static final long serialVersionUID = 6257277825459600068L; + @Schema(description = "Configuration for mapping LwM2M resources to telemetry and attributes") private TelemetryMappingConfiguration observeAttr; + @Schema(description = "Flag indicating whether LwM2M bootstrap server update is enabled") private boolean bootstrapServerUpdateEnable; + @ArraySchema(schema = @Schema(implementation = LwM2MBootstrapServerCredential.class)) private List bootstrap; + @Schema(description = "Other LwM2M client settings") private OtherConfiguration clientLwM2mSettings; public Lwm2mDeviceProfileTransportConfiguration() { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java index 4e165e7745..a5df705800 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/MqttDeviceProfileTransportConfiguration.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.StringUtils; @@ -23,19 +25,27 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.util.Objects; import java.util.Set; +@Schema @Data public class MqttDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { + @Schema @NoXss private String deviceTelemetryTopic = MqttTopics.DEVICE_TELEMETRY_TOPIC; + @Schema @NoXss private String deviceAttributesTopic = MqttTopics.DEVICE_ATTRIBUTES_TOPIC; + @Schema @NoXss private String deviceAttributesSubscribeTopic = MqttTopics.DEVICE_ATTRIBUTES_TOPIC; + @Schema private TransportPayloadTypeConfiguration transportPayloadTypeConfiguration; + @Schema private boolean sparkplug; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set sparkplugAttributesMetricNames; + @Schema private boolean sendAckOnValidationException; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java index c13f0b2d09..a1e55f3452 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/ProtoTransportPayloadConfiguration.java @@ -18,12 +18,14 @@ package org.thingsboard.server.common.data.device.profile; import com.google.protobuf.Descriptors; import com.google.protobuf.DynamicMessage; import com.squareup.wire.schema.Location; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.extern.slf4j.Slf4j; import org.thingsboard.server.common.data.DynamicProtoUtils; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.TransportPayloadType; +@Schema @Slf4j @Data public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeConfiguration { @@ -43,6 +45,9 @@ public class ProtoTransportPayloadConfiguration implements TransportPayloadTypeC private boolean enableCompatibilityWithJsonPayloadFormat; private boolean useJsonPayloadFormatForDefaultDownlinkTopics; + @Schema( + description = "Transport payload type", requiredMode = Schema.RequiredMode.REQUIRED + ) @Override public TransportPayloadType getTransportPayloadType() { return TransportPayloadType.PROTOBUF; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java index 323b29a327..1685b5da8e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/RepeatingAlarmConditionSpec.java @@ -16,17 +16,21 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.FilterPredicateValue; +@Schema @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated public class RepeatingAlarmConditionSpec implements AlarmConditionSpec { + @Schema(description = "Repeating predicate") private FilterPredicateValue predicate; @Override + @Schema(description = "Type of the Alarm Condition Specification", requiredMode = Schema.RequiredMode.REQUIRED) public AlarmConditionSpecType getType() { return AlarmConditionSpecType.REPEATING; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java index 53a87a6b99..caa65777e9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SimpleAlarmConditionSpec.java @@ -16,8 +16,10 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data @JsonIgnoreProperties(ignoreUnknown = true) @Deprecated diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java index ee1656c04a..a83920b777 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SnmpDeviceProfileTransportConfiguration.java @@ -16,16 +16,20 @@ package org.thingsboard.server.common.data.device.profile; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.transport.snmp.config.SnmpCommunicationConfig; import java.util.List; +@Schema @Data public class SnmpDeviceProfileTransportConfiguration implements DeviceProfileTransportConfiguration { private Integer timeoutMs; private Integer retries; + @ArraySchema(schema = @Schema(implementation = SnmpCommunicationConfig.class)) private List communicationConfigs; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java index dfaf6c763c..8bd56d07be 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/SpecificTimeSchedule.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.query.DynamicValue; import java.util.Set; +@Schema(hidden = true) @Data @Deprecated public class SpecificTimeSchedule implements AlarmSchedule { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java index c7111f8ffd..38176bd740 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/TransportPayloadTypeConfiguration.java @@ -19,10 +19,20 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.TransportPayloadType; import java.io.Serializable; +@Schema( + description = "Configuration for transport payload type", + discriminatorProperty = "transportPayloadType", + discriminatorMapping = { + @DiscriminatorMapping(value = "JSON", schema = JsonTransportPayloadConfiguration.class), + @DiscriminatorMapping(value = "PROTOBUF", schema = ProtoTransportPayloadConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java index ad0cdaa6bf..ab95dae195 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/X509CertificateChainProvisionConfiguration.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.device.profile; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.DeviceProfileProvisionType; +@Schema @Data @NoArgsConstructor public class X509CertificateChainProvisionConfiguration implements DeviceProfileProvisionConfiguration { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java index 393e08efee..6e83e82692 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/ObjectAttributes.java @@ -16,11 +16,13 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.eclipse.leshan.core.LwM2m; import java.io.Serializable; +@Schema @Data @JsonInclude(JsonInclude.Include.NON_NULL) public class ObjectAttributes implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java index 0b0d4095b1..cdf9ed89df 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/OtherConfiguration.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import lombok.EqualsAndHashCode; @@ -23,6 +24,7 @@ import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.device.data.PowerMode; import org.thingsboard.server.common.data.device.data.PowerSavingConfiguration; +@Schema @EqualsAndHashCode(callSuper = true) @Data @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java index 7362ac3ac3..6513e2fe55 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/lwm2m/TelemetryMappingConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.device.profile.lwm2m; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,16 +28,23 @@ import java.util.Set; @Data @NoArgsConstructor +@Schema public class TelemetryMappingConfiguration implements Serializable { private static final long serialVersionUID = -7594999741305410419L; + @Schema(description = "Map of LwM2M resource paths to telemetry key names") private Map keyName; + @Schema(description = "Set of resources to observe") private Set observe; + @Schema(description = "Set of attribute keys") private Set attribute; + @Schema(description = "Set of telemetry keys") private Set telemetry; + @Schema(description = "Map of resource paths to specific LwM2M object attributes") private Map attributeLwm2m; private Boolean initAttrTelAsObsStrategy; + @Schema(description = "Observation strategy for telemetry") private TelemetryObserveStrategy observeStrategy; @JsonCreator diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java index d50882f699..6866271aec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/Edge.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.edge; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; @@ -153,4 +154,13 @@ public class Edge extends BaseDataWithAdditionalInfo implements HasLabel return this.secret; } + @Schema(description = "Additional parameters of the edge. " + + "May include: 'description' (string).", + implementation = com.fasterxml.jackson.databind.JsonNode.class, + example = "{\"description\":\"Edge at location A\"}") + @Override + public JsonNode getAdditionalInfo() { + return super.getAdditionalInfo(); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java index b07c159fa9..474a0270c1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/edge/EdgeEventType.java @@ -38,7 +38,7 @@ public enum EdgeEventType { TENANT_PROFILE(true, EntityType.TENANT_PROFILE), WIDGETS_BUNDLE(true, EntityType.WIDGETS_BUNDLE), WIDGET_TYPE(true, EntityType.WIDGET_TYPE), - ADMIN_SETTINGS(true, null), + ADMIN_SETTINGS(true, EntityType.ADMIN_SETTINGS), OTA_PACKAGE(true, EntityType.OTA_PACKAGE), QUEUE(true, EntityType.QUEUE), NOTIFICATION_RULE(true, EntityType.NOTIFICATION_RULE), @@ -48,7 +48,8 @@ public enum EdgeEventType { OAUTH2_CLIENT(true, EntityType.OAUTH2_CLIENT), DOMAIN(true, EntityType.DOMAIN), CALCULATED_FIELD(false, EntityType.CALCULATED_FIELD), - AI_MODEL(true, EntityType.AI_MODEL); + AI_MODEL(true, EntityType.AI_MODEL), + API_KEY(true, EntityType.API_KEY); private final boolean allEdgesRelated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java index 8779416106..01463dade8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/DebugEventFilter.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.event; +import com.fasterxml.jackson.annotation.JsonProperty; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.StringUtils; @@ -26,10 +27,17 @@ public abstract class DebugEventFilter implements EventFilter { @Schema(description = "String value representing the server name, identifier or ip address where the platform is running", example = "ip-172-31-24-152") protected String server; @Schema(description = "Boolean value to filter the errors", allowableValues = {"false", "true"}) + @JsonProperty("isError") protected boolean isError; @Schema(description = "The case insensitive 'contains' filter based on error message", example = "not present in the DB") protected String errorStr; + @JsonProperty("isError") + public boolean isError() { + return isError; + } + + @JsonProperty("isError") public void setIsError(boolean isError) { this.isError = isError; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java index 07789b9208..4d955f1817 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/event/EventFilter.java @@ -17,9 +17,21 @@ package org.thingsboard.server.common.data.event; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; -@Schema +@Schema( + description = "Filter for various event types", + discriminatorProperty = "eventType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEBUG_RULE_NODE", schema = RuleNodeDebugEventFilter.class), + @DiscriminatorMapping(value = "DEBUG_RULE_CHAIN", schema = RuleChainDebugEventFilter.class), + @DiscriminatorMapping(value = "ERROR", schema = ErrorEventFilter.class), + @DiscriminatorMapping(value = "LC_EVENT", schema = LifeCycleEventFilter.class), + @DiscriminatorMapping(value = "STATS", schema = StatisticsEventFilter.class), + @DiscriminatorMapping(value = "DEBUG_CALCULATED_FIELD", schema = CalculatedFieldDebugEventFilter.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java index dea590295e..d66ec046de 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsDeletionHousekeeperTask.java @@ -23,6 +23,7 @@ import lombok.ToString; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; import java.util.List; import java.util.UUID; @@ -32,6 +33,9 @@ import java.util.UUID; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class AlarmsDeletionHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = 9214680001573764374L; + private List alarms; public AlarmsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java index 0313190056..445850d387 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/AlarmsUnassignHousekeeperTask.java @@ -24,6 +24,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import java.io.Serial; import java.util.List; import java.util.UUID; @@ -33,6 +34,9 @@ import java.util.UUID; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class AlarmsUnassignHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = 9156667024462937756L; + private String userTitle; private List alarms; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java index fe25a98a1d..c1a233b1c4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/EntitiesDeletionHousekeeperTask.java @@ -23,6 +23,7 @@ import lombok.ToString; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; import java.util.List; import java.util.UUID; @@ -32,6 +33,9 @@ import java.util.UUID; @NoArgsConstructor public class EntitiesDeletionHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = 9009068831061529286L; + private EntityType entityType; private List entities; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java index 875ef2765f..2df7cf4dd4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/HousekeeperTask.java @@ -29,6 +29,7 @@ import org.thingsboard.server.common.data.User; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; import java.io.Serializable; @JsonIgnoreProperties(ignoreUnknown = true) @@ -45,6 +46,9 @@ import java.io.Serializable; @NoArgsConstructor(access = AccessLevel.PROTECTED) public class HousekeeperTask implements Serializable { + @Serial + private static final long serialVersionUID = -2585974110832225152L; + private TenantId tenantId; private EntityId entityId; private HousekeeperTaskType taskType; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java index cd3e94e5c6..931c2931cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/LatestTsDeletionHousekeeperTask.java @@ -23,12 +23,17 @@ import lombok.ToString; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; + @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class LatestTsDeletionHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = 5193191938513490138L; + private String key; public LatestTsDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java index 443d929d8b..be7ff6f7ec 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TenantEntitiesDeletionHousekeeperTask.java @@ -23,12 +23,17 @@ import lombok.ToString; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; + @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @NoArgsConstructor public class TenantEntitiesDeletionHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = -8033108795318393447L; + private EntityType entityType; public TenantEntitiesDeletionHousekeeperTask(TenantId tenantId, EntityType entityType) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java index d9315f0ff4..b520899ca4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/housekeeper/TsHistoryDeletionHousekeeperTask.java @@ -23,12 +23,17 @@ import lombok.ToString; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; +import java.io.Serial; + @Data @ToString(callSuper = true) @EqualsAndHashCode(callSuper = true) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class TsHistoryDeletionHousekeeperTask extends HousekeeperTask { + @Serial + private static final long serialVersionUID = 4573851542705079043L; + private String key; public TsHistoryDeletionHousekeeperTask(TenantId tenantId, EntityId entityId, String key) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java index da7ade45e1..5923b8c02c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AdminSettingsId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class AdminSettingsId extends UUIDBased implements EntityId { @Serial @@ -33,7 +34,7 @@ public class AdminSettingsId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ADMIN_SETTINGS", allowableValues = "ADMIN_SETTINGS") @Override public EntityType getEntityType() { return EntityType.ADMIN_SETTINGS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java index dbb8b25635..70476564d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AiModelId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class AiModelId extends UUIDBased implements EntityId { @Serial @@ -36,6 +37,7 @@ public final class AiModelId extends UUIDBased implements EntityId { @Override @Schema( requiredMode = Schema.RequiredMode.REQUIRED, + accessMode = Schema.AccessMode.READ_ONLY, description = "Entity type of the AI model", example = "AI_MODEL", allowableValues = "AI_MODEL" diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java index 444492c328..db2c63d614 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AlarmId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class AlarmId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -36,7 +36,7 @@ public class AlarmId extends UUIDBased implements EntityId { return new AlarmId(UUID.fromString(alarmId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ALARM", allowableValues = "ALARM") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ALARM", allowableValues = "ALARM") @Override public EntityType getEntityType() { return EntityType.ALARM; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java index 514c29665c..186dcaca40 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiKeyId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class ApiKeyId extends UUIDBased implements EntityId { @Serial @@ -38,7 +39,7 @@ public class ApiKeyId extends UUIDBased implements EntityId { } @Override - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_KEY", allowableValues = "API_KEY") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "API_KEY", allowableValues = "API_KEY") public EntityType getEntityType() { return EntityType.API_KEY; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java index 78e5c90bee..02d6d387a2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/ApiUsageStateId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class ApiUsageStateId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class ApiUsageStateId extends UUIDBased implements EntityId { return new ApiUsageStateId(UUID.fromString(userId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "API_USAGE_STATE", allowableValues = "API_USAGE_STATE") @Override public EntityType getEntityType() { return EntityType.API_USAGE_STATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java index dc42fffeb5..2d57cc365d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class AssetId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -36,7 +36,7 @@ public class AssetId extends UUIDBased implements EntityId { return new AssetId(UUID.fromString(assetId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET", allowableValues = "ASSET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ASSET", allowableValues = "ASSET") @Override public EntityType getEntityType() { return EntityType.ASSET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java index f4f7a3de70..981f381a4a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/AssetProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class AssetProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class AssetProfileId extends UUIDBased implements EntityId { return new AssetProfileId(UUID.fromString(assetProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ASSET_PROFILE", allowableValues = "ASSET_PROFILE") @Override public EntityType getEntityType() { return EntityType.ASSET_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java index 5168515cd8..094df0e935 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CalculatedFieldId.java @@ -23,7 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class CalculatedFieldId extends UUIDBased implements EntityId { @Serial @@ -38,7 +38,7 @@ public class CalculatedFieldId extends UUIDBased implements EntityId { return new CalculatedFieldId(UUID.fromString(calculatedFieldId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CALCULATED_FIELD", allowableValues = "CALCULATED_FIELD") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "CALCULATED_FIELD", allowableValues = "CALCULATED_FIELD") @Override public EntityType getEntityType() { return EntityType.CALCULATED_FIELD; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java index a3b1e28147..4de326f26c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/CustomerId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public final class CustomerId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -32,7 +32,7 @@ public final class CustomerId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "CUSTOMER", allowableValues = "CUSTOMER") @Override public EntityType getEntityType() { return EntityType.CUSTOMER; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java index bd326257a6..c98e70177e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DashboardId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class DashboardId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class DashboardId extends UUIDBased implements EntityId { return new DashboardId(UUID.fromString(dashboardId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DASHBOARD", allowableValues = "DASHBOARD") @Override public EntityType getEntityType() { return EntityType.DASHBOARD; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java index 7dd840815b..a7b9c12e19 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class DeviceId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -37,7 +37,7 @@ public class DeviceId extends UUIDBased implements EntityId { } @Override - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE", allowableValues = "DEVICE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DEVICE", allowableValues = "DEVICE") public EntityType getEntityType() { return EntityType.DEVICE; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java index 2adba3640c..dea0dd2a65 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DeviceProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class DeviceProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class DeviceProfileId extends UUIDBased implements EntityId { return new DeviceProfileId(UUID.fromString(deviceProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DEVICE_PROFILE", allowableValues = "DEVICE_PROFILE") @Override public EntityType getEntityType() { return EntityType.DEVICE_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java index 0768eec45e..223ea8d8b5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/DomainId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class DomainId extends UUIDBased implements EntityId { @JsonCreator @@ -32,6 +34,7 @@ public class DomainId extends UUIDBased implements EntityId { return new DomainId(UUID.fromString(oauth2DomainId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "DOMAIN", allowableValues = "DOMAIN") @Override public EntityType getEntityType() { return EntityType.DOMAIN; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java index 05019a608f..45a95d6632 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EdgeId.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class EdgeId extends UUIDBased implements EntityId { @Serial @@ -43,7 +44,7 @@ public class EdgeId extends UUIDBased implements EntityId { return new EdgeId(UUID.fromString(edgeId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "EDGE", allowableValues = "EDGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "EDGE", allowableValues = "EDGE") @Override public EntityType getEntityType() { return EntityType.EDGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java index c57dc79514..c96b4c38d5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityId.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; @@ -26,7 +27,47 @@ import java.util.UUID; @JsonDeserialize(using = EntityIdDeserializer.class) @JsonSerialize(using = EntityIdSerializer.class) -@Schema +@Schema( + discriminatorProperty = "entityType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ADMIN_SETTINGS", schema = AdminSettingsId.class), + @DiscriminatorMapping(value = "AI_MODEL", schema = AiModelId.class), + @DiscriminatorMapping(value = "ALARM", schema = AlarmId.class), + @DiscriminatorMapping(value = "API_KEY", schema = ApiKeyId.class), + @DiscriminatorMapping(value = "API_USAGE_STATE", schema = ApiUsageStateId.class), + @DiscriminatorMapping(value = "ASSET", schema = AssetId.class), + @DiscriminatorMapping(value = "ASSET_PROFILE", schema = AssetProfileId.class), + @DiscriminatorMapping(value = "CALCULATED_FIELD", schema = CalculatedFieldId.class), + @DiscriminatorMapping(value = "CUSTOMER", schema = CustomerId.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = DashboardId.class), + @DiscriminatorMapping(value = "DEVICE", schema = DeviceId.class), + @DiscriminatorMapping(value = "DEVICE_PROFILE", schema = DeviceProfileId.class), + @DiscriminatorMapping(value = "DOMAIN", schema = DomainId.class), + @DiscriminatorMapping(value = "EDGE", schema = EdgeId.class), + @DiscriminatorMapping(value = "ENTITY_VIEW", schema = EntityViewId.class), + @DiscriminatorMapping(value = "JOB", schema = JobId.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppId.class), + @DiscriminatorMapping(value = "MOBILE_APP_BUNDLE", schema = MobileAppBundleId.class), + @DiscriminatorMapping(value = "NOTIFICATION", schema = NotificationId.class), + @DiscriminatorMapping(value = "NOTIFICATION_REQUEST", schema = NotificationRequestId.class), + @DiscriminatorMapping(value = "NOTIFICATION_RULE", schema = NotificationRuleId.class), + @DiscriminatorMapping(value = "NOTIFICATION_TARGET", schema = NotificationTargetId.class), + @DiscriminatorMapping(value = "NOTIFICATION_TEMPLATE", schema = NotificationTemplateId.class), + @DiscriminatorMapping(value = "OAUTH2_CLIENT", schema = OAuth2ClientId.class), + @DiscriminatorMapping(value = "OTA_PACKAGE", schema = OtaPackageId.class), + @DiscriminatorMapping(value = "QUEUE", schema = QueueId.class), + @DiscriminatorMapping(value = "QUEUE_STATS", schema = QueueStatsId.class), + @DiscriminatorMapping(value = "RPC", schema = RpcId.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = RuleChainId.class), + @DiscriminatorMapping(value = "RULE_NODE", schema = RuleNodeId.class), + @DiscriminatorMapping(value = "TB_RESOURCE", schema = TbResourceId.class), + @DiscriminatorMapping(value = "TENANT", schema = TenantId.class), + @DiscriminatorMapping(value = "TENANT_PROFILE", schema = TenantProfileId.class), + @DiscriminatorMapping(value = "USER", schema = UserId.class), + @DiscriminatorMapping(value = "WIDGETS_BUNDLE", schema = WidgetsBundleId.class), + @DiscriminatorMapping(value = "WIDGET_TYPE", schema = WidgetTypeId.class) + } +) public interface EntityId extends HasUUID, Serializable { //NOSONAR, the constant is closely related to EntityId UUID NULL_UUID = UUID.fromString("13814000-1dd2-11b2-8080-808080808080"); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java index 93851fa813..ae033f769c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityIdFactory.java @@ -114,6 +114,8 @@ public class EntityIdFactory { case DOMAIN -> new DomainId(uuid); case CALCULATED_FIELD -> new CalculatedFieldId(uuid); case AI_MODEL -> new AiModelId(uuid); + case API_KEY -> new ApiKeyId(uuid); + case ADMIN_SETTINGS -> new AdminSettingsId(uuid); default -> throw new IllegalArgumentException("EdgeEventType " + edgeEventType + " is not supported!"); }; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java index cd5b0164db..8bd357a4fc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/EntityViewId.java @@ -25,6 +25,7 @@ import java.util.UUID; /** * Created by Victor Basanets on 8/27/2017. */ +@Schema(allOf = EntityId.class) public class EntityViewId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -38,7 +39,7 @@ public class EntityViewId extends UUIDBased implements EntityId { return new EntityViewId(UUID.fromString(entityViewID)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "ENTITY_VIEW", allowableValues = "ENTITY_VIEW") @Override public EntityType getEntityType() { return EntityType.ENTITY_VIEW; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java index 51e6b8e539..2d369e8b71 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/JobId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class JobId extends UUIDBased implements EntityId { @Serial @@ -33,7 +34,7 @@ public class JobId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "JOB", allowableValues = "JOB") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "JOB", allowableValues = "JOB") @Override public EntityType getEntityType() { return EntityType.JOB; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java index 6a9c11207a..cfd0fd109f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppBundleId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class MobileAppBundleId extends UUIDBased implements EntityId{ @JsonCreator @@ -32,6 +34,7 @@ public class MobileAppBundleId extends UUIDBased implements EntityId{ return new MobileAppBundleId(UUID.fromString(mobileAppId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "MOBILE_APP_BUNDLE", allowableValues = "MOBILE_APP_BUNDLE") @Override public EntityType getEntityType() { return EntityType.MOBILE_APP_BUNDLE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java index f19e52cc46..bf3571bf7e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/MobileAppId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class MobileAppId extends UUIDBased implements EntityId{ @JsonCreator @@ -32,6 +34,7 @@ public class MobileAppId extends UUIDBased implements EntityId{ return new MobileAppId(UUID.fromString(mobileAppId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "MOBILE_APP", allowableValues = "MOBILE_APP") @Override public EntityType getEntityType() { return EntityType.MOBILE_APP; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java index 3ba6bf4ca4..a702648a52 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION", allowableValues = "NOTIFICATION") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java index 5ff42bc0e2..3caa5f1f8c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRequestId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationRequestId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationRequestId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_REQUEST", allowableValues = "NOTIFICATION_REQUEST") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_REQUEST; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java index ff57e4d252..39e2ccf54c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationRuleId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationRuleId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationRuleId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_RULE", allowableValues = "NOTIFICATION_RULE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_RULE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java index d1a1cfd00b..593ce1c1aa 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTargetId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationTargetId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationTargetId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_TARGET", allowableValues = "NOTIFICATION_TARGET") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TARGET; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java index ae2ea582fd..5a1635044a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/NotificationTemplateId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class NotificationTemplateId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class NotificationTemplateId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "NOTIFICATION_TEMPLATE", allowableValues = "NOTIFICATION_TEMPLATE") @Override public EntityType getEntityType() { return EntityType.NOTIFICATION_TEMPLATE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java index 9ef5871135..491643b921 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OAuth2ClientId.java @@ -17,10 +17,12 @@ package org.thingsboard.server.common.data.id; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class OAuth2ClientId extends UUIDBased implements EntityId { @JsonCreator @@ -32,6 +34,7 @@ public class OAuth2ClientId extends UUIDBased implements EntityId { return new OAuth2ClientId(UUID.fromString(oauth2ClientId)); } + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "OAUTH2_CLIENT", allowableValues = "OAUTH2_CLIENT") @Override public EntityType getEntityType() { return EntityType.OAUTH2_CLIENT; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java index 0261b2973e..f9f508f13f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/OtaPackageId.java @@ -23,6 +23,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public class OtaPackageId extends UUIDBased implements EntityId { @Serial @@ -37,7 +38,7 @@ public class OtaPackageId extends UUIDBased implements EntityId { return new OtaPackageId(UUID.fromString(firmwareId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "OTA_PACKAGE", allowableValues = "OTA_PACKAGE") @Override public EntityType getEntityType() { return EntityType.OTA_PACKAGE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java index c7ee46da2a..3c00c08bc1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class QueueId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class QueueId extends UUIDBased implements EntityId { return new QueueId(UUID.fromString(queueId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE", allowableValues = "QUEUE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "QUEUE", allowableValues = "QUEUE") @Override public EntityType getEntityType() { return EntityType.QUEUE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java index 7a921e8755..7869b7bbfe 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/QueueStatsId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class QueueStatsId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class QueueStatsId extends UUIDBased implements EntityId { return new QueueStatsId(UUID.fromString(queueId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "QUEUE_STATS", allowableValues = "QUEUE_STATS") @Override public EntityType getEntityType() { return EntityType.QUEUE_STATS; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java index 559855e194..a03b0138d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RpcId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class RpcId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class RpcId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RPC", allowableValues = "RPC") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RPC", allowableValues = "RPC") @Override public EntityType getEntityType() { return EntityType.RPC; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java index 0512f518af..c77cec808e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleChainId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class RuleChainId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class RuleChainId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RULE_CHAIN", allowableValues = "RULE_CHAIN") @Override public EntityType getEntityType() { return EntityType.RULE_CHAIN; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java index 645acfe5e2..6be3371969 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/RuleNodeId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class RuleNodeId extends UUIDBased implements EntityId { @JsonCreator @@ -29,7 +30,7 @@ public class RuleNodeId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "RULE_NODE", allowableValues = "RULE_NODE") @Override public EntityType getEntityType() { return EntityType.RULE_NODE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java index cf171855b2..b08dcb56b4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TbResourceId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class TbResourceId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public class TbResourceId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TB_RESOURCE", allowableValues = "TB_RESOURCE") @Override public EntityType getEntityType() { return EntityType.TB_RESOURCE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java index f219452c07..47be35fb84 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantId.java @@ -26,6 +26,7 @@ import org.thingsboard.server.common.data.EntityType; import java.io.Serial; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class TenantId extends UUIDBased implements EntityId { @JsonIgnore @@ -54,7 +55,7 @@ public final class TenantId extends UUIDBased implements EntityId { return this.equals(SYS_TENANT_ID); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT", allowableValues = "TENANT") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TENANT", allowableValues = "TENANT") @Override public EntityType getEntityType() { return EntityType.TENANT; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java index 5b46b772ba..7ced9faa43 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/TenantProfileId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public class TenantProfileId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -35,7 +36,7 @@ public class TenantProfileId extends UUIDBased implements EntityId { return new TenantProfileId(UUID.fromString(tenantProfileId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "TENANT_PROFILE", allowableValues = "TENANT_PROFILE") @Override public EntityType getEntityType() { return EntityType.TENANT_PROFILE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java index d4f113bef5..0bb939d958 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/UserId.java @@ -22,7 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; -@Schema +@Schema(allOf = EntityId.class) public class UserId extends UUIDBased implements EntityId { @JsonCreator @@ -34,7 +34,7 @@ public class UserId extends UUIDBased implements EntityId { return new UserId(UUID.fromString(userId)); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "USER", allowableValues = "USER") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "USER", allowableValues = "USER") @Override public EntityType getEntityType() { return EntityType.USER; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java index fe8b40a7b9..cd9dcec8b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetTypeId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class WidgetTypeId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class WidgetTypeId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "WIDGET_TYPE", allowableValues = "WIDGET_TYPE") @Override public EntityType getEntityType() { return EntityType.WIDGET_TYPE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java index f88d81bf74..295ac8c59d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/id/WidgetsBundleId.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.EntityType; import java.util.UUID; +@Schema(allOf = EntityId.class) public final class WidgetsBundleId extends UUIDBased implements EntityId { private static final long serialVersionUID = 1L; @@ -31,7 +32,7 @@ public final class WidgetsBundleId extends UUIDBased implements EntityId { super(id); } - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "string", example = "WIDGETS_BUNDLE", allowableValues = "WIDGETS_BUNDLE") @Override public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java index 28ebcd2095..4de12e3eeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/DummyJobConfiguration.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.job; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -24,6 +25,7 @@ import lombok.ToString; import java.util.List; +@Schema(description = "Dummy job configuration") @Data @EqualsAndHashCode(callSuper = true) @AllArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java index f68012d793..d99fdf1961 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/Job.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.job; +import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.job.task.TaskResult; import java.util.Set; import java.util.UUID; @@ -82,4 +84,36 @@ public class Job extends BaseData implements HasTenantId { return (C) configuration; } + @JsonIgnore + public String getError() { + if (status == JobStatus.CANCELLED) { + return "The task was cancelled"; + } + if (result.getGeneralError() != null) { + return result.getGeneralError(); + } + if (result.getFailedCount() > 0 && result.getResults() != null) { + StringBuilder errorMessage = new StringBuilder(); + for (TaskResult taskResult : result.getResults()) { + if (taskResult.isSuccess() || taskResult.isDiscarded()) { + continue; + } + String error = taskResult.getError(); + if (error == null) { + continue; + } + if (!errorMessage.isEmpty()) { + if (errorMessage.length() + 2 + error.length() > 256) { + errorMessage.append("..."); + break; + } + errorMessage.append("; "); + } + errorMessage.append(error); + } + return errorMessage.toString(); + } + return "Task failed"; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java index 9480ba11c3..6a10dc7ea1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobConfiguration.java @@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; import org.thingsboard.server.common.data.job.task.TaskResult; @@ -27,6 +30,12 @@ import org.thingsboard.server.common.data.job.task.TaskResult; import java.io.Serializable; import java.util.List; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DUMMY", schema = DummyJobConfiguration.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @@ -37,6 +46,7 @@ public abstract class JobConfiguration implements Serializable { @NotBlank private String tasksKey; // internal + @ArraySchema(schema = @Schema(ref = "#/components/schemas/TaskResult")) private List toReprocess; // internal @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java index 2009f10f9f..05c2d51879 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/JobResult.java @@ -20,6 +20,9 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.job.task.TaskResult; @@ -28,6 +31,13 @@ import java.io.Serializable; import java.util.ArrayList; import java.util.List; +@Schema( + description = "Job execution result", + discriminatorProperty = "jobType", + discriminatorMapping = { + @DiscriminatorMapping(value = "DUMMY", schema = DummyJobResult.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "jobType") @JsonSubTypes({ @@ -37,15 +47,24 @@ import java.util.List; @NoArgsConstructor public abstract class JobResult implements Serializable { + @Schema(description = "Count of successfully completed tasks") private int successfulCount; + @Schema(description = "Count of failed tasks") private int failedCount; + @Schema(description = "Count of discarded tasks") private int discardedCount; - private Integer totalCount = null; // set when all tasks are submitted + @Schema(description = "Total number of tasks, set when all tasks are submitted", nullable = true) + private Integer totalCount = null; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/TaskResult")) private List results = new ArrayList<>(); + @Schema(description = "General error message if the job failed") private String generalError; + @Schema(description = "Timestamp of the job start, in milliseconds") private long startTs; + @Schema(description = "Timestamp of the job finish, in milliseconds") private long finishTs; + @Schema(description = "Timestamp of the job cancellation, in milliseconds") private long cancellationTs; @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java index e6a4702b81..37f851f611 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/DummyTaskResult.java @@ -64,6 +64,11 @@ public class DummyTaskResult extends TaskResult { return JobType.DUMMY; } + @Override + public String getError() { + return failure != null ? failure.getError() : null; + } + @Data @NoArgsConstructor @EqualsAndHashCode(callSuper = true) diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java index 761b518e98..e882c74eb6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/job/task/TaskResult.java @@ -46,4 +46,6 @@ public abstract class TaskResult { @JsonIgnore public abstract JobType getJobType(); + public abstract String getError(); + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java index 97f28ba853..925cc73898 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/DataType.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.kv; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +@Schema public enum DataType { BOOLEAN(0), diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java index cb4092f433..595e1aa26b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/kv/TsKvEntry.java @@ -37,4 +37,9 @@ public interface TsKvEntry extends KvEntry, HasVersion { return new TsValue(getTs(), getValueAsString()); } + @JsonIgnore + default boolean isDeletedEntry() { + return getTs() == 0 && (getValue() == null || getValueAsString().isEmpty()); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java index f29863d731..877c727381 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/AbstractMobilePage.java @@ -20,6 +20,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.Views; +@Schema @Data public abstract class AbstractMobilePage implements MobilePage { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java index 83f82a571b..156436225f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/CustomMobilePage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java index 9f6114e4a4..232049adbb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DashboardPage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java index 710d951edc..20f25afe19 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/DefaultMobilePage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java index a45fd90838..783d3f68a9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobileLayoutConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.mobile.layout; import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.AllArgsConstructor; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.Views; import java.util.ArrayList; import java.util.List; +@Schema @Data @Builder @NoArgsConstructor @@ -35,7 +37,7 @@ import java.util.List; @EqualsAndHashCode public class MobileLayoutConfig { - @Schema(description = "List of pages") + @ArraySchema(schema = @Schema(implementation = MobilePage.class)) @JsonView(Views.Public.class) @Valid private List pages = new ArrayList<>(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java index 61fa5a0c46..4ab8388f8b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/MobilePage.java @@ -19,10 +19,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonView; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.Views; import java.io.Serializable; +@Schema( + description = "Configuration for a mobile page", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "DEFAULT", schema = DefaultMobilePage.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = DashboardPage.class), + @DiscriminatorMapping(value = "WEB_VIEW", schema = WebViewPage.class), + @DiscriminatorMapping(value = "CUSTOM", schema = CustomMobilePage.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, @@ -36,6 +48,7 @@ import java.io.Serializable; }) public interface MobilePage extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) @JsonView(Views.Private.class) MobilePageType getType(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java index c8eafbf58c..89e82ab90e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/layout/WebViewPage.java @@ -24,6 +24,7 @@ import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import org.thingsboard.server.common.data.Views; +@Schema @Data @Builder @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java index 72e185327b..20420e3971 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/mobile/qrCodeSettings/BadgePosition.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.mobile.qrCodeSettings; + public enum BadgePosition { RIGHT, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java index b6a4fec9f8..26b0d10efb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.notification; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; @@ -38,6 +39,7 @@ import org.thingsboard.server.common.data.notification.template.NotificationTemp import java.util.List; import java.util.UUID; +@Schema @Data @EqualsAndHashCode(callSuper = true) @NoArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java index 06b02c6183..c54e845a0d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestInfo.java @@ -15,18 +15,23 @@ */ package org.thingsboard.server.common.data.notification; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; import java.util.List; +@Schema @Data @NoArgsConstructor @EqualsAndHashCode(callSuper = true) public class NotificationRequestInfo extends NotificationRequest { + @Schema private String templateName; + @ArraySchema(schema = @Schema(implementation = NotificationDeliveryMethod.class)) private List deliveryMethods; public NotificationRequestInfo(NotificationRequest request, String templateName, List deliveryMethods) { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java index c102cfcae4..0e742b6fb1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/NotificationRequestStats.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.notification; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.NotificationRecipient; @@ -27,14 +28,19 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicInteger; +@Schema(description = "Notification request processing statistics") @Data public class NotificationRequestStats { + @Schema(description = "Number of successfully sent notifications per delivery method", example = "{\"WEB\": 10, \"EMAIL\": 5}") private final Map sent; @JsonIgnore private final AtomicInteger totalSent; + @Schema(description = "Errors per delivery method. Each entry maps recipient name to error message") private final Map> errors; + @Schema(description = "Total number of errors across all delivery methods") private final AtomicInteger totalErrors; + @Schema(description = "General error message if the entire request failed") private String error; @JsonIgnore private final Map> processedRecipients; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java index 9223f53509..bff983f2f4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/DefaultNotificationRuleRecipientsConfig.java @@ -15,17 +15,20 @@ */ package org.thingsboard.server.common.data.notification.rule; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.util.List; import java.util.Map; import java.util.UUID; +@Schema(description = "Default notification rule recipients configuration") @Data -@EqualsAndHashCode(callSuper = true) -public class DefaultNotificationRuleRecipientsConfig extends NotificationRuleRecipientsConfig { +@EqualsAndHashCode +public abstract class DefaultNotificationRuleRecipientsConfig implements NotificationRuleRecipientsConfig { @NotEmpty private List targets; @@ -35,4 +38,114 @@ public class DefaultNotificationRuleRecipientsConfig extends NotificationRuleRec return Map.of(0, targets); } + public static DefaultNotificationRuleRecipientsConfig forTriggerType(NotificationRuleTriggerType triggerType) { + return switch (triggerType) { + case ENTITY_ACTION -> new EntityActionRecipientsConfig(); + case ALARM_COMMENT -> new AlarmCommentRecipientsConfig(); + case ALARM_ASSIGNMENT -> new AlarmAssignmentRecipientsConfig(); + case DEVICE_ACTIVITY -> new DeviceActivityRecipientsConfig(); + case RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT -> new RuleEngineComponentLifecycleEventRecipientsConfig(); + case EDGE_CONNECTION -> new EdgeConnectionRecipientsConfig(); + case EDGE_COMMUNICATION_FAILURE -> new EdgeCommunicationFailureRecipientsConfig(); + case NEW_PLATFORM_VERSION -> new NewPlatformVersionRecipientsConfig(); + case ENTITIES_LIMIT -> new EntitiesLimitRecipientsConfig(); + case API_USAGE_LIMIT -> new ApiUsageLimitRecipientsConfig(); + case RATE_LIMITS -> new RateLimitsRecipientsConfig(); + case TASK_PROCESSING_FAILURE -> new TaskProcessingFailureRecipientsConfig(); + case RESOURCES_SHORTAGE -> new ResourceShortageRecipientsConfig(); + default -> throw new IllegalArgumentException("Unsupported trigger type for default recipients config: " + triggerType); + }; + } + + public static class EntityActionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ENTITY_ACTION; + } + } + + public static class AlarmCommentRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM_COMMENT; + } + } + + public static class AlarmAssignmentRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM_ASSIGNMENT; + } + } + + public static class DeviceActivityRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.DEVICE_ACTIVITY; + } + } + + public static class RuleEngineComponentLifecycleEventRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT; + } + } + + public static class EdgeConnectionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.EDGE_CONNECTION; + } + } + + public static class EdgeCommunicationFailureRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.EDGE_COMMUNICATION_FAILURE; + } + } + + public static class NewPlatformVersionRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.NEW_PLATFORM_VERSION; + } + } + + public static class EntitiesLimitRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ENTITIES_LIMIT; + } + } + + public static class ApiUsageLimitRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.API_USAGE_LIMIT; + } + } + + public static class RateLimitsRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RATE_LIMITS; + } + } + + public static class TaskProcessingFailureRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.TASK_PROCESSING_FAILURE; + } + } + + public static class ResourceShortageRecipientsConfig extends DefaultNotificationRuleRecipientsConfig { + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.RESOURCES_SHORTAGE; + } + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java index 459a15c0fd..fa5a10f983 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/EscalatedNotificationRuleRecipientsConfig.java @@ -15,21 +15,29 @@ */ package org.thingsboard.server.common.data.notification.rule; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.util.List; import java.util.Map; import java.util.UUID; +@Schema(description = "Escalated notification rule recipients configuration") @Data -@EqualsAndHashCode(callSuper = true) -public class EscalatedNotificationRuleRecipientsConfig extends NotificationRuleRecipientsConfig { +@EqualsAndHashCode +public class EscalatedNotificationRuleRecipientsConfig implements NotificationRuleRecipientsConfig { @NotEmpty private Map> escalationTable; + @Override + public NotificationRuleTriggerType getTriggerType() { + return NotificationRuleTriggerType.ALARM; + } + @Override public Map> getTargetsTable() { return escalationTable; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java index fda73b8059..9692175876 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/NotificationRuleRecipientsConfig.java @@ -20,8 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; -import jakarta.validation.constraints.NotNull; -import lombok.Data; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; import java.io.Serializable; @@ -29,18 +29,47 @@ import java.util.List; import java.util.Map; import java.util.UUID; -@JsonIgnoreProperties -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", visible = true, include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) +@Schema( + discriminatorProperty = "triggerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ENTITY_ACTION", schema = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM", schema = EscalatedNotificationRuleRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM_COMMENT", schema = DefaultNotificationRuleRecipientsConfig.AlarmCommentRecipientsConfig.class), + @DiscriminatorMapping(value = "ALARM_ASSIGNMENT", schema = DefaultNotificationRuleRecipientsConfig.AlarmAssignmentRecipientsConfig.class), + @DiscriminatorMapping(value = "DEVICE_ACTIVITY", schema = DefaultNotificationRuleRecipientsConfig.DeviceActivityRecipientsConfig.class), + @DiscriminatorMapping(value = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", schema = DefaultNotificationRuleRecipientsConfig.RuleEngineComponentLifecycleEventRecipientsConfig.class), + @DiscriminatorMapping(value = "EDGE_CONNECTION", schema = DefaultNotificationRuleRecipientsConfig.EdgeConnectionRecipientsConfig.class), + @DiscriminatorMapping(value = "EDGE_COMMUNICATION_FAILURE", schema = DefaultNotificationRuleRecipientsConfig.EdgeCommunicationFailureRecipientsConfig.class), + @DiscriminatorMapping(value = "NEW_PLATFORM_VERSION", schema = DefaultNotificationRuleRecipientsConfig.NewPlatformVersionRecipientsConfig.class), + @DiscriminatorMapping(value = "ENTITIES_LIMIT", schema = DefaultNotificationRuleRecipientsConfig.EntitiesLimitRecipientsConfig.class), + @DiscriminatorMapping(value = "API_USAGE_LIMIT", schema = DefaultNotificationRuleRecipientsConfig.ApiUsageLimitRecipientsConfig.class), + @DiscriminatorMapping(value = "RATE_LIMITS", schema = DefaultNotificationRuleRecipientsConfig.RateLimitsRecipientsConfig.class), + @DiscriminatorMapping(value = "TASK_PROCESSING_FAILURE", schema = DefaultNotificationRuleRecipientsConfig.TaskProcessingFailureRecipientsConfig.class), + @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) + }) +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType", include = JsonTypeInfo.As.EXISTING_PROPERTY, defaultImpl = DefaultNotificationRuleRecipientsConfig.class) @JsonSubTypes({ @Type(name = "ALARM", value = EscalatedNotificationRuleRecipientsConfig.class), + @Type(name = "ENTITY_ACTION", value = DefaultNotificationRuleRecipientsConfig.EntityActionRecipientsConfig.class), + @Type(name = "ALARM_COMMENT", value = DefaultNotificationRuleRecipientsConfig.AlarmCommentRecipientsConfig.class), + @Type(name = "ALARM_ASSIGNMENT", value = DefaultNotificationRuleRecipientsConfig.AlarmAssignmentRecipientsConfig.class), + @Type(name = "DEVICE_ACTIVITY", value = DefaultNotificationRuleRecipientsConfig.DeviceActivityRecipientsConfig.class), + @Type(name = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", value = DefaultNotificationRuleRecipientsConfig.RuleEngineComponentLifecycleEventRecipientsConfig.class), + @Type(name = "EDGE_CONNECTION", value = DefaultNotificationRuleRecipientsConfig.EdgeConnectionRecipientsConfig.class), + @Type(name = "EDGE_COMMUNICATION_FAILURE", value = DefaultNotificationRuleRecipientsConfig.EdgeCommunicationFailureRecipientsConfig.class), + @Type(name = "NEW_PLATFORM_VERSION", value = DefaultNotificationRuleRecipientsConfig.NewPlatformVersionRecipientsConfig.class), + @Type(name = "ENTITIES_LIMIT", value = DefaultNotificationRuleRecipientsConfig.EntitiesLimitRecipientsConfig.class), + @Type(name = "API_USAGE_LIMIT", value = DefaultNotificationRuleRecipientsConfig.ApiUsageLimitRecipientsConfig.class), + @Type(name = "RATE_LIMITS", value = DefaultNotificationRuleRecipientsConfig.RateLimitsRecipientsConfig.class), + @Type(name = "TASK_PROCESSING_FAILURE", value = DefaultNotificationRuleRecipientsConfig.TaskProcessingFailureRecipientsConfig.class), + @Type(name = "RESOURCES_SHORTAGE", value = DefaultNotificationRuleRecipientsConfig.ResourceShortageRecipientsConfig.class) }) -@Data -public abstract class NotificationRuleRecipientsConfig implements Serializable { +public interface NotificationRuleRecipientsConfig extends Serializable { - @NotNull - private NotificationRuleTriggerType triggerType; + NotificationRuleTriggerType getTriggerType(); @JsonIgnore - public abstract Map> getTargetsTable(); + Map> getTargetsTable(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java index fd6b089a42..7511fd7f2a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/ResourcesShortageTrigger.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; import org.thingsboard.server.common.data.id.EntityId; @@ -66,6 +67,7 @@ public class ResourcesShortageTrigger implements NotificationRuleTrigger { return NotificationRuleTriggerType.RESOURCES_SHORTAGE; } + @Schema public enum Resource { CPU, RAM, STORAGE } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java index e6deee9838..0cd2e441ce 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmAssignmentNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -30,15 +32,20 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmAssignmentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -5313556049809972096L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; + @ArraySchema(schema = @Schema(implementation = AlarmSearchStatus.class)) private Set alarmStatuses; @NotEmpty + @ArraySchema(schema = @Schema(implementation = Action.class)) private Set notifyOn; @Override @@ -46,6 +53,7 @@ public class AlarmAssignmentNotificationRuleTriggerConfig implements Notificatio return NotificationRuleTriggerType.ALARM_ASSIGNMENT; } + @Schema public enum Action { ASSIGNED, UNASSIGNED } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java index 3aa3e27638..c651077bed 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmCommentNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -29,15 +31,21 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmCommentNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -9164282098882339645L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; + @ArraySchema(schema = @Schema(implementation = AlarmSearchStatus.class)) private Set alarmStatuses; + @Schema private boolean onlyUserComments; + @Schema private boolean notifyOnCommentUpdate; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java index 20d484efa7..f2be38d6f9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/AlarmNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -31,16 +33,20 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial private static final long serialVersionUID = -7382883720381542344L; + @ArraySchema(schema = @Schema(implementation = String.class)) private Set alarmTypes; + @ArraySchema(schema = @Schema(implementation = AlarmSeverity.class)) private Set alarmSeverities; @NotEmpty + @ArraySchema(schema = @Schema(implementation = AlarmAction.class)) private Set notifyOn; - + @Schema private ClearRule clearRule; @Override @@ -55,6 +61,7 @@ public class AlarmNotificationRuleTriggerConfig implements NotificationRuleTrigg private Set alarmStatuses; } + @Schema public enum AlarmAction { CREATED, SEVERITY_CHANGED, ACKNOWLEDGED, CLEARED } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java index e7051778b2..77c75dfce0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ApiUsageLimitNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,9 +30,12 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class ApiUsageLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = ApiFeature.class)) private Set apiFeatures; + @ArraySchema(schema = @Schema(implementation = ApiUsageStateValue.class)) private Set notifyOn; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java index 2ad8586f75..568ec50d51 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/DeviceActivityNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,11 +30,15 @@ import java.util.UUID; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class DeviceActivityNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set devices; + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set deviceProfiles; // set either devices or profiles @NotEmpty + @ArraySchema(schema = @Schema(implementation = DeviceEvent.class)) private Set notifyOn; @Override @@ -40,6 +46,7 @@ public class DeviceActivityNotificationRuleTriggerConfig implements Notification return NotificationRuleTriggerType.DEVICE_ACTIVITY; } + @Schema public enum DeviceEvent { ACTIVE, INACTIVE } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java index 6def4c1799..8d180ae5bf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeCommunicationFailureNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +29,10 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Builder +@Schema public class EdgeCommunicationFailureNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set edges; // if empty - all edges @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java index 264fa9aa55..3caa45f32a 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EdgeConnectionNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,9 +29,12 @@ import java.util.UUID; @NoArgsConstructor @AllArgsConstructor @Builder +@Schema public class EdgeConnectionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set edges; // if empty - all edges + @ArraySchema(schema = @Schema(implementation = EdgeConnectivityEvent.class)) private Set notifyOn; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java index 7c8997dae5..9b51687a6c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntitiesLimitNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import lombok.AllArgsConstructor; import lombok.Builder; @@ -28,8 +30,10 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class EntitiesLimitNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = EntityType.class)) private Set entityTypes; @Max(1) private float threshold; // in percents, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java index bdc871ad96..d838d899f3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/EntityActionNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -27,8 +29,10 @@ import java.util.Set; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class EntityActionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = EntityType.class)) private Set entityTypes; // maybe add name filter ? private boolean created; private boolean updated; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java index b625efb506..64154b6841 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NewPlatformVersionNotificationRuleTriggerConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data +@Schema public class NewPlatformVersionNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java index 6fb4bbd48b..a58b3d5eef 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/NotificationRuleTriggerConfig.java @@ -20,9 +20,32 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + name = "NotificationRuleTriggerConfig", + description = "Configuration for notification rule trigger", + discriminatorProperty = "triggerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "ALARM", schema = AlarmNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "DEVICE_ACTIVITY", schema = DeviceActivityNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ENTITY_ACTION", schema = EntityActionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ALARM_COMMENT", schema = AlarmCommentNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT", schema = RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ALARM_ASSIGNMENT", schema = AlarmAssignmentNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "NEW_PLATFORM_VERSION", schema = NewPlatformVersionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "ENTITIES_LIMIT", schema = EntitiesLimitNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "API_USAGE_LIMIT", schema = ApiUsageLimitNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RATE_LIMITS", schema = RateLimitsNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "EDGE_CONNECTION", schema = EdgeConnectionNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "EDGE_COMMUNICATION_FAILURE", schema = EdgeCommunicationFailureNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "TASK_PROCESSING_FAILURE", schema = TaskProcessingFailureNotificationRuleTriggerConfig.class), + @DiscriminatorMapping(value = "RESOURCES_SHORTAGE", schema = ResourcesShortageNotificationRuleTriggerConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "triggerType") @JsonSubTypes({ @@ -43,6 +66,7 @@ import java.io.Serializable; }) public interface NotificationRuleTriggerConfig extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) NotificationRuleTriggerType getTriggerType(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java index 982e20284c..8f2ae1cdc9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RateLimitsNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,8 +30,10 @@ import java.util.stream.Collectors; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class RateLimitsNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = LimitedApi.class)) private Set apis; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java index 77782a73cf..f62ed241ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/ResourcesShortageNotificationRuleTriggerConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import lombok.AllArgsConstructor; import lombok.Builder; @@ -27,6 +28,7 @@ import java.io.Serial; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class ResourcesShortageNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Serial diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java index 4b46c26d65..7e987f1d0b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -28,14 +30,17 @@ import java.util.UUID; @AllArgsConstructor @NoArgsConstructor @Builder +@Schema public class RuleEngineComponentLifecycleEventNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set ruleChains; // if empty - all rule chains - + @ArraySchema(schema = @Schema(implementation = ComponentLifecycleEvent.class)) private Set ruleChainEvents; // available options: STARTED, UPDATED, STOPPED. if empty - all events private boolean onlyRuleChainLifecycleFailures; private boolean trackRuleNodeEvents; + @ArraySchema(schema = @Schema(implementation = ComponentLifecycleEvent.class)) private Set ruleNodeEvents; // available options: STARTED, UPDATED, STOPPED. if empty - all events private boolean onlyRuleNodeLifecycleFailures; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java index 6a88000124..59fd219705 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/rule/trigger/config/TaskProcessingFailureNotificationRuleTriggerConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.rule.trigger.config; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @Data @Builder +@Schema public class TaskProcessingFailureNotificationRuleTriggerConfig implements NotificationRuleTriggerConfig { @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java index b5b573a452..2a708ed998 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/MobileAppNotificationDeliveryMethodConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +@Schema @Data public class MobileAppNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java index 5eecb63b1e..eb3236ec00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationDeliveryMethodConfig.java @@ -20,10 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; import java.io.Serializable; +@Schema( + discriminatorProperty = "method", + discriminatorMapping = { + @DiscriminatorMapping(value = "SLACK", schema = SlackNotificationDeliveryMethodConfig.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppNotificationDeliveryMethodConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java index fbfc9f9d95..d5facf4d00 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/NotificationSettings.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import java.io.Serializable; import java.util.Map; +@Schema @Data public class NotificationSettings implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java index f2a8a0d6da..283c09bbd8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/settings/SlackNotificationDeliveryMethodConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.notification.settings; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import org.thingsboard.server.common.data.notification.NotificationDeliveryMethod; +@Schema @Data public class SlackNotificationDeliveryMethodConfig implements NotificationDeliveryMethodConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java index a241616712..d551aab4c9 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/MicrosoftTeamsNotificationTargetConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class MicrosoftTeamsNotificationTargetConfig extends NotificationTargetConfig implements NotificationRecipient { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java index 74a2544d11..932d954d58 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTarget.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -29,6 +30,7 @@ import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class NotificationTarget extends BaseData implements HasTenantId, HasName, ExportableEntity { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java index 98468b9521..2f7f2615bd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/NotificationTargetConfig.java @@ -20,12 +20,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.notification.targets.platform.PlatformUsersNotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.slack.SlackNotificationTargetConfig; import org.thingsboard.server.common.data.validation.Length; import org.thingsboard.server.common.data.validation.NoXss; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "PLATFORM_USERS", schema = PlatformUsersNotificationTargetConfig.class), + @DiscriminatorMapping(value = "SLACK", schema = SlackNotificationTargetConfig.class), + @DiscriminatorMapping(value = "MICROSOFT_TEAMS", schema = MicrosoftTeamsNotificationTargetConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java index 7dc10cd6ee..96dda12884 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedTenantAdministratorsFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AffectedTenantAdministratorsFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java index c1e600d5bb..b2c5348d3b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AffectedUserFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AffectedUserFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java index fbe42938f0..119859db7c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/AllUsersFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class AllUsersFilter implements SystemLevelUsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java index 34df18244f..bc63801124 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/CustomerUsersFilter.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; import lombok.Data; import java.util.UUID; +@Schema @Data public class CustomerUsersFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java index a7dfdff074..375b47cc03 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/OriginatorEntityOwnerUsersFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class OriginatorEntityOwnerUsersFilter implements UsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java index 1d97552c2e..df26d6ca65 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/PlatformUsersNotificationTargetConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -22,10 +23,12 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class PlatformUsersNotificationTargetConfig extends NotificationTargetConfig { + @Schema(implementation = UsersFilter.class) @NotNull @Valid private UsersFilter usersFilter; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java index 8c5330d165..8daf518089 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/SystemAdministratorsFilter.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class SystemAdministratorsFilter implements SystemLevelUsersFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java index 6f73f45e08..3b551b4fbf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/TenantAdministratorsFilter.java @@ -15,15 +15,20 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.Set; import java.util.UUID; +@Schema @Data public class TenantAdministratorsFilter implements SystemLevelUsersFilter { + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set tenantsIds; + @ArraySchema(schema = @Schema(implementation = UUID.class)) private Set tenantProfilesIds; @Override diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java index e0adb26d59..2534bc8dbc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UserListFilter.java @@ -15,15 +15,19 @@ */ package org.thingsboard.server.common.data.notification.targets.platform; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import java.util.List; import java.util.UUID; +@Schema @Data public class UserListFilter implements UsersFilter { + @ArraySchema(schema = @Schema(implementation = UUID.class)) @NotEmpty private List usersIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java index 596972be80..2b3628012c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/platform/UsersFilter.java @@ -20,7 +20,22 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "USER_LIST", schema = UserListFilter.class), + @DiscriminatorMapping(value = "CUSTOMER_USERS", schema = CustomerUsersFilter.class), + @DiscriminatorMapping(value = "TENANT_ADMINISTRATORS", schema = TenantAdministratorsFilter.class), + @DiscriminatorMapping(value = "AFFECTED_TENANT_ADMINISTRATORS", schema = AffectedTenantAdministratorsFilter.class), + @DiscriminatorMapping(value = "SYSTEM_ADMINISTRATORS", schema = SystemAdministratorsFilter.class), + @DiscriminatorMapping(value = "ALL_USERS", schema = AllUsersFilter.class), + @DiscriminatorMapping(value = "ORIGINATOR_ENTITY_OWNER_USERS", schema = OriginatorEntityOwnerUsersFilter.class), + @DiscriminatorMapping(value = "AFFECTED_USER", schema = AffectedUserFilter.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java index 4febcc1d72..0b8992309e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackConversation.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.notification.targets.slack; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; import lombok.AllArgsConstructor; @@ -28,6 +29,7 @@ import org.thingsboard.server.common.data.notification.targets.NotificationRecip import static org.apache.commons.lang3.StringUtils.isEmpty; +@Schema @Data @NoArgsConstructor @AllArgsConstructor diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java index 11c84549d2..ba21291465 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/targets/slack/SlackNotificationTargetConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.targets.slack; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.NotNull; import lombok.Data; @@ -22,6 +23,7 @@ import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.notification.targets.NotificationTargetConfig; import org.thingsboard.server.common.data.notification.targets.NotificationTargetType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SlackNotificationTargetConfig extends NotificationTargetConfig { @@ -31,6 +33,7 @@ public class SlackNotificationTargetConfig extends NotificationTargetConfig { @Valid private SlackConversation conversation; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the notification target") @Override public NotificationTargetType getType() { return NotificationTargetType.SLACK; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java index a679d0e6e7..e8a9784310 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/DeliveryMethodNotificationTemplate.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.NoArgsConstructor; @@ -27,6 +29,18 @@ import org.thingsboard.server.common.data.notification.NotificationDeliveryMetho import java.util.List; +@Schema( + description = "Base template for different delivery methods", + discriminatorProperty = "method", + discriminatorMapping = { + @DiscriminatorMapping(value = "WEB", schema = WebDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "SMS", schema = SmsDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "SLACK", schema = SlackDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "MICROSOFT_TEAMS", schema = MicrosoftTeamsDeliveryMethodNotificationTemplate.class), + @DiscriminatorMapping(value = "MOBILE_APP", schema = MobileAppDeliveryMethodNotificationTemplate.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "method") @JsonSubTypes({ diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java index 59293e9429..261c127ce4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/EmailDeliveryMethodNotificationTemplate.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.template; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -30,6 +31,7 @@ import java.util.List; @NoArgsConstructor @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) +@Schema public class EmailDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { @NoXss(fieldName = "email subject") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java index 97def8310e..4d03afd7e2 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MicrosoftTeamsDeliveryMethodNotificationTemplate.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.notification.template; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; @@ -30,8 +31,11 @@ import java.util.UUID; @ToString(callSuper = true) public class MicrosoftTeamsDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema private String subject; + @Schema private String themeColor; + @Schema private Button button; private final List templatableValues = List.of( @@ -82,6 +86,7 @@ public class MicrosoftTeamsDeliveryMethodNotificationTemplate extends DeliveryMe this.setEntityIdInState = other.setEntityIdInState; } + @Schema public enum LinkType { LINK, DASHBOARD } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java index 248d2dedf5..c7d8689f45 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/MobileAppDeliveryMethodNotificationTemplate.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.notification.template; import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -31,8 +32,10 @@ import java.util.List; @ToString(callSuper = true) public class MobileAppDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema(description = "Subject line for the mobile notification", example = "New Message Received") @NotEmpty private String subject; + @Schema(description = "Additional JSON configuration for web buttons/actions", type = "object") private JsonNode additionalConfig; private final List templatableValues = List.of( diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java index 9385c00abf..c0229678f6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/notification/template/WebDeliveryMethodNotificationTemplate.java @@ -19,6 +19,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.databind.node.TextNode; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -37,10 +38,12 @@ import java.util.Optional; @ToString(callSuper = true) public class WebDeliveryMethodNotificationTemplate extends DeliveryMethodNotificationTemplate implements HasSubject { + @Schema(description = "Subject line for the web notification", example = "New Message Received") @NoXss(fieldName = "web notification subject") @Length(fieldName = "web notification subject", max = 150, message = "cannot be longer than 150 chars") @NotEmpty private String subject; + @Schema(description = "Additional JSON configuration for web buttons/actions") private JsonNode additionalConfig; private final List templatableValues = List.of( diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java index 23c0241d51..d5f61fdbeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/oauth2/OAuth2Client.java @@ -93,7 +93,10 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp @Schema(description = "List of platforms for which usage of the OAuth2 client is allowed (empty for all allowed)") @Length(fieldName = "platforms") private List platforms; - @Schema(description = "Additional info of OAuth2 client (e.g. providerName)", requiredMode = Schema.RequiredMode.REQUIRED) + @Schema(description = "Additional info of OAuth2 client. " + + "Must include: 'providerName' (string, name of the OAuth2 provider).", + requiredMode = Schema.RequiredMode.REQUIRED, + example = "{\"providerName\":\"Google\"}") private JsonNode additionalInfo; public OAuth2Client() { @@ -128,4 +131,5 @@ public class OAuth2Client extends BaseDataWithAdditionalInfo imp public String getName() { return title; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java index 68f06d94d4..25cd734d77 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/page/SortOrder.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.page; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data @@ -36,6 +37,7 @@ public class SortOrder { return new SortOrder(property, direction); } + @Schema(name = "SortOrderDirection") public static enum Direction { ASC, DESC } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java index a678338864..3aabbae0a8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AbstractDataQuery.java @@ -49,4 +49,16 @@ public abstract class AbstractDataQuery extends En this.latestValues = latestValues; } + public AbstractDataQuery(EntityFilter entityFilter, + T pageLink, + List entityFields, + List latestValues, + List keyFilters, + ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + this.pageLink = pageLink; + this.entityFields = entityFields; + this.latestValues = latestValues; + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index f9d2e8d3d8..d45335021f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -31,6 +32,7 @@ import java.util.List; @AllArgsConstructor @Data @ToString +@Schema public class AlarmCountQuery extends EntityCountQuery { private long startTs; private long endTs; @@ -49,4 +51,8 @@ public class AlarmCountQuery extends EntityCountQuery { super(entityFilter, keyFilters); } + public AlarmCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, keyFilters, keyFiltersOperation); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java index 8d2ae6fae3..072f56abd1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmDataQuery.java @@ -39,8 +39,13 @@ public class AlarmDataQuery extends AbstractDataQuery { this.alarmFields = alarmFields; } + public AlarmDataQuery(EntityFilter entityFilter, AlarmDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, List alarmFields, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + this.alarmFields = alarmFields; + } + @JsonIgnore public AlarmDataQuery next() { - return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields); + return new AlarmDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, alarmFields, getKeyFiltersOperation()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java new file mode 100644 index 0000000000..536b70e4ff --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/AvailableEntityKeysV2.java @@ -0,0 +1,99 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; +import org.jspecify.annotations.Nullable; +import org.thingsboard.server.common.data.AttributeScope; +import org.thingsboard.server.common.data.EntityType; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@Schema( + description = """ + Contains unique time series and attribute key names discovered from entities matching a query, + optionally including a sample value for each key.""" +) +@JsonInclude(JsonInclude.Include.NON_NULL) +public record AvailableEntityKeysV2( + @Schema( + description = "Set of entity types found among the matched entities.", + example = "[\"DEVICE\", \"ASSET\"]", + requiredMode = Schema.RequiredMode.REQUIRED + ) + Set entityTypes, + + @ArraySchema( + arraySchema = @Schema( + description = """ + List of unique time series keys available on the matched entities, sorted alphabetically. + Omitted when timeseries keys were not requested.""", + nullable = true + ), + schema = @Schema(implementation = KeyInfo.class) + ) + @Nullable List timeseries, + + @Schema( + description = """ + Map of attribute scope to the list of unique attribute keys available on the matched entities. + Only scopes supported by the matched entity types are included. + Omitted when attribute keys were not requested or when none of the requested scopes apply to the matched entity types.""", + nullable = true + ) + @Nullable Map> attributes +) { + + @Schema(description = "Key name with an optional sample value.") + @JsonInclude(JsonInclude.Include.NON_NULL) + public record KeyInfo( + @Schema( + description = "Key name.", + example = "temperature", + requiredMode = Schema.RequiredMode.REQUIRED + ) + String key, + + @Schema( + description = "Most recent sample value for this key across the matched entities. Omitted when samples were not requested.", + nullable = true + ) + @Nullable KeySample sample + ) {} + + @Schema(description = "Most recent value and its timestamp.") + public record KeySample( + @Schema( + description = "Timestamp in milliseconds since epoch.", example = "1707000000000", + requiredMode = Schema.RequiredMode.REQUIRED + ) + long ts, + + @Schema( + description = "Sample value.", + example = "23.5", + requiredMode = Schema.RequiredMode.REQUIRED, + implementation = Object.class + ) + JsonNode value + ) {} + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java index 74c4f87c25..c469a7ef75 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/BooleanFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class BooleanFilterPredicate implements SimpleKeyFilterPredicate { @@ -28,6 +30,7 @@ public class BooleanFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.BOOLEAN; } + @Schema public enum BooleanOperation { EQUAL, NOT_EQUAL diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java index 4fb663c4c1..248211ad92 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexFilterPredicate.java @@ -15,14 +15,18 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.util.List; @Data +@Schema public class ComplexFilterPredicate implements KeyFilterPredicate { private ComplexOperation operation; + @ArraySchema(schema = @Schema(ref = "#/components/schemas/KeyFilterPredicate")) private List predicates; @Override @@ -30,8 +34,4 @@ public class ComplexFilterPredicate implements KeyFilterPredicate { return FilterPredicateType.COMPLEX; } - public enum ComplexOperation { - AND, - OR - } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java new file mode 100644 index 0000000000..f08646ec4e --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/ComplexOperation.java @@ -0,0 +1,24 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.query; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(name = "AlarmRuleComplexOperation") +public enum ComplexOperation { + AND, + OR +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java index 97fe9972ba..5451e557e7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/DynamicValue.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.RequiredArgsConstructor; import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; +@Schema @Data @RequiredArgsConstructor public class DynamicValue implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java index b4680bfd5b..ee39fe7d77 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityCountQuery.java @@ -34,6 +34,9 @@ public class EntityCountQuery { @Getter protected List keyFilters; + @Getter + private ComplexOperation keyFiltersOperation; + public EntityCountQuery() { } @@ -45,4 +48,14 @@ public class EntityCountQuery { this.entityFilter = entityFilter; this.keyFilters = keyFilters; } + + public EntityCountQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation) { + this.entityFilter = entityFilter; + this.keyFilters = keyFilters; + this.keyFiltersOperation = keyFiltersOperation; + } + + public ComplexOperation getKeyFiltersOperationOrDefault() { + return keyFiltersOperation != null ? keyFiltersOperation : ComplexOperation.AND; + } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java index 959e3e3af6..f24ffd5821 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataQuery.java @@ -34,9 +34,13 @@ public class EntityDataQuery extends AbstractDataQuery { super(entityFilter, pageLink, entityFields, latestValues, keyFilters); } + public EntityDataQuery(EntityFilter entityFilter, EntityDataPageLink pageLink, List entityFields, List latestValues, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, pageLink, entityFields, latestValues, keyFilters, keyFiltersOperation); + } + @JsonIgnore public EntityDataQuery next() { - return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters); + return new EntityDataQuery(getEntityFilter(), getPageLink().nextPageLink(), entityFields, latestValues, keyFilters, getKeyFiltersOperation()); } } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java index dae84fa598..c7b933add5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityDataSortOrder.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class EntityDataSortOrder { @@ -34,6 +36,7 @@ public class EntityDataSortOrder { this.direction = direction; } + @Schema public enum Direction { ASC, DESC } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java index 4263a4aeea..bf75f7e9ca 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityFilter.java @@ -19,12 +19,34 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; import static org.thingsboard.server.common.data.query.AliasEntityId.resolveAliasEntityId; +@Schema( + description = "Filter for selecting entities", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "singleEntity", schema = SingleEntityFilter.class), + @DiscriminatorMapping(value = "entityList", schema = EntityListFilter.class), + @DiscriminatorMapping(value = "entityName", schema = EntityNameFilter.class), + @DiscriminatorMapping(value = "entityType", schema = EntityTypeFilter.class), + @DiscriminatorMapping(value = "assetType", schema = AssetTypeFilter.class), + @DiscriminatorMapping(value = "deviceType", schema = DeviceTypeFilter.class), + @DiscriminatorMapping(value = "edgeType", schema = EdgeTypeFilter.class), + @DiscriminatorMapping(value = "entityViewType", schema = EntityViewTypeFilter.class), + @DiscriminatorMapping(value = "apiUsageState", schema = ApiUsageStateFilter.class), + @DiscriminatorMapping(value = "relationsQuery", schema = RelationsQueryFilter.class), + @DiscriminatorMapping(value = "assetSearchQuery", schema = AssetSearchQueryFilter.class), + @DiscriminatorMapping(value = "deviceSearchQuery", schema = DeviceSearchQueryFilter.class), + @DiscriminatorMapping(value = "entityViewSearchQuery", schema = EntityViewSearchQueryFilter.class), + @DiscriminatorMapping(value = "edgeSearchQuery", schema = EdgeSearchQueryFilter.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java index 8501ddca12..3103a1a161 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/EntityViewTypeFilter.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; import lombok.Getter; import lombok.Setter; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java index 6a080e3b28..cb844c5c44 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/FilterPredicateValue.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonCreator; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; import lombok.Getter; @@ -25,6 +26,7 @@ import org.thingsboard.server.common.data.validation.NoXss; import java.io.Serializable; +@Schema @Data public class FilterPredicateValue implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java index 949ccc9bc5..cb5c7a835f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/KeyFilterPredicate.java @@ -18,9 +18,21 @@ package org.thingsboard.server.common.data.query; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import java.io.Serializable; +@Schema( + description = "Filter predicate for key-based filtering", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "STRING", schema = StringFilterPredicate.class), + @DiscriminatorMapping(value = "NUMERIC", schema = NumericFilterPredicate.class), + @DiscriminatorMapping(value = "BOOLEAN", schema = BooleanFilterPredicate.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexFilterPredicate.class) + } +) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java index 0b19d56ea6..0d7953defc 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/NumericFilterPredicate.java @@ -15,8 +15,10 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class NumericFilterPredicate implements SimpleKeyFilterPredicate { @@ -28,6 +30,7 @@ public class NumericFilterPredicate implements SimpleKeyFilterPredicate return FilterPredicateType.NUMERIC; } + @Schema public enum NumericOperation { EQUAL, NOT_EQUAL, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java index 8c063201f7..027c4a7c5c 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/RelationsQueryFilter.java @@ -15,15 +15,17 @@ */ package org.thingsboard.server.common.data.query; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.EntityType; -import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; import java.util.List; import java.util.Set; +@Schema @Data public class RelationsQueryFilter implements EntityFilter { @@ -33,6 +35,7 @@ public class RelationsQueryFilter implements EntityFilter { } private AliasEntityId rootEntity; + @JsonProperty("multiRoot") private boolean isMultiRoot; private EntityType multiRootEntitiesType; private Set multiRootEntityIds; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java index 2a06eb57c2..f85c259034 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SimpleKeyFilterPredicate.java @@ -15,8 +15,12 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "Base interface for simple key filter predicates") public interface SimpleKeyFilterPredicate extends KeyFilterPredicate { + @Schema(description = "The value associated with the filter predicate") FilterPredicateValue getValue(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java index 046f86f414..c0bbd43378 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/SingleEntityFilter.java @@ -16,7 +16,6 @@ package org.thingsboard.server.common.data.query; import lombok.Data; -import org.thingsboard.server.common.data.id.EntityId; @Data public class SingleEntityFilter implements EntityFilter { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java index 915dc6ef5f..da127a58b3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/query/StringFilterPredicate.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.query; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import lombok.Data; @Data +@Schema public class StringFilterPredicate implements SimpleKeyFilterPredicate { private StringOperation operation; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java index 76918cc8d9..fc24327a6d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/relation/EntityRelation.java @@ -103,7 +103,9 @@ public class EntityRelation implements HasVersion, Serializable, EdqsObject { this.version = entityRelation.getVersion(); } - @Schema(description = "Additional parameters of the relation", implementation = JsonNode.class) + @Schema(description = "Additional parameters of the relation.", + implementation = JsonNode.class, + example = "{\"description\":\"Power supply connection\"}") public JsonNode getAdditionalInfo() { return BaseDataWithAdditionalInfo.getJson(() -> additionalInfo, () -> additionalInfoBytes); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java index 4d8bb35919..f81bf841e5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChain.java @@ -41,7 +41,7 @@ public class RuleChain extends BaseDataWithAdditionalInfo implement private static final long serialVersionUID = -5656679015121935465L; - @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) + @Schema(description = "JSON object with Tenant Id.", accessMode = Schema.AccessMode.READ_ONLY) private TenantId tenantId; @NoXss @Length(fieldName = "name") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java new file mode 100644 index 0000000000..439fcc01d1 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainDetails.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.rule; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; + +import java.util.ArrayList; +import java.util.List; + +@Data +@EqualsAndHashCode(callSuper = true) +public class RuleChainDetails extends RuleChain { + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @Schema(description = "List of sticky notes placed on the rule chain canvas") + private List notes; + + public RuleChainDetails() { + super(); + } + + public RuleChainDetails(RuleChain ruleChain) { + super(ruleChain); + } + + public RuleChainDetails(RuleChainDetails ruleChainDetails) { + super(ruleChainDetails); + this.notes = ruleChainDetails.getNotes() != null ? new ArrayList<>(ruleChainDetails.getNotes()) : null; + } + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java index c9fdd1a4c6..9109df0f88 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainMetaData.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.rule; +import com.fasterxml.jackson.annotation.JsonInclude; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.HasVersion; @@ -48,6 +49,10 @@ public class RuleChainMetaData implements HasVersion { @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "List of JSON objects that represent connections between rule nodes and other rule chains.") private List ruleChainConnections; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + @Schema(requiredMode = Schema.RequiredMode.NOT_REQUIRED, description = "List of sticky notes placed on the rule chain canvas") + private List notes; + public void addConnectionInfo(int fromIndex, int toIndex, String type) { NodeConnectionInfo connectionInfo = new NodeConnectionInfo(); connectionInfo.setFromIndex(fromIndex); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java new file mode 100644 index 0000000000..97caa0ffbe --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleChainNote.java @@ -0,0 +1,67 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.rule; + +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.ToString; + +/** + * Represents a sticky note on the rule chain canvas. + * Notes are purely visual metadata and are stored as part of the rule chain. + */ +@Schema +@Data +@JsonInclude(JsonInclude.Include.NON_NULL) +public class RuleChainNote { + + @Schema(description = "Unique identifier of the note on the canvas") + private String id; + + @Schema(description = "Horizontal position of the note on the canvas, in pixels") + private int x; + + @Schema(description = "Vertical position of the note on the canvas, in pixels") + private int y; + + @Schema(description = "Width of the note, in pixels") + private int width; + + @Schema(description = "Height of the note, in pixels") + private int height; + + @ToString.Exclude + @Schema(description = "Markdown or HTML content of the note") + private String content; + + @Schema(description = "Background color of the note in CSS hex format, e.g. '#FFF9C4'") + private String backgroundColor; + + @Schema(description = "Border color of the note in CSS hex format, e.g. '#E6C800'") + private String borderColor; + + @Schema(description = "Border width of the note in pixels") + private Integer borderWidth; + + @Schema(description = "Whether to apply the default markdown stylesheet to the note content") + private Boolean applyDefaultMarkdownStyle; + + @ToString.Exclude + @Schema(description = "Custom CSS styles applied to the note content") + private String markdownCss; + +} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java index 981d5c2598..ac1e85dbeb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/rule/RuleNode.java @@ -105,7 +105,12 @@ public class RuleNode extends BaseDataWithAdditionalInfo implements return super.getCreatedTime(); } - @Schema(description = "Additional parameters of the rule node. Contains 'layoutX' and 'layoutY' properties for visualization.", implementation = JsonNode.class) + @Schema(description = "Additional parameters of the rule node. " + + "May include: 'layoutX' (number, X coordinate for visualization), " + + "'layoutY' (number, Y coordinate for visualization), " + + "'description' (string).", + implementation = JsonNode.class, + example = "{\"layoutX\":320,\"layoutY\":160,\"description\":\"Filter temperature data\"}") @Override public JsonNode getAdditionalInfo() { return super.getAdditionalInfo(); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java index 90b777c11c..e26287708b 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/PlatformTwoFaSettings.java @@ -16,6 +16,8 @@ package org.thingsboard.server.common.data.security.model.mfa; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotNull; @@ -29,9 +31,11 @@ import java.util.List; import java.util.Optional; @Data +@Schema(description = "Platform Two-Factor Authentication settings") @JsonIgnoreProperties(ignoreUnknown = true) public class PlatformTwoFaSettings { + @ArraySchema(schema = @Schema(implementation = TwoFaProviderConfig.class)) @Valid @NotNull private List providers; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java index b24bd68c52..f613b37220 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/AccountTwoFaSettings.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; import java.util.LinkedHashMap; @Data +@Schema(description = "Account Two-Factor Authentication Settings") public class AccountTwoFaSettings { private LinkedHashMap configs; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java index 3c673c1e8e..74a804688d 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/BackupCodeTwoFaAccountConfig.java @@ -16,6 +16,7 @@ package org.thingsboard.server.common.data.security.model.mfa.account; import com.fasterxml.jackson.annotation.JsonGetter; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotEmpty; import lombok.Data; import lombok.EqualsAndHashCode; @@ -23,6 +24,7 @@ import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProvi import java.util.Set; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class BackupCodeTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java index 5960fa8a34..5e87cf5ce7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/EmailTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class EmailTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java index 574a8e01b3..e5903fecf5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/OtpBasedTwoFaAccountConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +@Schema @Data @EqualsAndHashCode(callSuper = true) public abstract class OtpBasedTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java index 97755f6195..1a9b2b718e 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/SmsTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @EqualsAndHashCode(callSuper = true) @Data public class SmsTwoFaAccountConfig extends OtpBasedTwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java index 384ca39e3b..799e381b4f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TotpTwoFaAccountConfig.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.security.model.mfa.account; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class TotpTwoFaAccountConfig extends TwoFaAccountConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java index d8783178e0..06a5088117 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/account/TwoFaAccountConfig.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import org.thingsboard.server.common.data.security.model.mfa.provider.TwoFaProviderType; @@ -35,6 +37,16 @@ import java.io.Serializable; @Type(name = "EMAIL", value = EmailTwoFaAccountConfig.class), @Type(name = "BACKUP_CODE", value = BackupCodeTwoFaAccountConfig.class) }) +@Schema( + description = "Base configuration for two-factor authentication accounts", + discriminatorProperty = "providerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TOTP", schema = TotpTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "SMS", schema = SmsTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailTwoFaAccountConfig.class), + @DiscriminatorMapping(value = "BACKUP_CODE", schema = BackupCodeTwoFaAccountConfig.class) + } +) @Data public abstract class TwoFaAccountConfig implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java index 18da5115f2..5346f996fd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/BackupCodeTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import lombok.Data; @Data +@Schema public class BackupCodeTwoFaProviderConfig implements TwoFaProviderConfig { @Min(value = 1, message = "must be greater than 0") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java index 56527ebf8a..f9b6d2e618 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/EmailTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class EmailTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java index 35f18d50e0..bd6b8f4332 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/OtpBasedTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Min; import lombok.Data; @Data +@Schema public abstract class OtpBasedTwoFaProviderConfig implements TwoFaProviderConfig { @Min(value = 1, message = "is required") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java index e848c7b942..dba2372989 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/SmsTwoFaProviderConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import lombok.Data; @@ -22,6 +23,7 @@ import lombok.EqualsAndHashCode; @EqualsAndHashCode(callSuper = true) @Data +@Schema public class SmsTwoFaProviderConfig extends OtpBasedTwoFaProviderConfig { @NotBlank(message = "is required") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java index de91a5b732..64cabcbbbd 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TotpTwoFaProviderConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.security.model.mfa.provider; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Data; @Data +@Schema public class TotpTwoFaProviderConfig implements TwoFaProviderConfig { @NotBlank diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java index 42c3556b08..1bb883b375 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/security/model/mfa/provider/TwoFaProviderConfig.java @@ -20,7 +20,19 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; +@Schema( + description = "Two-factor authentication provider configuration", + discriminatorProperty = "providerType", + discriminatorMapping = { + @DiscriminatorMapping(value = "TOTP", schema = TotpTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "SMS", schema = SmsTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "EMAIL", schema = EmailTwoFaProviderConfig.class), + @DiscriminatorMapping(value = "BACKUP_CODE", schema = BackupCodeTwoFaProviderConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( use = JsonTypeInfo.Id.NAME, diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java index 02f1db557b..e35f8da2e8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmppSmsProviderConfiguration.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.data.sms.config; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema @Data public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "SMPP version", allowableValues = "3.3, 3.4", requiredMode = Schema.RequiredMode.REQUIRED) @@ -89,7 +90,7 @@ public class SmppSmsProviderConfiguration implements SmsProviderConfiguration { @Schema(description = "Address range", requiredMode = Schema.RequiredMode.NOT_REQUIRED) private String addressRange; - @Schema(allowableValues = {"0-10" ,"13-14"}, + @Schema(minimum = "0", maximum = "14", description = "0 - SMSC Default Alphabet (ASCII for short and long code and to GSM for toll-free, used as default)\n" + "1 - IA5 (ASCII for short and long code, Latin 9 for toll-free (ISO-8859-9))\n" + "2 - Octet Unspecified (8-bit binary)\n" + diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java index 5bba9a578b..5b6bceec35 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sms/config/SmsProviderConfiguration.java @@ -19,6 +19,8 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo( @@ -30,6 +32,15 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo; @JsonSubTypes.Type(value = TwilioSmsProviderConfiguration.class, name = "TWILIO"), @JsonSubTypes.Type(value = SmppSmsProviderConfiguration.class, name = "SMPP") }) +@Schema( + description = "Base configuration for SMS providers", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "AWS_SNS", schema = AwsSnsSmsProviderConfiguration.class), + @DiscriminatorMapping(value = "TWILIO", schema = TwilioSmsProviderConfiguration.class), + @DiscriminatorMapping(value = "SMPP", schema = SmppSmsProviderConfiguration.class) + } +) public interface SmsProviderConfiguration { @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java index 883a8e383b..2373f53283 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/DeviceExportData.java @@ -18,17 +18,23 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.security.DeviceCredentials; +@Schema @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Data public class DeviceExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.DEVICE; } + @JsonProperty(index = 3) @JsonIgnoreProperties({"id", "deviceId", "createdTime", "version"}) private DeviceCredentials credentials; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java index 01b07026fa..18358c8d68 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/EntityExportData.java @@ -23,11 +23,25 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; import com.fasterxml.jackson.annotation.JsonTypeInfo.As; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +import org.thingsboard.server.common.data.Customer; +import org.thingsboard.server.common.data.Dashboard; +import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.EntityView; import org.thingsboard.server.common.data.ExportableEntity; +import org.thingsboard.server.common.data.TbResource; +import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.asset.Asset; +import org.thingsboard.server.common.data.asset.AssetProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.id.EntityId; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.targets.NotificationTarget; +import org.thingsboard.server.common.data.notification.template.NotificationTemplate; import org.thingsboard.server.common.data.relation.EntityRelation; import org.thingsboard.server.common.data.sync.JsonTbEntity; @@ -42,11 +56,44 @@ import java.util.Map; @Type(name = "RULE_CHAIN", value = RuleChainExportData.class), @Type(name = "WIDGET_TYPE", value = WidgetTypeExportData.class), @Type(name = "WIDGETS_BUNDLE", value = WidgetsBundleExportData.class), - @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class) + @Type(name = "OTA_PACKAGE", value = OtaPackageExportData.class), + @Type(name = "CUSTOMER", value = EntityExportData.CustomerExportData.class), + @Type(name = "TB_RESOURCE", value = EntityExportData.TbResourceExportData.class), + @Type(name = "DASHBOARD", value = EntityExportData.DashboardExportData.class), + @Type(name = "ASSET_PROFILE", value = EntityExportData.AssetProfileExportData.class), + @Type(name = "ASSET", value = EntityExportData.AssetExportData.class), + @Type(name = "DEVICE_PROFILE", value = EntityExportData.DeviceProfileExportData.class), + @Type(name = "ENTITY_VIEW", value = EntityExportData.EntityViewExportData.class), + @Type(name = "NOTIFICATION_TEMPLATE", value = EntityExportData.NotificationTemplateExportData.class), + @Type(name = "NOTIFICATION_TARGET", value = EntityExportData.NotificationTargetExportData.class), + @Type(name = "NOTIFICATION_RULE", value = EntityExportData.NotificationRuleExportData.class), + @Type(name = "AI_MODEL", value = EntityExportData.AiModelExportData.class) }) @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema( + description = "Base export container for ThingsBoard entities", + discriminatorProperty = "entityType", + discriminatorMapping = { + @DiscriminatorMapping(value = "CUSTOMER", schema = EntityExportData.CustomerExportData.class), + @DiscriminatorMapping(value = "DEVICE", schema = DeviceExportData.class), + @DiscriminatorMapping(value = "RULE_CHAIN", schema = RuleChainExportData.class), + @DiscriminatorMapping(value = "WIDGET_TYPE", schema = WidgetTypeExportData.class), + @DiscriminatorMapping(value = "WIDGETS_BUNDLE", schema = WidgetsBundleExportData.class), + @DiscriminatorMapping(value = "OTA_PACKAGE", schema = OtaPackageExportData.class), + @DiscriminatorMapping(value = "TB_RESOURCE", schema = EntityExportData.TbResourceExportData.class), + @DiscriminatorMapping(value = "DASHBOARD", schema = EntityExportData.DashboardExportData.class), + @DiscriminatorMapping(value = "ASSET_PROFILE", schema = EntityExportData.AssetProfileExportData.class), + @DiscriminatorMapping(value = "ASSET", schema = EntityExportData.AssetExportData.class), + @DiscriminatorMapping(value = "DEVICE_PROFILE", schema = EntityExportData.DeviceProfileExportData.class), + @DiscriminatorMapping(value = "ENTITY_VIEW", schema = EntityExportData.EntityViewExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_TEMPLATE", schema = EntityExportData.NotificationTemplateExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_TARGET", schema = EntityExportData.NotificationTargetExportData.class), + @DiscriminatorMapping(value = "NOTIFICATION_RULE", schema = EntityExportData.NotificationRuleExportData.class), + @DiscriminatorMapping(value = "AI_MODEL", schema = EntityExportData.AiModelExportData.class) + } +) @Data -public class EntityExportData> { +public abstract class EntityExportData> { public static final Comparator relationsComparator = Comparator .comparing(EntityRelation::getFrom, Comparator.comparing(EntityId::getId)) @@ -61,18 +108,45 @@ public class EntityExportData> { @JsonProperty(index = 2) @JsonTbEntity + @Schema(implementation = ExportableEntity.class) private E entity; @JsonProperty(index = 1) - private EntityType entityType; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED) + public abstract EntityType getEntityType(); @JsonProperty(index = 100) + @ArraySchema(schema = @Schema(implementation = EntityRelation.class)) private List relations; @JsonProperty(index = 101) + @Schema(description = "Map of attributes where key is the scope of attributes and value is the list of attributes for that scope") private Map> attributes; @JsonProperty(index = 102) @JsonIgnoreProperties({"id", "entityId", "createdTime", "version"}) + @ArraySchema(schema = @Schema(implementation = CalculatedField.class)) private List calculatedFields; + public static EntityExportData newInstance(EntityType entityType) { + return switch (entityType) { + case DEVICE -> new DeviceExportData(); + case RULE_CHAIN -> new RuleChainExportData(); + case WIDGET_TYPE -> new WidgetTypeExportData(); + case WIDGETS_BUNDLE -> new WidgetsBundleExportData(); + case OTA_PACKAGE -> new OtaPackageExportData(); + case CUSTOMER -> new CustomerExportData(); + case TB_RESOURCE -> new TbResourceExportData(); + case DASHBOARD -> new DashboardExportData(); + case ASSET_PROFILE -> new AssetProfileExportData(); + case ASSET -> new AssetExportData(); + case DEVICE_PROFILE -> new DeviceProfileExportData(); + case ENTITY_VIEW -> new EntityViewExportData(); + case NOTIFICATION_TEMPLATE -> new NotificationTemplateExportData(); + case NOTIFICATION_TARGET -> new NotificationTargetExportData(); + case NOTIFICATION_RULE -> new NotificationRuleExportData(); + case AI_MODEL -> new AiModelExportData(); + default -> throw new IllegalArgumentException("Unsupported entity type: " + entityType); + }; + } + public EntityExportData sort() { if (relations != null && !relations.isEmpty()) { relations.sort(relationsComparator); @@ -111,4 +185,92 @@ public class EntityExportData> { return calculatedFields != null && !calculatedFields.isEmpty(); } + @Schema + public static class CustomerExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.CUSTOMER; + } + } + + @Schema + public static class TbResourceExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.TB_RESOURCE; + } + } + + @Schema + public static class DashboardExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.DASHBOARD; + } + } + + @Schema + public static class AssetProfileExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ASSET_PROFILE; + } + } + + @Schema + public static class AssetExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ASSET; + } + } + + @Schema + public static class DeviceProfileExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.DEVICE_PROFILE; + } + } + + @Schema + public static class EntityViewExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.ENTITY_VIEW; + } + } + + @Schema + public static class NotificationTemplateExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_TEMPLATE; + } + } + + @Schema + public static class NotificationTargetExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_TARGET; + } + } + + @Schema + public static class NotificationRuleExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.NOTIFICATION_RULE; + } + } + + @Schema + public static class AiModelExportData extends EntityExportData { + @Override + public EntityType getEntityType() { + return EntityType.AI_MODEL; + } + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java index ebab6164b3..727c026bc3 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/OtaPackageExportData.java @@ -16,12 +16,18 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.OtaPackage; +@Schema @EqualsAndHashCode(callSuper = true) public class OtaPackageExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.OTA_PACKAGE; } + /* * OtaPackage is not a versioned entity; its 'version' field is part of the domain model (not used for optimistic locking) * We override both methods to ensure 'version' is not ignored during (de)serialization. diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java index c00e6a59a2..7b9ec8e0a5 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/RuleChainExportData.java @@ -17,17 +17,23 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import lombok.ToString; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainMetaData; +@Schema @EqualsAndHashCode(callSuper = true) @ToString(callSuper = true) @Data public class RuleChainExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.RULE_CHAIN; } + @JsonProperty(index = 3) @JsonIgnoreProperties({"ruleChainId", "version"}) private RuleChainMetaData metaData; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java index 1da9a64eb1..f69b3603db 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetTypeExportData.java @@ -15,12 +15,18 @@ */ package org.thingsboard.server.common.data.sync.ie; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class WidgetTypeExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.WIDGET_TYPE; } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java index 978b44357a..62140abdd1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/ie/WidgetsBundleExportData.java @@ -16,18 +16,27 @@ package org.thingsboard.server.common.data.sync.ie; import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.util.ArrayList; import java.util.List; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class WidgetsBundleExportData extends EntityExportData { + @Override + public EntityType getEntityType() { return EntityType.WIDGETS_BUNDLE; } + + @ArraySchema(arraySchema = @Schema(description = "List of widgets in the bundle"), schema = @Schema(implementation = JsonNode.class)) @JsonProperty(index = 3) private List widgets; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java index 71d715d586..c3ca21cdaf 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/EntityDataDiff.java @@ -15,13 +15,17 @@ */ package org.thingsboard.server.common.data.sync.vc; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Data; import org.thingsboard.server.common.data.sync.ie.EntityExportData; +@Schema @Data @AllArgsConstructor public class EntityDataDiff { + @Schema(implementation = EntityExportData.class) private EntityExportData currentVersion; + @Schema(implementation = EntityExportData.class) private EntityExportData otherVersion; } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java index d9ba1817cf..13fb3f08d6 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/ComplexVersionCreateRequest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.EntityType; import java.util.Map; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class ComplexVersionCreateRequest extends VersionCreateRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java index 8a1ebb06e0..4798393cb4 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/SingleEntityVersionCreateRequest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SingleEntityVersionCreateRequest extends VersionCreateRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java index fe30c1d6ae..81b4aca9a0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateConfig.java @@ -15,11 +15,13 @@ */ package org.thingsboard.server.common.data.sync.vc.request.create; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import java.io.Serial; import java.io.Serializable; +@Schema @Data public class VersionCreateConfig implements Serializable { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java index 1939f2e222..a3e3071474 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/create/VersionCreateRequest.java @@ -18,8 +18,18 @@ package org.thingsboard.server.common.data.sync.vc.request.create; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; +@Schema( + description = "Request for creating a version", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SINGLE_ENTITY", schema = SingleEntityVersionCreateRequest.class), + @DiscriminatorMapping(value = "COMPLEX", schema = ComplexVersionCreateRequest.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(name = "SINGLE_ENTITY", value = SingleEntityVersionCreateRequest.class), @@ -31,6 +41,7 @@ public abstract class VersionCreateRequest { private String versionName; private String branch; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the version to create") public abstract VersionCreateRequestType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java index 7156232eba..dfc783bffb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadConfig.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; @Data +@Schema @EqualsAndHashCode(callSuper = true) public class EntityTypeVersionLoadConfig extends VersionLoadConfig { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java index 8bb4902f43..c3a1260fb0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/EntityTypeVersionLoadRequest.java @@ -15,12 +15,14 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.EntityType; import java.util.Map; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class EntityTypeVersionLoadRequest extends VersionLoadRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java index 5379fc69e9..46c760a60f 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/SingleEntityVersionLoadRequest.java @@ -15,10 +15,12 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.EqualsAndHashCode; import org.thingsboard.server.common.data.id.EntityId; +@Schema @Data @EqualsAndHashCode(callSuper = true) public class SingleEntityVersionLoadRequest extends VersionLoadRequest { diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java index d7620d9ea5..7f0e3158d8 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadConfig.java @@ -15,9 +15,11 @@ */ package org.thingsboard.server.common.data.sync.vc.request.load; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @Data +@Schema public class VersionLoadConfig { private boolean loadRelations; diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java index dffa3bdf3d..85b2def3e1 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/sync/vc/request/load/VersionLoadRequest.java @@ -17,10 +17,20 @@ package org.thingsboard.server.common.data.sync.vc.request.load; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import static com.fasterxml.jackson.annotation.JsonSubTypes.Type; +@Schema( + description = "Request for loading a version", + discriminatorProperty = "type", + discriminatorMapping = { + @DiscriminatorMapping(value = "SINGLE_ENTITY", schema = SingleEntityVersionLoadRequest.class), + @DiscriminatorMapping(value = "ENTITY_TYPE", schema = EntityTypeVersionLoadRequest.class) + } +) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") @JsonSubTypes({ @Type(name = "SINGLE_ENTITY", value = SingleEntityVersionLoadRequest.class), @@ -31,6 +41,7 @@ public abstract class VersionLoadRequest { private String versionId; + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Type of the version to load") public abstract VersionLoadRequestType getType(); } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java index a04816365b..297f92cffb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/tenant/profile/DefaultTenantProfileConfiguration.java @@ -17,6 +17,7 @@ package org.thingsboard.server.common.data.tenant.profile; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.PositiveOrZero; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -173,6 +174,7 @@ public class DefaultTenantProfileConfiguration implements TenantProfileConfigura @Schema(example = "10") private long maxArgumentsPerCF = 10; @Schema(example = "10") + @PositiveOrZero private int minAllowedScheduledUpdateIntervalInSecForCF = 10; @Builder.Default @Schema(example = "2") diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java index 973b4807d3..008988e6cb 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java @@ -15,6 +15,9 @@ */ package org.thingsboard.server.common.data.transport.resource; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(hidden = true) public enum ResourceType { LWM2M_MODEL, JKS, PKCS_12 } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java b/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java index 0fa4d8f053..d38733af24 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/transport/snmp/config/SnmpCommunicationConfig.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonSubTypes.Type; import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.transport.snmp.SnmpCommunicationSpec; import org.thingsboard.server.common.data.transport.snmp.SnmpMapping; import org.thingsboard.server.common.data.transport.snmp.SnmpMethod; @@ -31,6 +33,17 @@ import org.thingsboard.server.common.data.transport.snmp.config.impl.ToDeviceRpc import java.io.Serializable; import java.util.List; +@Schema( + description = "SNMP communication configuration", + discriminatorProperty = "spec", + discriminatorMapping = { + @DiscriminatorMapping(value = "TELEMETRY_QUERYING", schema = TelemetryQueryingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "CLIENT_ATTRIBUTES_QUERYING", schema = ClientAttributesQueryingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "SHARED_ATTRIBUTES_SETTING", schema = SharedAttributesSettingSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "TO_DEVICE_RPC_REQUEST", schema = ToDeviceRpcRequestSnmpCommunicationConfig.class), + @DiscriminatorMapping(value = "TO_SERVER_RPC_REQUEST", schema = ToServerRpcRequestSnmpCommunicationConfig.class) + } +) @JsonIgnoreProperties(ignoreUnknown = true) @JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "spec") @JsonSubTypes({ @@ -42,6 +55,7 @@ import java.util.List; }) public interface SnmpCommunicationConfig extends Serializable { + @Schema(requiredMode = Schema.RequiredMode.REQUIRED, description = "Specification of the SNMP communication") SnmpCommunicationSpec getSpec(); @JsonIgnore diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java b/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java new file mode 100644 index 0000000000..a4ce7c77e4 --- /dev/null +++ b/common/data/src/main/java/org/thingsboard/server/common/data/util/KeyValueEntry.java @@ -0,0 +1,18 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.util; + +public record KeyValueEntry(K key, V value) {} diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java index 398f4b6929..1cbaa957f0 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/widget/WidgetTypeDetails.java @@ -33,7 +33,7 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = true) -@JsonPropertyOrder({"fqn", "name", "deprecated", "image", "description", "descriptor", "externalId", "resources"}) +@JsonPropertyOrder({"id", "createdTime", "tenantId", "fqn", "name", "deprecated", "scada", "version", "descriptor", "image", "description", "tags", "externalId", "resources"}) public class WidgetTypeDetails extends WidgetType implements HasName, HasTenantId, HasImage, ExportableEntity { @Schema(description = "Relative or external image URL. Replaced with image data URL (Base64) in case of relative URL and 'inlineImages' option enabled.") diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java new file mode 100644 index 0000000000..8fb6214dac --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/ResourceUtilsTest.java @@ -0,0 +1,40 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ResourceUtilsTest { + + @Test + public void givenNonExistentResource_whenGetUri_thenThrowsRuntimeException() { + assertThatThrownBy(() -> ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "non/existent/resource/path.txt")) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Unable to find resource"); + } + + @Test + public void givenExistingClasspathResource_whenGetUri_thenReturnsNonNullUri() { + String result = ResourceUtils.getUri(ResourceUtilsTest.class.getClassLoader(), "org/thingsboard/server/common/data/ResourceUtilsTest.class"); + + assertThat(result).isNotNull(); + assertThat(result).contains("ResourceUtilsTest"); + } + +} diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java index 23e0f1add3..a2df32b483 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/ScheduledUpdateSupportedCalculatedFieldConfigurationTest.java @@ -47,8 +47,8 @@ public class ScheduledUpdateSupportedCalculatedFieldConfigurationTest { assertThatThrownBy(() -> cfg.validate(minAllowedInterval)) .isInstanceOf(IllegalArgumentException.class) - .hasMessage("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: " + minAllowedInterval); + .hasMessage("Scheduled update interval (1 seconds) is less than " + + "minimum allowed interval in tenant profile: " + minAllowedInterval + " seconds"); } } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java index 2f391435b2..2c68bcd8c5 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/cf/configuration/geofencing/GeofencingCalculatedFieldConfigurationTest.java @@ -28,6 +28,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates.ENTITY_ID_LATITUDE_ARGUMENT_KEY; @@ -103,4 +104,17 @@ public class GeofencingCalculatedFieldConfigurationTest { assertThat(allowedZonesArgument.getRefEntityKey()).isEqualTo(new ReferencedEntityKey("perimeter", ArgumentType.ATTRIBUTE, AttributeScope.SERVER_SCOPE)); } + @Test + void validateShouldThrowWhenScheduledUpdateEnabledButIntervalNotSet() { + var cfg = new GeofencingCalculatedFieldConfiguration(); + cfg.setEntityCoordinates(mock(EntityCoordinates.class)); + cfg.setZoneGroups(Map.of("zone", mock(ZoneGroupConfiguration.class))); + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(null); + + assertThatThrownBy(cfg::validate) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Refresh interval is required when periodic zone group refresh is enabled."); + } + } diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java index d645d40dcc..9d6434a7c2 100644 --- a/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java +++ b/common/data/src/test/java/org/thingsboard/server/common/data/id/EntityIdTest.java @@ -15,8 +15,18 @@ */ package org.thingsboard.server.common.data.id; +import io.swagger.v3.oas.annotations.media.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Arrays; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; public class EntityIdTest { @@ -25,4 +35,44 @@ public class EntityIdTest { Assertions.assertEquals("13814000-1dd2-11b2-8080-808080808080", EntityId.NULL_UUID.toString()); } + @Test + public void allEntityIdImplementors_shouldBeInDiscriminatorMapping() { + Schema schemaAnnotation = EntityId.class.getAnnotation(Schema.class); + assertThat(schemaAnnotation).as("EntityId must have @Schema annotation").isNotNull(); + + DiscriminatorMapping[] mappings = schemaAnnotation.discriminatorMapping(); + Map> discriminatorMap = Arrays.stream(mappings) + .collect(Collectors.toMap(DiscriminatorMapping::value, DiscriminatorMapping::schema)); + + UUID testUuid = UUID.randomUUID(); + for (EntityType entityType : EntityType.values()) { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, testUuid); + String typeName = entityType.name(); + + assertThat(discriminatorMap) + .as("EntityId @Schema discriminatorMapping is missing entry for EntityType." + typeName) + .containsKey(typeName); + assertThat(discriminatorMap.get(typeName)) + .as("Discriminator mapping for " + typeName + " should point to " + entityId.getClass().getSimpleName()) + .isEqualTo(entityId.getClass()); + } + } + + @Test + public void allEntityIdImplementors_shouldHaveAllOfEntityId() { + UUID testUuid = UUID.randomUUID(); + for (EntityType entityType : EntityType.values()) { + EntityId entityId = EntityIdFactory.getByTypeAndUuid(entityType, testUuid); + Class idClass = entityId.getClass(); + Schema schemaAnnotation = idClass.getAnnotation(Schema.class); + + assertThat(schemaAnnotation) + .as(idClass.getSimpleName() + " must have @Schema annotation") + .isNotNull(); + assertThat(schemaAnnotation.allOf()) + .as(idClass.getSimpleName() + " @Schema must include allOf = EntityId.class") + .contains(EntityId.class); + } + } + } \ No newline at end of file diff --git a/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java b/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java new file mode 100644 index 0000000000..dd932fa2c5 --- /dev/null +++ b/common/data/src/test/java/org/thingsboard/server/common/data/sync/ie/EntityExportDataTest.java @@ -0,0 +1,52 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.data.sync.ie; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.EntityType; + +import java.util.Arrays; +import java.util.Set; +import java.util.stream.Collectors; + +import static org.assertj.core.api.Assertions.assertThat; + +public class EntityExportDataTest { + + @Test + public void newInstance_shouldSupportAllJsonSubTypes() { + JsonSubTypes subTypes = EntityExportData.class.getAnnotation(JsonSubTypes.class); + assertThat(subTypes).as("EntityExportData must have @JsonSubTypes annotation").isNotNull(); + + Set jsonSubTypeNames = Arrays.stream(subTypes.value()) + .map(JsonSubTypes.Type::name) + .collect(Collectors.toSet()); + + for (String typeName : jsonSubTypeNames) { + EntityType entityType = EntityType.valueOf(typeName); + EntityExportData instance = EntityExportData.newInstance(entityType); + + assertThat(instance) + .as("newInstance(%s) should not return null", typeName) + .isNotNull(); + assertThat(instance.getEntityType()) + .as("newInstance(%s).getEntityType() should return %s", typeName, entityType) + .isEqualTo(entityType); + } + } + +} diff --git a/common/discovery-api/pom.xml b/common/discovery-api/pom.xml index 5aa66562ed..c7658bd738 100644 --- a/common/discovery-api/pom.xml +++ b/common/discovery-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edge-api/pom.xml b/common/edge-api/pom.xml index 507bfc73f8..379e4fb8fb 100644 --- a/common/edge-api/pom.xml +++ b/common/edge-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common @@ -106,6 +106,11 @@ com.google.protobuf protobuf-java + + org.springframework.boot + spring-boot-starter-test + test + diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java index 1b17fbe849..7e58a2cb9f 100644 --- a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeGrpcClient.java @@ -143,16 +143,7 @@ public class EdgeGrpcClient implements EdgeRpcClient { } public static EdgeVersion getNewestEdgeVersion() { - EdgeVersion newest = null; - for (EdgeVersion v : EdgeVersion.values()) { - if (v == EdgeVersion.V_LATEST || v == EdgeVersion.UNRECOGNIZED) { - continue; - } - if (newest == null || v.getNumber() > newest.getNumber()) { - newest = v; - } - } - return newest; + return EdgeVersionComparator.getNewestEdgeVersion(); } private StreamObserver initOutputStream(String edgeKey, diff --git a/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeVersionComparator.java b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeVersionComparator.java new file mode 100644 index 0000000000..94ca577025 --- /dev/null +++ b/common/edge-api/src/main/java/org/thingsboard/edge/rpc/EdgeVersionComparator.java @@ -0,0 +1,86 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.edge.rpc; + +import org.thingsboard.server.gen.edge.v1.EdgeVersion; + +import java.util.Comparator; + +public class EdgeVersionComparator implements Comparator { + + public static final EdgeVersionComparator INSTANCE = new EdgeVersionComparator(); + + @Override + public int compare(EdgeVersion v1, EdgeVersion v2) { + if (v1 == v2) { + return 0; + } + // UNRECOGNIZED is less than any other version + if (v1 == EdgeVersion.UNRECOGNIZED) { + return -1; + } + if (v2 == EdgeVersion.UNRECOGNIZED) { + return 1; + } + // V_LATEST is treated as the newest version + if (v1 == EdgeVersion.V_LATEST) { + v1 = getNewestEdgeVersion(); + } + if (v2 == EdgeVersion.V_LATEST) { + v2 = getNewestEdgeVersion(); + } + return compareVersionParts(parseVersionParts(v1), parseVersionParts(v2)); + } + + public static EdgeVersion getNewestEdgeVersion() { + EdgeVersion newest = null; + for (EdgeVersion v : EdgeVersion.values()) { + if (v == EdgeVersion.V_LATEST || v == EdgeVersion.UNRECOGNIZED) { + continue; + } + if (newest == null || INSTANCE.compare(v, newest) > 0) { + newest = v; + } + } + return newest; + } + + private static int[] parseVersionParts(EdgeVersion version) { + String name = version.name(); + if (name.startsWith("V_")) { + name = name.substring(2); + } + String[] parts = name.split("_"); + int[] result = new int[parts.length]; + for (int i = 0; i < parts.length; i++) { + result[i] = Integer.parseInt(parts[i]); + } + return result; + } + + private static int compareVersionParts(int[] a, int[] b) { + int maxLen = Math.max(a.length, b.length); + for (int i = 0; i < maxLen; i++) { + int partA = i < a.length ? a[i] : 0; + int partB = i < b.length ? b[i] : 0; + if (partA != partB) { + return Integer.compare(partA, partB); + } + } + return 0; + } + +} diff --git a/common/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index a01e0e561a..bcea3c30d9 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -45,8 +45,17 @@ enum EdgeVersion { V_4_1_0 = 11; V_4_2_0 = 12; V_4_3_0 = 13; + V_4_2_1_2 = 14; + V_4_2_2 = 4220; + V_4_2_2_1 = 4221; + V_4_2_2_2 = 4222; + V_4_3_0_1 = 15; + V_4_3_1 = 4310; + V_4_3_1_1 = 4311; + V_4_3_1_2 = 4312; + V_4_4_0 = 4400; - V_LATEST = 999; + V_LATEST = 99999; } /** @@ -141,6 +150,13 @@ message AiModelUpdateMsg{ string entity = 4; } +message ApiKeyUpdateMsg{ + UpdateMsgType msgType = 1; + int64 idMSB = 2; + int64 idLSB = 3; + string entity = 4; +} + message EntityDataProto { int64 entityIdMSB = 1; int64 entityIdLSB = 2; @@ -451,6 +467,7 @@ message UplinkMsg { repeated AiModelUpdateMsg aiModelUpdateMsg = 27; repeated UserUpdateMsg userUpdateMsg = 28; repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29; + repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 30; } message UplinkResponseMsg { @@ -502,4 +519,5 @@ message DownlinkMsg { repeated OAuth2DomainUpdateMsg oAuth2DomainUpdateMsg = 34; repeated CalculatedFieldUpdateMsg calculatedFieldUpdateMsg = 35; repeated AiModelUpdateMsg aiModelUpdateMsg = 36; + repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 37; } diff --git a/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeVersionComparatorTest.java b/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeVersionComparatorTest.java new file mode 100644 index 0000000000..eab63fc092 --- /dev/null +++ b/common/edge-api/src/test/java/org/thingsboard/edge/rpc/EdgeVersionComparatorTest.java @@ -0,0 +1,102 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.edge.rpc; + +import org.junit.jupiter.api.Test; +import org.thingsboard.server.gen.edge.v1.EdgeVersion; + +import static org.assertj.core.api.Assertions.assertThat; + +class EdgeVersionComparatorTest { + + @Test + void compare_sameVersion_returnsZero() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_3_0)).isEqualTo(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_0_0)).isEqualTo(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_1_2)).isEqualTo(0); + } + + @Test + void compare_majorVersionDifference_returnsCorrectOrder() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_4_0_0)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_3_3_0)).isGreaterThan(0); + } + + @Test + void compare_minorVersionDifference_returnsCorrectOrder() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_3_6_0)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_3_0)).isGreaterThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_0_0, EdgeVersion.V_4_1_0)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_1_0, EdgeVersion.V_4_0_0)).isGreaterThan(0); + } + + @Test + void compare_patchVersionDifference_returnsCorrectOrder() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_0, EdgeVersion.V_3_6_1)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_0)).isGreaterThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_1, EdgeVersion.V_3_6_2)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_6_2, EdgeVersion.V_3_6_4)).isLessThan(0); + } + + @Test + void compare_fourPartVersion_returnsCorrectOrder() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_1_2, EdgeVersion.V_4_2_0)).isGreaterThan(0); + } + + @Test + void compare_threePartVsFourPart_treatsImplicitZero() { + // V_4_2_0 should be less than V_4_2_1_2 (4.2.0.0 < 4.2.1.2) + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_4_2_0, EdgeVersion.V_4_2_1_2)).isLessThan(0); + } + + @Test + void getNewestEdgeVersion_excludesLatestAndUnrecognized() { + EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion(); + assertThat(newest).isNotNull(); + assertThat(newest).isNotEqualTo(EdgeVersion.V_LATEST); + assertThat(newest).isNotEqualTo(EdgeVersion.UNRECOGNIZED); + } + + @Test + void compare_vLatest_treatedAsNewestVersion() { + EdgeVersion newest = EdgeVersionComparator.getNewestEdgeVersion(); + // V_LATEST equals the newest version + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, newest)).isEqualTo(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(newest, EdgeVersion.V_LATEST)).isEqualTo(0); + // V_LATEST is greater than older versions + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_3_3_0)).isGreaterThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.V_LATEST)).isLessThan(0); + } + + @Test + void compare_vLatest_withItself_returnsZero() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.V_LATEST)).isEqualTo(0); + } + + @Test + void compare_unrecognized_isLessThanAnyVersion() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_3_3_0)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.V_LATEST)).isLessThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_3_3_0, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0); + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.V_LATEST, EdgeVersion.UNRECOGNIZED)).isGreaterThan(0); + } + + @Test + void compare_unrecognized_withItself_returnsZero() { + assertThat(EdgeVersionComparator.INSTANCE.compare(EdgeVersion.UNRECOGNIZED, EdgeVersion.UNRECOGNIZED)).isEqualTo(0); + } +} diff --git a/common/edqs/pom.xml b/common/edqs/pom.xml index 5512d47b79..549ee1515f 100644 --- a/common/edqs/pom.xml +++ b/common/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java index 8ae3499d63..2d9e77633d 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/data/dp/StringDataPoint.java @@ -33,6 +33,11 @@ public class StringDataPoint extends AbstractDataPoint { this.value = deduplicate ? TbStringPool.intern(value) : value; } + @Override + public boolean getBool() { + return Boolean.parseBoolean(value); + } + @Override public double getDouble() { return Double.parseDouble(value); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java index 0d2ed67987..d338362aeb 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsCountQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Builder; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -23,8 +24,8 @@ import java.util.List; public class EdqsCountQuery extends EdqsQuery { @Builder - EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters) { - super(entityFilter, hasKeyFilters, keyFilters); + EdqsCountQuery(EntityFilter entityFilter, boolean hasKeyFilters, List keyFilters, ComplexOperation keyFiltersOperation) { + super(entityFilter, hasKeyFilters, keyFilters, keyFiltersOperation); } } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java index 72ccb68515..bd93eca2f1 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsDataQuery.java @@ -19,6 +19,7 @@ import lombok.Builder; import lombok.EqualsAndHashCode; import lombok.Getter; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityDataSortOrder; import org.thingsboard.server.common.data.query.EntityFilter; import org.thingsboard.server.common.data.query.EntityKeyType; @@ -41,10 +42,10 @@ public class EdqsDataQuery extends EdqsQuery { private final List latestValues; @Builder - public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, + public EdqsDataQuery(EntityFilter entityFilter, List keyFilters, ComplexOperation keyFiltersOperation, int pageSize, int page, String textSearch, DataKey sortKey, EntityDataSortOrder.Direction sortDirection, List entityFields, List latestValues) { - super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters); + super(entityFilter, CollectionsUtil.isNotEmpty(keyFilters), keyFilters, keyFiltersOperation); this.pageSize = pageSize; this.page = page; this.hasTextSearch = StringUtils.isNotBlank(textSearch); diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java index 9cfc5d442c..8d29ec89b8 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/query/EdqsQuery.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.query; import lombok.Data; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityFilter; import java.util.List; @@ -26,5 +27,6 @@ public abstract class EdqsQuery { private final EntityFilter entityFilter; private final boolean hasKeyFilters; private final List keyFilters; + private final ComplexOperation keyFiltersOperation; } diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java index a0a030ba08..6da25c260a 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/RepositoryUtils.java @@ -22,6 +22,7 @@ import org.thingsboard.server.common.data.edqs.DataPoint; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -60,8 +61,8 @@ import java.util.stream.Stream; import static org.apache.commons.lang3.StringUtils.containsIgnoreCase; import static org.thingsboard.server.common.data.StringUtils.equalsAny; import static org.thingsboard.server.common.data.StringUtils.splitByCommaWithoutQuotes; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.AND; -import static org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation.OR; +import static org.thingsboard.server.common.data.query.ComplexOperation.AND; +import static org.thingsboard.server.common.data.query.ComplexOperation.OR; @Slf4j public class RepositoryUtils { @@ -139,6 +140,7 @@ public class RepositoryUtils { } query.entityFilter(oldQuery.getEntityFilter()); query.keyFilters(toKeyFilters(oldQuery.getKeyFilters())); + query.keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()); query.entityFields(toNewKeys(oldQuery.getEntityFields())); query.latestValues(toNewKeys(oldQuery.getLatestValues())); return query.build(); @@ -149,6 +151,7 @@ public class RepositoryUtils { .entityFilter(oldQuery.getEntityFilter()) .hasKeyFilters(CollectionsUtil.isNotEmpty(oldQuery.getKeyFilters())) .keyFilters(toKeyFilters(oldQuery.getKeyFilters())) + .keyFiltersOperation(oldQuery.getKeyFiltersOperationOrDefault()) .build(); } @@ -196,38 +199,57 @@ public class RepositoryUtils { } public static boolean checkKeyFilters(EntityData entity, List keyFilters) { - for (EdqsFilter keyFilter : keyFilters) { - EntityKeyValueType valueType = keyFilter.valueType(); - if (valueType == null) { - valueType = switch (keyFilter.predicate().getType()) { - case STRING -> EntityKeyValueType.STRING; - case NUMERIC -> EntityKeyValueType.NUMERIC; - case BOOLEAN -> EntityKeyValueType.BOOLEAN; - default -> throw new IllegalStateException(); - }; - } - DataKey dataKey = keyFilter.key(); - DataPoint dp = entity.getDataPoint(dataKey, null); - boolean checkResult = switch (valueType) { - case STRING -> { - String str = dp != null ? dp.valueToString() : null; - yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : - (str != null && checkKeyFilter(str, keyFilter.predicate())); - } - case BOOLEAN -> { - Boolean booleanValue = dp != null ? dp.getBool() : null; - yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + return checkKeyFilters(entity, keyFilters, ComplexOperation.AND); + } + + public static boolean checkKeyFilters(EntityData entity, List keyFilters, ComplexOperation operation) { + ComplexOperation op = operation != null ? operation : ComplexOperation.AND; + if (op == ComplexOperation.OR) { + for (EdqsFilter keyFilter : keyFilters) { + if (evaluateSingleFilter(entity, keyFilter)) { + return true; } - case DATE_TIME, NUMERIC -> { - Double doubleValue = dp != null ? dp.getDouble() : null; - yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + // Vacuously true when OR is called with an empty filter list. Unreachable via checkFilters + // (which guards on isHasKeyFilters()), but kept defensive for any future direct caller. + return keyFilters.isEmpty(); + } else { + for (EdqsFilter keyFilter : keyFilters) { + if (!evaluateSingleFilter(entity, keyFilter)) { + return false; } - }; - if (!checkResult) { - return false; } + return true; } - return true; + } + + private static boolean evaluateSingleFilter(EntityData entity, EdqsFilter keyFilter) { + EntityKeyValueType valueType = keyFilter.valueType(); + if (valueType == null) { + valueType = switch (keyFilter.predicate().getType()) { + case STRING -> EntityKeyValueType.STRING; + case NUMERIC -> EntityKeyValueType.NUMERIC; + case BOOLEAN -> EntityKeyValueType.BOOLEAN; + default -> throw new IllegalStateException(); + }; + } + DataKey dataKey = keyFilter.key(); + DataPoint dp = entity.getDataPoint(dataKey, null); + return switch (valueType) { + case STRING -> { + String str = dp != null ? dp.valueToString() : null; + yield (dataKey.type() == EntityKeyType.ENTITY_FIELD) ? (str == null || checkKeyFilter(str, keyFilter.predicate())) : + (str != null && checkKeyFilter(str, keyFilter.predicate())); + } + case BOOLEAN -> { + Boolean booleanValue = dp != null ? dp.getBool() : null; + yield booleanValue != null && checkKeyFilter(booleanValue, keyFilter.predicate()); + } + case DATE_TIME, NUMERIC -> { + Double doubleValue = dp != null ? dp.getDouble() : null; + yield doubleValue != null && checkKeyFilter(doubleValue, keyFilter.predicate()); + } + }; } public static boolean checkKeyFilter(String value, KeyFilterPredicate keyFilterPredicate) { @@ -376,7 +398,7 @@ public class RepositoryUtils { if (entity == null || entity.getFields() == null) { return false; // Entity was already removed or not arrived yet; } - if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters())) { + if (query.isHasKeyFilters() && !checkKeyFilters(entity, query.getKeyFilters(), query.getKeyFiltersOperation())) { return false; } if (query instanceof EdqsDataQuery dataQuery) { diff --git a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java index 9141321dcb..c731cf4548 100644 --- a/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java +++ b/common/edqs/src/main/java/org/thingsboard/server/edqs/util/TbRocksDb.java @@ -16,6 +16,7 @@ package org.thingsboard.server.edqs.util; import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; import org.rocksdb.Options; import org.rocksdb.RocksDB; import org.rocksdb.RocksIterator; @@ -26,6 +27,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.function.BiConsumer; +@Slf4j public class TbRocksDb { protected final String path; @@ -45,6 +47,7 @@ public class TbRocksDb { @SneakyThrows public void init() { + log.debug("RocksDB init in {}", path); Files.createDirectories(Path.of(path).getParent()); db = RocksDB.open(dbOptions, path); } diff --git a/common/message/pom.xml b/common/message/pom.xml index 1f6bfb20e1..478368afdc 100644 --- a/common/message/pom.xml +++ b/common/message/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java index 0f50284d6c..2b3f273c70 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/EncryptionUtil.java @@ -17,7 +17,7 @@ package org.thingsboard.server.common.msg; import lombok.extern.slf4j.Slf4j; import org.bouncycastle.crypto.digests.SHA3Digest; -import org.bouncycastle.pqc.legacy.math.linearalgebra.ByteUtils; +import org.bouncycastle.util.encoders.Hex; /** * @author Valerii Sosliuk @@ -66,7 +66,7 @@ public class EncryptionUtil { md.update(dataBytes, 0, dataBytes.length); byte[] hashedBytes = new byte[256 / 8]; md.doFinal(hashedBytes, 0); - String sha3Hash = ByteUtils.toHexString(hashedBytes); + String sha3Hash = Hex.toHexString(hashedBytes); return sha3Hash; } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java index b1dd55676a..abfbdcbe30 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsg.java @@ -107,11 +107,10 @@ public final class TbMsg implements Serializable { .build(); } - public TbMsg copyWithNewCtx() { + public TbMsgBuilder copyWithNewCtx() { return copy() .ctx(ctx.copy()) - .callback(TbMsgCallback.EMPTY) - .build(); + .callback(TbMsgCallback.EMPTY); } private TbMsg(String queueName, UUID id, long ts, TbMsgType internalType, String type, EntityId originator, CustomerId customerId, TbMsgMetaData metaData, TbMsgDataType dataType, String data, @@ -202,18 +201,6 @@ public final class TbMsg implements Serializable { return builder.build(); } - @Deprecated(forRemoval = true, since = "4.1") // to be removed in 4.2 - public static TbMsg fromProto(String queueName, TbMsgProto proto, ByteString data, TbMsgCallback callback) { - try { - if (!data.isEmpty()) { - proto = TbMsgProto.parseFrom(data); - } - } catch (InvalidProtocolBufferException e) { - throw new IllegalStateException("Could not parse protobuf for TbMsg", e); - } - return fromProto(queueName, proto, callback); - } - public static TbMsg fromProto(String queueName, TbMsgProto proto, TbMsgCallback callback) { TbMsgMetaData metaData = new TbMsgMetaData(proto.getMetaData().getDataMap()); EntityId entityId = EntityIdFactory.getByTypeAndUuid(proto.getEntityType(), new UUID(proto.getEntityIdMSB(), proto.getEntityIdLSB())); @@ -263,6 +250,10 @@ public final class TbMsg implements Serializable { ctx.push(ruleChainId, ruleNodeId); } + public boolean isAlreadyInStack(RuleChainId ruleChainId, RuleNodeId ruleNodeId) { + return ctx.isAlreadyInStack(ruleChainId, ruleNodeId); + } + public TbMsgProcessingStackItem popFormStack() { return ctx.pop(); } diff --git a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java index b71845db31..968b5d4fc2 100644 --- a/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java +++ b/common/message/src/main/java/org/thingsboard/server/common/msg/TbMsgProcessingCtx.java @@ -63,6 +63,18 @@ public final class TbMsgProcessingCtx implements Serializable { stack.add(new TbMsgProcessingStackItem(ruleChainId, ruleNodeId)); } + public boolean isAlreadyInStack(RuleChainId ruleChainId, RuleNodeId ruleNodeId) { + if (stack == null || stack.isEmpty()) { + return false; + } + for (TbMsgProcessingStackItem item : stack) { + if (ruleChainId.equals(item.getRuleChainId()) && ruleNodeId.equals(item.getRuleNodeId())) { + return true; + } + } + return false; + } + public TbMsgProcessingStackItem pop() { if (stack == null || stack.isEmpty()) { return null; diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingCtxTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingCtxTest.java new file mode 100644 index 0000000000..65e5bda925 --- /dev/null +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/TbMsgProcessingCtxTest.java @@ -0,0 +1,89 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.msg; + +import org.junit.jupiter.api.Test; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class TbMsgProcessingCtxTest { + + private final RuleChainId RULE_CHAIN_ID = new RuleChainId(UUID.fromString("b87c4123-f9f2-41a6-9a09-e3a5b6580b11")); + private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("1ca5e2ef-1309-41d9-bafa-709e9df0e2a6")); + + @Test + void givenEmptyStack_whenIsAlreadyInStack_thenReturnFalse() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); + } + + @Test + void givenStackWithDifferentEntry_whenIsAlreadyInStack_thenReturnFalse() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); + } + + @Test + void givenStackWithMatchingEntry_whenIsAlreadyInStack_thenReturnTrue() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); + } + + @Test + void givenStackWithMatchingEntryAmongOthers_whenIsAlreadyInStack_thenReturnTrue() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); + ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); + ctx.push(new RuleChainId(UUID.randomUUID()), new RuleNodeId(UUID.randomUUID())); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isTrue(); + } + + @Test + void givenStackWithSameChainButDifferentNode_whenIsAlreadyInStack_thenReturnFalse() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(RULE_CHAIN_ID, new RuleNodeId(UUID.randomUUID())); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); + } + + @Test + void givenStackWithSameNodeButDifferentChain_whenIsAlreadyInStack_thenReturnFalse() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(new RuleChainId(UUID.randomUUID()), RULE_NODE_ID); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); + } + + @Test + void givenStackWithEntryThenPopped_whenIsAlreadyInStack_thenReturnFalse() { + TbMsgProcessingCtx ctx = new TbMsgProcessingCtx(); + ctx.push(RULE_CHAIN_ID, RULE_NODE_ID); + ctx.pop(); + + assertThat(ctx.isAlreadyInStack(RULE_CHAIN_ID, RULE_NODE_ID)).isFalse(); + } + +} diff --git a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java index 39840d8f1c..0ec7ce0d76 100644 --- a/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java +++ b/common/message/src/test/java/org/thingsboard/server/common/msg/tools/TbRateLimitsTest.java @@ -42,7 +42,7 @@ public class TbRateLimitsTest { assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); int expectedRefillTime = (int) (((double) period / capacity) * 1000); - int gap = 500; + int gap = 1000; for (int i = 0; i < capacity; i++) { await("token refill for rate limit " + rateLimitConfig) @@ -71,7 +71,7 @@ public class TbRateLimitsTest { assertThat(rateLimits.tryConsume()).as("new token is available").isFalse(); int expectedRefillTime = period * 1000; - int gap = 500; + int gap = 1000; await("tokens refill for rate limit " + rateLimitConfig) .pollInterval(new FixedPollInterval(10, TimeUnit.MILLISECONDS)) diff --git a/common/pom.xml b/common/pom.xml index c0140e733d..d241646816 100644 --- a/common/pom.xml +++ b/common/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard common diff --git a/common/proto/pom.xml b/common/proto/pom.xml index 27ca58ac0e..e24705c25a 100644 --- a/common/proto/pom.xml +++ b/common/proto/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java index 6dc9387a37..a47caae954 100644 --- a/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java +++ b/common/proto/src/main/java/org/thingsboard/server/common/adaptor/ProtoConverter.java @@ -156,11 +156,7 @@ public class ProtoConverter { case BOOLEAN_V: case LONG_V: case DOUBLE_V: - break; case STRING_V: - if (StringUtils.isEmpty(keyValueProto.getStringV())) { - throw new IllegalArgumentException("Value is empty for key: " + key + "!"); - } break; case JSON_V: try { diff --git a/common/proto/src/main/proto/queue.proto b/common/proto/src/main/proto/queue.proto index 8a44a19e17..68f7052c69 100644 --- a/common/proto/src/main/proto/queue.proto +++ b/common/proto/src/main/proto/queue.proto @@ -139,7 +139,6 @@ message SessionInfoProto { message RestApiCallResponseMsgProto { int64 requestIdMSB = 1; int64 requestIdLSB = 2; - bytes response = 5 [deprecated = true]; msgqueue.TbMsgProto responseProto = 6; } diff --git a/common/queue/pom.xml b/common/queue/pom.xml index b4fd04ef55..a434552363 100644 --- a/common/queue/pom.xml +++ b/common/queue/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common @@ -68,10 +68,6 @@ org.apache.kafka kafka-clients - - at.yawk.lz4 - lz4-java - com.google.cloud google-cloud-pubsub diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java index 87f566c90f..dcf9459965 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplate.java @@ -98,7 +98,10 @@ public abstract class AbstractTbQueueConsumerTemplate i doSubscribe(partitions); subscribed = true; } - records = partitions.isEmpty() ? emptyList() : doPoll(durationInMillis); + if (partitions.isEmpty()) { + return sleepAndReturnEmpty(startNanos, durationInMillis); + } + records = doPoll(durationInMillis); } finally { consumerLock.unlock(); } diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java index bf884958eb..279a2ddc7f 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationService.java @@ -32,6 +32,7 @@ import org.thingsboard.server.queue.util.PropertyUtils; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.TimeUnit; import static org.springframework.util.ConcurrentReferenceHashMap.ReferenceType.SOFT; @@ -59,41 +60,48 @@ public class DefaultNotificationDeduplicationService implements NotificationDedu } private boolean alreadyProcessed(NotificationRuleTrigger trigger, String deduplicationKey, boolean onlyLocalCache) { - Long lastProcessedTs = localCache.get(deduplicationKey); - if (lastProcessedTs == null && !onlyLocalCache) { - Cache externalCache = getExternalCache(); - if (externalCache != null) { - lastProcessedTs = externalCache.get(deduplicationKey, Long.class); - } else { - log.warn("Sent notifications cache is not set up"); + long deduplicationDuration = getDeduplicationDuration(trigger); + final long now = System.currentTimeMillis(); + boolean[] result = {false}; + + localCache.compute(deduplicationKey, (key, lastProcessedTs) -> { + if (lastProcessedTs == null && !onlyLocalCache) { + Cache externalCache = getExternalCache(); + if (externalCache != null) { + lastProcessedTs = externalCache.get(key, Long.class); + if (lastProcessedTs != null && lastProcessedTs > now + TimeUnit.HOURS.toMillis(1)) { + log.warn("Discarding dedup entry from external cache for key '{}': timestamp is {} ms in the future", + key, lastProcessedTs - now); + lastProcessedTs = null; + } + } else { + log.warn("Sent notifications cache is not set up"); + } } - } - boolean alreadyProcessed = false; - long deduplicationDuration = getDeduplicationDuration(trigger); - if (lastProcessedTs != null) { - long passed = System.currentTimeMillis() - lastProcessedTs; - log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", - trigger.getType(), deduplicationKey, deduplicationDuration, passed); - if (deduplicationDuration == 0 || passed <= deduplicationDuration) { - alreadyProcessed = true; + if (lastProcessedTs != null) { + long passed = now - lastProcessedTs; + log.trace("Deduplicating trigger {} by key '{}'. Deduplication duration: {} ms, passed: {} ms", + trigger.getType(), key, deduplicationDuration, passed); + if (deduplicationDuration == 0 || passed <= deduplicationDuration) { + result[0] = true; + return lastProcessedTs; + } } - } - if (!alreadyProcessed) { - lastProcessedTs = System.currentTimeMillis(); - } - localCache.put(deduplicationKey, lastProcessedTs); + return now; + }); + if (!onlyLocalCache) { - if (!alreadyProcessed || deduplicationDuration == 0) { + if (!result[0] || deduplicationDuration == 0) { // if lastProcessedTs is changed or if deduplicating infinitely (so that cache value not removed by ttl) Cache externalCache = getExternalCache(); if (externalCache != null) { - externalCache.put(deduplicationKey, lastProcessedTs); + externalCache.put(deduplicationKey, now); } } } - return alreadyProcessed; + return result[0]; } public static String getDeduplicationKey(NotificationRuleTrigger trigger, NotificationRule rule) { diff --git a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java index b6a785316b..50e131103b 100644 --- a/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java +++ b/common/queue/src/main/java/org/thingsboard/server/queue/usagestats/DefaultTbApiUsageReportClient.java @@ -63,8 +63,10 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { private boolean enabled; @Value("${usage.stats.report.enabled_per_customer:false}") private boolean enabledPerCustomer; - @Value("${usage.stats.report.interval:10}") + @Value("${usage.stats.report.interval:60}") private int interval; + @Value("${usage.stats.report.urgent_interval:10}") + private int urgentInterval; @Value("${usage.stats.report.pack_size:1024}") private int packSize; @@ -83,20 +85,30 @@ public class DefaultTbApiUsageReportClient implements TbApiUsageReportClient { for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { stats.put(key, new ConcurrentHashMap<>()); } + Random random = new Random(); scheduler.scheduleWithFixedDelay(() -> { try { - reportStats(); + reportStats(false); } catch (Exception e) { log.warn("Failed to report statistics: ", e); } - }, new Random().nextInt(interval), interval, TimeUnit.SECONDS); + }, random.nextInt(interval), interval, TimeUnit.SECONDS); + scheduler.scheduleWithFixedDelay(() -> { + try { + reportStats(true); + } catch (Exception e) { + log.warn("Failed to report urgent statistics: ", e); + } + }, random.nextInt(urgentInterval), urgentInterval, TimeUnit.SECONDS); } } - private void reportStats() { + private void reportStats(boolean urgent) { ConcurrentMap report = new ConcurrentHashMap<>(); for (ApiUsageRecordKey key : ApiUsageRecordKey.values()) { + if (key.isUrgent() != urgent) continue; + ConcurrentMap statsForKey = stats.get(key); statsForKey.forEach((reportLevel, statsValue) -> { long value = statsValue.get(); diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java new file mode 100644 index 0000000000..a016408ad4 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/common/AbstractTbQueueConsumerTemplateTest.java @@ -0,0 +1,146 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.common; + +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.server.common.msg.queue.TopicPartitionInfo; +import org.thingsboard.server.queue.TbQueueMsg; + +import java.util.Collections; +import java.util.List; +import java.util.Set; +import java.util.concurrent.TimeUnit; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.empty; +import static org.hamcrest.Matchers.greaterThanOrEqualTo; +import static org.hamcrest.Matchers.is; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.never; +import static org.mockito.BDDMockito.spy; +import static org.mockito.BDDMockito.times; +import static org.mockito.BDDMockito.verify; + +@Slf4j +@ExtendWith(MockitoExtension.class) +public class AbstractTbQueueConsumerTemplateTest { + + private static final long POLL_DURATION_MS = 100L; + private static final long SLEEP_TOLERANCE_MS = 20L; + + @Test + public void givenEmptyPartitionsAndLongPollingSupported_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + // Regression: with empty partitions AND isLongPollingSupported()==true (e.g. Kafka), + // poll() previously returned instantly with no sleep, causing the consumer loop to busy-spin. + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat("poll() must sleep ~durationInMillis when partitions are empty (no busy-wait)", + elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenEmptyPartitionsAndNoLongPolling_whenPoll_thenSleepsAndDoesNotCallDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", false)); + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, never()).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + @Test + public void givenNonEmptyPartitions_whenPoll_thenCallsDoPoll() { + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + + List result = consumer.poll(POLL_DURATION_MS); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + } + + @Test + public void givenPartitionsBecomeEmptyAfterRebalance_whenPollAgain_thenStopsCallingDoPoll() { + // Reproduces the observed trigger: a rebalance leaves the consumer with an empty + // partition assignment. Subsequent poll() calls must not busy-spin or call doPoll(). + TestConsumer consumer = spy(new TestConsumer("test-topic", true)); + consumer.subscribe(Collections.singleton(new TopicPartitionInfo("test-topic", null, 0, true))); + consumer.poll(POLL_DURATION_MS); + verify(consumer, times(1)).doPoll(POLL_DURATION_MS); + + consumer.subscribe(Collections.emptySet()); + + long startNs = System.nanoTime(); + List result = consumer.poll(POLL_DURATION_MS); + long elapsedMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startNs); + + assertThat(result, is(empty())); + verify(consumer, times(1)).doPoll(anyLong()); + assertThat(elapsedMs, greaterThanOrEqualTo(POLL_DURATION_MS - SLEEP_TOLERANCE_MS)); + } + + static class TestConsumer extends AbstractTbQueueConsumerTemplate { + + private final boolean longPollingSupported; + + TestConsumer(String topic, boolean longPollingSupported) { + super(topic); + this.longPollingSupported = longPollingSupported; + } + + @Override + protected List doPoll(long durationInMillis) { + return Collections.emptyList(); + } + + @Override + protected TbQueueMsg decode(Object record) { + return null; + } + + @Override + protected void doSubscribe(Set partitions) { + } + + @Override + protected void doCommit() { + } + + @Override + protected void doUnsubscribe() { + } + + @Override + protected boolean isLongPollingSupported() { + return longPollingSupported; + } + } + +} diff --git a/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java new file mode 100644 index 0000000000..826c1e1ef6 --- /dev/null +++ b/common/queue/src/test/java/org/thingsboard/server/queue/notification/DefaultNotificationDeduplicationServiceTest.java @@ -0,0 +1,160 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.queue.notification; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.cache.concurrent.ConcurrentMapCacheManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.CacheConstants; +import org.thingsboard.server.common.data.notification.rule.NotificationRule; +import org.thingsboard.server.common.data.notification.rule.trigger.NotificationRuleTrigger; +import org.thingsboard.server.common.data.notification.rule.trigger.config.NotificationRuleTriggerType; + +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DefaultNotificationDeduplicationServiceTest { + + private static final int TIMEOUT = 30; + + private DefaultNotificationDeduplicationService deduplicationService; + private CacheManager cacheManager; + + @BeforeEach + void setUp() { + deduplicationService = new DefaultNotificationDeduplicationService(); + deduplicationService.setDeduplicationDurations(""); + cacheManager = new ConcurrentMapCacheManager(CacheConstants.SENT_NOTIFICATIONS_CACHE); + ReflectionTestUtils.setField(deduplicationService, "cacheManager", cacheManager); + } + + @Test + void testFirstTriggerIsNotDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testSecondTriggerIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testTriggerPassesAfterDeduplicationWindowExpires() { + NotificationRuleTrigger trigger = mockTrigger(50); // 50ms dedup window + NotificationRule rule = mockRule(); + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + + try { + Thread.sleep(200); // wait well past the 50ms window + } catch (InterruptedException ignored) {} + + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testFutureTimestampFromExternalCacheIsDiscarded() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a timestamp 2 hours in the future into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis() + TimeUnit.HOURS.toMillis(2)); + + // Should NOT be deduplicated — future timestamp must be discarded + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isFalse(); + } + + @Test + void testValidTimestampFromExternalCacheIsDeduplicated() { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + String dedupKey = DefaultNotificationDeduplicationService.getDeduplicationKey(trigger, rule); + + // Put a recent timestamp into external cache + Cache externalCache = cacheManager.getCache(CacheConstants.SENT_NOTIFICATIONS_CACHE); + externalCache.put(dedupKey, System.currentTimeMillis()); + + // Should be deduplicated — valid external cache entry + assertThat(deduplicationService.alreadyProcessed(trigger, rule)).isTrue(); + } + + @Test + void testConcurrentTriggersProduceExactlyOneNonDeduplicated() throws Exception { + NotificationRuleTrigger trigger = mockTrigger(TimeUnit.HOURS.toMillis(1)); + NotificationRule rule = mockRule(); + + int threadCount = 10; + CyclicBarrier barrier = new CyclicBarrier(threadCount); + List results = new CopyOnWriteArrayList<>(); + + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + try { + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + barrier.await(TIMEOUT, TimeUnit.SECONDS); + } catch (Exception ignored) {} + results.add(deduplicationService.alreadyProcessed(trigger, rule)); + }); + } + executor.shutdown(); + assertThat(executor.awaitTermination(TIMEOUT, TimeUnit.SECONDS)).isTrue(); + + assertThat(results).hasSize(threadCount); + assertThat(results.stream().filter(r -> !r).count()) + .as("exactly one trigger should pass through deduplication") + .isEqualTo(1); + } finally { + executor.shutdownNow(); + } + } + + private NotificationRuleTrigger mockTrigger(long deduplicationDurationMs) { + NotificationRuleTrigger trigger = mock(NotificationRuleTrigger.class); + when(trigger.getType()).thenReturn(NotificationRuleTriggerType.RESOURCES_SHORTAGE); + when(trigger.getDeduplicationKey()).thenReturn("test:dedup:key"); + when(trigger.getDefaultDeduplicationDuration()).thenReturn(deduplicationDurationMs); + when(trigger.getDeduplicationStrategy()).thenReturn(NotificationRuleTrigger.DeduplicationStrategy.ONLY_MATCHING); + return trigger; + } + + private NotificationRule mockRule() { + NotificationRule rule = mock(NotificationRule.class); + when(rule.getDeduplicationKey()).thenReturn("rule:key"); + return rule; + } + +} diff --git a/common/script/pom.xml b/common/script/pom.xml index 2a5856782e..a11d320416 100644 --- a/common/script/pom.xml +++ b/common/script/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/script/remote-js-client/pom.xml b/common/script/remote-js-client/pom.xml index 6a50189371..202cc20bd3 100644 --- a/common/script/remote-js-client/pom.xml +++ b/common/script/remote-js-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/pom.xml b/common/script/script-api/pom.xml index 38e32661d1..b074db321a 100644 --- a/common/script/script-api/pom.xml +++ b/common/script/script-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT script org.thingsboard.common.script diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java index 7fb56a89ff..ec8eacbc67 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbUtils.java @@ -898,11 +898,11 @@ public class TbUtils { public static int parseBytesToInt(byte[] data, int offset, int length, boolean bigEndian) { validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX); - var bb = ByteBuffer.allocate(4); + var bb = ByteBuffer.allocate(BYTES_LEN_INT_MAX); if (!bigEndian) { bb.order(ByteOrder.LITTLE_ENDIAN); } - bb.position(bigEndian ? 4 - length : 0); + bb.position(bigEndian ? BYTES_LEN_INT_MAX - length : 0); bb.put(data, offset, length); bb.position(0); return bb.getInt(); @@ -923,11 +923,11 @@ public class TbUtils { public static long parseBytesToUnsignedInt(byte[] data, int offset, int length, boolean bigEndian) { validationNumberByLength(data, offset, length, BYTES_LEN_INT_MAX); - ByteBuffer bb = ByteBuffer.allocate(8); + ByteBuffer bb = ByteBuffer.allocate(BYTES_LEN_LONG_MAX); if (!bigEndian) { bb.order(ByteOrder.LITTLE_ENDIAN); } - bb.position(bigEndian ? 8 - length : 0); + bb.position(bigEndian ? BYTES_LEN_LONG_MAX - length : 0); bb.put(data, offset, length); bb.position(0); diff --git a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java index b03a460c31..2452bd8d61 100644 --- a/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java +++ b/common/script/script-api/src/main/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArg.java @@ -73,7 +73,7 @@ public class TbelCfTsRollingArg implements TbelCfArg, Iterable= 21 ? "середа, 6 вересня 2023\u202Fр." : "середа, 6 вересня 2023 р."; + Assertions.assertEquals(expectedDate_ukUA, d.toLocaleDateString("uk-UA", JacksonUtil.newObjectNode() .put("timeZone", "Europe/Kiev") .put("dateStyle", "full") .toString())); @@ -244,12 +246,18 @@ class TbDateTest { Assertions.assertNotNull(d.toLocaleTimeString()); Assertions.assertNotNull(d.toLocaleTimeString("en-US")); - Assertions.assertEquals("9:04:05 PM", d.toLocaleTimeString("en-US", "America/New_York")); + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + String expectedTime_enUS = Runtime.version().feature() >= 21 ? "9:04:05\u202FPM" : "9:04:05 PM"; + Assertions.assertEquals(expectedTime_enUS, d.toLocaleTimeString("en-US", "America/New_York")); Assertions.assertEquals("오후 9:04:05", d.toLocaleTimeString("ko-KR", "America/New_York")); Assertions.assertEquals("04:04:05", d.toLocaleTimeString( "uk-UA", "Europe/Kiev")); Assertions.assertEquals("9:04:05 م", d.toLocaleTimeString( "ar-EG", "America/New_York")); - Assertions.assertEquals("9:04:05 PM Eastern Daylight Time", d.toLocaleTimeString("en-US", JacksonUtil.newObjectNode() + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + String expectedFullTime_enUS = Runtime.version().feature() >= 21 + ? "9:04:05\u202FPM Eastern Daylight Time" + : "9:04:05 PM Eastern Daylight Time"; + Assertions.assertEquals(expectedFullTime_enUS, d.toLocaleTimeString("en-US", JacksonUtil.newObjectNode() .put("timeZone", "America/New_York") .put("timeStyle", "full") .toString())); @@ -292,14 +300,27 @@ class TbDateTest { Assertions.assertNotNull(d.toLocaleString()); Assertions.assertNotNull(d.toLocaleString("en-US")); - Assertions.assertEquals("9/5/23, 9:04:05 PM", d.toLocaleString("en-US", "America/New_York")); + // Java 21+ uses narrow no-break space (U+202F) before AM/PM per CLDR 42+ + String expectedLocale_enUS = Runtime.version().feature() >= 21 ? "9/5/23, 9:04:05\u202FPM" : "9/5/23, 9:04:05 PM"; + Assertions.assertEquals(expectedLocale_enUS, d.toLocaleString("en-US", "America/New_York")); Assertions.assertEquals("23. 9. 5. 오후 9:04:05", d.toLocaleString("ko-KR", "America/New_York")); Assertions.assertEquals("06.09.23, 04:04:05", d.toLocaleString( "uk-UA", "Europe/Kiev")); - String expected_ver = Runtime.version().feature() == 11 ? "5\u200F/9\u200F/2023 9:04:05 م" : - "5\u200F/9\u200F/2023, 9:04:05 م"; - Assertions.assertEquals(expected_ver, d.toLocaleString( "ar-EG", "America/New_York")); + // Java 11 has no comma, Java 17 uses ASCII comma, Java 21+ uses Arabic comma (U+060C) + String expected_ar; + if (Runtime.version().feature() == 11) { + expected_ar = "5\u200F/9\u200F/2023 9:04:05 م"; + } else if (Runtime.version().feature() >= 21) { + expected_ar = "5\u200F/9\u200F/2023\u060C 9:04:05 م"; + } else { + expected_ar = "5\u200F/9\u200F/2023, 9:04:05 م"; + } + Assertions.assertEquals(expected_ar, d.toLocaleString( "ar-EG", "America/New_York")); - Assertions.assertEquals("Tuesday, September 5, 2023 at 9:04:05 PM Eastern Daylight Time", d.toLocaleString("en-US", JacksonUtil.newObjectNode() + // Java 21+ changed "at" to "," and uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_full = Runtime.version().feature() >= 21 + ? "Tuesday, September 5, 2023, 9:04:05\u202FPM Eastern Daylight Time" + : "Tuesday, September 5, 2023 at 9:04:05 PM Eastern Daylight Time"; + Assertions.assertEquals(expected_enUS_full, d.toLocaleString("en-US", JacksonUtil.newObjectNode() .put("timeZone", "America/New_York") .put("dateStyle", "full") .put("timeStyle", "full") @@ -309,15 +330,26 @@ class TbDateTest { .put("dateStyle", "full") .put("timeStyle", "full") .toString())); - Assertions.assertEquals("середа, 6 вересня 2023 р. о 04:04:05 за східноєвропейським літнім часом", d.toLocaleString("uk-UA", JacksonUtil.newObjectNode() + // Java 21+ changed Ukrainian locale: "о" replaced with "," and NNBSP before "р." per CLDR 42+ + String expected_ukUA_full_summer = Runtime.version().feature() >= 21 + ? "середа, 6 вересня 2023\u202Fр., 04:04:05 за східноєвропейським літнім часом" + : "середа, 6 вересня 2023 р. о 04:04:05 за східноєвропейським літнім часом"; + Assertions.assertEquals(expected_ukUA_full_summer, d.toLocaleString("uk-UA", JacksonUtil.newObjectNode() .put("timeZone", "Europe/Kiev") .put("dateStyle", "full") .put("timeStyle", "full") .toString())); - expected_ver = Runtime.version().feature() == 11 ? "الثلاثاء، 5 سبتمبر 2023 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية" : - "الثلاثاء، 5 سبتمبر 2023 في 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية"; - Assertions.assertEquals(expected_ver, d.toLocaleString("ar-EG", JacksonUtil.newObjectNode() + // Java 11 has no connector, Java 17 uses "في", Java 21+ uses Arabic comma "،" + String expected_ar_full; + if (Runtime.version().feature() == 11) { + expected_ar_full = "الثلاثاء، 5 سبتمبر 2023 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية"; + } else if (Runtime.version().feature() >= 21) { + expected_ar_full = "الثلاثاء، 5 سبتمبر 2023\u060C 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية"; + } else { + expected_ar_full = "الثلاثاء، 5 سبتمبر 2023 في 9:04:05 م التوقيت الصيفي الشرقي لأمريكا الشمالية"; + } + Assertions.assertEquals(expected_ar_full, d.toLocaleString("ar-EG", JacksonUtil.newObjectNode() .put("timeZone", "America/New_York") .put("dateStyle", "full") .put("timeStyle", "full") @@ -825,16 +857,44 @@ class TbDateTest { @Test public void toStringAsJs() { TbDate d1 = new TbDate(1975, 12, 31, 23,15,30, 567,"-04:00"); - Assertions.assertEquals("четвер, 1 січня 1976 р. о 06:15:30 за східноєвропейським стандартним часом", d1.toString("uk-UA", "Europe/Kyiv")); - Assertions.assertEquals("Thursday, January 1, 1976 at 6:15:30 AM Eastern European Standard Time", d1.toString("en-US", "Europe/Kyiv")); - Assertions.assertEquals("1976 Jan 1, Thu 06:15:30 Eastern European Time", d1.toString("UTC", "Europe/Kyiv")); - Assertions.assertEquals("Wednesday, December 31, 1975 at 10:15:30 PM Eastern Standard Time", d1.toString("en-US", "America/New_York")); - Assertions.assertEquals("1975 Dec 31, Wed 22:15:30 Eastern Standard Time", d1.toString("GMT", "America/New_York")); - Assertions.assertEquals("1975 Dec 31, Wed 22:15:30 Eastern Standard Time", d1.toString("UTC", "America/New_York")); + // Java 21+ changed Ukrainian locale: "о" (at) replaced with "," and NNBSP before "р." per CLDR 42+ + String expected_ukUA_full = Runtime.version().feature() >= 21 + ? "четвер, 1 січня 1976\u202Fр., 06:15:30 за східноєвропейським стандартним часом" + : "четвер, 1 січня 1976 р. о 06:15:30 за східноєвропейським стандартним часом"; + Assertions.assertEquals(expected_ukUA_full, d1.toString("uk-UA", "Europe/Kyiv")); + // Java 21+ changed "at" to "," and uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_Kyiv = Runtime.version().feature() >= 21 + ? "Thursday, January 1, 1976, 6:15:30\u202FAM Eastern European Standard Time" + : "Thursday, January 1, 1976 at 6:15:30 AM Eastern European Standard Time"; + Assertions.assertEquals(expected_enUS_Kyiv, d1.toString("en-US", "Europe/Kyiv")); + // Java 21+ changed timezone display for UTC locale + String expected_UTC_Kyiv = Runtime.version().feature() >= 21 + ? "1976 Jan 1, Thu 06:15:30 Kyiv (+0)" + : "1976 Jan 1, Thu 06:15:30 Eastern European Time"; + Assertions.assertEquals(expected_UTC_Kyiv, d1.toString("UTC", "Europe/Kyiv")); + // Java 21+ changed "at" to "," and uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_NY = Runtime.version().feature() >= 21 + ? "Wednesday, December 31, 1975, 10:15:30\u202FPM Eastern Standard Time" + : "Wednesday, December 31, 1975 at 10:15:30 PM Eastern Standard Time"; + Assertions.assertEquals(expected_enUS_NY, d1.toString("en-US", "America/New_York")); + // Java 21+ changed timezone display for GMT/UTC locales + String expected_GMT_NY = Runtime.version().feature() >= 21 + ? "1975 Dec 31, Wed 22:15:30 New York (+0)" + : "1975 Dec 31, Wed 22:15:30 Eastern Standard Time"; + Assertions.assertEquals(expected_GMT_NY, d1.toString("GMT", "America/New_York")); + Assertions.assertEquals(expected_GMT_NY, d1.toString("UTC", "America/New_York")); Assertions.assertEquals(d1.toUTCString("UTC"), d1.toUTCString()); - Assertions.assertEquals("четвер, 1 січня 1976 р., 03:15:30", d1.toUTCString("uk-UA")); - Assertions.assertEquals("Thursday, January 1, 1976, 3:15:30 AM", d1.toUTCString("en-US")); + // Java 21+ uses NNBSP before "р." per CLDR 42+ + String expected_ukUA_utc = Runtime.version().feature() >= 21 + ? "четвер, 1 січня 1976\u202Fр., 03:15:30" + : "четвер, 1 січня 1976 р., 03:15:30"; + Assertions.assertEquals(expected_ukUA_utc, d1.toUTCString("uk-UA")); + // Java 21+ uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_utc = Runtime.version().feature() >= 21 + ? "Thursday, January 1, 1976, 3:15:30\u202FAM" + : "Thursday, January 1, 1976, 3:15:30 AM"; + Assertions.assertEquals(expected_enUS_utc, d1.toUTCString("en-US")); Assertions.assertEquals("1976-01-01T03:15:30.567Z", d1.toJSON()); Assertions.assertEquals("1976-01-01T03:15:30.567Z", d1.toISOString()); @@ -842,22 +902,44 @@ class TbDateTest { Assertions.assertEquals("1976-01-01 06:15:30", d1.toLocaleString("UTC", "Europe/Kyiv")); Assertions.assertEquals("01.01.76, 06:15:30", d1.toLocaleString("uk-UA", "Europe/Kyiv")); Assertions.assertEquals("1975-12-31 22:15:30", d1.toLocaleString("UTC", "America/New_York")); - Assertions.assertEquals("12/31/75, 10:15:30 PM", d1.toLocaleString("en-US", "America/New_York")); + // Java 21+ uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_locale_NY = Runtime.version().feature() >= 21 + ? "12/31/75, 10:15:30\u202FPM" + : "12/31/75, 10:15:30 PM"; + Assertions.assertEquals(expected_enUS_locale_NY, d1.toLocaleString("en-US", "America/New_York")); Assertions.assertEquals("1976 Jan 1, Thu", d1.toDateString("UTC", "Europe/Kyiv")); - Assertions.assertEquals("четвер, 1 січня 1976 р.", d1.toDateString("uk-UA", "Europe/Kyiv")); + // Java 21+ uses NNBSP before "р." per CLDR 42+ + String expected_ukUA_date = Runtime.version().feature() >= 21 + ? "четвер, 1 січня 1976\u202Fр." + : "четвер, 1 січня 1976 р."; + Assertions.assertEquals(expected_ukUA_date, d1.toDateString("uk-UA", "Europe/Kyiv")); Assertions.assertEquals("1975 Dec 31, Wed", d1.toDateString("UTC", "America/New_York")); Assertions.assertEquals("Wednesday, December 31, 1975", d1.toDateString("en-US", "America/New_York")); Assertions.assertEquals("06:15:30", d1.toLocaleTimeString("uk-UA", "Europe/Kyiv")); Assertions.assertEquals("06:15:30", d1.toLocaleTimeString("UTC", "Europe/Kyiv")); - Assertions.assertEquals("10:15:30 PM", d1.toLocaleTimeString("en-US", "America/New_York")); + // Java 21+ uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_time = Runtime.version().feature() >= 21 ? "10:15:30\u202FPM" : "10:15:30 PM"; + Assertions.assertEquals(expected_enUS_time, d1.toLocaleTimeString("en-US", "America/New_York")); Assertions.assertEquals("22:15:30", d1.toLocaleTimeString("UTC", "America/New_York")); Assertions.assertEquals("06:15:30 за східноєвропейським стандартним часом", d1.toTimeString("uk-UA", "Europe/Kyiv")); - Assertions.assertEquals("06:15:30 Eastern European Time", d1.toTimeString("UTC", "Europe/Kyiv")); - Assertions.assertEquals("10:15:30 PM Eastern Standard Time", d1.toTimeString("en-US", "America/New_York")); - Assertions.assertEquals("22:15:30 Eastern Standard Time", d1.toTimeString("UTC", "America/New_York")); + // Java 21+ changed timezone display for UTC locale + String expected_UTC_time_Kyiv = Runtime.version().feature() >= 21 + ? "06:15:30 Kyiv (+0)" + : "06:15:30 Eastern European Time"; + Assertions.assertEquals(expected_UTC_time_Kyiv, d1.toTimeString("UTC", "Europe/Kyiv")); + // Java 21+ uses NNBSP before AM/PM per CLDR 42+ + String expected_enUS_timeStr = Runtime.version().feature() >= 21 + ? "10:15:30\u202FPM Eastern Standard Time" + : "10:15:30 PM Eastern Standard Time"; + Assertions.assertEquals(expected_enUS_timeStr, d1.toTimeString("en-US", "America/New_York")); + // Java 21+ changed timezone display for UTC locale + String expected_UTC_time_NY = Runtime.version().feature() >= 21 + ? "22:15:30 New York (+0)" + : "22:15:30 Eastern Standard Time"; + Assertions.assertEquals(expected_UTC_time_NY, d1.toTimeString("UTC", "America/New_York")); } @Test diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java index bdafeac8a7..ef1729ab73 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbUtilsTest.java @@ -155,6 +155,101 @@ public class TbUtilsTest { Assertions.assertEquals(expected, TbUtils.parseBytesToInt(data, 0, 3, false)); } + @Test + public void parseBytesToInt_doesNotChangeInputData() { + byte[] data = new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD}; + byte[] copy = data.clone(); + TbUtils.parseBytesToInt(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesToInt(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesToUnsignedInt(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesToUnsignedInt(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesToLong(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesToLong(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesToFloat(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesToFloat(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesIntToFloat(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesIntToFloat(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesToDouble(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesToDouble(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + TbUtils.parseBytesLongToDouble(data, 0, 4, true); + Assertions.assertArrayEquals(copy, data); + TbUtils.parseBytesLongToDouble(data, 0, 4, false); + Assertions.assertArrayEquals(copy, data); + + List listData = toList(new byte[]{(byte) 0xAA, (byte) 0xBB, (byte) 0xCC, (byte) 0xDD}); + List listCopy = new ArrayList<>(listData); + TbUtils.parseBytesToInt(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesToInt(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesToUnsignedInt(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesToUnsignedInt(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesToLong(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesToLong(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesToFloat(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesToFloat(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesIntToFloat(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesIntToFloat(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesToDouble(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesToDouble(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + + TbUtils.parseBytesLongToDouble(listData, 0, 4, true); + Assertions.assertEquals(listCopy, listData); + TbUtils.parseBytesLongToDouble(listData, 0, 4, false); + Assertions.assertEquals(listCopy, listData); + } + + @Test + public void compare_parseBytesToInt_and_parseBytesToUnsignedInt() { + byte[] data = new byte[]{(byte) 0xFF, (byte) 0xFF, (byte) 0xFF, (byte) 0xFF}; + + // 4 bytes: parseBytesToInt returns -1, parseBytesToUnsignedInt returns 4294967295L + Assertions.assertEquals(-1, TbUtils.parseBytesToInt(data, 0, 4, true)); + Assertions.assertEquals(4294967295L, TbUtils.parseBytesToUnsignedInt(data, 0, 4, true)); + + // 2 bytes (0xFFFF): both return 65535 (no sign extension for parseBytesToInt when length < 4) + Assertions.assertEquals(65535, TbUtils.parseBytesToInt(data, 0, 2, true)); + Assertions.assertEquals(65535L, TbUtils.parseBytesToUnsignedInt(data, 0, 2, true)); + + // 2 bytes with high bit set (0x8000) + byte[] data2 = new byte[]{(byte) 0x80, (byte) 0x00}; + Assertions.assertEquals(32768, TbUtils.parseBytesToInt(data2, 0, 2, true)); + Assertions.assertEquals(32768L, TbUtils.parseBytesToUnsignedInt(data2, 0, 2, true)); + } + @Test public void toFlatMap() { ExecutionHashMap inputMap = new ExecutionHashMap<>(16, ctx); diff --git a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java index 83f21c1b7e..48414d224b 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbelCfTsRollingArgTest.java @@ -57,6 +57,19 @@ public class TbelCfTsRollingArgTest { assertThat(rollingArg.max(false)).isNaN(); } + @Test + void testMaxOverAllNegativeValues() { + TbelCfTsRollingArg negativeArg = new TbelCfTsRollingArg( + new TbTimeWindow(ts - 30000, ts - 10), + List.of( + new TbelCfTsDoubleVal(ts - 10, -50.0), + new TbelCfTsDoubleVal(ts - 20, -100.0), + new TbelCfTsDoubleVal(ts - 30, -75.0) + ) + ); + assertThat(negativeArg.max()).isEqualTo(-50.0); + } + @Test void testMin() { assertThat(rollingArg.min()).isEqualTo(2.0); diff --git a/common/stats/pom.xml b/common/stats/pom.xml index fe35fedaec..da650623f9 100644 --- a/common/stats/pom.xml +++ b/common/stats/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/coap/pom.xml b/common/transport/coap/pom.xml index bb8213666f..76ac33b450 100644 --- a/common/transport/coap/pom.xml +++ b/common/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java index 708263b26a..843f857218 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResource.java @@ -245,7 +245,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } List getEfentoMeasurements(MeasurementsProtos.ProtoMeasurements protoMeasurements, UUID sessionId) { - String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNum().toByteArray()); + String serialNumber = CoapEfentoUtils.convertByteArrayToString(protoMeasurements.getSerialNumber().toByteArray()); boolean batteryStatus = protoMeasurements.getBatteryStatus(); int measurementPeriodBase = protoMeasurements.getMeasurementPeriodBase(); int measurementPeriodFactor = protoMeasurements.getMeasurementPeriodFactor(); @@ -258,6 +258,9 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } Map valuesMap = new TreeMap<>(); + // general measurements per message + valuesMap.put(TimeUnit.SECONDS.toMillis(channelsList.get(0).getTimestamp()), CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, nextTransmissionAtMillis, signal)); + for (int channel = 0; channel < channelsList.size(); channel++) { ProtoChannel protoChannel = channelsList.get(channel); List sampleOffsetsList = protoChannel.getSampleOffsetsList(); @@ -271,6 +274,10 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { long measurementPeriodMillis = TimeUnit.SECONDS.toMillis(measurementPeriod); long startTimestampMillis = TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()); + // measurements per channel + JsonObject tsValues = valuesMap.computeIfAbsent(startTimestampMillis, k -> new JsonObject()); + tsValues.addProperty("measurement_interval_" + (channel + 1), measurementPeriod); + for (int i = 0; i < sampleOffsetsList.size(); i++) { int sampleOffset = sampleOffsetsList.get(i); if (isSensorError(sampleOffset)) { @@ -290,22 +297,16 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } long sampleOffsetMillis = TimeUnit.SECONDS.toMillis(sampleOffset); long measurementTimestamp = startTimestampMillis + Math.abs(sampleOffsetMillis); - values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> - CoapEfentoUtils.setDefaultMeasurements(serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(measurementTimestamp - 1000, k -> new JsonObject()); addBinarySample(protoChannel, currentIsOk, values, channel + 1, sessionId); } else { long timestampMillis = startTimestampMillis + i * measurementPeriodMillis; - values = valuesMap.computeIfAbsent(timestampMillis, k -> CoapEfentoUtils.setDefaultMeasurements( - serialNumber, batteryStatus, measurementPeriod, nextTransmissionAtMillis, signal, k)); + values = valuesMap.computeIfAbsent(timestampMillis, k -> new JsonObject()); addContinuesSample(protoChannel, sampleOffset, values, channel + 1, sessionId); } } } - if (CollectionUtils.isEmpty(valuesMap)) { - throw new IllegalStateException("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); - } - return valuesMap.entrySet().stream() .map(entry -> new EfentoTelemetry(entry.getKey(), entry.getValue())) .collect(Collectors.toList()); @@ -446,7 +447,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { } } - private EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { + EfentoTelemetry getEfentoDeviceInfo(DeviceInfoProtos.ProtoDeviceInfo protoDeviceInfo) { JsonObject values = new JsonObject(); values.addProperty("sw_version", protoDeviceInfo.getSwVersion()); @@ -476,41 +477,86 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { //modem info DeviceInfoProtos.ProtoModem modem = protoDeviceInfo.getModem(); - values.addProperty("modem_types", modem.getType().toString()); - values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); - values.addProperty("sc_EARFCN", modem.getParameters(1)); - values.addProperty("sc_PCI", modem.getParameters(2)); - values.addProperty("sc_Cell_id", modem.getParameters(3)); - values.addProperty("sc_RSRP", modem.getParameters(4)); - values.addProperty("sc_RSRQ", modem.getParameters(5)); - values.addProperty("sc_RSSI", modem.getParameters(6)); - values.addProperty("sc_SINR", modem.getParameters(7)); - values.addProperty("sc_Band", modem.getParameters(8)); - values.addProperty("sc_TAC", modem.getParameters(9)); - values.addProperty("sc_ECL", modem.getParameters(10)); - values.addProperty("sc_TX_PWR", modem.getParameters(11)); - values.addProperty("op_mode", modem.getParameters(12)); - values.addProperty("nc_EARFCN", modem.getParameters(13)); - values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); - values.addProperty("nc_PCI", modem.getParameters(15)); - values.addProperty("nc_RSRP", modem.getParameters(16)); - values.addProperty("RLC_UL_BLER", modem.getParameters(17)); - values.addProperty("RLC_DL_BLER", modem.getParameters(18)); - values.addProperty("MAC_UL_BLER", modem.getParameters(19)); - values.addProperty("MAC_DL_BLER", modem.getParameters(20)); - values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); - values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); - values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); - values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); - values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); - values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); - values.addProperty("RLC_UL_tput", modem.getParameters(27)); - values.addProperty("RLC_DL_tput", modem.getParameters(28)); - values.addProperty("MAC_UL_tput", modem.getParameters(29)); - values.addProperty("MAC_DL_tput", modem.getParameters(30)); - values.addProperty("sleep_duration", modem.getParameters(31)); - values.addProperty("rx_time", modem.getParameters(32)); - values.addProperty("tx_time", modem.getParameters(33)); + DeviceInfoProtos.ModemType modemType = modem.getType(); + values.addProperty("modem_types", modemType.toString()); + values.addProperty("sim_card_identification", modem.getSimCardIdentification()); + values.addProperty("firmware_version", modem.getFirmwareVersion().toString()); + values.addProperty("modem_identification", modem.getModemIdentification()); + if (modem.getModemStatisticsCount() >= 4) { + values.addProperty("modem_transmissions_count", modem.getModemStatistics(0)); + values.addProperty("modem_time_since_last_devinfo", modem.getModemStatistics(1)); + values.addProperty("modem_total_psm_time", modem.getModemStatistics(2)); + values.addProperty("modem_total_active_time", modem.getModemStatistics(3)); + } + switch (modemType) { + case MODEM_TYPE_BC660: + values.addProperty("sc_EARFCN", modem.getParameters(0)); + values.addProperty("sc_EARNFCN_offset", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_PCI", modem.getParameters(14)); + values.addProperty("nc_RSRP", modem.getParameters(15)); + values.addProperty("nc_RSRQ", modem.getParameters(16)); + values.addProperty("sleep_duration", modem.getParameters(17)); + values.addProperty("rx_time", modem.getParameters(18)); + values.addProperty("tx_time", modem.getParameters(19)); + values.addProperty("PLMN_state", modem.getParameters(20)); + values.addProperty("select_PLMN", modem.getParameters(21)); + break; + case MODEM_TYPE_SHARED_MODEM: + values.addProperty("RSRP", modem.getParameters(0)); + values.addProperty("RSRQ", modem.getParameters(1)); + values.addProperty("RSSI", modem.getParameters(2)); + values.addProperty("SINR", modem.getParameters(3)); + break; + default: + // MODEM_TYPE_UNSPECIFIED, MODEM_TYPE_BC66, MODEM_TYPE_BC66NA + values.addProperty("sc_EARNFCN_offset", modem.getParameters(0)); + values.addProperty("sc_EARFCN", modem.getParameters(1)); + values.addProperty("sc_PCI", modem.getParameters(2)); + values.addProperty("sc_Cell_id", modem.getParameters(3)); + values.addProperty("sc_RSRP", modem.getParameters(4)); + values.addProperty("sc_RSRQ", modem.getParameters(5)); + values.addProperty("sc_RSSI", modem.getParameters(6)); + values.addProperty("sc_SINR", modem.getParameters(7)); + values.addProperty("sc_Band", modem.getParameters(8)); + values.addProperty("sc_TAC", modem.getParameters(9)); + values.addProperty("sc_ECL", modem.getParameters(10)); + values.addProperty("sc_TX_PWR", modem.getParameters(11)); + values.addProperty("op_mode", modem.getParameters(12)); + values.addProperty("nc_EARFCN", modem.getParameters(13)); + values.addProperty("nc_EARNFCN_offset", modem.getParameters(14)); + values.addProperty("nc_PCI", modem.getParameters(15)); + values.addProperty("nc_RSRP", modem.getParameters(16)); + values.addProperty("RLC_UL_BLER", modem.getParameters(17)); + values.addProperty("RLC_DL_BLER", modem.getParameters(18)); + values.addProperty("MAC_UL_BLER", modem.getParameters(19)); + values.addProperty("MAC_DL_BLER", modem.getParameters(20)); + values.addProperty("MAC_UL_TOTAL_BYTES", modem.getParameters(21)); + values.addProperty("MAC_DL_TOTAL_BYTES", modem.getParameters(22)); + values.addProperty("MAC_UL_total_HARQ_Tx", modem.getParameters(23)); + values.addProperty("MAC_DL_total_HARQ_Tx", modem.getParameters(24)); + values.addProperty("MAC_UL_HARQ_re_Tx", modem.getParameters(25)); + values.addProperty("MAC_DL_HARQ_re_Tx", modem.getParameters(26)); + values.addProperty("RLC_UL_tput", modem.getParameters(27)); + values.addProperty("RLC_DL_tput", modem.getParameters(28)); + values.addProperty("MAC_UL_tput", modem.getParameters(29)); + values.addProperty("MAC_DL_tput", modem.getParameters(30)); + values.addProperty("sleep_duration", modem.getParameters(31)); + values.addProperty("rx_time", modem.getParameters(32)); + values.addProperty("tx_time", modem.getParameters(33)); + break; + } //Runtime info DeviceInfoProtos.ProtoRuntime runtimeInfo = protoDeviceInfo.getRuntimeInfo(); @@ -521,7 +567,7 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { values.addProperty("counter_of_non_confirmable_messages_attempts", runtimeInfo.getMessageCounters(1)); values.addProperty("counter_of_succeeded_messages", runtimeInfo.getMessageCounters(2)); values.addProperty("min_battery_mcu_temp", runtimeInfo.getMinBatteryMcuTemperature()); - values.addProperty("min_battery_voltage", runtimeInfo.getMinBatteryVoltage()); + values.addProperty("min_battery_voltage", runtimeInfo.getBatteryVoltage()); values.addProperty("min_mcu_temp", runtimeInfo.getMinMcuTemperature()); values.addProperty("runtime_errors", runtimeInfo.getRuntimeErrorsCount()); values.addProperty("up_time", runtimeInfo.getUpTime()); @@ -529,8 +575,8 @@ public class CoapEfentoTransportResource extends AbstractCoapTransportResource { return new EfentoTelemetry(System.currentTimeMillis(), values); } - private JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { - return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(2))); + JsonElement getEfentoConfiguration(byte[] bytes) throws InvalidProtocolBufferException { + return parseString(ProtoConverter.dynamicMsgToJson(bytes, ConfigProtos.getDescriptor().getMessageTypes().get(0))); } private static String getDate(long seconds) { diff --git a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java index 015bf07292..1acac0ec75 100644 --- a/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java +++ b/common/transport/coap/src/main/java/org/thingsboard/server/transport/coap/efento/utils/CoapEfentoUtils.java @@ -59,14 +59,12 @@ public class CoapEfentoUtils { return String.format("%s UTC", simpleDateFormat.format(new Date(timestampInMillis))); } - public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long measurementPeriod, long nextTransmissionAtMillis, long signal, long startTimestampMillis) { + public static JsonObject setDefaultMeasurements(String serialNumber, boolean batteryStatus, long nextTransmissionAtMillis, long signal) { JsonObject values = new JsonObject(); values.addProperty("serial", serialNumber); values.addProperty("battery", batteryStatus ? "ok" : "low"); - values.addProperty("measured_at", convertTimestampToUtcString(startTimestampMillis)); values.addProperty("next_transmission_at", convertTimestampToUtcString(nextTransmissionAtMillis)); values.addProperty("signal", signal); - values.addProperty("measurement_interval", measurementPeriod); return values; } diff --git a/common/transport/coap/src/main/proto/efento/proto_config.proto b/common/transport/coap/src/main/proto/efento/proto_config.proto index ae6d7902b3..1052d338e7 100644 --- a/common/transport/coap/src/main/proto/efento/proto_config.proto +++ b/common/transport/coap/src/main/proto/efento/proto_config.proto @@ -15,338 +15,471 @@ */ syntax = "proto3"; -import "efento/proto_measurement_types.proto"; import "efento/proto_rule.proto"; +import "efento/proto_config_types.proto"; +import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ConfigProtos"; -/* Message containing optional channels control parameters */ -message ProtoOutputControlState { - - /* Channel index */ - uint32 channel_index = 1; - - /* Channel state ON/OFF. Range (1 - OFF; 2 - ON) */ - uint32 channel_state = 2; -} - -/* Message containing request data for accessing calibration parameters */ -message ProtoCalibrationParameters { - - /* Request details. Bitmask: */ - /* - calibration_request[0:2] - requested channel number. */ - uint32 calibration_request = 1; - - /* Assignment of a channel. */ - uint32 channel_assignment = 2; - - /* Table of calibration parameters. Max size = 8. */ - repeated int32 parameters = 3; -} - -enum BleAdvertisingPeriodMode { - - /* Invalid value */ - BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; +message ProtoConfig { - /* Default behavior - faster advertising when measurement period is < 15s. */ - BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* User-configured normal interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + reserved 1,48; - /* User-configured fast interval is used. */ - BLE_ADVERTISING_PERIOD_MODE_FAST = 3; -} + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ -/* Message containing BLE advertising period configuration */ -message ProtoBleAdvertisingPeriod { + /* Serial number of the device. * + * Length: 6 bytes. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + bytes serial_number = 25; - /* BLE advertising mode: */ - /* - 1: Default, BLE advertising interval is set to 1022.5ms or some lower value, based on continuous measurement period. */ - /* - 2: Normal, uses user-configured value from 'normal' field. */ - /* - 3: Fast, uses user-configured value from 'fast' field (must be lower than or equal to 'normal' field). */ - BleAdvertisingPeriodMode mode = 1; + /* Configuration payload split information: * + * - Values < 0 - Payload split, expect another part of the payload in the next message. * + * The absolute value indicates an index of the current message * + * - Values = 0 - Payload not split * + * - Values > 0 - Last part of the split payload, the value indicates the total number of the messages sent * + * This field is only sent by the device. * + * Status: In use [06.08.00 - LATEST] */ + sint32 payload_split_info = 44; - /* BLE advertising interval when in normal mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 normal = 2; + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 21; + + /* Timestamp when the new configuration was set. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as hash_timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_hash_timestamp = 39; + + /* Configuration errors. * + * Up to 20 error codes supported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as errors [06.00 - 06.xx.xx] */ + repeated uint32 configuration_errors = 20; + + /* Timestamp when a new configuration error was reported. * + * This field is only sent by the device. * + * Status: In use [07.00.00 - LATEST] / Previously as timestamp [06.02 - 06.xx.xx] */ + uint32 configuration_error_timestamp = 38; + + /* Measurement channel types. * + * This field is only sent by the device. * + * Status: In use [06.00 - LATEST] */ + repeated MeasurementType channel_types = 27; - /* BLE advertising interval when in fast mode, configured in 0.625ms steps. */ - /* Range: [32:16384] */ - uint32 fast = 3; -} + /* NvM status: * + * - 1 - Defaults restored on CRC error: default sensor configuration restored due to the non-volatile memory configuration * + * data integrity failure * + * - 2 - NvM initialization error: changes in the sensor configuration won't be stored in the non-volatile memory, a power * + * reset of the sensor is required * + * When empty field is sent by the sensor NvM status is OK. * + * NvM status can be cleared by sending to the sensor value 0x7F. * + * Status: In use [07.00.00 - LATEST] */ + uint32 nvm_status = 62; + + /* SERVER STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Current time in seconds since 1st of January 1970 (epoch time). * + * This field is only sent by the server. * + * Status: In use [06.00 - LATEST] */ + uint32 current_time = 8; -/* Main message sent in the payload. Each field in this message is independent of the others - only parameters that should be */ -/* changed need to be sent in the payload. */ -/* If the value of a selected parameter shall not be changed, do not include it in the payload */ -message ProtoConfig { + /* REQUEST FIELDS ----------------------------------------------------------------------------------------------------------- */ + + /* Specifies whether the device should accept the configuration without functional testing (e.g., network connection). * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as accept_without_testing [06.00 - 06.xx.xx] */ + bool accept_without_testing_request = 22; + + /* Specifies whether to send the configuration from the sensor to the configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_configuration [06.00 - 06.xx.xx] */ + bool configuration_request = 19; + + /* Specifies whether to send the device information from the sensor to the device information endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_device_info [06.00 - 06.xx.xx] */ + bool device_info_request = 6; + + /* Specifies whether to send the extended configuration from the sensor to the extended configuration endpoint. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool extended_configuration_request = 63; + + /* Specifies, if software update is available. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_fw_update [06.00 - 06.xx.xx] */ + bool update_software_request = 7; + + /* Device will clear all runtime errors. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as request_runtime_errors_clear [06.02 - 06.xx.xx] */ + bool clear_runtime_errors_request = 37; + + /* Device will power off its cellular modem for requested number of seconds. * + * Range: [60:604800] (1 minute : 7 days) * + * This field is only sent by the user/server. * + * Status: In use [06.00 - LATEST] */ + uint32 disable_modem_request = 18; - /* DEPRECATED - Used for backward compatibility with fw versions 5.x */ - /* repeated Threshold thresholds = 1; */ - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Specifies, if the modem firmware update is available. * + * String up to 48 characters: * + * - DFOTA URL - For use by BC66/BC660 modem. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as modem_update_request [06.08.00 - 06.xx.xx] */ + string update_modem_request = 45; + + /* Device will erase all measurements from memory. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as memory_reset_request [06.00 - 06.xx.xx] */ + bool reset_memory_request = 30; + + /* Device will restart the collection of memory statistics. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + bool restart_memory_stats_request = 64; + + /* Specifies whether to send measurements from the sensor starting at the specified timestamp. * + * Tiemstamp in seconds since 1st of January 1970 (epoch time). * + * All previous measurements will be marked as sent. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] */ + uint32 data_transfer_start_timestamp_request = 65; + + /* Device will set new calibration parameters or will send the current set of parameters to the configuration endpoint. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + ProtoCalibrationParametersRequest calibration_parameters_request = 49; + + /* Device will set the new output state of the output control channel pins. * + * Up to 3 channels supported in the one request. * + * This field is only sent by the user/server. * + * Status: In use [07.00.00 - LATEST] / Previously as output_control_state_request [06.13.00/06.21.00 - 06.xx.xx] */ + repeated ProtoOutputControlState set_output_control_state_request = 58; + + /* MEASUREMENTS CONFIGURATION ----------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 2; - /* Measurement period factor */ - /* Range [1:65535] - minimum value can vary depends on installed sensors */ + /* Measurement period factor. * + * Range: [1:65535] (minimum value may vary depending on sensors installed) * + * Group: Measurement Period * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 26; - /* Transmission interval in seconds. Range: [60:604800] */ - uint32 transmission_interval = 3; + /* BLUETOOTH CONFIGURATION -------------------------------------------------------------------------------------------------- */ - /* BLE turnoff time in seconds. Once receiving this setting, BLE will be switched off after the set number of seconds. */ - /* If BLE is already switched off, it will switch on for the set number of seconds and switch off afterwards. */ - /* Range [60:604800] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always on */ + /* Bluetooth turn-off time: * + * - [60:604800] - Time in seconds after which Bluetooth is turned off * + * - 0xFFFFFFFF - Bluetooth is always on * + * When this setting is received, Bluetooth is turned off after the set number of seconds. * + * If Bluetooth is already off, it will turn on for the set number of seconds and then turn off. * + * Group: Bluetooth Turn-Off * + * Status: In use [06.00 - LATEST] */ uint32 ble_turnoff_time = 4; - /* ACK interval in seconds */ - /* Range [180:2592000] and 0xFFFFFFFF */ - /* 0xFFFFFFFF - always request ACK */ - uint32 ack_interval = 5; - - /* Specifies, if the additional device info is requested. If true, sensor will send a message to endpoint '/i' with the */ - /* device info. This field is only sent by server */ - bool request_device_info = 6; - - /* Specifies, if software update is available. This field is only sent by server */ - bool request_fw_update = 7; - - /* Current time in seconds sine 1st of January 1970 (epoch time). */ - uint32 current_time = 8; - - /* NB-IoT transfer limit */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit = 9; - - /* NB-IoT transfer limit timer in seconds */ - /* Range: [1:65535] */ - /* 65535 - disable transfer limit function */ - uint32 transfer_limit_timer = 10; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the data (measurements) server */ - /* The IP or URL of the data server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the data (measurements) server */ - /* For example, use "18.184.24.239" */ - string data_server_ip = 11; - - /* Data (measurements) server port */ - /* Range: [1:65535] */ - uint32 data_server_port = 12; - - /* For firmware >= 6.07.00: */ - /* IP or URL address of the update server */ - /* The IP or URL of the update server, provided as string with a maximum length of 31 characters */ - /* For example, use "18.184.24.239" for an IP address or "efento.test.io" for a URL */ - /* For firmware < 6.07.00: */ - /* IP address of the update server */ - /* For example, use "18.184.24.239" */ - string update_server_ip = 13; - - /* Update server port for UDP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_udp = 14; - - /* Update server port for CoAP transmission */ - /* Range: [1:65535] */ - uint32 update_server_port_coap = 15; - - /* APN as string. Max length 49 */ - /* String with special character 0x7F (DEL) only indicates that automatic apn is turn on */ - string apn = 16; + /* Bluetooth Tx power level. * + * Value is the index of the absolute value of the Tx power, which depends on the BLE module. * + * Range: [1:4] * + * Group: Bluetooth Tx Power * + * Status: In use [06.02 - LATEST] */ + uint32 ble_tx_power_level = 36; - /* PLMN selection */ - /* Range: [100:999999] */ - /* 0xFFFFFFFF or 1000000 - automatic selection */ - uint32 plmn_selection = 17; + /* Encryption key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables encryption. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the encryption key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Encryption * + * Status: In use [06.11.00 - LATEST] */ + bytes encryption_key = 54; - /* Device will power off its cellular modem for requested number of seconds. */ - /* Range: [60:604800] (1 minute : 7 days) */ - /* This field is only sent by server */ - uint32 disable_modem_request = 18; + /* Bluetooth advertising period. * + * Group: Bluetooth Advertising Period * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + ProtoBleAdvertisingPeriod ble_advertising_period = 59; - /* If set, the device will send its configuration to the endpoint '/c' as a confirmable message */ - /* This field is only sent by server */ - bool request_configuration = 19; + /* Advertisement manufacturer specific data format. * + * Group: Advertisement Manufacturer Data Format * + * Status: In use [07.00.00 - LATEST] */ + AdvertisementManufacturerDataFormat advertisement_manufacturer_data_format = 60; - /* Device's error codes. */ - /* This field is only sent by device */ - repeated uint32 errors = 20; + /* EDGE LOGIC CONFIGURATION ------------------------------------------------------------------------------------------------- */ - /* Identifier of current configuration - Every change of the configuration results in change of the value of this field */ - /* This field is only sent by device */ - uint32 hash = 21; + /* Edge logic rules set on the device. * + * Up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]). * + * Group: Edge Logic Rule No. # * + * Status: In use [06.00 - LATEST] */ + repeated ProtoRule rules = 28; - /* If true, the device will accept the configuration without functional testing (eg. network connection) */ - bool accept_without_testing = 22; + /* Calendars set on the device. * + * Up to 6 calendars supported. * + * Group: Calendar No. # * + * Status: In use [06.08.00 - LATEST] */ + repeated ProtoCalendar calendars = 47; - /* Cloud token configuration: */ - /* - 1: cloud token set to the value of cloud_token field */ - /* - 2: cloud token set to IMEI of the cellular module */ - /* - 255: do not send cloud_token field */ - uint32 cloud_token_config = 23; + /* SERVER COMMUNICATION CONFIGURATION --------------------------------------------------------------------------------------- */ - /* Cloud token that should be sent with each measurement frame */ - string cloud_token = 24; + /* Transmission interval in seconds. * + * Range: [60:604800] * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 transmission_interval = 3; - /* Serial number of the device */ - /* This field is only sent by device */ - bytes serial_number = 25; + /* ACK interval: * + * - [180:2592000] - Time in seconds after which the device will request an ACK * + * - 0xFFFFFFFF - Always request ACK * + * Group: Server Intervals * + * Status: In use [06.00 - LATEST] */ + uint32 ack_interval = 5; - /* Type of channel */ - /* This field is only sent by device */ - repeated MeasurementType channel_types = 27; + /* Server transfer limit: * + * - [1:65534] - Number of transfers * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit = 9; - /* Edge logic rules set on the device. Up to 12 rules are supported */ - repeated ProtoRule rules = 28; + /* Server transfer limit timer: * + * - [1:65534] - Time in seconds after which the transfer is renewed * + * - 65535 - Transfer limit disabled * + * Group: Server Transfer Limit * + * Status: In use [06.00 - LATEST] */ + uint32 transfer_limit_timer = 10; - /* Supervision period */ - /* Range: [180:604800] */ - /* 0xFFFFFFFF - Functionality disabled */ + /* Server supervision period: * + * - [180:604800] - Time in seconds after which the device resets itself if there is no communication with the server * + * - 0xFFFFFFFF - Server supervision disabled * + * Group: Server Supervision * + * Status: In use [06.00 - LATEST] */ uint32 supervision_period = 29; - /* If true, sensor's measurement memory will be erased */ - bool memory_reset_request = 30; + /* DATA SERVER CONFIGURATION ------------------------------------------------------------------------------------------------ */ - /* Bytes 0-4 - Band selection mask. Mask = 1 << position */ - /* Band | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | */ - /* Position: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | */ - /* example: To enable band 3, 8 and 20 set to (1 << 2) + (1 << 5) + (1 << 11) = 2084 */ - uint32 modem_bands_mask = 31; + /* Data server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string data_server_ip = 11; + + /* Data server port. * + * Range: [1:65535] * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 data_server_port = 12; - /* Data endpoint (string - max length 16) */ + /* Data endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string data_endpoint = 32; - /* Configuration endpoint (string - max length 16) */ + /* Configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [06.02 - LATEST] */ string configuration_endpoint = 33; - /* Device info endpoint (string - max length 16) */ + /* Extended configuration endpoint. * + * String up to 11 characters. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] */ + string extended_configuration_endpoint = 61; + + /* Device information endpoint. * + * String up to 11 characters. * + * Group: Device Information Endpoint * + * Status: In use [06.02 - LATEST] */ string device_info_endpoint = 34; - /* Time endpoint (string - max length 16) */ + /* Time endpoint. * + * String up to 11 characters. * + * Group: Time Endpoint * + * Status: In use [06.02 - LATEST] */ string time_endpoint = 35; - /* Bluetooth TX power level. Value is the index of the absolute value of TX power, that depends on the BLE module */ - /* Range: [1:4] */ - uint32 ble_tx_power_level = 36; + /* UPDATE SERVER CONFIGURATION ---------------------------------------------------------------------------------------------- */ - /* Deprecated field */ - /* If true, the sensor's runtime errors will be cleared */ - bool request_runtime_errors_clear = 37; + /* Update server address. * + * String up to 31 characters: * + * - IPv4 address (examples: "18.184.24.239") [06.00 - 06.06.xx] * + * - IPv4/URL address (examples: "18.184.24.239", "efento.test.io") [06.07.00 - LATEST] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + string update_server_ip = 13; - /* Timestamp when a new error code was reported */ - uint32 error_timestamp = 38; + /* Update server port for UDP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_udp = 14; - /* Timestamp when the new configuration was set */ - uint32 hash_timestamp = 39; + /* Update server port for CoAP transfer. * + * Range: [1:65535] * + * Group: Update Server * + * Status: In use [06.00 - LATEST] */ + uint32 update_server_port_coap = 15; - /* Cloud token CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing cloud token */ - /* - 65000 - cloud token sent in the payload */ - uint32 cloud_token_coap_option = 40; + /* CLOUD CONFIGURATION ------------------------------------------------------------------------------------------------------ */ - /* ECDSA payload signature CoAP option ID: */ - /* - [1:64999] - CoAP option ID containing ECDSA payload signature */ - /* - 65000 - no payload signature in CoAP option */ - uint32 payload_signature_coap_option = 41; + /* Cloud token configuration: * + * - 1 - Cloud token set to the value of the 'cloud_token' field * + * - 2 - Cloud token set to the modem identification (IMEI for cellular modems) * + * - 255 - Do not send 'cloud_token' field * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + uint32 cloud_token_config = 23; - /* DNS server IP address grouped in the array as four octets. Set 255.255.255.255 to use a network DNS server */ - /* Note: when setting less than four octets the remaining will be filled with zeros. */ - repeated uint32 dns_server_ip = 42; + /* Cloud token that should be sent with each measurement frame. * + * String up to 36 characters. * + * Group: Cloud Token * + * Status: In use [06.00 - LATEST] */ + string cloud_token = 24; - /* DNS TTL configuration: */ - /* - [1:864000] - custom TTL in seconds (additionally, the DNS request when communication has failed) */ - /* - 864001 - accept TTL from the DNS server (additionally, the DNS request when communication has failed) */ - /* - 864002 - DNS request is only after communication failed */ - uint32 dns_ttl_config = 43; + /* Cloud token CoAP option ID: * + * - [1:64999] - CoAP option ID with cloud token * + * - 65000 - Cloud token sent in the payload * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 cloud_token_coap_option = 40; - /* Configuration payload split information. Information about dividing the payload into parts */ - /* values < 0 - payload has been split, expect another part of the payload in the next message. */ - /* The absolute value indicates an index of the current message. */ - /* value = 0 - payload has not been splitted */ - /* values > 0 - last part of the split payload, the value indicates the total number of the messages sent */ - sint32 payload_split_info = 44; + /* ECDSA payload signature CoAP option ID: * + * - [1:64999] - CoAP option ID with payload signature * + * - 65000 - Payload signature is not sent * + * Group: Cloud Token * + * Status: In use [06.07.00 - LATEST] */ + uint32 payload_signature_coap_option = 41; - /* Modem update request (string - max length 48) */ - /* This field is only sent by server */ - /* For BC66 module, this field is a DFOTA URL */ - string modem_update_request = 45; + /* Modem identification CoAP option ID: * + * - [1:64999] - CoAP option ID with modem identification * + * - 65000 - Modem identification is not sent * + * Group: Cloud Token * + * Status: In use [07.00.00 - LATEST] */ + uint32 modem_identification_coap_option = 52; - /* Cellular configuration parameters. */ - /* 1st item - Number of used cellular parameters */ - /* 2nd - 12th items - Cellular parameters */ - repeated uint32 cellular_config_params = 46; + /* NETWORK CONFIGURATION ---------------------------------------------------------------------------------------------------- */ - /* Calendar configuration. Up to 6 calendars are supported */ - repeated ProtoCalendar calendars = 47; + /* DNS server IP address. * + * Grouped in the array as four octets. Set 255.255.255.255 to use a cellular network DNS server. * + * Note: when setting less than four octets the remaining will be filled with zeros. * + * Group: Data Server * + * Status: In use [06.07.00 - LATEST] */ + repeated uint32 dns_server_ip = 42; - /* DEPRECATED - Used for backward compatibility */ - reserved 48; - - /* Set/get calibration parameters for single channel. */ - ProtoCalibrationParameters calibration_parameters_request = 49; - - /* LED behaviour configuration: */ - /* Period of LEDs flashing (5-600 seconds in 5 seconds resolution): */ - /* - led_config[0] - green LED */ - /* - led_config[1] - red LED */ - /* Time from entering the normal state, after which the LED indication is turned off */ - /* (0-240 minutes in 1 minute resolution, or 255 for always turned on): */ - /* - led_config[2] - flashing red led on communication problem */ - /* - led_config[3] - flashing red led on a sensor problem */ - /* - led_config[4] - flashing red led on a low power */ - /* - led_config[5] - flashing green led on measurement */ - /* - led_config[6] - flashing green led on transmission */ - /* - led_config[7] - flashing green led to indicate sensor's proper operation */ - /* - led_config[8] - Blink duration (20-1000ms in 5 ms resolution) */ - repeated uint32 led_config = 50; + /* DNS TTL configuration: * + * - [1:864000] - Custom TTL in seconds (if communication fails, DNS is also queried) * + * - 864001 - Accept TTL from the DNS server (if communication fails, DNS is also queried) * + * - 864002 - DNS query only after communication failure * + * Group: DNS * + * Status: In use [06.07.00 - LATEST] */ + uint32 dns_ttl_config = 43; - /* Network troubleshooting configuration, if bluetooth is turned off and communication with the server is faulty, */ - /* bluetooth will be automatically turned on until the connection is stabilized */ - /* - 1: network troubleshooting disabled */ - /* - 2: network troubleshooting enabled */ + /* Network troubleshooting: + * - 1 - Network troubleshooting disabled * + * - 2 - Network troubleshooting enabled * + * If Bluetooth is turned off and communication with the server is faulty, Bluetooth will be automatically turned on until * + * the connection is stabilized. * + * Group: Network * + * Status: In use [06.10.00 - LATEST] */ uint32 network_troubleshooting = 51; - /* Reserved by gateway client */ - reserved 52, 53; - - /* Encryption key configuration. Sensor sends in this field two last bytes of SHA256 hash calculated from its current */ - /* encryption_key configuration. When encryption key is disabled one byte 0x7F (DEL) is sent. */ - /* Max length: 16 bytes. */ - /* 0x7F - encryption key disabled. */ - bytes encryption_key = 54; - - /* User name as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic user name is turn on */ - /* User name can only be set to custom value if apn has been configured (is not automatic) */ - string apn_user_name = 55; + /* Network key. * + * Max length: 16 bytes. * + * A one-element array containing only one 0x7F (DEL) byte disables network key. * + * Device sends two last bytes of SHA256 hash of current key in this field. * + * When the network key is disabled, the device sends a 0x7F (DEL) byte. * + * Group: Local Network * + * Status: In use [07.00.00 - LATEST] */ + bytes network_key = 53; + + /* MODEM CONFIGURATION ------------------------------------------------------------------------------------------------------ */ + + /* APN (Access Point Name). * + * String up to 49 characters. * + * A string containing only the special character 0x7F (DEL) indicates that automatic APN is enabled. * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + string apn = 16; - /* Password as string. Max length 31 */ - /* String with special character 0x7F (DEL) only indicates that automatic password is turn on */ - /* Password can only be set to custom value if apn_user_name has been configured (is not automatic) */ + /* APN username. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the username is not being used. * + * The username can only be set to a custom value if APN has been configured. * + * Group: Data Server * + * Status: In use [07.00.00 - LATEST] / Previously as apn_user_name [06.11.00 - 06.xx.xx] */ + string apn_username = 55; + + /* APN password. * + * String up to 31 characters. * + * A string containing only the special character 0x7F (DEL) indicates that the password is not being used. * + * The password can only be set to a custom value if APN username has been configured. * + * Group: Data Server * + * Status: In use [06.11.00 - LATEST] */ string apn_password = 56; - /* Reserved by versions above 06.20.00 */ - reserved 57; + /* PLMN selection: * + * - [100:999999] - Selected operator code * + * - 1000000 - Automatic selection * + * - 0xFFFFFFFF - Automatic selection (legacy) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 plmn_selection = 17; - /* Control output state on channel pin. Maximal number of requests equals 3 */ - /* This field is only sent by server */ - repeated ProtoOutputControlState output_control_state_request = 58; + /* Modem bands mask. * + * 4-byte bit mask, where each bit represents a specific band: * + * Band: | 1 | 2 | 3 | 4 | 5 | 8 | 12 | 13 | 17 | 18 | 19 | 20 | 25 | 26 | 28 | 66 | 71 | 85 | 70 | * + * Bit: | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | * + * Example: To enable band 3, 8 and 20 set to 2084 = (1 << 2) + (1 << 5) + (1 << 11) * + * Group: Data Server * + * Status: In use [06.00 - LATEST] */ + uint32 modem_bands_mask = 31; - /* BLE advertising period configuration. */ - ProtoBleAdvertisingPeriod ble_advertising_period = 59; -} + /* Modem configuration parameters. * + * 1st item - The number of cellular parameters that are used (depending on the modem in use). * + * 2nd - 16th item - Cellular parameters. * + * Group: Cellular Configuration * + * Status: In use [06.08.00 - LATEST] */ + repeated uint32 cellular_config_params = 46; + + /* Network search schema. * + * Group: Network Search * + * Status: In use [06.20.00 - LATEST] */ + ProtoNetworkSearch network_search = 57; + + /* USER INTERFACE CONFIGURATION --------------------------------------------------------------------------------------------- */ + + /* LEDs behavior. * + * Period of LED blinking in 5-second unit (value is multiplied by 5). Range: [1:120]: * + * - 1st item - Period of GREEN LED blinking * + * - 2nd item - Period of RED LED blinking * + * Time elapsing from the entry into the normal state, after which the LED indicator is switched off, in minutes. * + * Range: [0:240; 255]. Value of 255 means always on: * + * - 3rd item - Blinking RED LED on communication problem * + * - 4th item - Blinking RED LED on a sensor problem * + * - 5th item - Blinking RED LED on a low power * + * - 6th item - Blinking GREEN LED on measurement * + * - 7th item - Blinking GREEN LED on transmission * + * - 8th item - Blinking GREEN LED to indicate sensor's proper operation * + * 9th item - Duration of LED blinking in 5-millisecond unit. Range: [4:200] * + * Group: LED * + * Status: In use [06.10.00 - LATEST] */ + repeated uint32 led_config = 50; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_config_types.proto b/common/transport/coap/src/main/proto/efento/proto_config_types.proto new file mode 100644 index 0000000000..44fb9197a4 --- /dev/null +++ b/common/transport/coap/src/main/proto/efento/proto_config_types.proto @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +syntax = "proto3"; + +option java_package = "org.thingsboard.server.gen.transport.coap"; +option java_outer_classname = "ConfigTypesProtos"; + +message ProtoCalibrationParametersRequest { + + /* Request details. * + * Bitmask: * + * - Bit 0:2 - Requested channel number, range: [1:6] * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 calibration_request = 1; + + /* Channel assignment - the sensor code. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + uint32 channel_assignment = 2; + + /* Channel calibration parameters. * + * Up to 8 parameters supported. * + * If this field is empty, the sensor will send the current set of parameters. * + * Status: Deprecated [06.10.00 - 06.xx.xx] */ + repeated int32 parameters = 3; +} + +message ProtoOutputControlState { + + /* Channel index. * + * Range: [0:5] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_index = 1; + + /* Channel output state: * + * - 1 - OFF * + * - 2 - ON * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 channel_state = 2; +} + +enum BleAdvertisingPeriodMode { + + /* Invalid value. */ + BLE_ADVERTISING_PERIOD_MODE_UNSPECIFIED = 0; + + /* Default mode. * + * Bluetooth advertising interval is set to 1022.5ms or a lower value, based on the continuous measurement period. */ + BLE_ADVERTISING_PERIOD_MODE_DEFAULT = 1; + + /* Normal mode. * + * Uses the value configured by the user from the 'normal' field. */ + BLE_ADVERTISING_PERIOD_MODE_NORMAL = 2; + + /* Fast mode. * + * Uses the value configured by the user from the 'fast' field. */ + BLE_ADVERTISING_PERIOD_MODE_FAST = 3; +} + +message ProtoBleAdvertisingPeriod { + + /* Bluetooth advertising mode. * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + BleAdvertisingPeriodMode mode = 1; + + /* Bluetooth advertising interval in normal mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 normal = 2; + + /* Bluetooth advertising interval in fast mode, configured in steps of 0.625 ms. * + * Range: [32:16384] * + * Status: In use [06.13.00/06.21.00 - LATEST] */ + uint32 fast = 3; +} + +enum AdvertisementManufacturerDataFormat { + + /* Invalid value. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_UNSPECIFIED = 0; + + /* Advertisement manufacturer specific data format 3. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V3 = 1; + + /* Advertisement manufacturer specific data format 5. */ + ADVERTISEMENT_MANUFACTURER_DATA_FORMAT_V5 = 2; +} + +message ProtoNetworkSearch { + + /* Timing schema, if successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_ok = 1; + + /* Timing schema, if no successful registration since the last reset. * + * Length: 6 items. * + * 1st - 6th item - Time in minutes. Range: [1:255]. * + * Status: In use [06.20.00 - LATEST] */ + repeated uint32 time_schema_last_registration_not_ok = 2; + + /* Disable base period in minutes. * + * Disable time = 'disable_period_base' * counter (from 1 to 'counter_max'). * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 disable_period_base = 3; + + /* Disable counter maximum. * + * Range: [1:255] * + * Status: In use [06.20.00 - LATEST] */ + uint32 counter_max = 4; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_device_info.proto b/common/transport/coap/src/main/proto/efento/proto_device_info.proto index 3e1f066c59..f3908826ad 100644 --- a/common/transport/coap/src/main/proto/efento/proto_device_info.proto +++ b/common/transport/coap/src/main/proto/efento/proto_device_info.proto @@ -18,185 +18,330 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "DeviceInfoProtos"; +message ProtoRuntime +{ + + /* Up-time in seconds (since reset). * + * Status: In use [06.00 - LATEST] */ + uint32 up_time = 1; + + /* Message counters (since reset). * + * 1st item - Confirmable message attempts counter. * + * 2nd item - Non-confirmable message attempts counter. * + * 3rd item - Successful message counter. * + * Status: In use [06.00 - LATEST] */ + repeated uint32 message_counters = 2; + + /* MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 mcu_temperature = 3; + + /* Battery voltage in mV. * + * - [0:65534] - Battery voltage in millivolts * + * - 65535 - No measurement * + * Status: In use [07.00.00 - LATEST] / Previously as min_battery_voltage [06.00 - 06.xx.xx] */ + uint32 battery_voltage = 4; + + /* MCU temperature in Celsius when minimum battery voltage was reached. * + * Status: Deprecated [06.00 - 06.xx.xx] */ + sint32 min_battery_mcu_temperature = 5; + + /* Battery reset timestamp in seconds since 1st of January 1970 (epoch time). * + * Status: Deprecated [06.00 - 06.xx.xx] */ + uint32 battery_reset_timestamp = 6; + + /* Maximum MCU temperature in Celsius. * + * Status: In use [06.00 - LATEST] */ + sint32 max_mcu_temperature = 7; + + /* Minimum MCU temperature in Celsius * + * Status: In use [06.00 - LATEST] */ + sint32 min_mcu_temperature = 8; + + /* Device's runtime errors. * + * Up to 20 error items supported. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 runtime_errors = 9; + + /* Number of sensor resets (since power-up). * + * Status: In use [06.21.00 - LATEST] */ + uint32 reset_counter = 10; +} + +message ProtoUpdateInfo +{ + + /* Timestamp of the last update check in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ + uint32 timestamp = 1; + + /* Status of the last update check: * + * - 1 - No update check * + * - 2 - No error * + * - 3 - UDP socekt error * + * - 4 - Invalid hash * + * - 5 - Missing packet * + * - 6 - Invalid data * + * - 7 - Sending timeout * + * - 8 - No software to update * + * - 9 - Sending unexpected error * + * - 10 - Unexpected error * + * Status: In use [06.00 - LATEST] */ + uint32 status = 2; +} + enum ModemType { - /* Invalid value */ + /* Invalid value. */ MODEM_TYPE_UNSPECIFIED = 0; - /* Quectel BC66 modem */ + /* Quectel BC66 modem. */ MODEM_TYPE_BC66 = 1; - /* Quectel BC66-NA modem */ + /* Quectel BC66-NA modem. */ MODEM_TYPE_BC66NA = 2; + + /* Uses a shared modem from another sensor. */ + MODEM_TYPE_SHARED_MODEM = 3; + + /* Quectel BC660 modem. */ + MODEM_TYPE_BC660 = 4; } -message ProtoRuntime +enum ModemFirmwareVersion { + /* Invalid value. */ + MODEM_FIRMWARE_VERSION_UNSPECIFIED = 0; - /* Up-time in seconds (since reset) */ - uint32 up_time = 1; + /* Unable to read firmware version from device. */ + MODEM_FIRMWARE_VERSION_READING_ERROR = 1; - /* Message counters (since reset). There are 3 counters: */ - /* message_counters[0] - Counter of confirmable messages attempts */ - /* message_counters[1] - Counter of non-confirmable messages attempts */ - /* message_counters[2] - Counter of succeeded messages */ - repeated uint32 message_counters = 2; + /* Unknown firmware version. */ + MODEM_FIRMWARE_VERSION_UNKNOWN = 2; - /* MCU temperature in Celsius */ - sint32 mcu_temperature = 3; + /* BC660KGLAAR01A05_01.002.01.002. */ + MODEM_FIRMWARE_VERSION_BC660_V1 = 3; - /* Minimum battery voltage in mV */ - uint32 min_battery_voltage = 4; + /* BC660KGLAAR01A05_01.200.01.200. */ + MODEM_FIRMWARE_VERSION_BC660_V2 = 4; - /* MCU temperature in Celsius, while the minimum battery voltage was reached */ - sint32 min_battery_mcu_temperature = 5; + /* BC660KGLAAR01A05_01.202.01.202. */ + MODEM_FIRMWARE_VERSION_BC660_V3 = 5; - /* Battery reset timestamp (Unix timestamp) */ - uint32 battery_reset_timestamp = 6; + /* BC660KGLAAR01A05_01.203.01.203. */ + MODEM_FIRMWARE_VERSION_BC660_V4 = 6; - /* Max MCU temperature in Celsius */ - sint32 max_mcu_temperature = 7; + /* BC660KGLAAR01A05_01.204.01.204. */ + MODEM_FIRMWARE_VERSION_BC660_V5 = 7; - /* Min MCU temperature in Celsius */ - sint32 min_mcu_temperature = 8; + /* BC660KGLAAR01A05_01.205.01.205. */ + MODEM_FIRMWARE_VERSION_BC660_V6 = 8; - /* Table of runtime errors. Max length: 20 */ - repeated uint32 runtime_errors = 9; + /* BC660KGLAAR01A05_01.301.01.301. */ + MODEM_FIRMWARE_VERSION_BC660_V7 = 9; + + /* BC660KGLAAR01A05_01.303.01.303. */ + MODEM_FIRMWARE_VERSION_BC660_V8 = 10; } message ProtoModem { + /* Modem type. * + * Status: In use [06.00 - LATEST] */ ModemType type = 1; - /* Parameters for BC66 modem: */ - /* parameters[0] - sc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[1] - sc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[2] - sc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[3] - sc_Cell id - Range: [1:268435456]. Unknown value: 0 */ - /* parameters[4] - sc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[5] - sc_RSRQ - [dB] - Range: [-20:-3]. Unknown value: 0 */ - /* parameters[6] - sc_RSSI - [dBm] - Range: [-110:-3] Unknown value: 0 */ - /* parameters[7] - sc_SINR - [dB] - Range: [-10:30]. Unknown value: 31 */ - /* parameters[8] - sc_Band - Range: [see module supported bands]. The current serving cell band. Unknown value: -1 */ - /* parameters[9] - sc_TAC - Range: [0:65536]. Unknown value: -1 */ - /* parameters[10] - sc_ECL - Range: [0:2]. Unknown value: -1 */ - /* parameters[11] - sc_TX_PWR - [0.1cBm] - Range [-440:230]. Unknown value: -1000 */ - /* parameters[12] - OP_MODE - Range: [0:3]. Unknown value: -1 */ - /* parameters[13] - nc_EARFCN - Range: [0:262143]. Unknown value: -1 */ - /* parameters[14] - nc_EARNFCN_offset - Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 */ - /* parameters[15] - nc_PCI - Range: [0:502]. Unknown value: -1 */ - /* parameters[16] - nc_RSRP - [dBm] - Range: [-140:-44]. Unknown value: 0 */ - /* parameters[17] - RLC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[18] - RLC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[19] - MAC_UL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[20] - MAC_DL_BLER - Range: [0:100]. Unknown value: -1 */ - /* parameters[21] - MAC_UL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[22] - MAC_DL_TOTAL_BYTES - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[23] - MAC_UL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[24] - MAC_DL_total_HARQ_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[25] - MAC_UL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[26] - MAC_DL_HARQ_re_Tx - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[27] - RLC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[28] - RLC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[29] - MAC_UL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[30] - MAC_DL_tput - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[31] - sleep_duration - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[32] - Rx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ - /* parameters[33] - Tx_time - [0.1s] - Range: [0:2147483647]. Unknown value: -1 */ + /* Modem runtime parameters. * + * For BC66 modem (34 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:502]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [0.1dBm]. Range [-440:230]. Unknown value: -1000 * + * - 13th item - OP_MODE. Range: [0:3]. Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_EARNFCN_offset. Range: [0:4] mapped to [-2, -1, -0.5, 0, 1]. Unknown value: -1 * + * - 16th item - nc_PCI. Range: [0:502]. Unknown value: -1 * + * - 17th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - RLC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 19th item - RLC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 20th item - MAC_UL_BLER. Range: [0:100]. Unknown value: -1 * + * - 21th item - MAC_DL_BLER. Range: [0:100]. Unknown value: -1 * + * - 22th item - MAC_UL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 23th item - MAC_DL_TOTAL_BYTES. Range: [0:2147483647]. Unknown value: -1 * + * - 24th item - MAC_UL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 25th item - MAC_DL_total_HARQ_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 26th item - MAC_UL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 27th item - MAC_DL_HARQ_re_Tx. Range: [0:2147483647]. Unknown value: -1 * + * - 28th item - RLC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 29th item - RLC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 30th item - MAC_UL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 31th item - MAC_DL_tput. Range: [0:2147483647]. Unknown value: -1 * + * - 32th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 33th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 34th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * For BC660 modem (22 parameters): * + * - 1st item - sc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 2nd item - sc_EARNFCN_offset. Range: [0:21] mapped to * + * [Invalid, -10, -9, -8, -7, -6, -5, -4, -3, -2, -1, -0.5, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9]. * + * Unknown value: -1 * + * - 3rd item - sc_PCI. Range: [0:503]. Unknown value: -1 * + * - 4th item - sc_Cell_Id. Range: [1:268435456]. Unknown value: 0 * + * - 5th item - sc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 6th item - sc_RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 7th item - sc_RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 8th item - sc_SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * - 9th item - sc_Band. Range: [see module supported bands]. The current serving cell band. Unknown value: -1 * + * - 10th item - sc_TAC. Range: [0:65536]. Unknown value: -1 * + * - 11th item - sc_ECL. Range: [0:2]. Unknown value: -1 * + * - 12th item - sc_TX_PWR [dBm]. Range [-45:23]. Unknown value: 128 * + * - 13th item - OP_MODE. Range: [0:3] mapped to [In-band same PCI, In-band different PCI, Guard band, Stand alone]. * + * Unknown value: -1 * + * - 14th item - nc_EARFCN. Range: [0:262143]. Unknown value: -1 * + * - 15th item - nc_PCI. Range: [0:503]. Unknown value: -1 * + * - 16th item - nc_RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 17th item - nc_RSRQ [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 18th item - sleep_duration [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 19th item - Rx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 20th item - Tx_time [0.1s]. Range: [0:2147483647]. Unknown value: -1 * + * - 21st item - PLMN_state. Range: [0:3] mapped to [No PLMN, Searching, Selected, Unknown]. Unknown value: 3 * + * - 22nd item - select_PLMN. Range: [100:999999]. Unknown value: -1 * + * For shared modem client (4 parameters): * + * - 1st item - RSRP [dBm]. Range: [-140:-44]. Unknown value: 0 * + * - 2nd item - RSRQ [dB]. Range: [-20:-3]. Unknown value: 0 * + * - 3rd item - RSSI [dBm]. Range: [-110:-3] Unknown value: 0 * + * - 4th item - SINR [dB]. Range: [-10:30]. Unknown value: 31 * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 2; - /* ICCID of inserted/soldered sim card. String up to 22 characters long. */ - /* 0x7F if sim card is not detected, empty (not sent) if device does not have modem. */ - /* This field is only sent by device */ + /* Integrated Circuit Card Identifier (ICCID) of the inserted/soldered SIM card. * + * String up to 22 characters: * + * A string containing only the special character 0x7F (DEL) indicates that the SIM card is not detected. * + * Status: In use [06.07.04/06.09.09/06.10.10/06.11.09/06.12.06/06.13.00/06.21.00 - LATEST] */ string sim_card_identification = 3; + + /* Modem firmware version. * + * Status: In use [06.20.05/06.21.00 - LATEST] */ + ModemFirmwareVersion firmware_version = 4; + + /* Modem identification: * + * - IMEI (International Mobile Equipment Identity) of the modem * + * - Serial number of the most recently used modem-sharing sensor * + * String up to 15 characters. * + * Status: In use [07.00.00 - LATEST] */ + string modem_identification = 5; + + /* Modem statistics. * + * 1st item - Number of transmissions since the last device information message. Undefined value: 0. * + * 2nd item - Time in seconds since the last device information message. Undefined value: 0. * + * 3rd item - Total time in the power saving mode, in seconds. Undefined value: 0. * + * 4th item - Total time in the active state in seconds. Undefined value: 0. * + * Status: In use [07.00.00 - LATEST] */ + repeated uint32 modem_statistics = 6; } -message ProtoUpdateInfo +message ProtoDeviceInfo { - /* Timestamp of update (Unix timestamp) */ - uint32 timestamp = 1; + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ - /* Update status, possible values: */ - /* - 1 - No update yet */ - /* - 2 - No error */ - /* - 3 - UDP socekt error */ - /* - 4 - Hash error */ - /* - 5 - Missing packet error */ - /* - 6 - Invalid data error */ - /* - 7 - Sending timeout error */ - /* - 8 - No SW to update error */ - /* - 9 - Sending unexpected error */ - /* - 10 - Unexpected error */ - uint32 status = 2; -} + reserved 2,4,5,6,7,8,9,10,11,12; -message ProtoDeviceInfo -{ + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ - /* Serial number of device */ - bytes serial_num = 1; + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; - /* Deprecated field */ - reserved 2; + /* DEVICE INFORMATION FIELDS ------------------------------------------------------------------------------------------------ */ - /* Software version e.g ver 06.10 -> 0x060A -> 1546 */ + /* Software version (excluding LTS). * + * Example: 1546 -> 0x060A -> SW version 06.10 * + * Status: In use [06.00 - LATEST] */ uint32 sw_version = 3; - /* Deprecated fields */ - reserved 4,5,6,7,8,9,10,11,12; + /* Software commit ID and LTS version. * + * String of 7 characters: * + * - Commit ID (examples: "fa02cd0" means the beginning of the commit ID "fa02cd0") [06.00 - 06.06.xx] * + * - LTS version and commit ID (examples: "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f") * + [06.07.00 - LATEST] * + * Status: In use [06.00 - LATEST] */ + string commit_id = 15; - /* Structure with battery and temperature information */ + /* Device runtime information. * + * Status: In use [06.00 - LATEST] */ ProtoRuntime runtime_info = 13; - /* Structure with modem specific runtime information */ + /* Memory statistics: * + * 1st item - Status of the non-volatile storage: * + * - 0 - Non-volatile storage works properly * + * - 1 - Non-volatile storage has some corrupt packets. Memory is read-only * + * - 2 - Non-volatile storage is corrupted. Memory is unavailable * + * 2nd item - Timestamp of the end of collecting statistics in seconds since 1st of January 1970 (epoch time). * + Undefined value: 4294967295. * + * 3rd item - Capacity of the memory in bytes. * + * 4th item - Used space in bytes. * + * 5th item - Size of invalid (outdated) packets in bytes. * + * 6th item - Size of corrupt packets in bytes. * + * 7th item - Number of valid packets. * + * 8th item - Number of invalid (outdated) packets. * + * 9th item - Number of corrupt packets. * + * 10th item - Number of all samples for channel 1 (valid packets). * + * 11th item - Number of all samples for channel 2 (valid packets). * + * 12th item - Number of all samples for channel 3 (valid packets). * + * 13th item - Number of all samples for channel 4 (valid packets). * + * 14th item - Number of all samples for channel 5 (valid packets). * + * 15th item - Number of all samples for channel 6 (valid packets). * + * 16th item - Timestamp of the first binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 17th item - Timestamp of the last binary measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 18th item - Timestamp of the last binary measurement marked as sent, in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 19th item - Timestamp of the first continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 20th item - Timestamp of the last continuous measurement in seconds since 1st of January 1970 (epoch time). * + * Undefined value: 4294967295. * + * 21th item - Timestamp of the last continuous measurement marked as sent, in seconds since 1st of January 1970 (epoch time).* + * Undefined value: 4294967295. * + * 22th item - NvM write counter. * + * Status: In use [06.02 - LATEST] */ + repeated uint32 memory_statistics = 17; + + /* Last update check information. * + * Status: In use [06.08.00 - LATEST] */ + ProtoUpdateInfo last_update_info = 18; + + /* Modem information. * + * Only used if the sensor has a modem. * + * Status: In use [06.00 - LATEST] */ ProtoModem modem = 14; - /* String up to 7 bytes long. Software commit id e.g. "e0e8556" */ - /* From version 06.07 the first two characters indicate the LTS version. */ - /* For example: the value "0bdd23f" means LTS version 11 and the beginning of the commit ID "dd23f" */ - string commit_id = 15; + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; - /* Memory statistics: */ - /* memory_statistics[0] - Status of Nv storage: */ - /* - 0 - Nv storage hasn't errors */ - /* - 1 - Nv storage has some corrupted packet. Memory is read-only */ - /* - 2 - Nv storage is corrupted. Memory is unavailable */ - /* memory_statistics[1] - Timestamp of the end of collecting statistics. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[2] - Capacity of memory in bytes */ - /* memory_statistics[3] - Used space in bytes */ - /* memory_statistics[4] - Size of invalid (outdated) packets in bytes */ - /* memory_statistics[5] - Size of corrupted packets in bytes */ - /* memory_statistics[6] - Number of valid packets */ - /* memory_statistics[7] - Number of invalid (outdated) packets */ - /* memory_statistics[8] - Number of corrupted packets */ - /* memory_statistics[9] - Number of all samples for channel 1 (valid packets) */ - /* memory_statistics[10] - Number of all samples for channel 2 (valid packets) */ - /* memory_statistics[11] - Number of all samples for channel 3 (valid packets) */ - /* memory_statistics[12] - Number of all samples for channel 4 (valid packets) */ - /* memory_statistics[13] - Number of all samples for channel 5 (valid packets) */ - /* memory_statistics[14] - Number of all samples for channel 6 (valid packets) */ - /* memory_statistics[15] - Timestamp of the first binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[16] - Timestamp of the last binary measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[17] - Timestamp of the last binary measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[18] - Timestamp of the first continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[19] - Timestamp of the last continuous measurement. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[20] - Timestamp of the last continuous measurement, that marked as sent. */ - /* Value in seconds since UNIX EPOCH 01-01-1970. Undefined value: 4294967295 */ - /* memory_statistics[21] - NVM write counter */ - repeated uint32 memory_statistics = 17; - - /* Information about last sensor SW update */ - ProtoUpdateInfo last_update_info = 18; + /* Enclosure tamper alert: * + * - 0 - Inactive * + * - 1 - Active (enclosure opened) * + * - 2 - Inactive, was active (enclosure closed) * + * The transition from 'Inactive, was active' to 'Inactive' occurs after the alert has been successfully sent to the server * + * or the user has reset the alert via Bluetooth. * + * This field is only sent during server communication. * + * Status: In use [07.00.01 - LATEST] */ + uint32 enclosure_tamper_alert = 19; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto index fc72cc10e3..7e7e908e16 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurement_types.proto @@ -19,160 +19,201 @@ option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "MeasurementTypeProtos"; enum MeasurementType { - /* [] - No sensor on the channel */ + + /* [] - No sensor on the channel. */ MEASUREMENT_TYPE_NO_SENSOR = 0; - /* [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. Type: Continuous */ + /* [°C] - Celsius degree. Temperature. Resolution: 0.1°C. Range: [-273.2:4000.0]. Type: Continuous. */ MEASUREMENT_TYPE_TEMPERATURE = 1; - /* [% RH] - Relative humidity. Resolution 1%. Range [0:100]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity. Resolution: 1%. Range: [0:100]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY = 2; - /* [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. Atmospheric pressure. Type: Continuous */ + /* [hPa] - Hectopascal. Atmospheric pressure. Resolution: 0.1hPa. Range: [1.0:2000.0]. Type: Continuous. */ MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE = 3; - /* [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000]. Differential pressure. Type: Continuous */ + /* [Pa] - Pascal. Differential pressure. Resolution: 1Pa. Range: [-10000:10000]. Type: Continuous. */ MEASUREMENT_TYPE_DIFFERENTIAL_PRESSURE = 4; - /* Sign indicates state: (+) ALARM, (-) OK. Type: Binary */ + /* Sign indicates state: (+) Alarm, (-) OK. Type: Binary. */ MEASUREMENT_TYPE_OK_ALARM = 5; - /* [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. To get IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [IAQ] - IAQ index. Air quality. Resolution: 1IAQ. Range: [0:500]. Type: Continuous. * + * To obtain the IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_IAQ = 6; - /* Sign indicates water presence: (+) water not detected, (-) water detected. Type: Binary */ + /* Sign indicates state: (+) Water detected, (-) Water not detected. Type: Binary. */ MEASUREMENT_TYPE_FLOODING = 7; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. Type: Continuous */ + /* [NB] - Number of pulses. Number of pulses in a single period. Resolution: 1 pulse. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_PULSE_CNT = 8; - /* [Wh] - Watthour; Resolution 1Wh. Range [0:8000000]. Number of Watthours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Number of watt-hours in a single period. Resolution: 1Wh. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_ELECTRICITY_METER = 9; - /* [l] - Liter. Resolution 1l. Range [0:8000000]. Number of litres in a single period. Type: Continuous */ + /* [l] - Liter. Number of litres in a single period. Resolution: 1l. Range: [0:8000000]. Type: Continuous. */ MEASUREMENT_TYPE_WATER_METER = 10; - /* [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). Type: Continuous */ + /* [kPa] - Kilopascal. Soil moisture (tension). Resolution: 1kPa. Range: [-1000:0]. Type: Continuous. */ MEASUREMENT_TYPE_SOIL_MOISTURE = 11; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon monoxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CO_GAS = 12; - /* [ppm] - Parts per million. Resolution 1.0ppm. Range [0:1000000]. Nitrogen dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Nitrogen dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NO2_GAS = 13; - /* [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Hydrogen sulfide concentration. Resolution: 0.01ppm. Range: [0.00:80000.00]. Type: Continuous. */ MEASUREMENT_TYPE_H2S_GAS = 14; - /* [lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. Type: Continuous */ + /* [lx] - Lux. Illuminance. Resolution: 0.1lx. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_AMBIENT_LIGHT = 15; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 1 micrometer. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 1 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_1_0 = 16; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 2.5 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 2.5 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_2_5 = 17; - /* [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3. Range [0:1000]. */ - /* Particles with an aerodynamic diameter less than 10 micrometers. Type: Continuous */ + /* [µg/m^3] - Microgram per cubic meter. Particles with an aerodynamic diameter of less than 10 micrometers. * + * Resolution: 1µg/m^3. Range: [0:1000]. Type: Continuous. */ MEASUREMENT_TYPE_PM_10_0 = 18; - /* [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. Type: Continuous */ + /* [dB] - Decibel. Noise level. Resolution: 0.1 dB. Range: [0.0:200.0]. Type: Continuous. */ MEASUREMENT_TYPE_NOISE_LEVEL = 19; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. Type: Continuous */ + /* [ppm] - Parts per million. Ammonia concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_NH3_GAS = 20; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. Type: Continuous */ + /* [ppm] - Parts per million. Methane concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. */ MEASUREMENT_TYPE_CH4_GAS = 21; - /* [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. Type: Continuous */ + /* [kPa] - Kilopascal (100kPa = 1bar). Pressure. Resolution: 1kPa. Range: [0:200000]. Type: Continuous. */ MEASUREMENT_TYPE_HIGH_PRESSURE = 22; - /* [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. Type: Continuous */ + /* [mm] - Millimeter. Distance. Resolution: 1mm. Range: [0:100000]. Type: Continuous. */ MEASUREMENT_TYPE_DISTANCE_MM = 23; - /* [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). Type: Continuous */ + /* [l] - Liter. Water meter (minor). Resolution: 1l. Range: [0:99]. Type: Continuous. * + * To obtain the liters, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_WATER_METER_ACC_MINOR = 24; - /* [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). Type: Continuous */ + /* [hl] - Hectoliter. Water meter (major). Resolution: 1hl. Range: [0:999999]. Type: Continuous. * + * To obtain the hectoliters, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_WATER_METER_ACC_MAJOR = 25; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. Type: Continuous */ + /* [ppm] - Parts per million. Carbon dioxide concentration. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the carbon dioxide concentration, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Auto calibration has not yet been performed * + * - 1 - The last auto calibration was successful * + * - 2 - Last auto calibration failed */ MEASUREMENT_TYPE_CO2_GAS = 26; - /* [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. Type: Continuous */ + /* [% RH] - Percentage. Relative humidity (accurate). Resolution: 0.1%. Range: [0.0:100.0]. Type: Continuous. */ MEASUREMENT_TYPE_HUMIDITY_ACCURATE = 27; - /* [sIAQ] - Static IAQ index. Resolution 1IAQ. Range [0:10000]. To get static IAQ index the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [sIAQ] - Static IAQ index. Air quality. Resolution: 1sIAQ. Range: [0:10000]. Type: Continuous. * + * To obtain the static IAQ index, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_STATIC_IAQ = 28; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. CO2 equivalent. */ - /* To get CO2 equivalent the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. CO2 equivalent. Resolution: 1ppm. Range: [0:1000000]. Type: Continuous. * + * To obtain the CO2 equivalent, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_CO2_EQUIVALENT = 29; - /* [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ - /* To get breath VOC estimate the value should be divided by 3. */ - /* Sensor return also calibration status as metadata (is the remainder when the absolute value is divided by 3): */ - /* - 0: Calibration required (sensor returns not accurate values) */ - /* - 1: Calibration on-going (sensor returns not accurate values) */ - /* - 2: Calibration done (best accuracy of IAQ sensor) */ - /* Type: Continuous */ + /* [ppm] - Parts per million. Breath VOC estimate. Resolution: 1ppm. Range: [0:100000]. Type: Continuous. * + * To obtain the breath VOC estimate, the measurement value should be divided by 3. * + * Measurement also includes calibration status as metadata (is the remainder of the absolute value divided by 3): * + * - 0 - Calibration required (sensor returns not accurate values) * + * - 1 - Calibration on-going (sensor returns not accurate values) * + * - 2 - Calibration done (best accuracy of IAQ sensor) */ MEASUREMENT_TYPE_BREATH_VOC = 30; - /* Special measurement type reserved for cellular gateway. */ - /* Type: Continuous */ - MEASUREMENT_TYPE_CELLULAR_GATEWAY = 31; + /* Reserved for the Shared Modem functionality. Type: Continuous. */ + MEASUREMENT_TYPE_SHARED_MODEM = 31; - /* [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. Type: Continuous */ + /* [%] - Percentage. Generic type. Resolution: 0.01%. Range: [0.00:100.00]. Type: Continuous. */ MEASUREMENT_TYPE_PERCENTAGE = 32; - /* [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. Type: Continuous */ + /* [mV] - Millivolt. Voltage. Resolution: 0.1mV. Range: [0.0:100000.0]. Type: Continuous. */ MEASUREMENT_TYPE_VOLTAGE = 33; - /* [mA] - Milliampere. Resolution 0.01mA. Range [0.0:10000.00]. Type: Continuous */ + /* [mA] - Milliampere. Current. Resolution: 0.01mA. Range: [0.00:10000.00]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT = 34; - /* [NB] Number of pulses. Resolution 1 pulse. Range [0:1000000]. Type: Continuous */ + /* [NB] - Number of pulses. Pulse counter (minor). Resolution: 1 pulse. Range: [0:999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MINOR = 35; - /* [kNB] Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. Type: Continuous */ + /* [kNB] - Number of kilopulses. Pulse counter (major). Resolution: 1 kilopulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of kilopulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_MAJOR = 36; - /* [Wh] - Watt-hour; Resolution 1Wh. Range [0:1000000]. Number of watt-hours in a single period. Type: Continuous */ + /* [Wh] - Watt-hour. Electricity meter (minor). Resolution: 1Wh. Range: [0:999]. Type: Continuous. * + * To obtain the watt-hours, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_ELEC_METER_ACC_MINOR = 37; - /* [kWh] - Kilowatt-hour; Resolution 1kWh. Range [0:1000000]. Number of kilowatt-hours in a single period. Type: Continuous */ + /* [kWh] - Kilowatt-hour. Electricity meter (major). Resolution: 1kWh. Range: [0:999999]. Type: Continuous. * + * To obtain the kilowatt-hours, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_ELEC_METER_ACC_MAJOR = 38; - /* [NB] Number of pulses (wide range). Resolution 1 pulse. Range [0:999999]. Type: Continuous */ + /* [NB] - Number of pulses. Wide-range pulse counter (minor). Resolution: 1 pulse. Range: [0:999999]. Type: Continuous. * + * To obtain the number of pulses, the measurement value should be divided by 6. * + * Measurement also includes a major channel index related with the minor channel as metadata * + * (is the remainder of the absolute value divided by 6). */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MINOR = 39; - /* [MNB] Number of megapulses (wide range). Resolution 1 megapulse. Range [0:999999]. Type: Continuous */ + /* [MNB] - Number of megapulses. Wide-range pulse counter (major). Resolution: 1 megapulse. Range: [0:999999]. * + * Type: Continuous. * + * To obtain the number of megapulses, the measurement value should be divided by 4. * + * Measurement also includes a measurement status as metadata (is the remainder of the absolute value divided by 4): * + * - 0 - Counter works properly * + * - 1 - Error occurred (the counted value may be underestimated) * + * - 2 - Sensor reset occurred (the counted value may be underestimated) * + * - 3 - Sensor reset and error occurred (the counted value may be underestimated) */ MEASUREMENT_TYPE_PULSE_CNT_ACC_WIDE_MAJOR = 40; - /* [mA] - Milliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. Type: Continuous */ + /* [mA] - Milliampere. Current (precise). Resolution: 0.001mA. Range: [-4000.000:4000.000]. Type: Continuous. */ MEASUREMENT_TYPE_CURRENT_PRECISE = 41; - /* Sign indicates state: (+) ON, (-) OFF. Type: Binary */ + /* Sign indicates state: (+) ON, (-) OFF. Type: Binary. */ MEASUREMENT_TYPE_OUTPUT_CONTROL = 42; -} - + /* [Ω] - Ohm. Resolution: 1Ω. Range: [0:1000000]. Type: Continuous. */ + MEASUREMENT_TYPE_RESISTANCE = 43; +} \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_measurements.proto b/common/transport/coap/src/main/proto/efento/proto_measurements.proto index 650487ff29..12626d4834 100644 --- a/common/transport/coap/src/main/proto/efento/proto_measurements.proto +++ b/common/transport/coap/src/main/proto/efento/proto_measurements.proto @@ -14,6 +14,7 @@ * limitations under the License. */ syntax = "proto3"; + import "efento/proto_measurement_types.proto"; option java_package = "org.thingsboard.server.gen.transport.coap"; @@ -21,106 +22,166 @@ option java_outer_classname = "MeasurementsProtos"; message ProtoChannel { - /* Type of channel */ + /* Reserved fields. */ + reserved 6,7,8; + + /* Type of measurement. * + * Status: In use [06.00 - LATEST] */ MeasurementType type = 1; - /* Timestamp of the first sample (the oldest one) in seconds since UNIX EPOCH 01-01-1970 */ + /* Timestamp of the first (oldest) sample in seconds since 1st of January 1970 (epoch time). * + * Status: In use [06.00 - LATEST] */ int32 timestamp = 2; - /* Only used for 'Continuous' sensor types. Value used as the starting point for calculating the values of all */ - /* measurements in the package. */ - /* Format defined by 'MeasurementType' field */ + /* Start point of measurement values. * + * This is used as the base value for calculating the values of all channel measurements in the message. * + * Used for 'Continuous' sensor types only. * + * Format is defined by 'MeasurementType' field. * + * Status: In use [06.00 - LATEST] */ sint32 start_point = 4; - /* 'Continuous' sensor types */ - /* Value of the offset from the 'start_point' for each measurement in the package. The oldest sample first ([0]). */ - /* 'sample_offsets' format defined by 'MeasurementType' field. */ - /* If the 'sample_offset' has a value from the range [8355840: 8388607], it should be interpreted as a sensor error code. */ - /* In that case value of the 'start_point' field should not be added to this 'sample_offset'. See ES6-264 for error codes. */ - /* Example: MeasurementType = 1 (temperature), start_point = 100, sample_offsets[0] = 15, sample_offsets[1] = 20, */ - /* sample_offset[2] = 8388605 */ - /* 1st sample in the package temperature value = 11.5 °C, 2nd sample in the package temperature value = 12 °C */ - /* 3rd sample in the package has no temperature value. It has information about failure of MCP9808 (temperature) sensor. */ - /* Calculating timestamps of the measurements: timestamp = 1606391700, measurement_period_base = 60, */ - /* measurement_period_factor = 1. Timestamp of the 1st sample = 1606391700, timestamp of the 2nd sample = 1606391760, */ - /* timestamp of the 3rd sample 1606391820 */ - - /* 'Binary' sensor types: */ - /* Absolute value of the 'sample_offsets' field indicates the offset in seconds from 'timestamp' field. */ - /* Sign (- or +) indicates the state of measurements depending on the sensor type. */ - /* Value of this field equals to '1' or '-1' indicates the state at the 'timestamp'. Other values */ - /* indicate the state of the relay at the time (in seconds) equal to 'timestamp' + absolute value -1. */ - /* Values of this field are incremented starting from 1 (1->0: state at the time */ - /* of 'timestamp', 2->1: state at the time equal to 'timestamp' + 1 s, 3->2 : */ - /* state at the time equal to 'timestamp' + 2 s, etc.). The first and the last sample define the time range of the */ - /* measurements. Only state changes in the time range are included in the 'sample_offsets' field */ - /* Examples: if 'timestamp' value is 1553518060 and 'sample_offsets' equals '1', it means that at 1553518060 the state */ - /* was high, if 'timestamp' value is 1553518060 and 'sample_offsets' equals '-9', it means at 1553518068 the state was low */ + /* Measurement value offsets. * + * For 'Continuous' sensor types: * + * These are the values of the offsets from the 'start_point' for each channel measurement in the message. * + * Format is defined by 'MeasurementType' field. The first sample is the oldest. * + * If the sample offset has a value in the range [8355840:8388607], it should be interpreted as a sensor error code. * + * In this case, the value of the 'start_point' field should not be added to this sample offset. * + * Example: type = 1 (temperature) * + * start_point = 100 * + * sample_offsets[0] = 15, sample_offsets[1] = 20, sample_offsets[2] = 8388557 * + * timestamp = 1606391700 * + * measurement_period_base = 60 * + * measurement_period_factor = 1 * + * 1st measurement is the temperature value = 11.5°C, measured at 1606391700 * + * 2nd measurement is the temperature value = 12°C, measured at 1606391760 * + * 3rd measurement is the error code (failure of the SHT4x temperature sensor), measured at 1606391820 * + * For 'Binary' sensor types: * + * The absolute value of the sample offset minus 1 is the offset in seconds from the 'timestamp' field. * + * Sign (- or +) indicates the state of the measurement depending on the sensor type. * + * The first and the last samples define the time range of the measurements. * + * Only state changes within the time range are included in the 'sample_offsets' field. * + * Example: type = 5 (OK/Alarm) * + * sample_offsets[0] = 1, sample_offsets[1] = -9, sample_offsets[2] = 13 * + * timestamp = 1606391700 * + * 1st measurement is the state at 1606391700, which is Alarm (+) * + * 2nd measurement is the state at 1606391708, which is OK (-) * + * 3rd measurement is the state at 1606391712, which is Alarm (+) * + * Status: In use [06.00 - LATEST] */ repeated sint32 sample_offsets = 5 [packed=true]; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 lo_threshold = 6; */ - reserved 6; - - /* Deprecated - configuration is sent to endpoint 'c' */ - /* int32 hi_threshold = 7; */ - reserved 7; - - /* Deprecated - configurations sent to endpoint 'c' */ - /* int32 diff_threshold = 8; */ - reserved 8; } message ProtoMeasurements { - /* Serial number of the device */ - bytes serial_num = 1; - - /* Battery status: true - battery ok, false - battery low */ - bool battery_status = 2; - - /* 'Measurement_period_base' and 'measurement_period_factor' define how often the measurements are taken. */ - /* Sensors of 'Continuous' type take measurement each Measurement_period_base * measurement_period_factor. */ - /* Sensors of 'Binary' type take measurement each Measurement_period_base. */ - /* For backward compatibility with versions 5.x in case of binary/mixed sensors, if the 'measurement_period_factor' is */ - /* not sent (equal to 0), then the default value '14' shall be used for period calculation. */ - /* For backward compatibility with versions 5.x in case of continues sensors, if the measurement_period_factor is */ - /* not sent (equal to 0), then the default value '1' shall be used for period calculation. */ - /* measurement period base in seconds */ + /* RESERVED FIELDS ---------------------------------------------------------------------------------------------------------- */ + + reserved 10,11,12,13,14,15; + + /* DEVICE STATUS FIELDS ----------------------------------------------------------------------------------------------------- */ + + /* Serial number of the device. * + * Length: 6 bytes. * + * Status: In use [07.00.00 - LATEST] / Previously as serial_num [06.00 - 06.xx.xx] */ + bytes serial_number = 1; + + /* Identifier of the current configuration. * + * The value of this field changes with every configuration change. * + * Status: In use [07.00.00 - LATEST] / Previously as hash [06.00 - 06.xx.xx] */ + uint32 configuration_hash = 9; + + /* Identifier of the current extended configuration. * + * The value of this field changes with every extended configuration change. * + * Status: In use [07.00.00 - LATEST] */ + uint32 extended_configuration_hash = 17; + + /* Battery level and status: * + * - [1:65525] - Battery voltage measurement and a battery status * + * - [65526:65531] - No battery voltage measurement and a battery status * + * To obtain the battery voltage, the battery_level value should be divided by 6 and the remainder of the division discarded. * + * The result is in hundredths of a volt or 10921 (no battery voltage measurement). * + * Battery level also includes a battery status as metadata (the remainder of the value divided by 6): * + * - 0 - Device is powered by a battery * + * - 1 - Device is powered by a rechargeable battery * + * - 2 - Device is powered by an external source and a battery is charging * + * - 3 - Reserved for the future use * + * - 4 - Reserved for the future use * + * - 5 - Reserved for the future use * + * Example: battery_level = 2474 * + * Battery voltage = 2474/6 = 412 = 4.12[V] * + * Battery status = 2474 % 6 = 2 * + * Status: In use [07.00.00 - LATEST] */ + uint32 battery_level = 18; + + /* MEASUREMENT FIELDS ------------------------------------------------------------------------------------------------------- */ + + /* Measurement period defines how often the measurements are to be taken. * + * Sensors of 'Continuous' type take measurement each 'measurement_period_base' * 'measurement_period_factor'. * + * Sensors of 'Binary' type take measurement each 'measurement_period_base'. * + * For backward compatibility with versions 5.xx in case of 'Binary/Mixed' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '14' shall be used for the period calculation. * + * For backward compatibility with versions 5.xx in case of 'Continuous' sensors, if the 'measurement_period_factor' is * + * not sent (equal to 0), then the default value '1' shall be used for the period calculation. */ + + /* Measurement period base in seconds. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_base = 3; - /* Measurement period factor */ + /* Measurement period factor. * + * Status: In use [06.00 - LATEST] */ uint32 measurement_period_factor = 8; + /* Measurements grouped by channel. * + * Status: In use [06.00 - LATEST] */ repeated ProtoChannel channels = 4; - /* Timestamp of the next scheduled transmission. If the device will not send data until this time, */ - /* it should be considered as 'lost' */ + /* SERVER COMMUNICATION FIELDS ---------------------------------------------------------------------------------------------- */ + + /* Battery status: * + * - True - Battery OK * + * - False - Battery discharged * + * Status: In use [06.00 - LATEST] */ + bool battery_status = 2; + + /* Timestamp of the next scheduled transmission. * + * If the device has not sent any data by this time, it should be considered 'lost'. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 next_transmission_at = 5; - /* Reason of transmission - unsigned integer where each bit indicates different possible communication reason. */ - /* Can be more than one: */ - /* - bit 0: first message after sensor reset */ - /* - bit 1: user button triggered */ - /* - bit 2: user BLE triggered */ - /* - bit 3-7: number of retries -> incremented after each unsuccessful transmission. Max value 31. */ - /* Set to 0 after a successful transmission. */ - /* - bit 8...19: rule 1...12 was met */ - /* - bit 20: triggered after the end of the limit */ + /* Reason for transmission. * + * Bitmask, where each bit represents a specific reason for initiating communication (multiple reasons can be active * + * simultaneously): * + * - Bit 0 - First message after sensor reset * + * - Bit 1 - User-triggered (button press) * + * - Bit 2 - User-triggered (Bluetooth) * + * - Bit 3:7 - Number of retries - incremented after each failed transmission and reset upon success. Range: [0:31] * + * - Bit 8:19 - Rule-based triggers (12 bits): * + * - [06.00 - 07.01.xx]: Bitmask indicating which of the rules 1-12 were met * + * - [07.02.00 - LATEST]: The transfer bit 'i' (where i = 0 to 11) is set if either rule 'i' (0-11) is set * + * or if rule 'i + 12' (12-15) is set. This aggregation applies only to bits 0-3 (rules 0-3 and 12-15). * + * The mask aggregates 16 rules (0-15) onto 12 bits. * + * - Bit 20 - Triggered at the end of the defined limit * + * - Bit 21 - Triggered after a PIN tamper alert * + * - Bit 22 - Triggered after an enclosure tamper alert * + * - Bit 23 - Triggered before shutdown * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ uint32 transfer_reason = 6; - /* Signal strength level mapped from RSSI: */ - /* - 0: RSSI < -110 dBm */ - /* - 1: -110 dBm <= RSSI < -109 dBm */ - /* - 2...61: -109 <= RSSI < -108 dBm ... -50 dBm <= RSSI < -49 dBm */ - /* - 62: -49 dBm <= RSSI < -48 dBm */ - /* - 63: RSSI >= -48 dBm */ - /* - 99: Not known or not detectable */ + /* Signal strength (RSSI - Received Signal Strength Level): * + * - 0 - RSSI < -110dBm * + * - 1 - -110dBm <= RSSI < -109dBm * + * - 2:61 - -109dBm <= RSSI < -108dBm ... -50dBm <= RSSI < -49dBm * + * - 62 - -49dBm <= RSSI < -48dBm * + * - 63 - RSSI >= -48dBm * + * - 99 - Unknown or undetectable * + * This field is only sent during server communication. * + * Status: Deprecated [06.00 - 06.xx.xx] */ uint32 signal = 7; - /* Hash of the current configuration. Hash value changes each time a device receives a new configuration */ - uint32 hash = 9; - - /* Optional string up to 36 bytes long. Can be set to any user define value or hold device's IMEI */ + /* Cloud token. * + * Can be empty, set to any user-defined value, or hold device IMEI. * + * String up to 36 characters. * + * This field is only sent during server communication. * + * Status: In use [06.00 - LATEST] */ string cloud_token = 16; } \ No newline at end of file diff --git a/common/transport/coap/src/main/proto/efento/proto_rule.proto b/common/transport/coap/src/main/proto/efento/proto_rule.proto index 7473d9b52a..5d91712864 100644 --- a/common/transport/coap/src/main/proto/efento/proto_rule.proto +++ b/common/transport/coap/src/main/proto/efento/proto_rule.proto @@ -18,270 +18,287 @@ syntax = "proto3"; option java_package = "org.thingsboard.server.gen.transport.coap"; option java_outer_classname = "ProtoRuleProtos"; -/* Encoding A: used to set absolute values in the Rules (e.g. upper and lower threshold values) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [-273.2:4000.0]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [0:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [1.0:2000.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [-10000:10000] */ -/* - OK/ALARM - Not applicable */ -/* - IAQ - [IAQ] - IAQ index. Resolution 1IAQ. Range [0:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [0:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [0:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [0:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [-1000:0]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.00:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.0:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [0:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.0:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [0:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [0:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [0:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [0:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.0:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [0:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [0:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [0:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.00:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.0:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.00:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [0:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [0:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [0:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [0:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [-4 000.000:4 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Encoding R: used to set relative values in the Rules (e.g. differential threshold and hysteresis) */ -/* - TEMPERATURE - [°C] - Celsius degree. Resolution 0.1°C. Range [0.1:4273.2]. */ -/* - HUMIDITY - [% RH] - Relative humidity. Resolution 1%. Range [1:100]. */ -/* - ATMOSPHERIC_PRESSURE - [hPa] - Hectopascal (1hPa = 100Pa). Resolution 0.1hPa. Range: [0.1:1999.0]. */ -/* - DIFERENTIAL_PRESSURE - [Pa] - Pascal. Resolution 1Pa. Range [1:20000] */ -/* - OK/ALARM - Not applicable */ -/* - VOC - [IAQ] - Iaq index. Resolution 1IAQ. Range [1:500]. */ -/* - FLOODING - Not applicable */ -/* - PULSE_CNT - [NB] Number of pulses. Resolution 1 pulse. Range [1:8000000]. */ -/* - ELECTRICITY_METER - [W] - Watt; Resolution 1W. Range [1:8000000]. Average power consumption in period */ -/* - WATER_METER [l/min] - Liter per minute. Resolution 1l/min. Range [1:8000000]. Average water flow in period. */ -/* - SOIL_MOISTURE - [kPa] - Kilopascal (1kPa = 1000Pa); Resolution 1kPa. Range [1:1000]. Soil moisture (tension). */ -/* - CO_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon monoxide concentration. */ -/* - NO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Nitrogen dioxide concentration. */ -/* - H2S_GAS - [ppm] - Parts per million. Resolution 0.01ppm. Range [0.01:80000.00]. Hydrogen sulfide concentration. */ -/* - AMBIENT_LIGHT -[lx] - Lux. Resolution 0.1lx. Range [0.1:100000.0]. Illuminance. */ -/* - PM_1_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_2_5 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - PM_10_0 - [µg/m^3] - Micro gram per cubic meter. Resolution 1µg/m^3 Range [1:1000]. */ -/* - NOISE_LEVEL - [dB] - Decibels. Resolution 0.1 dB. Range: [0.1:200.0]. Noise level. */ -/* - NH3_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Ammonia concentration. */ -/* - CH4_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Methane concentration. */ -/* - HIGH_PRESSURE - [kPa] - Kilopascal (1kPa = 1000Pa, 100kPa = 1bar). Resolution 1kPa. Range [1:200000]. Pressure. */ -/* - DISTANCE_MM - [mm] - Millimeter. Resolution 1mm. Range [1:100000]. Distance. */ -/* - WATER_METER_ACC_MINOR - [l] - Liter. Resolution 1l. Range [1:1000000]. Accumulative water meter (minor). */ -/* - WATER_METER_ACC_MAJOR - [hl] - Hectoliter. Resolution 1hl. Range [1:1000000]. Accumulative water meter (major). */ -/* - CO2_GAS - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide concentration. */ -/* - HUMIDITY ACCURATE - [% RH] - Relative humidity. Resolution 0.1%. Range [0.1:100.0]. */ -/* - STATIC_IAQ - [sIAQ] - Static IAQ index. Resolution 1sIAQ. Range [1:10000]. */ -/* - CO2_EQUIVALENT - [ppm] - Parts per million. Resolution 1ppm. Range [1:1000000]. Carbon dioxide equivalent. */ -/* - BREATH_VOC - [ppm] - Parts per million. Resolution 1ppm. Range [1:100000]. Breath VOC estimate. */ -/* - PERCENTAGE - [%] - Percentage. Resolution 0.01%. Range [0.01:100.00]. */ -/* - VOLTAGE - [mV] - Milivolt. Resolution 0.1mV. Range [0.1:100000.0]. */ -/* - CURRENT - [mA] - Miliampere. Resolution 0.01mA. Range [0.01:10000.00]. */ -/* - PULSE_CNT_ACC_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:1000000]. Accumulative pulse counter (minor). */ -/* - PULSE_CNT_ACC_MAJOR - [kNB] - Number of kilopulses. Resolution 1 kilopulse. Range [1:1000000]. */ -/* Accumulative pulse counter (major). */ -/* - ELEC_METER_ACC_MINOR - [Wh] - Watt-hour. Resolution 1Wh. Range [1:1000000]. Accumulative electricity meter (minor). */ -/* - ELEC_METER_ACC_MAJOR - [kWh] - Kilowatt-hour. Resolution 1kWh. Range [1:1000000]. Accumulative electricity meter (major). */ -/* - PULSE_CNT_ACC_WIDE_MINOR - [NB] - Number of pulses. Resolution 1 pulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (minor). */ -/* - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] - Number of megapulses. Resolution 1 megapulse. Range [1:999999]. */ -/* Accumulative pulse counter wide range (major). */ -/* - CURRENT_PRECISE - [mA] - Miliampere. Resolution 0.001mA. Range [0.001:8 000.000]. */ -/* - OUTPUT_CONTROL - Not applicable */ - -/* Condition to be checked by the device. If the condition is true, an action is triggered */ +/* Encoding A: used to set absolute values in rules (e.g., upper and lower thresholds): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [-273.2:4000.0] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [0:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [1.0:2000.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [-10000:10000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [0:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [0:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [0:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [-1000:0] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.00:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.0:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [0:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.0:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [0:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [0:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [0:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [0:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.0:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [0:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [0:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [0:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.00:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.0:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.00:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [0:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [0:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [0:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [0:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [-4000.000:4000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [0:1000000] */ + +/* Encoding R: used to set relative values in rules (e.g., differential threshold and hysteresis): * + * - TEMPERATURE - [°C] Celsius degree. Resolution: 0.1°C. Range: [0.1:4273.2] * + * - HUMIDITY - [% RH] Percentage (Relative humidity). Resolution: 1%. Range: [1:100] * + * - ATMOSPHERIC_PRESSURE - [hPa] Hectopascal. Resolution: 0.1hPa. Range: [0.1:1999.0] * + * - DIFERENTIAL_PRESSURE - [Pa] Pascal. Resolution: 1Pa. Range: [1:20000] * + * - OK_ALARM - Not applicable * + * - IAQ - [IAQ] IAQ index. Resolution: 1IAQ. Range: [1:500] * + * - FLOODING - Not applicable * + * - PULSE_CNT - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:8000000]. Period number of pulses * + * - ELECTRICITY_METER - [W] Watt. Resolution: 1W. Range: [1:8000000]. Period average power consumption * + * - WATER_METER - [l/min] Liter per minute. Resolution: 1l/min. Range: [1:8000000]. Period average water * + * - SOIL_MOISTURE - [kPa] Kilopascal. Resolution: 1kPa. Range: [1:1000] * + * - CO_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - NO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - H2S_GAS - [ppm] Parts per million. Resolution: 0.01ppm. Range: [0.01:80000.00] * + * - AMBIENT_LIGHT - [lx] Lux. Resolution: 0.1lx. Range: [0.1:100000.0] * + * - PM_1_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_2_5 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - PM_10_0 - [µg/m^3] Microgram per cubic meter. Resolution: 1µg/m^3. Range: [1:1000] * + * - NOISE_LEVEL - [dB] Decibel. Resolution: 0.1 dB. Range: [0.1:200.0] * + * - NH3_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - CH4_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HIGH_PRESSURE - [kPa] Kilopascal (100kPa = 1bar). Resolution: 1kPa. Range: [1:200000] * + * - DISTANCE_MM - [mm] Millimeter. Resolution: 1mm. Range: [1:100000] * + * - WATER_METER_ACC_MINOR - [l] Liter. Resolution: 1l. Range: [1:99] * + * - WATER_METER_ACC_MAJOR - [hl] Hectoliter. Resolution: 1hl. Range: [1:999999] * + * - CO2_GAS - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - HUMIDITY_ACCURATE - [% RH] Percentage (Relative humidity). Resolution: 0.1%. Range: [0.1:100.0] * + * - STATIC_IAQ - [sIAQ] Static IAQ index. Resolution: 1sIAQ. Range: [1:10000] * + * - CO2_EQUIVALENT - [ppm] Parts per million. Resolution: 1ppm. Range: [1:1000000] * + * - BREATH_VOC - [ppm] Parts per million. Resolution: 1ppm. Range: [1:100000] * + * - CELLULAR_GATEWAY - Not applicable * + * - PERCENTAGE - [%] Percentage. Resolution: 0.01%. Range: [0.01:100.00] * + * - VOLTAGE - [mV] Millivolt. Resolution: 0.1mV. Range: [0.1:100000.0] * + * - CURRENT - [mA] Milliampere. Resolution: 0.01mA. Range: [0.01:10000.00] * + * - PULSE_CNT_ACC_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999] * + * - PULSE_CNT_ACC_MAJOR - [kNB] Number of kilopulses. Resolution: 1 kilopulse. Range: [1:999999] * + * - ELEC_METER_ACC_MINOR - [Wh] Watt-hour. Resolution: 1Wh. Range: [1:999] * + * - ELEC_METER_ACC_MAJOR - [kWh] Kilowatt-hour. Resolution: 1kWh. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MINOR - [NB] Number of pulses. Resolution: 1 pulse. Range: [1:999999] * + * - PULSE_CNT_ACC_WIDE_MAJOR - [MNB] Number of megapulses. Resolution: 1 megapulse. Range: [1:999999] * + * - CURRENT_PRECISE - [mA] Milliampere. Resolution: 0.001mA. Range: [0.001:8000.000] * + * - OUTPUT_CONTROL - Not applicable * + * - RESISTANCE - [Ω] Ohm. Resolution: 1Ω. Range: [1:1000000] */ + +/* Condition to be checked by the device. If the condition is true, an action is triggered. */ enum Condition { - /* Invalid value */ + /* Invalid value. */ CONDITION_UNSPECIFIED = 0; - /* Threshold function for given rule_id is disabled */ + /* The rule is disabled. */ CONDITION_DISABLED = 1; - /* Upper threshold. Continuous sensors only. If the measurement (or average from a few measurements) is over the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample above threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Upper threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is over the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample above threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_HIGH_THRESHOLD = 2; - /* Lower threshold. Continuous sensors only. If the measurement (or average from a few measurements) is below the threshold, */ - /* an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding A" format. Must match channel type */ - /* parameter[1] - Hysteresis value in "Encoding R" format. Must much channel type. Set to "0" to disable */ - /* parameter[2] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[3] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample below threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[4] - Type of measurement (as described in MeasurementType). */ + /* Lower threshold. 'Continuous' sensors only. * + * If the measurement (or average from a few measurements) is below the threshold, an action is triggered. * + * parameter[0] - Threshold value in "Encoding A" format. Must match channel type. * + * parameter[1] - Hysteresis value in "Encoding R" format. Must match channel type. Set to "0" to disable. * + * parameter[2] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[3] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample below threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[4] - Type of measurement (as described in MeasurementType). */ CONDITION_LOW_THRESHOLD = 3; - /* Differential threshold. Continuous sensors only. If the absolute value of the difference between the last value sent to */ - /* the server and the measurement value (or average from a few measurements) is greater or equal to the value of */ - /* the threshold set, an action is triggered. */ - /* parameter[0] - Threshold value in "Encoding R" format. Must match channel type */ - /* parameter[1] - Triggering mode: */ - /* - 1 - moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) */ - /* - 2 - window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) */ - /* - 3 - consecutive samples (number of consecutive samples above threshold) */ - /* parameter[2] - Number of measurements for trigger determination. E.g parameter[3] equals 3, average value from three */ - /* samples will be calculated and compared to the threshold value in average mode or the third consecutive */ - /* sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. */ - /* parameter[3] - Type of measurement (as described in MeasurementType). */ + /* Differential threshold. 'Continuous' sensors only. * + * If the absolute value of the difference between the last value sent to the server and the measurement value * + * (or average from a few measurements) is greater or equal to the value of the threshold set, an action is triggered. * + * parameter[0] - Threshold value in "Encoding R" format. Must match channel type. * + * parameter[1] - Triggering mode: * + * - 1 - Moving average (a1=(n1+n2+n3)/3, a2=(n2+n3+n4)/3, etc.) * + * - 2 - Window average (a1=(n1+n2+n3)/3, a2=(n4+n5+n6)/3, etc.) * + * - 3 - Consecutive samples (number of consecutive samples above threshold) * + * parameter[2] - Number of measurements for trigger determination. E.g., parameter[3] equals 3, average value from three * + * samples will be calculated and compared to the threshold value in average mode or the third consecutive * + * sample exceeding threshold will trigger action in consecutive mode. Range: [1:10]. * + * parameter[3] - Type of measurement (as described in MeasurementType). */ CONDITION_DIFF_THRESHOLD = 4; - /* Change of binary sensor's state. Binary sensors only. Each change of the binary's sensor state will trigger an action. */ + /* Change of binary sensor's state. 'Binary' sensors only. * + * Each change of the binary sensor's state will trigger an action. */ CONDITION_BINARY_CHANGE_STATE = 5; - /* Logic operator. Used for combining multiple rules into more complex conditions. If the logic condition specified by */ - /* parameters (logic operator and selected rules) is met, an action is triggered. */ - /* parameter[0] - Logic operator (as described in LogicOperation). */ - /* parameter[1] - Rule selector (bit mask). Specifies which rules should be taken into account while determining */ - /* rules outcome. */ - /* parameter[2] - Rule negation (bit mask). Specifies which of chosen in parameter[1] rules should be negated */ - /* before determining rules outcome. */ - /* parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. */ - /* Range: [0:864000]. */ - /* parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. */ - /* Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ + /* Logic operator. Used for combining multiple rules into more complex conditions. * + * If the logic condition specified by parameters (logic operator and selected rules) is met, an action is triggered. * + * parameter[0] - Logic operator (as described in LogicOperator). * + * parameter[1] - Rule selector (bitmask). Specifies which rules should be taken into account while determining * + * rules outcome. * + * parameter[2] - Rule negation (bitmask). Specifies which of chosen in parameter[1] rules should be negated * + * before determining rules outcome. * + * parameter[3] - Rule action delay [s]. Specifies time delay between the rule activation and rule action being triggered. * + * Range: [0:864000]. * + * parameter[4] - Rule return delay [s]. Specifies time delay between the rule deactivation and rule action being triggered. * + * Range: [0:864001]. Max parameter value disables action triggering on rule deactivation. */ CONDITION_LOGIC_OPERATOR = 6; - /* On measurement. Continous sensors only. The basic function is to trigger communication after measurement if at least 60s */ - /* have passed since the last one. Transmission may occur every x measurement. Optionally dependency on the other rule can */ - /* be configured, then, when all conditions are met, transmission is triggered. */ - /* parameter[0] - Send every n measurement. This parameter specifies every which measurement transmission will be triggered */ - /* if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will occur after */ - /* every measurement. */ - /* parameter[1] - Optional. Rule selector (bit mask). Specifies which rule should be taken into account while determining */ - /* the measurement rule outcome. */ - /* parameter[2] - Optional. Rule negation (bit mask). Specifies which of chosen in parameter[1] rule should be negated */ - /* before determining the measurement rule outcome. */ + /* On measurement. 'Continous' sensors only. + * The basic function is to trigger communication after measurement if at least 60s have passed since the last one. * + * Transmission may occur every n-th measurement. Optionally dependency on the other rule can be configured, then, when all * + * conditions are met, transmission is triggered. * + * parameter[0] - Send every n-th measurement. This parameter specifies every which measurement transmission will be * + * triggered if all other conditions are met. Range: [1:500]. If parameter[0] equals 1, transmission will * + * occur after every measurement. * + * parameter[1] - Optional. Rule selector (bitmask). Specifies which rule should be taken into account while determining * + * the measurement rule outcome. * + * parameter[2] - Optional. Rule negation (bitmask). Specifies which of the rules selected in parameter[1] should be negated * + * before determining the measurement rule outcome. */ CONDITION_ON_MEASUREMENT = 7; + + /* On sensor error. 'Continous' sensors only. * + * If the sensor returns an error code, an action will be triggered. * + * The rule becomes inactive when a correct measurement appears. */ + CONDITION_ON_SENSOR_ERROR = 8; } /* Logic operators to be used for determining the outcome of rules with logic operator condition. */ enum LogicOperator { - /* Invalid use */ + /* Invalid value. */ LOGIC_OPERATOR_UNSPECIFIED = 0; - /* Logic AND */ + /* Logic AND. */ LOGIC_OPERATOR_AND = 1; - /* Logic OR */ + /* Logic OR. */ LOGIC_OPERATOR_OR = 2; } -/* Action to be triggered. Currently the only possible action is to trigger the transmission. */ -/* Other actions will be available in next SW releases. */ +/* Action to be triggered. */ enum Action { - /* Invalid value */ + /* Invalid value. */ ACTION_UNSPECIFIED = 0; - /* To trigger the transmission */ + /* To trigger the transmission. */ ACTION_TRIGGER_TRANSMISSION = 1; - /* To take no action. Possible for logic operator components */ + /* To take no action. Possible for logic operator components. */ ACTION_NO_ACTION = 2; - /* To trigger the transmission with ACK */ + /* To trigger the transmission with ACK. */ ACTION_TRIGGER_TRANSMISSION_WITH_ACK = 3; - /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). */ - /* Once the rule is deactived avertising period mode returns to previously configured value. */ + /* To change BLE advertising period mode to fast (with lower user-configured advertising interval). * + * Once the rule is deactived avertising period mode returns to previously configured value. */ ACTION_FAST_ADVERTISING_MODE = 4; } /* Type of a rule calendars. */ enum CalendarType { - /* Invalid value */ + /* Invalid value. */ CALENDAR_TYPE_UNSPECIFIED = 0; - /* Type for inactive calendars */ + /* Calendar is inactive. */ CALENDAR_TYPE_DISABLED = 1; - /* Week type. Enables selcted rules on specified days of the week in specified time periods. */ - /* parameter[0] - Week day mask. Bitmask of days when selected rules are enabled */ - /* - Bit 0 - Sunday */ - /* - Bit 1 - Monday */ - /* ... */ - /* - Bit 6 - Saturday */ - /* parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). */ - /* parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). */ - /* Note: if 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' */ - /* and from 'From time' to 23:59. */ - /* parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets */ - /* - example - if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. */ + /* Week calendar. Enables selcted rules on specified days of the week in specified time periods. * + * parameter[0] - Week day mask. Bitmask of days when selected rules are enabled: * + * - Bit 0 - Sunday * + * - Bit 1 - Monday * + * ... * + * - Bit 6 - Saturday * + * parameter[1] - 'From time' - point in time from which selected rules will be enabled (in minutes from midnight). * + * parameter[2] - 'To time' - point in time from which selected rules will be disabled (in minutes from midnight). * + * parameter[3] - Timezone - desired timezone for date comparison. Encoded as number (N) of 15 minutes offsets, * + * for example: if N = 4, then offset = 4 * 15min = 1h. I.e. timezone is UTC+1. * + * Note: If 'From time' is bigger than 'To time' there are two periods when rules are enabled - from 00:00 to 'To time' * + * and from 'From time' to 23:59. */ CALENDAR_TYPE_WEEK = 2; } /* Rules calendars. Used for enabling/disabling rules based on date/time. */ -/* It is possible to configure up to 6 calendars. Each of them can affect any number of rules. */ message ProtoCalendar { - /* Bit mask of selected rules. Mask on bits [0:11] */ - /* - Bit 0 - Rule ID 0 */ - /* - Bit 1 - Rule ID 1 */ - /* ... */ - /* - Bit 11 - Rule ID 11 */ + /* Selected rules. * + * Bitmask - up to 16 rules supported (previously 12 rules [06.00 - 07.01.xx]): * + * - Bit 0 - Rule 1 * + * - Bit 1 - Rule 2 * + * ... * + * - Bit 15 - Rule 16 * + * Status: In use [06.08.00 - LATEST] */ uint32 rule_mask = 1; - /* Calendars's parameters. Described in Type. */ + /* Calendar parameters (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ repeated sint32 parameters = 2; - /* Calendar's type. Described in Type. */ + /* Calendar type (as described in CalendarType). * + * Status: In use [06.08.00 - LATEST] */ CalendarType type = 3; } -/* Rules used to define edge logic on the device. Rules are defined by conditions and actions: */ -/* If Condition is true, trigger Action. It is possible to configure up to 12 rules and assign them to different channels. */ -/* One rule can be assigned to any number of channels. For instance rule "If temperature is over 10 C, trigger the transmission"*/ -/* can be assigned to channels 1 and 2. No matter to how many channels a rule is assigned, it's still counted as one rule. */ +/* Rules are used to define edge logic on the device. Rules are defined by conditions and actions. * + * If 'condition' is true, trigger 'action'. One rule can be assigned to any number of channels. * + * For instance rule "If temperature is over 10 C, trigger the transmission" can be assigned to channels 1 and 2. * + * No matter to how many channels a rule is assigned, it's still counted as one rule. */ message ProtoRule { - /* Channels to which the rule is assigned. One rule can be assigned to multiple channels as long as those are of the same type*/ - /* Bit mask on bits [0:5]. E.g. To assign the rule for channel 1: "000001", to assign rule to channels 2 and 4: "001010" */ + /* Channels to which the rule is assigned. * + * One rule can be assigned to multiple channels as long as those are of the same type. * + * Bitmask: * + * - Bit 0 - Channel 1 * + * - Bit 1 - Channel 2 * + * ... * + * - Bit 5 - Channel 6 * + * Status: In use [06.00 - LATEST] */ uint32 channel_mask = 1; - /* Rule's condition (as described in Condition). */ + /* Rule condition (as described in Condition). * + * Status: In use [06.00 - LATEST] */ Condition condition = 2; - /* Condition's parameters (as described in Condition). For binary sensors there are no parameters */ + /* Condition parameters (as described in Condition). * + * For 'Binary' sensors there are no parameters. * + * Status: In use [06.00 - LATEST] */ repeated sint32 parameters = 3; - /* Action to be triggered. */ + /* Action to be triggered. * + * Status: In use [06.00 - LATEST] */ Action action = 4; } \ No newline at end of file diff --git a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java index eb0a764f5e..f6eaa9976f 100644 --- a/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java +++ b/common/transport/coap/src/test/java/org/thingsboard/server/transport/coap/efento/CoapEfentoTransportResourceTest.java @@ -16,23 +16,32 @@ package org.thingsboard.server.transport.coap.efento; import com.google.protobuf.ByteString; +import com.google.protobuf.InvalidProtocolBufferException; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.thingsboard.server.gen.transport.coap.ConfigProtos; +import org.thingsboard.server.gen.transport.coap.ConfigTypesProtos; +import org.thingsboard.server.gen.transport.coap.DeviceInfoProtos; import org.thingsboard.server.gen.transport.coap.MeasurementTypeProtos.MeasurementType; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos; import org.thingsboard.server.gen.transport.coap.MeasurementsProtos.ProtoMeasurements; +import org.thingsboard.server.gen.transport.coap.ProtoRuleProtos; import org.thingsboard.server.transport.coap.CoapTransportContext; import org.thingsboard.server.transport.coap.efento.utils.CoapEfentoUtils; import java.nio.ByteBuffer; +import java.text.SimpleDateFormat; import java.time.Instant; import java.util.Arrays; +import java.util.Comparator; +import java.util.Date; import java.util.List; import java.util.UUID; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; @@ -85,7 +94,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensorWithSomeMeasurements() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(5) @@ -93,7 +102,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) .setTimestamp(Math.toIntExact(tsInSec)) @@ -114,7 +123,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 180 * 5) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("temperature_1").getAsDouble()).isEqualTo(22.4); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get("humidity_2").getAsDouble()).isEqualTo(30); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 5); } @ParameterizedTest @@ -122,7 +131,7 @@ class CoapEfentoTransportResourceTest { void checkContinuousSensor(MeasurementType measurementType, List sampleOffsets, String property, double expectedValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -130,7 +139,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(List.of(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -142,7 +151,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsDouble()).isEqualTo(expectedValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkContinuousSensor() { @@ -176,7 +185,7 @@ class CoapEfentoTransportResourceTest { String totalPropertyName, double expectedTotalValue) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -184,7 +193,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addAllChannels(Arrays.asList(MeasurementsProtos.ProtoChannel.newBuilder() .setType(majorType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -200,7 +209,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(totalPropertyName + "_2").getAsDouble()).isEqualTo(expectedTotalValue); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, false); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkPulseCounterSensors() { @@ -220,7 +229,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensor() { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(0) @@ -228,7 +237,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(MEASUREMENT_TYPE_OK_ALARM) .setTimestamp(Math.toIntExact(tsInSec)) @@ -239,7 +248,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements).hasSize(1); assertThat(efentoMeasurements.get(0).getTs()).isEqualTo(tsInSec * 1000); assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get("ok_alarm_1").getAsString()).isEqualTo("ALARM"); - checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180 * 14); } @ParameterizedTest @@ -247,7 +256,7 @@ class CoapEfentoTransportResourceTest { void checkBinarySensorWhenValueIsVarying(MeasurementType measurementType, String property, String expectedValueWhenOffsetNotOk, String expectedValueWhenOffsetOk) { long tsInSec = Instant.now().getEpochSecond(); ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -255,7 +264,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() .setType(measurementType) .setTimestamp(Math.toIntExact(tsInSec)) @@ -268,7 +277,7 @@ class CoapEfentoTransportResourceTest { assertThat(efentoMeasurements.get(0).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetNotOk); assertThat(efentoMeasurements.get(1).getTs()).isEqualTo((tsInSec + 9) * 1000); assertThat(efentoMeasurements.get(1).getValues().getAsJsonObject().get(property).getAsString()).isEqualTo(expectedValueWhenOffsetOk); - checkDefaultMeasurements(measurements, efentoMeasurements, 180, true); + checkDefaultMeasurements(measurements, efentoMeasurements, 180); } private static Stream checkBinarySensorWhenValueIsVarying() { @@ -282,7 +291,7 @@ class CoapEfentoTransportResourceTest { @Test void checkExceptionWhenChannelsListIsEmpty() { ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) + .setSerialNumber(integerToByteString(1234)) .setCloudToken("test_token") .setMeasurementPeriodBase(180) .setMeasurementPeriodFactor(1) @@ -290,7 +299,7 @@ class CoapEfentoTransportResourceTest { .setSignal(0) .setNextTransmissionAt(1000) .setTransferReason(0) - .setHash(0) + .setConfigurationHash(0) .build(); UUID sessionId = UUID.randomUUID(); @@ -299,29 +308,587 @@ class CoapEfentoTransportResourceTest { .hasMessage("[" + sessionId + "]: Failed to get Efento measurements, reason: channels list is empty!"); } + // ------------------------------------------------------------------------- + // ProtoDeviceInfo parsing tests + // ------------------------------------------------------------------------- + @Test - void checkExceptionWhenValuesMapIsEmpty() { - long tsInSec = Instant.now().getEpochSecond(); - ProtoMeasurements measurements = ProtoMeasurements.newBuilder() - .setSerialNum(integerToByteString(1234)) - .setCloudToken("test_token") - .setMeasurementPeriodBase(180) - .setMeasurementPeriodFactor(1) - .setBatteryStatus(true) - .setSignal(0) - .setNextTransmissionAt(1000) - .setTransferReason(0) - .setHash(0) - .addChannels(MeasurementsProtos.ProtoChannel.newBuilder() - .setType(MEASUREMENT_TYPE_TEMPERATURE) - .setTimestamp(Math.toIntExact(tsInSec)) + void getEfentoDeviceInfo_parsesSwVersion() { + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setSwVersion(1546) // ver 06.10 => 0x060A => 1546 + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + + assertThat(result.getValues().getAsJsonObject().get("sw_version").getAsInt()).isEqualTo(1546); + } + + @Test + void getEfentoDeviceInfo_parsesAllMemoryStatistics() { + // proto uint32 maps to Java int; the "undefined" sentinel is 0xFFFFFFFF = -1 in signed int + int undefinedTs = -1; + int knownTs = 1_700_000_000; // arbitrary known Unix timestamp fitting in uint32 + // clearMemoryStatistics() is needed because minimalDeviceInfo() pre-populates 22 zeros; + // addAllMemoryStatistics() appends in proto, so without clear the test values land at indices 22-43 + DeviceInfoProtos.ProtoDeviceInfo.Builder builder = minimalDeviceInfo().clearMemoryStatistics(); + builder.addAllMemoryStatistics(List.of( + 0, // [0] nv_storage_status: 0 = no errors + knownTs, // [1] timestamp_of_the_end_of_collecting_statistics + 1048576, // [2] capacity_of_memory_in_bytes + 512000, // [3] used_space_in_bytes + 1024, // [4] size_of_invalid_packets_in_bytes + 256, // [5] size_of_corrupted_packets_in_bytes + 100, // [6] number_of_valid_packets + 10, // [7] number_of_invalid_packets + 2, // [8] number_of_corrupted_packets + 500, // [9] number_of_all_samples_for_channel_1 + 400, // [10] number_of_all_samples_for_channel_2 + 300, // [11] number_of_all_samples_for_channel_3 + 200, // [12] number_of_all_samples_for_channel_4 + 100, // [13] number_of_all_samples_for_channel_5 + 50, // [14] number_of_all_samples_for_channel_6 + undefinedTs, // [15] timestamp_of_the_first_binary_measurement (undefined) + undefinedTs, // [16] timestamp_of_the_last_binary_measurement (undefined) + undefinedTs, // [17] timestamp_of_the_first_binary_measurement_sent (undefined) + knownTs, // [18] timestamp_of_the_first_continuous_measurement + knownTs, // [19] timestamp_of_the_last_continuous_measurement + knownTs, // [20] timestamp_of_the_last_continuous_measurement_sent + 42 // [21] nvm_write_counter + )); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(builder.build()); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("nv_storage_status").getAsLong()).isEqualTo(0); + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("capacity_of_memory_in_bytes").getAsLong()).isEqualTo(1048576L); + assertThat(json.get("used_space_in_bytes").getAsLong()).isEqualTo(512000L); + assertThat(json.get("size_of_invalid_packets_in_bytes").getAsLong()).isEqualTo(1024L); + assertThat(json.get("size_of_corrupted_packets_in_bytes").getAsLong()).isEqualTo(256L); + assertThat(json.get("number_of_valid_packets").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_invalid_packets").getAsLong()).isEqualTo(10L); + assertThat(json.get("number_of_corrupted_packets").getAsLong()).isEqualTo(2L); + assertThat(json.get("number_of_all_samples_for_channel_1").getAsLong()).isEqualTo(500L); + assertThat(json.get("number_of_all_samples_for_channel_2").getAsLong()).isEqualTo(400L); + assertThat(json.get("number_of_all_samples_for_channel_3").getAsLong()).isEqualTo(300L); + assertThat(json.get("number_of_all_samples_for_channel_4").getAsLong()).isEqualTo(200L); + assertThat(json.get("number_of_all_samples_for_channel_5").getAsLong()).isEqualTo(100L); + assertThat(json.get("number_of_all_samples_for_channel_6").getAsLong()).isEqualTo(50L); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo(formatDate(knownTs)); + assertThat(json.get("nvm_write_counter").getAsLong()).isEqualTo(42L); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfo() { + // 34 modem parameters (indices 0-33) as defined in proto_device_info.proto + List params = List.of( + 0, // [0] sc_EARNFCN_offset + 1000, // [1] sc_EARFCN + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 1, // [14] nc_EARNFCN_offset + 100, // [15] nc_PCI + -95, // [16] nc_RSRP + 5, // [17] RLC_UL_BLER + 3, // [18] RLC_DL_BLER + 4, // [19] MAC_UL_BLER + 2, // [20] MAC_DL_BLER + 50000,// [21] MAC_UL_TOTAL_BYTES + 60000,// [22] MAC_DL_TOTAL_BYTES + 200, // [23] MAC_UL_total_HARQ_Tx + 150, // [24] MAC_DL_total_HARQ_Tx + 10, // [25] MAC_UL_HARQ_re_Tx + 8, // [26] MAC_DL_HARQ_re_Tx + 1000, // [27] RLC_UL_tput + 1200, // [28] RLC_DL_tput + 900, // [29] MAC_UL_tput + 1100, // [30] MAC_DL_tput + 5000, // [31] sleep_duration + 300, // [32] rx_time + 100 // [33] tx_time + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(params) .build()) .build(); - UUID sessionId = UUID.randomUUID(); - assertThatThrownBy(() -> coapEfentoTransportResource.getEfentoMeasurements(measurements, sessionId)) - .isInstanceOf(IllegalStateException.class) - .hasMessage("[" + sessionId + "]: Failed to collect Efento measurements, reason, values map is empty!"); + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC66"); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(0); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_EARNFCN_offset").getAsInt()).isEqualTo(1); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("RLC_UL_BLER").getAsInt()).isEqualTo(5); + assertThat(json.get("RLC_DL_BLER").getAsInt()).isEqualTo(3); + assertThat(json.get("MAC_UL_BLER").getAsInt()).isEqualTo(4); + assertThat(json.get("MAC_DL_BLER").getAsInt()).isEqualTo(2); + assertThat(json.get("MAC_UL_TOTAL_BYTES").getAsInt()).isEqualTo(50000); + assertThat(json.get("MAC_DL_TOTAL_BYTES").getAsInt()).isEqualTo(60000); + assertThat(json.get("MAC_UL_total_HARQ_Tx").getAsInt()).isEqualTo(200); + assertThat(json.get("MAC_DL_total_HARQ_Tx").getAsInt()).isEqualTo(150); + assertThat(json.get("MAC_UL_HARQ_re_Tx").getAsInt()).isEqualTo(10); + assertThat(json.get("MAC_DL_HARQ_re_Tx").getAsInt()).isEqualTo(8); + assertThat(json.get("RLC_UL_tput").getAsInt()).isEqualTo(1000); + assertThat(json.get("RLC_DL_tput").getAsInt()).isEqualTo(1200); + assertThat(json.get("MAC_UL_tput").getAsInt()).isEqualTo(900); + assertThat(json.get("MAC_DL_tput").getAsInt()).isEqualTo(1100); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoBC660() { + // 22 modem parameters for MODEM_TYPE_BC660 as defined in proto_device_info.proto + List params = List.of( + 1000, // [0] sc_EARFCN + 5, // [1] sc_EARNFCN_offset + 42, // [2] sc_PCI + 123456, // [3] sc_Cell_id + -90, // [4] sc_RSRP + -10, // [5] sc_RSRQ + -80, // [6] sc_RSSI + 15, // [7] sc_SINR + 3, // [8] sc_Band + 1234, // [9] sc_TAC + 1, // [10] sc_ECL + -30, // [11] sc_TX_PWR + 2, // [12] op_mode + 999, // [13] nc_EARFCN + 100, // [14] nc_PCI + -95, // [15] nc_RSRP + -12, // [16] nc_RSRQ + 5000, // [17] sleep_duration + 300, // [18] rx_time + 100, // [19] tx_time + 1, // [20] PLMN_state + 26201 // [21] select_PLMN + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC660) + .addAllParameters(params) + .setSimCardIdentification("89001012012341234120") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_BC660_V2) + .setModemIdentification("123456789012345") + .addAllModemStatistics(List.of(10, 3600, 7200, 600)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_BC660"); + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89001012012341234120"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_BC660_V2"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("123456789012345"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(10); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(7200); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(600); + assertThat(json.get("sc_EARFCN").getAsInt()).isEqualTo(1000); + assertThat(json.get("sc_EARNFCN_offset").getAsInt()).isEqualTo(5); + assertThat(json.get("sc_PCI").getAsInt()).isEqualTo(42); + assertThat(json.get("sc_Cell_id").getAsInt()).isEqualTo(123456); + assertThat(json.get("sc_RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("sc_RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("sc_RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("sc_SINR").getAsInt()).isEqualTo(15); + assertThat(json.get("sc_Band").getAsInt()).isEqualTo(3); + assertThat(json.get("sc_TAC").getAsInt()).isEqualTo(1234); + assertThat(json.get("sc_ECL").getAsInt()).isEqualTo(1); + assertThat(json.get("sc_TX_PWR").getAsInt()).isEqualTo(-30); + assertThat(json.get("op_mode").getAsInt()).isEqualTo(2); + assertThat(json.get("nc_EARFCN").getAsInt()).isEqualTo(999); + assertThat(json.get("nc_PCI").getAsInt()).isEqualTo(100); + assertThat(json.get("nc_RSRP").getAsInt()).isEqualTo(-95); + assertThat(json.get("nc_RSRQ").getAsInt()).isEqualTo(-12); + assertThat(json.get("sleep_duration").getAsInt()).isEqualTo(5000); + assertThat(json.get("rx_time").getAsInt()).isEqualTo(300); + assertThat(json.get("tx_time").getAsInt()).isEqualTo(100); + assertThat(json.get("PLMN_state").getAsInt()).isEqualTo(1); + assertThat(json.get("select_PLMN").getAsInt()).isEqualTo(26201); + // BC66-specific fields must not be present + assertThat(json.has("nc_EARNFCN_offset")).isFalse(); + assertThat(json.has("RLC_UL_BLER")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesModemInfoSharedModem() { + // 4 modem parameters for MODEM_TYPE_SHARED_MODEM as defined in proto_device_info.proto + List params = List.of( + -90, // [0] RSRP + -10, // [1] RSRQ + -80, // [2] RSSI + 15 // [3] SINR + ); + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_SHARED_MODEM) + .addAllParameters(params) + .setModemIdentification("SN-ABCDEF") + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("modem_types").getAsString()).isEqualTo("MODEM_TYPE_SHARED_MODEM"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("SN-ABCDEF"); + assertThat(json.get("RSRP").getAsInt()).isEqualTo(-90); + assertThat(json.get("RSRQ").getAsInt()).isEqualTo(-10); + assertThat(json.get("RSSI").getAsInt()).isEqualTo(-80); + assertThat(json.get("SINR").getAsInt()).isEqualTo(15); + // BC66/BC660-specific fields must not be present + assertThat(json.has("sc_EARFCN")).isFalse(); + assertThat(json.has("sc_EARNFCN_offset")).isFalse(); + } + + @Test + void getEfentoDeviceInfo_parsesNewModemFields() { + // New ProtoModem fields: sim_card_identification, firmware_version, modem_identification, modem_statistics + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_BC66) + .addAllParameters(java.util.Collections.nCopies(34, 0)) + .setSimCardIdentification("89012345678901234567") + .setFirmwareVersion(DeviceInfoProtos.ModemFirmwareVersion.MODEM_FIRMWARE_VERSION_READING_ERROR) + .setModemIdentification("352519100417272") + .addAllModemStatistics(List.of(5, 1800, 3600, 120)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("sim_card_identification").getAsString()).isEqualTo("89012345678901234567"); + assertThat(json.get("firmware_version").getAsString()).isEqualTo("MODEM_FIRMWARE_VERSION_READING_ERROR"); + assertThat(json.get("modem_identification").getAsString()).isEqualTo("352519100417272"); + assertThat(json.get("modem_transmissions_count").getAsInt()).isEqualTo(5); + assertThat(json.get("modem_time_since_last_devinfo").getAsInt()).isEqualTo(1800); + assertThat(json.get("modem_total_psm_time").getAsInt()).isEqualTo(3600); + assertThat(json.get("modem_total_active_time").getAsInt()).isEqualTo(120); + } + + @Test + void getEfentoDeviceInfo_parsesRuntimeInfo() { + long batteryResetTs = 1_700_000_000L; + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo() + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(3600) + .addAllMessageCounters(List.of(10, 5, 9)) + .setMcuTemperature(25) + .setBatteryVoltage(3200) + .setMinBatteryMcuTemperature(20) + .setBatteryResetTimestamp((int) batteryResetTs) + .setMaxMcuTemperature(40) + .setMinMcuTemperature(10) + .addAllRuntimeErrors(List.of(0, 1)) + .build()) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("up_time").getAsLong()).isEqualTo(3600); + assertThat(json.get("mcu_temp").getAsInt()).isEqualTo(25); + assertThat(json.get("min_battery_voltage").getAsLong()).isEqualTo(3200); + assertThat(json.get("min_battery_mcu_temp").getAsInt()).isEqualTo(20); + assertThat(json.get("battery_reset_timestamp").getAsString()).isEqualTo(formatDate(batteryResetTs)); + assertThat(json.get("max_mcu_temp").getAsInt()).isEqualTo(40); + assertThat(json.get("min_mcu_temp").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_confirmable_messages_attempts").getAsInt()).isEqualTo(10); + assertThat(json.get("counter_of_non_confirmable_messages_attempts").getAsInt()).isEqualTo(5); + assertThat(json.get("counter_of_succeeded_messages").getAsInt()).isEqualTo(9); + assertThat(json.get("runtime_errors").getAsInt()).isEqualTo(2); // count of errors, not values + } + + @Test + void getEfentoDeviceInfo_undefinedTimestampRenderedAsUndefinedString() { + // -1 as signed int == 0xFFFFFFFF == uint32 max (4294967295), the "Undefined" sentinel + DeviceInfoProtos.ProtoDeviceInfo deviceInfo = minimalDeviceInfo().clearMemoryStatistics() + .addAllMemoryStatistics(List.of( + 0, -1, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, + -1, -1, -1, + -1, -1, -1, + 0)) + .build(); + + CoapEfentoTransportResource.EfentoTelemetry result = coapEfentoTransportResource.getEfentoDeviceInfo(deviceInfo); + var json = result.getValues().getAsJsonObject(); + + assertThat(json.get("timestamp_of_the_end_of_collecting_statistics").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_binary_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_binary_measurement_sent").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_first_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement").getAsString()).isEqualTo("Undefined"); + assertThat(json.get("timestamp_of_the_last_continuous_measurement_sent").getAsString()).isEqualTo("Undefined"); + } + + @Test + void getEfentoDeviceInfo_tsIsCurrentTimeMillis() { + long before = System.currentTimeMillis(); + CoapEfentoTransportResource.EfentoTelemetry result = + coapEfentoTransportResource.getEfentoDeviceInfo(minimalDeviceInfo().build()); + long after = System.currentTimeMillis(); + + assertThat(result.getTs()).isBetween(before, after); + } + + // ------------------------------------------------------------------------- + // ProtoConfig / getEfentoConfiguration parsing tests + // ------------------------------------------------------------------------- + + @Test + void getEfentoConfiguration_parsesServerCommunicationFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataServerIp("18.184.24.239") + .setDataServerPort(5683) + .setUpdateServerIp("efento.update.io") + .setUpdateServerPortCoap(5684) + .setUpdateServerPortUdp(5685) + .setTransmissionInterval(300) + .setAckInterval(600) + .setSupervisionPeriod(3600) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataServerIp").getAsString()).isEqualTo("18.184.24.239"); + assertThat(json.get("dataServerPort").getAsLong()).isEqualTo(5683); + assertThat(json.get("updateServerIp").getAsString()).isEqualTo("efento.update.io"); + assertThat(json.get("updateServerPortCoap").getAsLong()).isEqualTo(5684); + assertThat(json.get("updateServerPortUdp").getAsLong()).isEqualTo(5685); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(300); + assertThat(json.get("ackInterval").getAsLong()).isEqualTo(600); + assertThat(json.get("supervisionPeriod").getAsLong()).isEqualTo(3600); + } + + @Test + void getEfentoConfiguration_parsesMeasurementPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setMeasurementPeriodBase(60) + .setMeasurementPeriodFactor(5) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(60); + assertThat(json.get("measurementPeriodFactor").getAsLong()).isEqualTo(5); + } + + @Test + void getEfentoConfiguration_parsesBooleanRequestFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDeviceInfoRequest(true) + .setUpdateSoftwareRequest(true) + .setConfigurationRequest(true) + .setAcceptWithoutTestingRequest(true) + .setResetMemoryRequest(true) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isTrue(); + assertThat(json.get("updateSoftwareRequest").getAsBoolean()).isTrue(); + assertThat(json.get("configurationRequest").getAsBoolean()).isTrue(); + assertThat(json.get("acceptWithoutTestingRequest").getAsBoolean()).isTrue(); + assertThat(json.get("resetMemoryRequest").getAsBoolean()).isTrue(); + } + + @Test + void getEfentoConfiguration_parsesModemAndNetworkFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setApn("internet.example.com") + .setApnUsername("user") + .setApnPassword("pass") + .setPlmnSelection(26001) + .setModemBandsMask(2084) // bands 3, 8, 20 + .setNetworkTroubleshooting(2) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("apn").getAsString()).isEqualTo("internet.example.com"); + assertThat(json.get("apnUsername").getAsString()).isEqualTo("user"); + assertThat(json.get("apnPassword").getAsString()).isEqualTo("pass"); + assertThat(json.get("plmnSelection").getAsLong()).isEqualTo(26001); + assertThat(json.get("modemBandsMask").getAsLong()).isEqualTo(2084); + assertThat(json.get("networkTroubleshooting").getAsLong()).isEqualTo(2); + } + + @Test + void getEfentoConfiguration_parsesCloudTokenFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setCloudToken("my-device-token") + .setCloudTokenConfig(1) + .setCloudTokenCoapOption(65000) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("cloudToken").getAsString()).isEqualTo("my-device-token"); + assertThat(json.get("cloudTokenConfig").getAsLong()).isEqualTo(1); + assertThat(json.get("cloudTokenCoapOption").getAsLong()).isEqualTo(65000); + } + + @Test + void getEfentoConfiguration_parsesEndpointFields() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setDataEndpoint("/m") + .setConfigurationEndpoint("/c") + .setDeviceInfoEndpoint("/i") + .setTimeEndpoint("/t") + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + assertThat(json.get("dataEndpoint").getAsString()).isEqualTo("/m"); + assertThat(json.get("configurationEndpoint").getAsString()).isEqualTo("/c"); + assertThat(json.get("deviceInfoEndpoint").getAsString()).isEqualTo("/i"); + assertThat(json.get("timeEndpoint").getAsString()).isEqualTo("/t"); + } + + @Test + void getEfentoConfiguration_parsesBleAdvertisingPeriod() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .setBleAdvertisingPeriod(ConfigTypesProtos.ProtoBleAdvertisingPeriod.newBuilder() + .setMode(ConfigTypesProtos.BleAdvertisingPeriodMode.BLE_ADVERTISING_PERIOD_MODE_NORMAL) + .setNormal(1600) + .setFast(320) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var ble = json.get("bleAdvertisingPeriod").getAsJsonObject(); + + assertThat(ble.get("mode").getAsString()).isEqualTo("BLE_ADVERTISING_PERIOD_MODE_NORMAL"); + assertThat(ble.get("normal").getAsLong()).isEqualTo(1600); + assertThat(ble.get("fast").getAsLong()).isEqualTo(320); + } + + @Test + void getEfentoConfiguration_parsesRepeatedChannelTypes() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_TEMPERATURE) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_HUMIDITY) + .addChannelTypes(MeasurementType.MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var channelTypes = json.get("channelTypes").getAsJsonArray(); + + assertThat(channelTypes).hasSize(3); + assertThat(channelTypes.get(0).getAsString()).isEqualTo("MEASUREMENT_TYPE_TEMPERATURE"); + assertThat(channelTypes.get(1).getAsString()).isEqualTo("MEASUREMENT_TYPE_HUMIDITY"); + assertThat(channelTypes.get(2).getAsString()).isEqualTo("MEASUREMENT_TYPE_ATMOSPHERIC_PRESSURE"); + } + + @Test + void getEfentoConfiguration_parsesEdgeLogicRules() throws InvalidProtocolBufferException { + // ProtoRule uses channel_mask (bit mask), condition, parameters, action + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder() + .addRules(ProtoRuleProtos.ProtoRule.newBuilder() + .setChannelMask(1) // channel 1 → bit mask 0b000001 + .setCondition(ProtoRuleProtos.Condition.CONDITION_HIGH_THRESHOLD) + .addParameters(500) // threshold value + .setAction(ProtoRuleProtos.Action.ACTION_TRIGGER_TRANSMISSION) + .build()) + .build() + .toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + var rules = json.get("rules").getAsJsonArray(); + + assertThat(rules).hasSize(1); + var rule = rules.get(0).getAsJsonObject(); + assertThat(rule.get("channelMask").getAsInt()).isEqualTo(1); + assertThat(rule.get("condition").getAsString()).isEqualTo("CONDITION_HIGH_THRESHOLD"); + assertThat(rule.get("parameters").getAsJsonArray().get(0).getAsInt()).isEqualTo(500); + assertThat(rule.get("action").getAsString()).isEqualTo("ACTION_TRIGGER_TRANSMISSION"); + } + + @Test + void getEfentoConfiguration_emptyConfigProducesJsonWithDefaultValues() throws InvalidProtocolBufferException { + byte[] bytes = ConfigProtos.ProtoConfig.newBuilder().build().toByteArray(); + + var json = coapEfentoTransportResource.getEfentoConfiguration(bytes).getAsJsonObject(); + + // JsonFormat with includingDefaultValueFields prints all fields — verify a representative subset + assertThat(json.get("measurementPeriodBase").getAsLong()).isEqualTo(0); + assertThat(json.get("transmissionInterval").getAsLong()).isEqualTo(0); + assertThat(json.get("dataServerIp").getAsString()).isEmpty(); + assertThat(json.get("deviceInfoRequest").getAsBoolean()).isFalse(); + assertThat(json.get("cloudToken").getAsString()).isEmpty(); + } + + /** + * Builds a ProtoDeviceInfo with the minimum set of repeated fields required by getEfentoDeviceInfo: + * 22 memory_statistics, 34 modem parameters and 3 message_counters — all set to 0. + */ + private static DeviceInfoProtos.ProtoDeviceInfo.Builder minimalDeviceInfo() { + List zeroMemStats = java.util.Collections.nCopies(22, 0); + List zeroModemParams = java.util.Collections.nCopies(34, 0); + return DeviceInfoProtos.ProtoDeviceInfo.newBuilder() + .setSerialNumber(integerToByteString(1234)) + .setCloudToken("test_token") + .setSwVersion(0) + .addAllMemoryStatistics(zeroMemStats) + .setModem(DeviceInfoProtos.ProtoModem.newBuilder() + .setType(DeviceInfoProtos.ModemType.MODEM_TYPE_UNSPECIFIED) + .addAllParameters(zeroModemParams) + .build()) + .setRuntimeInfo(DeviceInfoProtos.ProtoRuntime.newBuilder() + .setUpTime(0) + .addAllMessageCounters(List.of(0, 0, 0)) + .build()); + } + + private static String formatDate(long seconds) { + SimpleDateFormat sdf = new SimpleDateFormat("dd MMM yyyy HH:mm:ss Z"); + return sdf.format(new Date(TimeUnit.SECONDS.toMillis(seconds))); } public static ByteString integerToByteString(Integer intValue) { @@ -340,20 +907,17 @@ class CoapEfentoTransportResourceTest { private void checkDefaultMeasurements(ProtoMeasurements incomingMeasurements, List actualEfentoMeasurements, - long expectedMeasurementInterval, - boolean isBinarySensor) { - for (int i = 0; i < actualEfentoMeasurements.size(); i++) { - CoapEfentoTransportResource.EfentoTelemetry actualEfentoMeasurement = actualEfentoMeasurements.get(i); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNum().toByteArray())); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); - MeasurementsProtos.ProtoChannel protoChannel = incomingMeasurements.getChannelsList().get(0); - long measuredAt = isBinarySensor ? - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp()) + Math.abs(TimeUnit.SECONDS.toMillis(protoChannel.getSampleOffsetsList().get(i))) - 1000 : - TimeUnit.SECONDS.toMillis(protoChannel.getTimestamp() + i * expectedMeasurementInterval); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measured_at").getAsString()).isEqualTo(convertTimestampToUtcString(measuredAt)); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); - assertThat(actualEfentoMeasurement.getValues().getAsJsonObject().get("measurement_interval").getAsDouble()).isEqualTo(expectedMeasurementInterval); + long expectedMeasurementInterval) { + CoapEfentoTransportResource.EfentoTelemetry efentoTelemetry = actualEfentoMeasurements.stream() + .sorted(Comparator.comparing(CoapEfentoTransportResource.EfentoTelemetry::getTs)) + .toList().get(0); + + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("serial").getAsString()).isEqualTo(CoapEfentoUtils.convertByteArrayToString(incomingMeasurements.getSerialNumber().toByteArray())); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("battery").getAsString()).isEqualTo(incomingMeasurements.getBatteryStatus() ? "ok" : "low"); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("next_transmission_at").getAsString()).isEqualTo(convertTimestampToUtcString(TimeUnit.SECONDS.toMillis(incomingMeasurements.getNextTransmissionAt()))); + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("signal").getAsLong()).isEqualTo(incomingMeasurements.getSignal()); + for (int i = 1; i < incomingMeasurements.getChannelsCount() + 1; i++) { + assertThat(efentoTelemetry.getValues().getAsJsonObject().get("measurement_interval_" + i).getAsDouble()).isEqualTo(expectedMeasurementInterval); } } diff --git a/common/transport/http/pom.xml b/common/transport/http/pom.xml index d341540104..275a497b6e 100644 --- a/common/transport/http/pom.xml +++ b/common/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java index 9a7cfe43ff..d0205314bc 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/DeviceApiController.java @@ -76,10 +76,6 @@ import java.util.List; import java.util.UUID; import java.util.function.Consumer; - -/** - * @author Andrew Shvayka - */ @RestController @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @RequestMapping("/api/v1") @@ -213,7 +209,7 @@ public class DeviceApiController implements TbTransportService { return responseWriter; } - @Operation(summary = "Save claiming information (claimDevice)", + @Operation(summary = "Save claiming information (saveClaimingInfo)", description = "Saves the information required for user to claim the device. " + "See more info about claiming in the corresponding 'Claiming devices' platform documentation." + "\n Example of the request payload: " @@ -224,7 +220,7 @@ public class DeviceApiController implements TbTransportService { "In case the secretKey is not specified, the empty string as a default value is used. In case the durationMs is not specified, the system parameter device.claim.duration is used.\n\n" + REQUIRE_ACCESS_TOKEN) @RequestMapping(value = "/{deviceToken}/claim", method = RequestMethod.POST) - public DeferredResult claimDevice( + public DeferredResult saveClaimingInfo( @Parameter(description = ACCESS_TOKEN_PARAM_DESCRIPTION, required = true , schema = @Schema(defaultValue = "YOUR_DEVICE_ACCESS_TOKEN")) @PathVariable("deviceToken") String deviceToken, @RequestBody(required = false) String json) { diff --git a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java index 830e081dfd..db72ba747c 100644 --- a/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java +++ b/common/transport/http/src/main/java/org/thingsboard/server/transport/http/HttpTransportContext.java @@ -26,9 +26,6 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportContext; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || ('${service.type:null}'=='monolith' && '${transport.api_enabled:true}'=='true' && '${transport.http.enabled}'=='true')") @Component @@ -52,4 +49,5 @@ public class HttpTransportContext extends TransportContext { } }; } + } diff --git a/common/transport/lwm2m/pom.xml b/common/transport/lwm2m/pom.xml index cd579c9011..f47255ac7e 100644 --- a/common/transport/lwm2m/pom.xml +++ b/common/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java index 412036677a..9b370d0b71 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2MTransportBootstrapService.java @@ -29,6 +29,7 @@ import org.eclipse.leshan.server.californium.bootstrap.LwM2mBootstrapPskStore; import org.eclipse.leshan.server.californium.bootstrap.endpoint.CaliforniumBootstrapServerEndpointsProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coap.CoapBootstrapServerProtocolProvider; import org.eclipse.leshan.server.californium.bootstrap.endpoint.coaps.CoapsBootstrapServerProtocolProvider; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.TransportService; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; @@ -55,7 +56,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @Component @TbLwM2mBootstrapTransportComponent @RequiredArgsConstructor -public class LwM2MTransportBootstrapService { +public class LwM2MTransportBootstrapService implements SmartInitializingSingleton { private final LwM2MTransportServerConfig serverConfig; private final LwM2MTransportBootstrapConfig bootstrapConfig; @@ -63,7 +64,20 @@ public class LwM2MTransportBootstrapService { private final LwM2MInMemoryBootstrapConfigStore lwM2MInMemoryBootstrapConfigStore; private final TransportService transportService; private final TbLwM2MDtlsBootstrapCertificateVerifier certificateVerifier; - private LeshanBootstrapServer server; + private volatile LeshanBootstrapServer server; + + @Override + public void afterSingletonsInstantiated() { + bootstrapConfig.registerServerReloadCallback(() -> { + try { + log.info("LwM2M Bootstrap certificates reloaded. Recreating bootstrap server..."); + recreateBootstrapServer(); + log.info("LwM2M Bootstrap server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M Bootstrap server after certificate reload", e); + } + }); + } @PostConstruct public void init() { @@ -110,7 +124,7 @@ public class LwM2MTransportBootstrapService { // Create Californium Configuration Configuration serverCoapConfig = endpointsBuilder.createDefaultConfiguration(); - getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(),serverConfig); + getCoapConfig(serverCoapConfig, bootstrapConfig.getPort(), bootstrapConfig.getSecurePort(), serverConfig); serverCoapConfig.setTransient(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY); serverCoapConfig.set(DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY, serverConfig.isRecommendedCiphers()); serverCoapConfig.setTransient(DtlsConfig.DTLS_CONNECTION_ID_LENGTH); @@ -119,7 +133,7 @@ public class LwM2MTransportBootstrapService { serverCoapConfig.set(DTLS_RETRANSMISSION_TIMEOUT, serverConfig.getDtlsRetransmissionTimeout(), MILLISECONDS); if (serverConfig.getDtlsCidLength() != null) { - setDtlsConnectorConfigCidLength( serverCoapConfig, serverConfig.getDtlsCidLength()); + setDtlsConnectorConfigCidLength(serverCoapConfig, serverConfig.getDtlsCidLength()); } /* Create DTLS Config */ @@ -164,4 +178,42 @@ public class LwM2MTransportBootstrapService { builder.setTrustedCertificates(new X509Certificate[0]); } } + + private synchronized void recreateBootstrapServer() { + LeshanBootstrapServer oldServer = this.server; + + log.info("Creating new LwM2M Bootstrap server with updated certificates..."); + LeshanBootstrapServer newServer = getLhBootstrapServer(); + + // Stop (not destroy) the old server to release ports but keep it restartable for rollback + if (oldServer != null) { + log.info("Stopping old LwM2M Bootstrap server to release ports..."); + oldServer.stop(); + } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M Bootstrap server", e); + newServer.destroy(); + // Attempt to restart the old server (only stopped, not destroyed) + if (oldServer != null) { + try { + oldServer.start(); + log.info("Restored old LwM2M Bootstrap server successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M Bootstrap server", restoreEx); + } + } + throw e; + } + this.server = newServer; + log.info("New LwM2M Bootstrap server started successfully."); + + // Destroy the old server only after a successful swap + if (oldServer != null) { + oldServer.destroy(); + } + } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java index b15a757ae3..d3bf9e33fe 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportBootstrapConfig.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -27,6 +28,9 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Component @ConditionalOnExpression("'${service.type:null}'=='tb-transport' || '${service.type:null}'=='monolith' || '${service.type:null}'=='tb-core'") @@ -62,8 +66,33 @@ public class LwM2MTransportBootstrapConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mBootstrapCredentials") private SslCredentialsConfig credentialsConfig; + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Bootstrap DTLS certificates reloaded. Triggering bootstrap server reload..."); + notifyServerReload(); + }); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M bootstrap server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java index 5373c56320..2cb6c425a7 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfig.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.transport.lwm2m.config; +import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.Getter; import lombok.Setter; import lombok.extern.slf4j.Slf4j; @@ -26,11 +28,17 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; import org.thingsboard.server.common.data.TbProperty; import org.thingsboard.server.common.transport.config.ssl.SslCredentials; import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; @Slf4j @Component @@ -38,6 +46,13 @@ import java.util.List; @ConfigurationProperties(prefix = "transport.lwm2m") public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { + private static final long RELOAD_DEBOUNCE_SECONDS = 2; + + private final List serverReloadCallbacks = new CopyOnWriteArrayList<>(); + private final ScheduledExecutorService reloadDebouncer = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("lwm2m-reload-debouncer")); + + private volatile ScheduledFuture pendingReload; + @Getter @Value("${transport.lwm2m.dtls.retransmission_timeout:9000}") private int dtlsRetransmissionTimeout; @@ -134,6 +149,52 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { @Qualifier("lwm2mTrustCredentials") private SslCredentialsConfig trustCredentialsConfig; + @PostConstruct + public void init() { + credentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Server DTLS certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); + }); + + trustCredentialsConfig.registerReloadCallback(() -> { + log.info("LwM2M Trust certificates reloaded. Scheduling debounced server reload..."); + scheduleServerReload(); + }); + } + + @PreDestroy + public void destroy() { + reloadDebouncer.shutdownNow(); + } + + public void registerServerReloadCallback(Runnable callback) { + serverReloadCallbacks.add(callback); + } + + /** + * Debounces server reload so that if both server and trust credentials change in the same + * poll cycle, only the 'single server recreation' is triggered after both are reloaded. + */ + private synchronized void scheduleServerReload() { + if (pendingReload != null) { + pendingReload.cancel(false); + } + pendingReload = reloadDebouncer.schedule(() -> { + log.info("Debounce window elapsed. Triggering LwM2M server reload..."); + notifyServerReload(); + }, RELOAD_DEBOUNCE_SECONDS, TimeUnit.SECONDS); + } + + private void notifyServerReload() { + for (Runnable callback : serverReloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("Error executing LwM2M server reload callback", e); + } + } + } + @Override public SslCredentials getSslCredentials() { return this.credentialsConfig.getCredentials(); @@ -142,4 +203,5 @@ public class LwM2MTransportServerConfig implements LwM2MSecureServerConfig { public SslCredentials getTrustSslCredentials() { return this.trustCredentialsConfig.getCredentials(); } + } diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java index c4fb504864..b72cf62d99 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/DefaultLwM2mTransportService.java @@ -32,7 +32,9 @@ import org.eclipse.leshan.server.californium.LwM2mPskStore; import org.eclipse.leshan.server.californium.endpoint.CaliforniumServerEndpointsProvider; import org.eclipse.leshan.server.californium.endpoint.coap.CoapServerProtocolProvider; import org.eclipse.leshan.server.californium.endpoint.coaps.CoapsServerProtocolProvider; +import org.eclipse.leshan.server.endpoint.LwM2mServerEndpointsProvider; import org.eclipse.leshan.server.registration.RegistrationStore; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.context.annotation.DependsOn; import org.springframework.stereotype.Component; import org.thingsboard.server.cache.ota.OtaPackageDataCache; @@ -68,7 +70,7 @@ import static org.thingsboard.server.transport.lwm2m.utils.LwM2MTransportUtil.se @DependsOn({"lwM2mDownlinkMsgHandler", "lwM2mUplinkMsgHandler"}) @TbLwM2mTransportComponent @RequiredArgsConstructor -public class DefaultLwM2mTransportService implements LwM2MTransportService { +public class DefaultLwM2mTransportService implements LwM2MTransportService, SmartInitializingSingleton { public static final CipherSuite[] RPK_OR_X509_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256, TLS_ECDHE_ECDSA_WITH_AES_128_CCM_8, TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256}; public static final CipherSuite[] PSK_CIPHER_SUITES = {TLS_PSK_WITH_AES_128_CCM_8, TLS_PSK_WITH_AES_128_CBC_SHA256}; @@ -83,7 +85,21 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private final TbLwM2MAuthorizer authorizer; private final LwM2mVersionedModelProvider modelProvider; - private LeshanServer server; + private volatile LeshanServer server; + private volatile LwM2mServerListener serverListener; + + @Override + public void afterSingletonsInstantiated() { + config.registerServerReloadCallback(() -> { + try { + log.info("LwM2M certificates reloaded. Recreating LwM2M server..."); + recreateLwM2mServer(); + log.info("LwM2M server recreated successfully with new certificates."); + } catch (Exception e) { + log.error("Failed to recreate LwM2M server after certificate reload", e); + } + }); + } @AfterStartUp(order = AfterStartUp.AFTER_TRANSPORT_SERVICE) public void init() { @@ -95,11 +111,11 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { private void startLhServer() { log.info("Starting LwM2M transport server..."); this.server.start(); - LwM2mServerListener lhServerCertListener = new LwM2mServerListener(handler); - this.server.getRegistrationService().addListener(lhServerCertListener.registrationListener); - this.server.getPresenceService().addListener(lhServerCertListener.presenceListener); - this.server.getObservationService().addListener(lhServerCertListener.observationListener); - this.server.getSendService().addListener(lhServerCertListener.sendListener); + serverListener = new LwM2mServerListener(handler); + this.server.getRegistrationService().addListener(serverListener.registrationListener); + this.server.getPresenceService().addListener(serverListener.presenceListener); + this.server.getObservationService().addListener(serverListener.observationListener); + this.server.getSendService().addListener(serverListener.sendListener); log.info("Started LwM2M transport server."); } @@ -214,6 +230,82 @@ public class DefaultLwM2mTransportService implements LwM2MTransportService { } } + private synchronized void recreateLwM2mServer() { + LeshanServer oldServer = this.server; + LwM2mServerListener oldListener = this.serverListener; + + log.info("Creating new LwM2M server with updated certificates..."); + LeshanServer newServer = getLhServer(); + + // Only cycle the endpoint providers (CoAP/DTLS). The RegistrationStore and SecurityStore are + // Spring singletons shared with newServer — calling oldServer.stop()/destroy() would propagate + // to them (LeshanServer.stop/destroy propagate to Stoppable/Destroyable stores), which would + // shut down the shared schedulers (TbInMemoryRegistrationStore.destroy calls schedExecutor.shutdownNow), + // killing newServer's cleaner tasks. Leaving the stores running preserves existing device + // registrations across the swap — clients only need to re-establish DTLS on next uplink. + if (oldServer != null) { + log.info("Stopping old LwM2M endpoints to release ports..."); + if (oldListener != null) { + oldServer.getRegistrationService().removeListener(oldListener.registrationListener); + oldServer.getPresenceService().removeListener(oldListener.presenceListener); + oldServer.getObservationService().removeListener(oldListener.observationListener); + oldServer.getSendService().removeListener(oldListener.sendListener); + } + stopEndpoints(oldServer); + } + + try { + newServer.start(); + } catch (Exception e) { + log.error("Failed to start new LwM2M server", e); + destroyEndpoints(newServer); + // Attempt to restart the old endpoints (shared stores are still running). + if (oldServer != null) { + try { + startEndpoints(oldServer); + if (oldListener != null) { + oldServer.getRegistrationService().addListener(oldListener.registrationListener); + oldServer.getPresenceService().addListener(oldListener.presenceListener); + oldServer.getObservationService().addListener(oldListener.observationListener); + oldServer.getSendService().addListener(oldListener.sendListener); + } + log.info("Restored old LwM2M endpoints successfully."); + } catch (Exception restoreEx) { + log.error("Failed to restore old LwM2M endpoints", restoreEx); + } + } + throw e; + } + + LwM2mServerListener newListener = new LwM2mServerListener(handler); + newServer.getRegistrationService().addListener(newListener.registrationListener); + newServer.getPresenceService().addListener(newListener.presenceListener); + newServer.getObservationService().addListener(newListener.observationListener); + newServer.getSendService().addListener(newListener.sendListener); + + this.server = newServer; + this.context.setServer(newServer); + this.serverListener = newListener; + log.info("New LwM2M server started with refreshed certificates. Existing device registrations preserved; clients will re-establish DTLS on next uplink."); + + // Destroy old endpoints only — leave the shared stores alone. + if (oldServer != null) { + destroyEndpoints(oldServer); + } + } + + private void stopEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::stop); + } + + private void startEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::start); + } + + private void destroyEndpoints(LeshanServer server) { + server.getEndpointsProvider().forEach(LwM2mServerEndpointsProvider::destroy); + } + @Override public String getName() { return DataConstants.LWM2M_TRANSPORT_NAME; diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java index 4b2ef07994..8158e82a81 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbLwM2mRedisRegistrationStore.java @@ -323,22 +323,24 @@ public class TbLwM2mRedisRegistrationStore implements RegistrationStore, Startab @Override public Iterator getAllRegistrations() { - try (var connection = connectionFactory.getConnection()) { + try (var scanConnection = connectionFactory.getConnection(); + var getConnection = connectionFactory.getConnection()) { Collection list = new LinkedList<>(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(REG_EP + "*").build(); List> scans = new ArrayList<>(); - if (connection instanceof RedisClusterConnection) { - ((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { - scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); - }); + if (scanConnection instanceof RedisClusterConnection clusterConnection) { + clusterConnection.clusterGetNodes().forEach(node -> + scans.add(clusterConnection.scan(node, scanOptions))); } else { - scans.add(connection.scan(scanOptions)); + scans.add(scanConnection.scan(scanOptions)); } scans.forEach(scan -> { scan.forEachRemaining(key -> { - byte[] element = connection.get(key); - list.add(deserializeReg(element)); + byte[] element = getConnection.get(key); + if (element != null) { + list.add(deserializeReg(element)); + } }); }); return list.iterator(); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java index 3293cd8b53..4beefb4896 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStore.java @@ -61,21 +61,21 @@ public class TbRedisLwM2MClientStore implements TbLwM2MClientStore { @Override public Set getAll() { - try (var connection = connectionFactory.getConnection()) { + try (var scanConnection = connectionFactory.getConnection(); + var getConnection = connectionFactory.getConnection()) { Set clients = new HashSet<>(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(CLIENT_EP + "*").build(); List> scans = new ArrayList<>(); - if (connection instanceof RedisClusterConnection) { - ((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { - scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); - }); + if (scanConnection instanceof RedisClusterConnection clusterConnection) { + clusterConnection.clusterGetNodes().forEach(node -> + scans.add(clusterConnection.scan(node, scanOptions))); } else { - scans.add(connection.scan(scanOptions)); + scans.add(scanConnection.scan(scanOptions)); } scans.forEach(scan -> { scan.forEachRemaining(key -> { - byte[] element = connection.get(key); + byte[] element = getConnection.get(key); if (element != null) { try { clients.add(deserialize(element)); diff --git a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java index 73b6f3c8df..31a78234d0 100644 --- a/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java +++ b/common/transport/lwm2m/src/main/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MModelConfigStore.java @@ -35,22 +35,24 @@ public class TbRedisLwM2MModelConfigStore implements TbLwM2MModelConfigStore { @Override public List getAll() { - try (var connection = connectionFactory.getConnection()) { + try (var scanConnection = connectionFactory.getConnection(); + var getConnection = connectionFactory.getConnection()) { List configs = new ArrayList<>(); ScanOptions scanOptions = ScanOptions.scanOptions().count(100).match(MODEL_EP + "*").build(); List> scans = new ArrayList<>(); - if (connection instanceof RedisClusterConnection) { - ((RedisClusterConnection) connection).clusterGetNodes().forEach(node -> { - scans.add(((RedisClusterConnection) connection).scan(node, scanOptions)); - }); + if (scanConnection instanceof RedisClusterConnection clusterConnection) { + clusterConnection.clusterGetNodes().forEach(node -> + scans.add(clusterConnection.scan(node, scanOptions))); } else { - scans.add(connection.scan(scanOptions)); + scans.add(scanConnection.scan(scanOptions)); } scans.forEach(scan -> { scan.forEachRemaining(key -> { - byte[] element = connection.get(key); - configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class)); + byte[] element = getConnection.get(key); + if (element != null) { + configs.add(JacksonUtil.fromBytes(element, LwM2MModelConfig.class)); + } }); }); return configs; diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java new file mode 100644 index 0000000000..bbcbc921f6 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/bootstrap/LwM2mBootstrapCertificateReloadTest.java @@ -0,0 +1,198 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.bootstrap; + +import org.eclipse.leshan.server.bootstrap.LeshanBootstrapServer; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.bootstrap.secure.TbLwM2MDtlsBootstrapCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MBootstrapSecurityStore; +import org.thingsboard.server.transport.lwm2m.bootstrap.store.LwM2MInMemoryBootstrapConfigStore; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportBootstrapConfig; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mBootstrapCertificateReloadTest { + + @Mock + private LwM2MTransportServerConfig mockServerConfig; + + @Mock + private LwM2MTransportBootstrapConfig mockBootstrapConfig; + + @Mock + private LwM2MBootstrapSecurityStore mockSecurityStore; + + @Mock + private LwM2MInMemoryBootstrapConfigStore mockConfigStore; + + @Mock + private TransportService mockTransportService; + + @Mock + private TbLwM2MDtlsBootstrapCertificateVerifier mockCertificateVerifier; + + @Mock + private LeshanBootstrapServer mockBootstrapServer; + + @Mock + private SslCredentials mockSslCredentials; + + private LwM2MTransportBootstrapService bootstrapService; + + @BeforeEach + public void setup() { + bootstrapService = new LwM2MTransportBootstrapService( + mockServerConfig, + mockBootstrapConfig, + mockSecurityStore, + mockConfigStore, + mockTransportService, + mockCertificateVerifier + ); + + when(mockBootstrapConfig.getHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getPort()).thenReturn(5687); + when(mockBootstrapConfig.getSecureHost()).thenReturn("localhost"); + when(mockBootstrapConfig.getSecurePort()).thenReturn(5688); + when(mockBootstrapConfig.getSslCredentials()).thenReturn(mockSslCredentials); + when(mockServerConfig.getDtlsRetransmissionTimeout()).thenReturn(9000); + } + + @Test + public void givenInit_whenCalled_thenShouldRegisterCertificateReloadCallback() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + bootstrapService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + // Force getLhBootstrapServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockBootstrapConfig.getHost()).thenReturn(null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // getLhBootstrapServer() will fail due to null host before old server is stopped. + // The old server should NOT be destroyed since the new server was never created. + reloadCallback.run(); + + verify(mockBootstrapServer, never()).stop(); + verify(mockBootstrapServer, never()).destroy(); + assertThat(ReflectionTestUtils.getField(bootstrapService, "server")).isSameAs(mockBootstrapServer); + } + + @Test + public void givenNullServer_whenRecreate_thenShouldNotThrow() { + ReflectionTestUtils.setField(bootstrapService, "server", null); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + bootstrapService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // Should not throw — callback catches exceptions internally + reloadCallback.run(); + } + + @Test + public void givenCertificateUpdate_whenRecreate_thenShouldUseNewCredentials() { + SslCredentials oldCredentials = mockSslCredentials; + SslCredentials newCredentials = mock(SslCredentials.class); + + when(mockBootstrapConfig.getSslCredentials()).thenReturn(oldCredentials).thenReturn(newCredentials); + + SslCredentials firstCall = mockBootstrapConfig.getSslCredentials(); + assertThat(firstCall).isEqualTo(oldCredentials); + + SslCredentials secondCall = mockBootstrapConfig.getSslCredentials(); + assertThat(secondCall).isEqualTo(newCredentials); + + verify(mockBootstrapConfig, times(2)).getSslCredentials(); + } + + @Test + public void givenReloadCallback_whenRegistered_thenShouldRegisterExactlyOne() { + bootstrapService.afterSingletonsInstantiated(); + + verify(mockBootstrapConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenReloadCallback_whenNewServerStartFails_thenOldServerRestarted() { + // GIVEN + ReflectionTestUtils.setField(bootstrapService, "server", mockBootstrapServer); + + LeshanBootstrapServer mockNewServer = mock(LeshanBootstrapServer.class); + doThrow(new RuntimeException("start failed")).when(mockNewServer).start(); + + LwM2MTransportBootstrapService spyService = Mockito.spy(bootstrapService); + doReturn(mockNewServer).when(spyService).getLhBootstrapServer(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + spyService.afterSingletonsInstantiated(); + verify(mockBootstrapConfig).registerServerReloadCallback(callbackCaptor.capture()); + + Runnable reloadCallback = callbackCaptor.getValue(); + + // WHEN + reloadCallback.run(); + + // THEN + // Old server is stopped (not destroyed) to release ports + verify(mockBootstrapServer).stop(); + verify(mockBootstrapServer, never()).destroy(); + // The new server fails to start and is destroyed + verify(mockNewServer).destroy(); + // Old server is restarted (not rebuilt from potentially stale credentials) + verify(mockBootstrapServer).start(); + assertThat(ReflectionTestUtils.getField(spyService, "server")).isSameAs(mockBootstrapServer); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java new file mode 100644 index 0000000000..93f305a190 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/config/LwM2MTransportServerConfigDebounceTest.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.config; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@ExtendWith(MockitoExtension.class) +public class LwM2MTransportServerConfigDebounceTest { + + private static final long DEBOUNCE_SECONDS = (long) ReflectionTestUtils.getField(LwM2MTransportServerConfig.class, "RELOAD_DEBOUNCE_SECONDS"); + + @Mock + private SslCredentialsConfig credentialsConfig; + + @Mock + private SslCredentialsConfig trustCredentialsConfig; + + private LwM2MTransportServerConfig config; + + @BeforeEach + public void setup() { + config = new LwM2MTransportServerConfig(); + ReflectionTestUtils.setField(config, "credentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(config, "trustCredentialsConfig", trustCredentialsConfig); + } + + @AfterEach + public void teardown() { + config.destroy(); + } + + @Test + public void givenSingleTrigger_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTwoRapidTriggers_whenScheduleServerReload_thenCallbackFiresOnce() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + // Wait extra to confirm no second invocation + await().during(DEBOUNCE_SECONDS + 1, SECONDS) + .atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + } + + @Test + public void givenTriggersOutsideDebounceWindow_whenScheduleServerReload_thenCallbackFiresTwice() { + AtomicInteger callCount = new AtomicInteger(0); + config.registerServerReloadCallback(callCount::incrementAndGet); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(1)); + + invokeScheduleServerReload(); + + await().atMost(DEBOUNCE_SECONDS + 2, SECONDS) + .untilAsserted(() -> assertThat(callCount.get()).isEqualTo(2)); + } + + private void invokeScheduleServerReload() { + ReflectionTestUtils.invokeMethod(config, "scheduleServerReload"); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java new file mode 100644 index 0000000000..93b74447fd --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/LwM2mServerCertificateReloadTest.java @@ -0,0 +1,192 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server; + +import org.eclipse.leshan.server.LeshanServer; +import org.eclipse.leshan.server.observation.ObservationService; +import org.eclipse.leshan.server.registration.RegistrationService; +import org.eclipse.leshan.server.registration.RegistrationStore; +import org.eclipse.leshan.server.send.SendService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.cache.ota.OtaPackageDataCache; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.transport.lwm2m.config.LwM2MTransportServerConfig; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MAuthorizer; +import org.thingsboard.server.transport.lwm2m.secure.TbLwM2MDtlsCertificateVerifier; +import org.thingsboard.server.transport.lwm2m.server.store.TbSecurityStore; +import org.thingsboard.server.transport.lwm2m.server.uplink.LwM2mUplinkMsgHandler; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class LwM2mServerCertificateReloadTest { + + @Mock + private LwM2mTransportContext mockContext; + + @Mock + private LwM2MTransportServerConfig mockConfig; + + @Mock + private OtaPackageDataCache mockOtaCache; + + @Mock + private LwM2mUplinkMsgHandler mockHandler; + + @Mock + private RegistrationStore mockRegistrationStore; + + @Mock + private TbSecurityStore mockSecurityStore; + + @Mock + private TbLwM2MDtlsCertificateVerifier mockCertificateVerifier; + + @Mock + private TbLwM2MAuthorizer mockAuthorizer; + + @Mock + private LwM2mVersionedModelProvider mockModelProvider; + + @Mock + private LeshanServer mockLeshanServer; + + @Mock + private RegistrationService mockRegistrationService; + + @Mock + private ObservationService mockObservationService; + + @Mock + private SendService mockSendService; + + @Mock + private SslCredentials mockSslCredentials; + + private DefaultLwM2mTransportService lwm2mTransportService; + + @BeforeEach + public void setup() { + lwm2mTransportService = new DefaultLwM2mTransportService( + mockContext, + mockConfig, + mockOtaCache, + mockHandler, + mockRegistrationStore, + mockSecurityStore, + mockCertificateVerifier, + mockAuthorizer, + mockModelProvider + ); + + when(mockConfig.getHost()).thenReturn("localhost"); + when(mockConfig.getPort()).thenReturn(5683); + when(mockConfig.getSecureHost()).thenReturn("localhost"); + when(mockConfig.getSecurePort()).thenReturn(5684); + when(mockConfig.getSslCredentials()).thenReturn(mockSslCredentials); + + when(mockLeshanServer.getRegistrationService()).thenReturn(mockRegistrationService); + when(mockLeshanServer.getObservationService()).thenReturn(mockObservationService); + when(mockLeshanServer.getSendService()).thenReturn(mockSendService); + } + + @Test + public void givenRegisterCertificateReloadCallback_whenInvoked_thenShouldRegisterCallback() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenNewServerCreationFails_thenOldServerIsPreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + // Force getLhServer() to fail by returning null host (causes InetSocketAddress to throw) + when(mockConfig.getHost()).thenReturn(null); + + // With create-then-swap, the old server should NOT be stopped/destroyed if the new one fails to build. + reloadCallback.run(); + + verify(mockLeshanServer, never()).stop(); + verify(mockLeshanServer, never()).destroy(); + // Old server should still be the active one + assertThat(ReflectionTestUtils.getField(lwm2mTransportService, "server")).isSameAs(mockLeshanServer); + } + + @Test + public void givenServerWithListeners_whenNewServerCreationFails_thenListenersArePreserved() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", mockLeshanServer); + + LwM2mServerListener serverListener = new LwM2mServerListener(mockHandler); + ReflectionTestUtils.setField(lwm2mTransportService, "serverListener", serverListener); + + // Force getLhServer() to fail by returning null host + when(mockConfig.getHost()).thenReturn(null); + + // Invoke the callback — new server creation will fail, old listeners should stay + callbackCaptor.getValue().run(); + + verify(mockRegistrationService, never()).removeListener(any()); + } + + @Test + public void givenMultipleReloadCallbacks_whenInvoked_thenShouldRegisterExactlyOne() { + lwm2mTransportService.afterSingletonsInstantiated(); + + verify(mockConfig, times(1)).registerServerReloadCallback(any()); + } + + @Test + public void givenCertificateReload_whenServerNull_thenShouldNotThrow() { + lwm2mTransportService.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockConfig).registerServerReloadCallback(callbackCaptor.capture()); + + ReflectionTestUtils.setField(lwm2mTransportService, "server", null); + + // Should not throw - callback catches exceptions internally + callbackCaptor.getValue().run(); + } + +} diff --git a/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStoreTest.java b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStoreTest.java new file mode 100644 index 0000000000..9fe29c1188 --- /dev/null +++ b/common/transport/lwm2m/src/test/java/org/thingsboard/server/transport/lwm2m/server/store/TbRedisLwM2MClientStoreTest.java @@ -0,0 +1,137 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.lwm2m.server.store; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.Cursor; +import org.springframework.data.redis.core.ScanOptions; +import org.thingsboard.server.transport.lwm2m.server.client.LwM2MClientState; +import org.thingsboard.server.transport.lwm2m.server.client.LwM2mClient; + +import java.util.List; +import java.util.Set; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.thingsboard.server.transport.lwm2m.server.store.util.LwM2MClientSerDes.serialize; + +/** + * Verifies that {@link TbRedisLwM2MClientStore#getAll()} uses separate connections for + * SCAN and GET operations to prevent Jedis 5.x response-ordering corruption that occurs + * when both commands share the same connection. + */ +@ExtendWith(MockitoExtension.class) +class TbRedisLwM2MClientStoreTest { + + @Mock + RedisConnectionFactory connectionFactory; + + @Mock + RedisConnection scanConnection; + + @Mock + RedisConnection getConnection; + + TbRedisLwM2MClientStore store; + + @BeforeEach + void setUp() { + // First getConnection() call → scanConnection, second → getConnection + when(connectionFactory.getConnection()) + .thenReturn(scanConnection) + .thenReturn(getConnection); + store = new TbRedisLwM2MClientStore(connectionFactory); + } + + @Test + void getAll_returnsSingleClient() { + LwM2mClient client = new LwM2mClient("nodeId", "testEndpoint"); + client.setState(LwM2MClientState.REGISTERED); + byte[] key = "CLIENT#EP#testEndpoint".getBytes(); + byte[] value = serialize(client); + + // Cursor created before thenReturn to avoid Mockito unfinished-stubbing error + Cursor cursor = cursorOf(key); + when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); + when(getConnection.get(key)).thenReturn(value); + + Set result = store.getAll(); + + assertThat(result).hasSize(1); + assertThat(result.iterator().next().getEndpoint()).isEqualTo("testEndpoint"); + } + + @Test + void getAll_getIsNeverCalledOnScanConnection() { + Cursor cursor = cursorOf(); + when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); + + store.getAll(); + + verify(scanConnection, never()).get(any(byte[].class)); + } + + @Test + void getAll_scanIsNeverCalledOnGetConnection() { + Cursor cursor = cursorOf(); + when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); + + store.getAll(); + + verify(getConnection, never()).scan(any(ScanOptions.class)); + } + + @Test + void getAll_skipsKeyWhenValueIsNull() { + byte[] key = "CLIENT#EP#gone".getBytes(); + Cursor cursor = cursorOf(key); + when(scanConnection.scan(any(ScanOptions.class))).thenReturn(cursor); + // getConnection.get(key) returns null by default — no stubbing needed + + Set result = store.getAll(); + + assertThat(result).isEmpty(); + } + + /** + * Creates a mock {@link Cursor} that iterates over the given keys via {@code forEachRemaining}. + * The cursor is created separately (not inside a {@code thenReturn()} argument) to avoid + * Mockito's "unfinished stubbing" error caused by nested {@code when()} calls. + */ + @SuppressWarnings("unchecked") + private static Cursor cursorOf(byte[]... keys) { + Cursor cursor = mock(Cursor.class); + List keyList = List.of(keys); + doAnswer(inv -> { + Consumer action = inv.getArgument(0); + keyList.forEach(action); + return null; + }).when(cursor).forEachRemaining(any(Consumer.class)); + return cursor; + } +} diff --git a/common/transport/mqtt/pom.xml b/common/transport/mqtt/pom.xml index a1e13b9d37..985c2a82f6 100644 --- a/common/transport/mqtt/pom.xml +++ b/common/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java index 1551ef9023..a954d7ae7f 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProvider.java @@ -17,6 +17,7 @@ package org.thingsboard.server.transport.mqtt; import io.netty.handler.ssl.SslHandler; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; @@ -48,7 +49,7 @@ import java.util.concurrent.TimeUnit; @Slf4j @Component("MqttSslHandlerProvider") @TbMqttSslTransportComponent -public class MqttSslHandlerProvider { +public class MqttSslHandlerProvider implements SmartInitializingSingleton { @Value("${transport.mqtt.ssl.protocol}") private String sslProtocol; @@ -66,13 +67,35 @@ public class MqttSslHandlerProvider { @Qualifier("mqttSslCredentials") private SslCredentialsConfig mqttSslCredentialsConfig; - private SSLContext sslContext; + private volatile SSLContext sslContext; + + @Override + public void afterSingletonsInstantiated() { + // Eagerly build the initial context so the handshake path is a lock-free volatile read. + this.sslContext = createSslContext(); + mqttSslCredentialsConfig.registerReloadCallback(() -> { + log.info("MQTT SSL certificates reloaded. Rebuilding SSL context..."); + // Build the new context first; if it fails, the old one stays in place, and + // the exception propagates to CertificateReloadManager's retry/backoff logic. + this.sslContext = createSslContext(); + log.info("MQTT SSL context rebuilt. New connections will use the new certificate."); + }); + } public SslHandler getSslHandler() { - if (sslContext == null) { - sslContext = createSslContext(); + SSLContext ctx = sslContext; + // Defensive lazy init in case afterSingletonsInstantiated hasn't run yet (e.g., test wiring). + // In normal operation ctx is non-null here, so the handshake path is lock-free. + if (ctx == null) { + synchronized (this) { + ctx = sslContext; + if (ctx == null) { + ctx = createSslContext(); + sslContext = ctx; + } + } } - SSLEngine sslEngine = sslContext.createSSLEngine(); + SSLEngine sslEngine = ctx.createSSLEngine(); sslEngine.setUseClientMode(false); sslEngine.setNeedClientAuth(false); sslEngine.setWantClientAuth(true); @@ -98,7 +121,7 @@ public class MqttSslHandlerProvider { sslContext.init(km, tm, null); return sslContext; } catch (Exception e) { - log.error("Unable to set up SSL context. Reason: " + e.getMessage(), e); + log.error("Unable to set up SSL context. Reason: {}", e.getMessage(), e); throw new RuntimeException("Failed to get SSL context", e); } } @@ -106,8 +129,8 @@ public class MqttSslHandlerProvider { private TrustManager getX509TrustManager(TrustManagerFactory tmf) throws Exception { X509TrustManager x509Tm = null; for (TrustManager tm : tmf.getTrustManagers()) { - if (tm instanceof X509TrustManager) { - x509Tm = (X509TrustManager) tm; + if (tm instanceof X509TrustManager x509TrustManager) { + x509Tm = x509TrustManager; break; } } @@ -191,5 +214,7 @@ public class MqttSslHandlerProvider { return false; } } + } + } diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java index 3d05e999e0..b69b5d5258 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportContext.java @@ -32,9 +32,6 @@ import org.thingsboard.server.transport.mqtt.gateway.GatewayMetricsService; import java.net.InetSocketAddress; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ @Slf4j @Component @TbMqttTransportComponent @@ -88,20 +85,30 @@ public class MqttTransportContext extends TransportContext { @Value("${transport.mqtt.proxy_enabled:false}") private boolean proxyEnabled; - private final AtomicInteger connectionsCounter = new AtomicInteger(); + private final AtomicInteger connectionsActiveCounterMQTT = new AtomicInteger(); + private final AtomicInteger connectionsActiveCounterMQTTS = new AtomicInteger(); @PostConstruct public void init() { super.init(); - transportService.createGaugeStats("openConnections", connectionsCounter); + transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTT, "protocol", "MQTT"); + transportService.createGaugeStats("connections_active", connectionsActiveCounterMQTTS, "protocol", "MQTTS"); } - public void channelRegistered() { - connectionsCounter.incrementAndGet(); + public void channelRegistered(boolean isSSL) { + if (isSSL) { + connectionsActiveCounterMQTTS.incrementAndGet(); + } else { + connectionsActiveCounterMQTT.incrementAndGet(); + } } - public void channelUnregistered() { - connectionsCounter.decrementAndGet(); + public void channelUnregistered(boolean isSSL) { + if (isSSL) { + connectionsActiveCounterMQTTS.decrementAndGet(); + } else { + connectionsActiveCounterMQTT.decrementAndGet(); + } } public boolean checkAddress(InetSocketAddress address) { diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java index f753579292..48fbad3032 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportHandler.java @@ -180,16 +180,20 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement this.rpcAwaitingAck = new ConcurrentHashMap<>(); } + boolean isSSL() { + return sslHandler != null; + } + @Override public void channelRegistered(ChannelHandlerContext ctx) throws Exception { super.channelRegistered(ctx); - context.channelRegistered(); + context.channelRegistered(isSSL()); } @Override public void channelUnregistered(ChannelHandlerContext ctx) throws Exception { super.channelUnregistered(ctx); - context.channelUnregistered(); + context.channelUnregistered(isSSL()); } @Override @@ -254,6 +258,17 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement } } + String getClientAddr(ChannelHandlerContext ctx) { + try { + InetSocketAddress remote = getAddress(ctx); + if (remote == null) return "unknown"; + String host = remote.getAddress() != null ? remote.getAddress().getHostAddress() : remote.getHostString(); + return host + ":" + remote.getPort(); + } catch (Exception ignored) { + return "unknown"; + } + } + InetSocketAddress getAddress(ChannelHandlerContext ctx) { var address = ctx.channel().attr(MqttTransportService.ADDRESS).get(); if (address == null) { @@ -1150,19 +1165,25 @@ public class MqttTransportHandler extends ChannelInboundHandlerAdapter implement public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { if (cause instanceof IOException) { if (log.isDebugEnabled()) { - log.debug("[{}][{}][{}] IOException: {}", sessionId, + String clientAddr = getClientAddr(ctx); + log.debug("[{}][{}][{}][{}] {}: {}", sessionId, Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""), + clientAddr, + cause.getClass().getSimpleName(), cause.getMessage(), cause); } else if (log.isInfoEnabled()) { - log.info("[{}][{}][{}] IOException: {}", sessionId, + String clientAddr = getClientAddr(ctx); + log.info("[{}][{}][{}][{}] {}: {}", sessionId, Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceId).orElse(null), Optional.ofNullable(this.deviceSessionCtx.getDeviceInfo()).map(TransportDeviceInfo::getDeviceName).orElse(""), + clientAddr, + cause.getClass().getSimpleName(), cause.getMessage()); } } else { - log.error("[{}] Unexpected Exception", sessionId, cause); + log.error("[{}][{}] Unexpected Exception", sessionId, getClientAddr(ctx), cause); } closeCtx(ctx, MqttReasonCodes.Disconnect.SERVER_SHUTTING_DOWN); diff --git a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java index 8b780c6ab4..4e41fa7eca 100644 --- a/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java +++ b/common/transport/mqtt/src/main/java/org/thingsboard/server/transport/mqtt/MqttTransportService.java @@ -78,22 +78,47 @@ public class MqttTransportService implements TbTransportService { ResourceLeakDetector.setLevel(ResourceLeakDetector.Level.valueOf(leakDetectorLevel.toUpperCase())); log.info("Starting MQTT transport..."); - bossGroup = new NioEventLoopGroup(bossGroupThreadCount); - workerGroup = new NioEventLoopGroup(workerGroupThreadCount); - ServerBootstrap b = new ServerBootstrap(); - b.group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, false)) - .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - - serverChannel = b.bind(host, port).sync().channel(); - if (sslEnabled) { - b = new ServerBootstrap(); + try { + bossGroup = new NioEventLoopGroup(bossGroupThreadCount); + workerGroup = new NioEventLoopGroup(workerGroupThreadCount); + ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) - .childHandler(new MqttTransportServerInitializer(context, true)) + .childHandler(new MqttTransportServerInitializer(context, false)) .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); - sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + + serverChannel = b.bind(host, port).sync().channel(); + if (sslEnabled) { + b = new ServerBootstrap(); + b.group(bossGroup, workerGroup) + .channel(NioServerSocketChannel.class) + .childHandler(new MqttTransportServerInitializer(context, true)) + .childOption(ChannelOption.SO_KEEPALIVE, keepAlive); + sslServerChannel = b.bind(sslHost, sslPort).sync().channel(); + } + } catch (Exception e) { + log.error("Failed to start MQTT transport, releasing resources", e); + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + try { + if (serverChannel != null) { + serverChannel.close().sync(); + } + if (sslServerChannel != null) { + sslServerChannel.close().sync(); + } + } catch (Exception suppressed) { + e.addSuppressed(suppressed); + } finally { + if (workerGroup != null) { + workerGroup.shutdownGracefully(); + } + if (bossGroup != null) { + bossGroup.shutdownGracefully(); + } + } + throw e; } log.info("Mqtt transport started!"); } diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java new file mode 100644 index 0000000000..84ef4f2979 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslCertificateReloadIntegrationTest.java @@ -0,0 +1,276 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.util.io.pem.PemObject; +import org.bouncycastle.util.io.pem.PemWriter; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.PemSslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLServerSocket; +import javax.net.ssl.SSLSocket; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; +import java.io.OutputStreamWriter; +import java.math.BigInteger; +import java.net.InetAddress; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.cert.Certificate; +import java.security.cert.X509Certificate; +import java.util.Date; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(MockitoExtension.class) +public class MqttSslCertificateReloadIntegrationTest { + + @TempDir + Path tempDir; + + @Mock + private TransportService transportService; + + @Test + public void givenMqttSslProvider_whenCertFileChangedAndReloadTriggered_thenNewConnectionSeesNewCert() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctxA = getProviderSslContext(provider); + X509Certificate servedA; + try (SSLServerSocket ss = createServerSocket(ctxA)) { + servedA = doHandshakeAndGetServerCert(ss); + } + assertThat(servedA.getSubjectX500Principal()).isEqualTo(certA.getSubjectX500Principal()); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctxB = getProviderSslContext(provider); + assertThat(ctxB).isNotSameAs(ctxA); + X509Certificate servedB; + try (SSLServerSocket ss = createServerSocket(ctxB)) { + servedB = doHandshakeAndGetServerCert(ss); + } + assertThat(servedB.getSubjectX500Principal()).isEqualTo(certB.getSubjectX500Principal()); + assertThat(servedB.getSubjectX500Principal()).isNotEqualTo(servedA.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenReloadCalledWithSameFiles_thenSslContextIsRecreated() throws Exception { + KeyPair keyPair = generateKeyPair(); + X509Certificate cert = generateSelfSignedCert(keyPair, "CN=SameCert"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, cert); + writeKeyPem(keyFile, keyPair); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + assertThat(ctx1).isNotNull(); + + credentialsConfig.onCertificateFileChanged(); + + SSLContext ctx2 = getProviderSslContext(provider); + assertThat(ctx2).isNotSameAs(ctx1); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx2)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(cert.getSubjectX500Principal()); + } + + @Test + public void givenMqttSslProvider_whenMultipleReloads_thenEachProducesNewContext() throws Exception { + KeyPair keyPairA = generateKeyPair(); + X509Certificate certA = generateSelfSignedCert(keyPairA, "CN=CertA"); + KeyPair keyPairB = generateKeyPair(); + X509Certificate certB = generateSelfSignedCert(keyPairB, "CN=CertB"); + KeyPair keyPairC = generateKeyPair(); + X509Certificate certC = generateSelfSignedCert(keyPairC, "CN=CertC"); + + Path certFile = tempDir.resolve("server-cert.pem"); + Path keyFile = tempDir.resolve("server-key.pem"); + writeCertPem(certFile, certA); + writeKeyPem(keyFile, keyPairA); + + SslCredentialsConfig credentialsConfig = createSslCredentialsConfig(certFile, keyFile); + MqttSslHandlerProvider provider = createMqttSslHandlerProvider(credentialsConfig); + + SSLContext ctx1 = getProviderSslContext(provider); + + writeCertPem(certFile, certB); + writeKeyPem(keyFile, keyPairB); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx2 = getProviderSslContext(provider); + + writeCertPem(certFile, certC); + writeKeyPem(keyFile, keyPairC); + credentialsConfig.onCertificateFileChanged(); + SSLContext ctx3 = getProviderSslContext(provider); + + assertThat(ctx1).isNotSameAs(ctx2); + assertThat(ctx2).isNotSameAs(ctx3); + + X509Certificate served; + try (SSLServerSocket ss = createServerSocket(ctx3)) { + served = doHandshakeAndGetServerCert(ss); + } + assertThat(served.getSubjectX500Principal()).isEqualTo(certC.getSubjectX500Principal()); + } + + private SslCredentialsConfig createSslCredentialsConfig(Path certFile, Path keyFile) throws Exception { + PemSslCredentials pem = new PemSslCredentials(); + pem.setCertFile(certFile.toAbsolutePath().toString()); + pem.setKeyFile(keyFile.toAbsolutePath().toString()); + + SslCredentialsConfig config = new SslCredentialsConfig("MQTT SSL Test", false); + config.setEnabled(true); + config.setType(org.thingsboard.server.common.transport.config.ssl.SslCredentialsType.PEM); + config.setPem(pem); + config.setKeystore(new org.thingsboard.server.common.transport.config.ssl.KeystoreSslCredentials()); + config.init(); + return config; + } + + private MqttSslHandlerProvider createMqttSslHandlerProvider(SslCredentialsConfig credentialsConfig) { + MqttSslHandlerProvider provider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(provider, "sslProtocol", "TLSv1.2"); + ReflectionTestUtils.setField(provider, "mqttSslCredentialsConfig", credentialsConfig); + ReflectionTestUtils.setField(provider, "transportService", transportService); + provider.afterSingletonsInstantiated(); + return provider; + } + + /** + * Triggers SSLContext creation through the provider's getSslHandler() path, + * then extracts the cached SSLContext for direct server socket use. + */ + private SSLContext getProviderSslContext(MqttSslHandlerProvider provider) { + provider.getSslHandler(); + return (SSLContext) ReflectionTestUtils.getField(provider, "sslContext"); + } + + private KeyPair generateKeyPair() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + return kpg.generateKeyPair(); + } + + private X509Certificate generateSelfSignedCert(KeyPair kp, String subjectDn) throws Exception { + X500Name subject = new X500Name(subjectDn); + Date now = new Date(); + Date expiry = new Date(now.getTime() + TimeUnit.DAYS.toMillis(1)); + return new JcaX509CertificateConverter().getCertificate( + new JcaX509v3CertificateBuilder( + subject, BigInteger.valueOf(System.nanoTime()), now, expiry, + subject, kp.getPublic()) + .build(new JcaContentSignerBuilder("SHA256withRSA").build(kp.getPrivate()))); + } + + private void writeCertPem(Path path, X509Certificate cert) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("CERTIFICATE", cert.getEncoded())); + } + } + + private void writeKeyPem(Path path, KeyPair keyPair) throws Exception { + try (PemWriter writer = new PemWriter(new OutputStreamWriter(Files.newOutputStream(path)))) { + writer.writeObject(new PemObject("PRIVATE KEY", keyPair.getPrivate().getEncoded())); + } + } + + private SSLServerSocket createServerSocket(SSLContext ctx) throws Exception { + return (SSLServerSocket) ctx.getServerSocketFactory().createServerSocket(0, 1, InetAddress.getLoopbackAddress()); + } + + private X509Certificate doHandshakeAndGetServerCert(SSLServerSocket serverSocket) throws Exception { + Thread acceptor = new Thread(() -> { + try (var conn = serverSocket.accept()) { + conn.getInputStream().read(); + } catch (Exception ignored) {} + }); + acceptor.setDaemon(true); + acceptor.start(); + + SSLContext clientCtx = SSLContext.getInstance("TLSv1.2"); + clientCtx.init(null, new TrustManager[]{new TrustAllManager()}, null); + + try (SSLSocket client = (SSLSocket) clientCtx.getSocketFactory() + .createSocket(InetAddress.getLoopbackAddress(), serverSocket.getLocalPort())) { + client.setSoTimeout(5000); + client.startHandshake(); + + Certificate[] peerCerts = client.getSession().getPeerCertificates(); + assertThat(peerCerts).isNotEmpty(); + return (X509Certificate) peerCerts[0]; + } finally { + acceptor.join(5000); + } + } + + private static class TrustAllManager implements X509TrustManager { + + @Override + public void checkClientTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public void checkServerTrusted(X509Certificate[] chain, String authType) { + + } + + @Override + public X509Certificate[] getAcceptedIssuers() { + return new X509Certificate[0]; + } + + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java new file mode 100644 index 0000000000..9b4ce007f8 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttSslHandlerProviderTest.java @@ -0,0 +1,197 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import io.netty.handler.ssl.SslHandler; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.transport.TransportService; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; + +import javax.net.ssl.KeyManager; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import javax.net.ssl.X509TrustManager; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +public class MqttSslHandlerProviderTest { + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private TransportService mockTransportService; + + private MqttSslHandlerProvider sslHandlerProvider; + + @BeforeEach + public void setup() throws Exception { + sslHandlerProvider = new MqttSslHandlerProvider(); + ReflectionTestUtils.setField(sslHandlerProvider, "mqttSslCredentialsConfig", mockCredentialsConfig); + ReflectionTestUtils.setField(sslHandlerProvider, "transportService", mockTransportService); + ReflectionTestUtils.setField(sslHandlerProvider, "sslProtocol", "TLSv1.2"); + + KeyManagerFactory mockKmf = mock(KeyManagerFactory.class); + TrustManagerFactory mockTmf = mock(TrustManagerFactory.class); + X509TrustManager mockTrustManager = mock(X509TrustManager.class); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.createKeyManagerFactory()).thenReturn(mockKmf); + when(mockCredentials.createTrustManagerFactory()).thenReturn(mockTmf); + when(mockKmf.getKeyManagers()).thenReturn(new KeyManager[0]); + when(mockTmf.getTrustManagers()).thenReturn(new TrustManager[]{mockTrustManager}); + } + + @Test + public void givenInitialized_whenGetSslHandler_thenShouldCreateSSLContext() { + sslHandlerProvider.afterSingletonsInstantiated(); + + SslHandler handler1 = sslHandlerProvider.getSslHandler(); + SslHandler handler2 = sslHandlerProvider.getSslHandler(); + + assertThat(handler1).isNotNull(); + assertThat(handler2).isNotNull(); + assertThat(handler1).isNotSameAs(handler2); + + SSLContext context = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context).isNotNull(); + } + + @Test + public void givenCertificatesReloaded_whenReloadCallbackInvoked_thenShouldRebuildSSLContextEagerly() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + reloadCallback.run(); + + // After reload the context is rebuilt eagerly (no null-invalidation), so handshakes stay lock-free. + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); + + SslHandler handler = sslHandlerProvider.getSslHandler(); + assertThat(handler).isNotNull(); + } + + @Test + public void givenConcurrentGetSslHandlerCalls_whenContextAlreadyBuilt_thenAllReadsReturnSameContext() throws Exception { + sslHandlerProvider.afterSingletonsInstantiated(); + + SSLContext contextBefore = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextBefore).isNotNull(); + + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + List handlers = new CopyOnWriteArrayList<>(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + handlers.add(sslHandlerProvider.getSslHandler()); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlers).hasSize(5).allSatisfy(h -> assertThat(h).isNotNull()); + // Concurrent handshakes read the same pre-built context without the old sync bottleneck. + SSLContext contextAfter = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfter).isSameAs(contextBefore); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldSwapSSLContextEagerly() { + sslHandlerProvider.afterSingletonsInstantiated(); + + sslHandlerProvider.getSslHandler(); + SSLContext initialContext = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(initialContext).isNotNull(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + SSLContext contextAfterReload = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(contextAfterReload).isNotNull(); + assertThat(contextAfterReload).isNotSameAs(initialContext); + } + + @Test + public void givenMultipleReloads_whenGetSslHandler_thenShouldRecreateEachTime() { + sslHandlerProvider.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + SSLContext context1; + SSLContext context2; + SSLContext context3; + + sslHandlerProvider.getSslHandler(); + context1 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context1).isNotNull(); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context2 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context2).isNotNull(); + assertThat(context2).isNotSameAs(context1); + + reloadCallback.run(); + sslHandlerProvider.getSslHandler(); + context3 = (SSLContext) ReflectionTestUtils.getField(sslHandlerProvider, "sslContext"); + assertThat(context3).isNotNull(); + assertThat(context3).isNotSameAs(context2); + assertThat(context3).isNotSameAs(context1); + } + +} diff --git a/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java new file mode 100644 index 0000000000..ab21209a48 --- /dev/null +++ b/common/transport/mqtt/src/test/java/org/thingsboard/server/transport/mqtt/MqttTransportServiceTest.java @@ -0,0 +1,111 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.transport.mqtt; + +import io.netty.channel.Channel; +import io.netty.channel.EventLoopGroup; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.net.BindException; +import java.net.InetAddress; +import java.net.ServerSocket; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.awaitility.Awaitility.await; +import static org.mockito.Mockito.mock; + +public class MqttTransportServiceTest { + + private static final String HOST = "127.0.0.1"; + + private MqttTransportService service; + private ServerSocket occupiedSocket; + private int occupiedPort; + + @BeforeEach + public void setUp() throws Exception { + occupiedSocket = new ServerSocket(0, 50, InetAddress.getByName(HOST)); + occupiedPort = occupiedSocket.getLocalPort(); + + service = new MqttTransportService(); + ReflectionTestUtils.setField(service, "host", HOST); + ReflectionTestUtils.setField(service, "port", occupiedPort); + ReflectionTestUtils.setField(service, "sslEnabled", false); + ReflectionTestUtils.setField(service, "sslHost", HOST); + ReflectionTestUtils.setField(service, "sslPort", 0); + ReflectionTestUtils.setField(service, "leakDetectorLevel", "DISABLED"); + ReflectionTestUtils.setField(service, "bossGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "workerGroupThreadCount", 1); + ReflectionTestUtils.setField(service, "keepAlive", true); + ReflectionTestUtils.setField(service, "context", mock(MqttTransportContext.class)); + } + + @AfterEach + public void tearDown() throws Exception { + if (occupiedSocket != null && !occupiedSocket.isClosed()) { + occupiedSocket.close(); + } + } + + @Test + public void whenPlainBindFails_thenInitThrowsAndReleasesNettyResources() { + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } + + @Test + public void whenSslBindFailsAfterPlainBound_thenInitThrowsAndClosesPlainChannelAndReleasesNettyResources() { + ReflectionTestUtils.setField(service, "port", 0); + ReflectionTestUtils.setField(service, "sslEnabled", true); + ReflectionTestUtils.setField(service, "sslPort", occupiedPort); + + assertThatThrownBy(() -> service.init()) + .isInstanceOf(BindException.class); + + Channel serverChannel = (Channel) ReflectionTestUtils.getField(service, "serverChannel"); + Channel sslServerChannel = (Channel) ReflectionTestUtils.getField(service, "sslServerChannel"); + EventLoopGroup boss = (EventLoopGroup) ReflectionTestUtils.getField(service, "bossGroup"); + EventLoopGroup worker = (EventLoopGroup) ReflectionTestUtils.getField(service, "workerGroup"); + + assertThat(serverChannel).isNotNull(); + assertThat(sslServerChannel).isNull(); + assertThat(boss).isNotNull(); + assertThat(worker).isNotNull(); + + await().atMost(10, TimeUnit.SECONDS).until(() -> !serverChannel.isOpen()); + + assertThat(boss.isShuttingDown()).isTrue(); + assertThat(worker.isShuttingDown()).isTrue(); + await().atMost(30, TimeUnit.SECONDS).until(boss::isTerminated); + await().atMost(30, TimeUnit.SECONDS).until(worker::isTerminated); + } +} diff --git a/common/transport/pom.xml b/common/transport/pom.xml index 697874ea7e..810b22670a 100644 --- a/common/transport/pom.xml +++ b/common/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/transport/snmp/pom.xml b/common/transport/snmp/pom.xml index 011c01a453..f98b2426dc 100644 --- a/common/transport/snmp/pom.xml +++ b/common/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java index 7d92639f1a..af5e1f04b2 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/SnmpTransportContext.java @@ -160,7 +160,6 @@ public class SnmpTransportContext extends TransportContext { return; } sessions.put(device.getId(), sessionContext); - snmpTransportService.createQueryingTasks(sessionContext); log.info("Established SNMP device session for device {}", device.getId()); } @@ -224,6 +223,8 @@ public class SnmpTransportContext extends TransportContext { registerTransportSession(sessionContext, msg); }); transportService.lifecycleEvent(sessionContext.getTenantId(), sessionContext.getDeviceId(), ComponentLifecycleEvent.STARTED, true, null); + snmpTransportService.createQueryingTasks(sessionContext); + log.info("[{}] Session registered and querying tasks created", sessionContext.getDeviceId()); } else { log.warn("[{}] Failed to process device auth", sessionContext.getDeviceId()); } diff --git a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java index 0c39b7e740..a15a21b6e3 100644 --- a/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java +++ b/common/transport/snmp/src/main/java/org/thingsboard/server/transport/snmp/service/SnmpTransportService.java @@ -164,7 +164,7 @@ public class SnmpTransportService implements TbTransportService, CommandResponde ScheduledTask scheduledTask = new ScheduledTask(); scheduledTask.init(() -> { try { - if (sessionContext.isActive()) { + if (sessionContext.isActive() && sessionContext.isConnected()) { return sendRequest(sessionContext, repeatingCommunicationConfig); } } catch (Exception e) { @@ -390,7 +390,11 @@ public class SnmpTransportService implements TbTransportService, CommandResponde JsonObject responseData = responseDataMappers.get(requestContext.getCommunicationSpec()).map(response, requestContext); if (responseData.size() == 0) { - log.warn("[{}] No values in the response", sessionContext.getDeviceId()); + log.warn("[{}] No values in the response for spec {}. Response PDUs count: {}, Mappings count: {}", + sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), + response.size(), requestContext.getResponseMappings().size()); + log.debug("[{}] No values in the response for spec {}. Response PDUs: {}", + sessionContext.getDeviceId(), requestContext.getCommunicationSpec(), response); throw new IllegalArgumentException("No values in the response"); } diff --git a/common/transport/transport-api/pom.xml b/common/transport/transport-api/pom.xml index 50a0a07b03..73e0bdeec0 100644 --- a/common/transport/transport-api/pom.xml +++ b/common/transport/transport-api/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.common - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.common.transport diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java index df30e2879b..c595e3ca83 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/DeviceDeletedEvent.java @@ -19,9 +19,13 @@ import lombok.Getter; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.queue.discovery.event.TbApplicationEvent; +import java.io.Serial; + public final class DeviceDeletedEvent extends TbApplicationEvent { + @Serial private static final long serialVersionUID = -7453664970966733857L; + @Getter private final DeviceId deviceId; @@ -29,4 +33,5 @@ public final class DeviceDeletedEvent extends TbApplicationEvent { super(new Object()); this.deviceId = deviceId; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java index 1e03156ec8..12857e3208 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/SessionMsgListener.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos.UplinkNotificationMs import java.util.Optional; import java.util.UUID; -/** - * Created by ashvayka on 04.10.18. - */ public interface SessionMsgListener { void onGetAttributesResponse(GetAttributeResponseMsg getAttributesResponse); diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java index 9317652719..afd7bd2fed 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.queue.scheduler.SchedulerComponent; import java.util.concurrent.ExecutorService; -/** - * Created by ashvayka on 15.10.18. - */ @Slf4j @Data public abstract class TransportContext { @@ -77,6 +74,4 @@ public abstract class TransportContext { return serviceInfoProvider.getServiceId(); } - - } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java index 5c7d552855..61c22d84c0 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportService.java @@ -66,9 +66,6 @@ import java.util.List; import java.util.concurrent.ExecutorService; import java.util.concurrent.atomic.AtomicInteger; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportService { GetEntityProfileResponseMsg getEntityProfile(GetEntityProfileRequestMsg msg); @@ -161,5 +158,6 @@ public interface TransportService { boolean hasSession(SessionInfoProto sessionInfo); - void createGaugeStats(String openConnections, AtomicInteger connectionsCounter); + void createGaugeStats(String statsName, AtomicInteger number, String... tags); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java index 41cb57da04..82849b5bdd 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/TransportServiceCallback.java @@ -15,9 +15,6 @@ */ package org.thingsboard.server.common.transport; -/** - * Created by ashvayka on 04.10.18. - */ public interface TransportServiceCallback { TransportServiceCallback EMPTY = new TransportServiceCallback() { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java index f15fc42364..55e6f63e5d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/AbstractSslCredentials.java @@ -37,41 +37,55 @@ import java.util.Enumeration; import java.util.HashSet; import java.util.Optional; import java.util.Set; +import java.util.concurrent.atomic.AtomicReference; public abstract class AbstractSslCredentials implements SslCredentials { - private char[] keyPasswordArray; + private record SslState( + char[] keyPasswordArray, + KeyStore keyStore, + PrivateKey privateKey, + PublicKey publicKey, + X509Certificate[] chain, + X509Certificate[] trusts + ) {} - private KeyStore keyStore; - - private PrivateKey privateKey; - - private PublicKey publicKey; - - private X509Certificate[] chain; - - private X509Certificate[] trusts; + private final AtomicReference state = new AtomicReference<>(); @Override public void init(boolean trustsOnly) throws IOException, GeneralSecurityException { + SslState newState = buildState(trustsOnly); + state.set(newState); + } + + @Override + public void reload(boolean trustsOnly) throws IOException, GeneralSecurityException { + init(trustsOnly); + } + + private SslState buildState(boolean trustsOnly) throws IOException, GeneralSecurityException { String keyPassword = getKeyPassword(); + char[] keyPasswordArray; if (StringUtils.isEmpty(keyPassword)) { - this.keyPasswordArray = new char[0]; + keyPasswordArray = new char[0]; } else { - this.keyPasswordArray = keyPassword.toCharArray(); + keyPasswordArray = keyPassword.toCharArray(); } - this.keyStore = this.loadKeyStore(trustsOnly, this.keyPasswordArray); - Set trustedCerts = getTrustedCerts(this.keyStore, trustsOnly); - this.trusts = trustedCerts.toArray(new X509Certificate[0]); + KeyStore keyStore = this.loadKeyStore(trustsOnly, keyPasswordArray); + Set trustedCerts = getTrustedCerts(keyStore, trustsOnly); + X509Certificate[] trusts = trustedCerts.toArray(new X509Certificate[0]); + PrivateKey privateKey = null; + PublicKey publicKey = null; + X509Certificate[] chain = null; if (!trustsOnly) { PrivateKeyEntry privateKeyEntry = null; String keyAlias = this.getKeyAlias(); if (!StringUtils.isEmpty(keyAlias)) { - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, keyAlias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, keyAlias, keyPasswordArray); } else { - for (Enumeration e = this.keyStore.aliases(); e.hasMoreElements(); ) { + for (Enumeration e = keyStore.aliases(); e.hasMoreElements(); ) { String alias = e.nextElement(); - privateKeyEntry = tryGetPrivateKeyEntry(this.keyStore, alias, this.keyPasswordArray); + privateKeyEntry = tryGetPrivateKeyEntry(keyStore, alias, keyPasswordArray); if (privateKeyEntry != null) { this.updateKeyAlias(alias); break; @@ -82,50 +96,61 @@ public abstract class AbstractSslCredentials implements SslCredentials { throw new IllegalArgumentException("Failed to get private key from the keystore or pem files. " + "Please check if the private key exists in the keystore or pem files and if the provided private key password is valid."); } - this.chain = asX509Certificates(privateKeyEntry.getCertificateChain()); - this.privateKey = privateKeyEntry.getPrivateKey(); - if (this.chain.length > 0) { - this.publicKey = this.chain[0].getPublicKey(); + chain = asX509Certificates(privateKeyEntry.getCertificateChain()); + privateKey = privateKeyEntry.getPrivateKey(); + if (chain.length > 0) { + publicKey = chain[0].getPublicKey(); } } + return new SslState(keyPasswordArray, keyStore, privateKey, publicKey, chain, trusts); + } + + private SslState getState() { + SslState s = state.get(); + if (s == null) { + throw new IllegalStateException("SSL credentials not initialized. Call init() first."); + } + return s; } @Override public KeyStore getKeyStore() { - return this.keyStore; + return getState().keyStore; } @Override public PrivateKey getPrivateKey() { - return this.privateKey; + return getState().privateKey; } @Override public PublicKey getPublicKey() { - return this.publicKey; + return getState().publicKey; } @Override public X509Certificate[] getCertificateChain() { - return this.chain; + return getState().chain; } @Override public X509Certificate[] getTrustedCertificates() { - return this.trusts; + return getState().trusts; } @Override public TrustManagerFactory createTrustManagerFactory() throws NoSuchAlgorithmException, KeyStoreException { + SslState s = getState(); TrustManagerFactory tmFactory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()); - tmFactory.init(this.keyStore); + tmFactory.init(s.keyStore); return tmFactory; } @Override public KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException { + SslState s = getState(); KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(this.keyStore, this.keyPasswordArray); + kmf.init(s.keyStore, s.keyPasswordArray); return kmf; } @@ -133,7 +158,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { public String getValueFromSubjectNameByKey(String subjectName, String key) { String[] dns = subjectName.split(","); Optional cn = (Arrays.stream(dns).filter(dn -> dn.contains(key + "="))).findFirst(); - String value = cn.isPresent() ? cn.get().replace(key + "=", "") : null; + String value = cn.map(s -> s.replace(key + "=", "")).orElse(null); return StringUtils.isNotEmpty(value) ? value : null; } @@ -189,7 +214,7 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (cert instanceof X509Certificate) { if (trustsOnly) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } else { @@ -203,12 +228,12 @@ public abstract class AbstractSslCredentials implements SslCredentials { if (trustsOnly) { for (Certificate cert : certs) { // is CA certificate - if (((X509Certificate) cert).getBasicConstraints()>=0) { + if (((X509Certificate) cert).getBasicConstraints() >= 0) { set.add((X509Certificate) cert); } } } else { - set.add((X509Certificate)certs[0]); + set.add((X509Certificate) certs[0]); } } } @@ -216,4 +241,5 @@ public abstract class AbstractSslCredentials implements SslCredentials { } catch (KeyStoreException ignored) {} return Collections.unmodifiableSet(set); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java index 33851f50b1..3986704a33 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/KeystoreSslCredentials.java @@ -22,8 +22,11 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; +import java.util.Collections; +import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @@ -54,4 +57,15 @@ public class KeystoreSslCredentials extends AbstractSslCredentials { protected void updateKeyAlias(String keyAlias) { this.keyAlias = keyAlias; } + + @Override + public List getCertificateFilePaths() { + if (!StringUtils.isEmpty(storeFile) && !storeFile.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g., mounted after boot) will be detected and trigger a reload. + return Collections.singletonList(Path.of(storeFile).toAbsolutePath()); + } + return Collections.emptyList(); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java index cb2c9ba97b..fb2e5a4a0d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/PemSslCredentials.java @@ -33,6 +33,7 @@ import org.thingsboard.server.common.data.StringUtils; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.PrivateKey; @@ -76,13 +77,13 @@ public class PemSslCredentials extends AbstractSslCredentials { if (object instanceof X509CertificateHolder) { X509Certificate x509Cert = certConverter.getCertificate((X509CertificateHolder) object); certificates.add(x509Cert); - } else if (object instanceof PEMEncryptedKeyPair) { + } else if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -93,15 +94,15 @@ public class PemSslCredentials extends AbstractSslCredentials { try (PEMParser pemParser = new PEMParser(new InputStreamReader(inStream))) { Object object; while ((object = pemParser.readObject()) != null) { - if (object instanceof PEMEncryptedKeyPair) { + if (object instanceof PEMEncryptedKeyPair pemEncryptedKeyPair) { PEMDecryptorProvider decProv = new JcePEMDecryptorProviderBuilder().build(keyPasswordArray); - privateKey = keyConverter.getKeyPair(((PEMEncryptedKeyPair) object).decryptKeyPair(decProv)).getPrivate(); + privateKey = keyConverter.getKeyPair(pemEncryptedKeyPair.decryptKeyPair(decProv)).getPrivate(); break; - } else if (object instanceof PEMKeyPair) { - privateKey = keyConverter.getKeyPair((PEMKeyPair) object).getPrivate(); + } else if (object instanceof PEMKeyPair pemKeyPair) { + privateKey = keyConverter.getKeyPair(pemKeyPair).getPrivate(); break; - } else if (object instanceof PrivateKeyInfo) { - privateKey = keyConverter.getPrivateKey((PrivateKeyInfo) object); + } else if (object instanceof PrivateKeyInfo privateKeyInfo) { + privateKey = keyConverter.getPrivateKey(privateKeyInfo); } } } @@ -138,6 +139,22 @@ public class PemSslCredentials extends AbstractSslCredentials { } @Override - protected void updateKeyAlias(String keyAlias) { + protected void updateKeyAlias(String keyAlias) {} + + @Override + public List getCertificateFilePaths() { + List paths = new ArrayList<>(); + addIfFileSystemPath(paths, certFile); + addIfFileSystemPath(paths, keyFile); + return paths; + } + + private static void addIfFileSystemPath(List paths, String filePath) { + if (!StringUtils.isEmpty(filePath) && !filePath.startsWith(ResourceUtils.CLASSPATH_URL_PREFIX)) { + // Include the path even if the file doesn't exist yet — the watcher uses mtime=0 / checksum="" as + // baseline, so a late-appearing file (e.g. mounted after boot) will be detected and trigger a reload. + paths.add(Path.of(filePath).toAbsolutePath()); + } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java index 89d4540b77..89dccac18e 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentials.java @@ -18,6 +18,7 @@ package org.thingsboard.server.common.transport.config.ssl; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.TrustManagerFactory; import java.io.IOException; +import java.nio.file.Path; import java.security.GeneralSecurityException; import java.security.KeyStore; import java.security.KeyStoreException; @@ -26,11 +27,14 @@ import java.security.PrivateKey; import java.security.PublicKey; import java.security.UnrecoverableKeyException; import java.security.cert.X509Certificate; +import java.util.List; public interface SslCredentials { void init(boolean trustsOnly) throws IOException, GeneralSecurityException; + void reload(boolean trustsOnly) throws IOException, GeneralSecurityException; + KeyStore getKeyStore(); String getKeyPassword(); @@ -50,4 +54,7 @@ public interface SslCredentials { KeyManagerFactory createKeyManagerFactory() throws NoSuchAlgorithmException, UnrecoverableKeyException, KeyStoreException; String getValueFromSubjectNameByKey(String subjectName, String key); + + List getCertificateFilePaths(); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java index 0df22a33cb..e747de4931 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfig.java @@ -19,6 +19,9 @@ import jakarta.annotation.PostConstruct; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + @Slf4j @Data public class SslCredentialsConfig { @@ -33,6 +36,8 @@ public class SslCredentialsConfig { private final String name; private final boolean trustsOnly; + private final List reloadCallbacks = new CopyOnWriteArrayList<>(); + public SslCredentialsConfig(String name, boolean trustsOnly) { this.name = name; this.trustsOnly = trustsOnly; @@ -62,4 +67,29 @@ public class SslCredentialsConfig { } } + public void onCertificateFileChanged() { + log.info("{}: Certificate file changed. Reloading SSL credentials...", name); + try { + this.credentials.reload(this.trustsOnly); + } catch (Exception e) { + log.error("{}: Failed to reload SSL credentials", name, e); + // Rethrow, so CertificateReloadManager's watcher counts this as a failure + // and applies MAX_CONSECUTIVE_FAILURES backoff instead of treating it as a successful reload. + throw new RuntimeException(name + ": Failed to reload SSL credentials", e); + } + log.info("{}: SSL credentials reloaded successfully.", name); + + for (Runnable callback : reloadCallbacks) { + try { + callback.run(); + } catch (Exception e) { + log.error("{}: Error executing reload callback", name, e); + } + } + } + + public void registerReloadCallback(Runnable callback) { + this.reloadCallbacks.add(callback); + } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index e5f81dafc4..2a291b3888 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java @@ -15,11 +15,14 @@ */ package org.thingsboard.server.common.transport.config.ssl; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.SmartInitializingSingleton; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.boot.autoconfigure.web.ServerProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.ssl.NoSuchSslBundleException; import org.springframework.boot.ssl.SslBundle; import org.springframework.boot.ssl.SslBundles; import org.springframework.boot.ssl.SslStoreBundle; @@ -30,65 +33,124 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.function.BiConsumer; import java.util.function.Consumer; +@Slf4j @Component @ConditionalOnExpression("'${spring.main.web-environment:true}'=='true' && '${server.ssl.enabled:false}'=='true'") -public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer { +public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustomizer, SmartInitializingSingleton { - @Bean - @ConfigurationProperties(prefix = "server.ssl.credentials") - public SslCredentialsConfig httpServerSslCredentials() { - return new SslCredentialsConfig("HTTP Server SSL Credentials", false); - } + private static final String DEFAULT_BUNDLE_NAME = "default"; + + private final ServerProperties serverProperties; + private final List> updateHandlers = new CopyOnWriteArrayList<>(); @Autowired @Qualifier("httpServerSslCredentials") private SslCredentialsConfig httpServerSslCredentialsConfig; @Autowired - SslBundles sslBundles; - - private final ServerProperties serverProperties; + private SslBundles sslBundles; public SslCredentialsWebServerCustomizer(ServerProperties serverProperties) { this.serverProperties = serverProperties; } + @Bean + @ConfigurationProperties(prefix = "server.ssl.credentials") + public SslCredentialsConfig httpServerSslCredentials() { + return new SslCredentialsConfig("HTTP Server SSL Credentials", false); + } + + @Bean + public SslBundles sslBundles() { + return new DynamicSslBundles(); + } + @Override public void customize(ConfigurableServletWebServerFactory factory) { - SslCredentials sslCredentials = this.httpServerSslCredentialsConfig.getCredentials(); + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + Ssl ssl = serverProperties.getSsl(); - ssl.setBundle("default"); - ssl.setKeyAlias(sslCredentials.getKeyAlias()); - ssl.setKeyPassword(sslCredentials.getKeyPassword()); + ssl.setBundle(DEFAULT_BUNDLE_NAME); + ssl.setKeyAlias(credentials.getKeyAlias()); + ssl.setKeyPassword(credentials.getKeyPassword()); + factory.setSsl(ssl); factory.setSslBundles(sslBundles); } - @Bean - public SslBundles sslBundles() { + @Override + public void afterSingletonsInstantiated() { + httpServerSslCredentialsConfig.registerReloadCallback(this::reloadSslCertificates); + } + + private void reloadSslCertificates() { + try { + log.info("Reloading HTTP Server SSL certificates..."); + + SslBundle newBundle = createSslBundle(); + notifyUpdateHandlers(newBundle); + + log.info("HTTP Server SSL certificates reloaded successfully"); + } catch (Exception e) { + log.error("Failed to reload HTTP Server SSL certificates", e); + } + } + + private SslBundle createSslBundle() { + SslCredentials credentials = httpServerSslCredentialsConfig.getCredentials(); + SslStoreBundle storeBundle = SslStoreBundle.of( - httpServerSslCredentialsConfig.getCredentials().getKeyStore(), - httpServerSslCredentialsConfig.getCredentials().getKeyPassword(), + credentials.getKeyStore(), + credentials.getKeyPassword(), null ); - return new SslBundles() { - @Override - public SslBundle getBundle(String name) { - return SslBundle.of(storeBundle); + return SslBundle.of(storeBundle); + } + + private void notifyUpdateHandlers(SslBundle newBundle) { + for (Consumer handler : updateHandlers) { + try { + handler.accept(newBundle); + } catch (Exception e) { + log.error("Failed to notify SSL bundle update handler", e); } + } + } - @Override - public List getBundleNames() { - return List.of("default"); + private class DynamicSslBundles implements SslBundles { + + @Override + public SslBundle getBundle(String name) { + if (!DEFAULT_BUNDLE_NAME.equals(name)) { + throw new NoSuchSslBundleException(name, "Unknown SSL bundle: " + name); } + return createSslBundle(); + } + + @Override + public List getBundleNames() { + return List.of(DEFAULT_BUNDLE_NAME); + } - @Override - public void addBundleUpdateHandler(String name, Consumer handler) { - // no-op + @Override + public void addBundleUpdateHandler(String name, Consumer handler) { + if (DEFAULT_BUNDLE_NAME.equals(name)) { + updateHandlers.add(handler); + log.debug("Registered SSL bundle update handler for bundle: {}", name); + } else { + log.warn("Attempted to register update handler for unknown bundle: {}", name); } - }; + } + + @Override + public void addBundleRegisterHandler(BiConsumer registerHandler) { + log.debug("addBundleRegisterHandler is not supported for dynamic SSL bundles"); + } + } } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java new file mode 100644 index 0000000000..63f2247aba --- /dev/null +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/CertificateReloadManager.java @@ -0,0 +1,299 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.DisposableBean; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.stereotype.Component; +import org.thingsboard.common.util.ThingsBoardThreadFactory; +import org.thingsboard.server.common.transport.config.ssl.SslCredentials; +import org.thingsboard.server.common.transport.config.ssl.SslCredentialsConfig; +import org.thingsboard.server.queue.util.TbTransportComponent; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +@Slf4j +@Component +@TbTransportComponent +public class CertificateReloadManager implements SmartInitializingSingleton, DisposableBean { + + private static final int MAX_CONSECUTIVE_FAILURES = 10; + + @Value("${transport.ssl.certificate.reload.enabled:true}") + private boolean reloadEnabled; + + @Value("${transport.ssl.certificate.reload.check_interval_seconds:60}") + private long checkIntervalInSeconds; + + @Autowired + protected ApplicationContext applicationContext; + + private final Map watchers = new ConcurrentHashMap<>(); + private volatile ScheduledExecutorService scheduler; + + public void registerWatcher(String name, Path certPath, Runnable reloadCallback) { + registerWatcher(name, List.of(certPath), reloadCallback); + } + + public void registerWatcher(String name, List certPaths, Runnable reloadCallback) { + watchers.put(name, new CertificateWatcher(certPaths, reloadCallback)); + log.info("Registered certificate watcher for: {} (watching {} file(s))", name, certPaths.size()); + } + + private void checkCertificates() { + watchers.forEach((name, watcher) -> { + try { + watcher.checkAndReload(name); + } catch (Exception e) { + log.error("Error checking certificate for {}: {}", name, e.getMessage(), e); + } + }); + } + + private void discoverAndRegisterSslCredentials() { + try { + Map sslConfigBeans = applicationContext.getBeansOfType(SslCredentialsConfig.class); + + log.info("Found {} SslCredentialsConfig beans", sslConfigBeans.size()); + + for (Map.Entry entry : sslConfigBeans.entrySet()) { + String beanName = entry.getKey(); + SslCredentialsConfig config = entry.getValue(); + + try { + if (!config.isEnabled()) { + log.debug("Skipping disabled SSL config: {} ({})", config.getName(), beanName); + continue; + } + + SslCredentials credentials = config.getCredentials(); + if (credentials == null) { + log.debug("Skipping uninitialized SSL config: {} ({})", config.getName(), beanName); + continue; + } + + List filePaths = credentials.getCertificateFilePaths(); + if (filePaths == null || filePaths.isEmpty()) { + log.debug("No file-system certificate paths to watch for: {} ({}) — certificates may be classpath-based", config.getName(), beanName); + continue; + } + + // Register all configured paths, including those that don't exist yet — the watcher uses + // mtime=0 / checksum="" as baseline, so files that appear later (e.g. delayed mounts) are + // picked up and trigger a reload on the next poll. + List pathsToWatch = new ArrayList<>(filePaths.size()); + for (Path filePath : filePaths) { + if (filePath == null) { + continue; + } + pathsToWatch.add(filePath); + if (!Files.exists(filePath)) { + log.warn("Certificate file does not exist yet: {} (from {}) — will be watched and picked up when it appears", + filePath, config.getName()); + } + } + + if (!pathsToWatch.isEmpty()) { + registerWatcher(config.getName(), pathsToWatch, config::onCertificateFileChanged); + log.info("Registered certificate watcher: {} -> {}", config.getName(), pathsToWatch); + } + + } catch (Exception e) { + log.error("Error registering watchers for SSL config: {} ({})", config.getName(), beanName, e); + } + } + + } catch (Exception e) { + log.error("Error discovering SSL credentials configs", e); + } + } + + @Override + public void destroy() throws Exception { + if (scheduler != null) { + scheduler.shutdown(); + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + scheduler.shutdownNow(); + } + } + } + + @Override + public void afterSingletonsInstantiated() { + if (!reloadEnabled) { + log.trace("Auto-reload of certificates is disabled. Skipping initialization..."); + return; + } + log.info("Initializing Certificate Reload Manager..."); + + discoverAndRegisterSslCredentials(); + + scheduler = Executors.newSingleThreadScheduledExecutor(ThingsBoardThreadFactory.forName("certificate-reload-manager")); + scheduler.scheduleWithFixedDelay(this::checkCertificates, checkIntervalInSeconds, checkIntervalInSeconds, TimeUnit.SECONDS); + } + + static class CertificateWatcher { + private final List paths; + private final Runnable reloadCallback; + private final Map lastModifiedMap; + private final Map lastChecksumMap; + private int consecutiveFailures; + private String failedCombinedChecksum; + + CertificateWatcher(List paths, Runnable reloadCallback) { + this.paths = paths; + this.reloadCallback = reloadCallback; + this.lastModifiedMap = new HashMap<>(); + this.lastChecksumMap = new HashMap<>(); + for (Path path : paths) { + lastModifiedMap.put(path, getLastModifiedTime(path)); + lastChecksumMap.put(path, calculateChecksum(path)); + } + this.consecutiveFailures = 0; + } + + synchronized void checkAndReload(String name) { + boolean anyModifiedChanged = false; + for (Path path : paths) { + long currentModified = getLastModifiedTime(path); + Long lastModified = lastModifiedMap.getOrDefault(path, 0L); + if (currentModified != lastModified) { + anyModifiedChanged = true; + break; + } + } + if (!anyModifiedChanged) { + return; + } + + // Capture mtimes and checksums together before the callback runs. + // Pairing a post-callback mtime with a pre-callback checksum would let a write-during-reload be missed on the next poll. + Map currentModifiedTimes = new HashMap<>(); + Map currentChecksums = new HashMap<>(); + StringBuilder combined = new StringBuilder(); + for (Path path : paths) { + currentModifiedTimes.put(path, getLastModifiedTime(path)); + String checksum = calculateChecksum(path); + currentChecksums.put(path, checksum); + if (!combined.isEmpty()) { + combined.append("|"); + } + combined.append(path).append("=").append(checksum); + } + String combinedChecksum = combined.toString(); + + // Build old combined checksum for comparison + StringBuilder oldCombined = new StringBuilder(); + for (Path path : paths) { + if (!oldCombined.isEmpty()) { + oldCombined.append("|"); + } + oldCombined.append(path).append("=").append(lastChecksumMap.getOrDefault(path, "")); + } + String oldCombinedChecksum = oldCombined.toString(); + + if (combinedChecksum.equals(oldCombinedChecksum)) { + // Content unchanged, just update modification times + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + } + return; + } + + if (!combinedChecksum.equals(failedCombinedChecksum) && consecutiveFailures > 0) { + // File content has changed since the last failure - reset and retry + consecutiveFailures = 0; + failedCombinedChecksum = null; + } + + if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) { + // Update modification times to avoid re-checking mtime and re-computing checksums every poll cycle + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + } + return; + } + + try { + log.info("Certificate change detected for: {}. Triggering reload...", name); + reloadCallback.run(); + for (Path path : paths) { + lastModifiedMap.put(path, currentModifiedTimes.get(path)); + lastChecksumMap.put(path, currentChecksums.get(path)); + } + consecutiveFailures = 0; + failedCombinedChecksum = null; + } catch (Exception e) { + consecutiveFailures++; + failedCombinedChecksum = combinedChecksum; + // Deliberately NOT updating the lastModifiedMap here, so the next poll cycle retries + // (mtime mismatch passes the early gate, checksum matches failedCombinedChecksum). + log.error("Failed to reload certificate for {} (attempt {}/{}): {}", + name, consecutiveFailures, MAX_CONSECUTIVE_FAILURES, e.getMessage(), e); + } + } + + private long getLastModifiedTime(Path path) { + try { + if (!Files.exists(path)) { + return 0; + } + return Files.getLastModifiedTime(path).toMillis(); + } catch (IOException e) { + return 0; + } + } + + private String calculateChecksum(Path path) { + try { + if (!Files.exists(path)) { + return ""; + } + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] buf = new byte[8192]; + try (InputStream is = Files.newInputStream(path)) { + int bytesRead; + while ((bytesRead = is.read(buf)) != -1) { + md.update(buf, 0, bytesRead); + } + } + return Base64.getEncoder().encodeToString(md.digest()); + } catch (Exception e) { + log.warn("Failed to calculate checksum for certificate file: {}", path, e); + return ""; + } + } + + } + +} diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java index 260957c990..361453abff 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/DefaultTransportService.java @@ -127,9 +127,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; import java.util.stream.Collectors; -/** - * Created by ashvayka on 17.10.18. - */ @Slf4j @Service @TbTransportComponent @@ -508,7 +505,9 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void process(TransportProtos.SessionInfoProto sessionInfo, TransportProtos.SessionEventMsg msg, TransportServiceCallback callback) { if (checkLimits(sessionInfo, msg, callback)) { - recordActivityInternal(sessionInfo); + if (msg.getEvent() != TransportProtos.SessionEvent.CLOSED) { + recordActivityInternal(sessionInfo); + } sendToDeviceActor(sessionInfo, TransportToDeviceActorMsg.newBuilder().setSessionInfo(sessionInfo) .setSessionEvent(msg).build(), callback); } @@ -789,7 +788,7 @@ public class DefaultTransportService extends TransportActivityManager implements TransportProtos.SessionCloseNotificationProto notification = TransportProtos.SessionCloseNotificationProto.newBuilder().setMessage("session timeout!").build(); - ScheduledFuture executorFuture = scheduler.schedule(() -> { + ScheduledFuture executorFuture = scheduler.schedule(() -> { listener.onRemoteSessionCloseCommand(sessionId, notification); deregisterSession(sessionInfo); }, timeout, TimeUnit.MILLISECONDS); @@ -1169,6 +1168,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private static class StatsCallback implements TbQueueCallback { @@ -1183,16 +1183,19 @@ public class DefaultTransportService extends TransportActivityManager implements @Override public void onSuccess(TbQueueMsgMetadata metadata) { stats.incrementSuccessful(); - if (callback != null) + if (callback != null) { callback.onSuccess(metadata); + } } @Override public void onFailure(Throwable t) { stats.incrementFailed(); - if (callback != null) + if (callback != null) { callback.onFailure(t); + } } + } private class MsgPackCallback implements TbQueueCallback { @@ -1215,6 +1218,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onFailure(Throwable t) { DefaultTransportService.this.transportCallbackExecutor.submit(() -> callback.onError(t)); } + } private class ApiStatsProxyCallback implements TransportServiceCallback { @@ -1244,6 +1248,7 @@ public class DefaultTransportService extends TransportActivityManager implements public void onError(Throwable e) { callback.onError(e); } + } @Override @@ -1257,9 +1262,21 @@ public class DefaultTransportService extends TransportActivityManager implements } @Override - public void createGaugeStats(String statsName, AtomicInteger number) { - statsFactory.createGauge(StatsType.TRANSPORT + "." + statsName, number); - statsMap.put(statsName, number); + public void createGaugeStats(String statsName, AtomicInteger number, String... tags) { + String key = "thingsboard" + "." + StatsType.TRANSPORT.getName() + "." + statsName; + statsFactory.createGauge(key, number, tags); + statsMap.put(statsName + tagsKey(tags), number); + } + + private String tagsKey(String... tags) { + if (tags == null || tags.length < 2) return ""; + StringBuilder sb = new StringBuilder("["); + for (int i = 0; i < tags.length; i += 2) { + if (i > 0) sb.append(','); + sb.append(tags[i]).append('=').append(i + 1 < tags.length ? tags[i + 1] : ""); + } + sb.append(']'); + return sb.toString(); } @Scheduled(fixedDelayString = "${transport.stats.print-interval-ms:60000}") @@ -1270,4 +1287,5 @@ public class DefaultTransportService extends TransportActivityManager implements log.info("Transport Stats: {}", values); } } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java index 245f167ef8..612871f22a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/SessionMetaData.java @@ -21,9 +21,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.concurrent.ScheduledFuture; -/** - * Created by ashvayka on 15.10.18. - */ @Data public class SessionMetaData { @@ -47,11 +44,8 @@ public class SessionMetaData { this.scheduledFuture = scheduledFuture; } - public ScheduledFuture getScheduledFuture() { - return scheduledFuture; - } - public boolean hasScheduledFuture() { return null != this.scheduledFuture; } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java index a353cf94e4..fd144e0110 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToRuleEngineMsgEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.ToRuleEngineMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class ToRuleEngineMsgEncoder implements TbKafkaEncoder { @Override public byte[] encode(ToRuleEngineMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java index 2e1d292a63..13a99686ad 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/ToTransportMsgResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class ToTransportMsgResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java index 2de10a70fd..4a907c836a 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiRequestEncoder.java @@ -18,9 +18,6 @@ package org.thingsboard.server.common.transport.service; import org.thingsboard.server.gen.transport.TransportProtos.TransportApiRequestMsg; import org.thingsboard.server.queue.kafka.TbKafkaEncoder; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiRequestEncoder implements TbKafkaEncoder { @Override public byte[] encode(TransportApiRequestMsg value) { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java index cfb7168e66..563d1078c9 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/service/TransportApiResponseDecoder.java @@ -21,9 +21,6 @@ import org.thingsboard.server.queue.kafka.TbKafkaDecoder; import java.io.IOException; -/** - * Created by ashvayka on 05.10.18. - */ public class TransportApiResponseDecoder implements TbKafkaDecoder { @Override diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java index cd0efe2210..535baf921d 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/DeviceAwareSessionContext.java @@ -30,9 +30,6 @@ import org.thingsboard.server.gen.transport.TransportProtos; import java.util.Optional; import java.util.UUID; -/** - * @author Andrew Shvayka - */ @Data public abstract class DeviceAwareSessionContext implements SessionContext { diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java index ee0786aeb1..df28bb3390 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/session/SessionContext.java @@ -31,4 +31,5 @@ public interface SessionContext { void onDeviceProfileUpdate(TransportProtos.SessionInfoProto sessionInfo, DeviceProfile deviceProfile); void onDeviceUpdate(TransportProtos.SessionInfoProto sessionInfo, Device device, Optional deviceProfileOpt); + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java index ecdfc479cb..4d5451d4ca 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/JsonUtils.java @@ -27,8 +27,7 @@ import java.util.regex.Pattern; public class JsonUtils { - private static final Pattern BASE64_PATTERN = - Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); + private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); public static JsonObject getJsonObject(List tsKv) { JsonObject json = new JsonObject(); @@ -68,12 +67,12 @@ public class JsonUtils { } return JsonParser.parseString((String) value); } - } else if (value instanceof Boolean) { - return new JsonPrimitive((Boolean) value); - } else if (value instanceof Double) { - return new JsonPrimitive((Double) value); - } else if (value instanceof Float) { - return new JsonPrimitive((Float) value); + } else if (value instanceof Boolean booleanValue) { + return new JsonPrimitive(booleanValue); + } else if (value instanceof Double doubleValue) { + return new JsonPrimitive(doubleValue); + } else if (value instanceof Float floatValue) { + return new JsonPrimitive(floatValue); } else { throw new IllegalArgumentException("Unsupported type: " + value.getClass().getSimpleName()); } @@ -91,4 +90,5 @@ public class JsonUtils { public static boolean isBase64(String value) { return value.length() % 4 == 0 && BASE64_PATTERN.matcher(value).matches(); } + } diff --git a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java index 30598925d5..0158e23d93 100644 --- a/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java +++ b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/util/SslUtil.java @@ -31,10 +31,6 @@ import java.security.cert.CertificateFactory; import java.security.cert.X509Certificate; import java.util.Base64; - -/** - * @author Valerii Sosliuk - */ @Slf4j public class SslUtil { @@ -51,7 +47,7 @@ public class SslUtil { String begin = "-----BEGIN CERTIFICATE-----"; String end = "-----END CERTIFICATE-----"; StringBuilder stringBuilder = new StringBuilder(); - for (Certificate cert: chain) { + for (Certificate cert : chain) { stringBuilder.append(begin).append(EncryptionUtil.certTrimNewLines(Base64.getEncoder().encodeToString(cert.getEncoded()))).append(end).append("\n"); } return stringBuilder.toString(); @@ -85,4 +81,5 @@ public class SslUtil { RDN cn = x500name.getRDNs(BCStyle.CN)[0]; return IETFUtils.valueToString(cn.getFirst().getValue()); } + } diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java new file mode 100644 index 0000000000..ec6d2a0117 --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsConfigTest.java @@ -0,0 +1,185 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +public class SslCredentialsConfigTest { + + @Mock + private SslCredentials mockCredentials; + + private SslCredentialsConfig config; + + @BeforeEach + public void setup() { + config = new SslCredentialsConfig("Test SSL Config", false); + } + + @Test + public void givenConfig_whenCreated_thenShouldHaveCorrectName() { + assertThat(config.getName()).isEqualTo("Test SSL Config"); + assertThat(config.isTrustsOnly()).isFalse(); + } + + @Test + public void givenTrustsOnlyConfig_whenCreated_thenShouldHaveCorrectTrustsOnly() { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + assertThat(trustsOnlyConfig.isTrustsOnly()).isTrue(); + } + + @Test + public void givenCallback_whenRegistered_thenShouldBeStoredInList() { + AtomicInteger callCount = new AtomicInteger(0); + + config.registerReloadCallback(callCount::incrementAndGet); + config.setCredentials(mockCredentials); + + try { + doNothing().when(mockCredentials).reload(false); + } catch (Exception e) { + throw new RuntimeException(e); + } + + config.onCertificateFileChanged(); + + assertThat(callCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleCallbacks_whenCertificateChanged_thenAllShouldBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + AtomicInteger callback3Count = new AtomicInteger(0); + + config.registerReloadCallback(callback1Count::incrementAndGet); + config.registerReloadCallback(callback2Count::incrementAndGet); + config.registerReloadCallback(callback3Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + assertThat(callback3Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCertificateChanged_thenOtherCallbacksShouldStillBeCalled() throws Exception { + AtomicInteger callback1Count = new AtomicInteger(0); + AtomicInteger callback2Count = new AtomicInteger(0); + + config.registerReloadCallback(() -> { + callback1Count.incrementAndGet(); + throw new RuntimeException("Simulated callback failure"); + }); + config.registerReloadCallback(callback2Count::incrementAndGet); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(callback1Count.get()).isEqualTo(1); + assertThat(callback2Count.get()).isEqualTo(1); + } + + @Test + public void givenCredentialsReloadFails_whenCertificateChanged_thenShouldRethrowAndNotCallCallbacks() throws Exception { + AtomicInteger callbackCount = new AtomicInteger(0); + + config.registerReloadCallback(callbackCount::incrementAndGet); + config.setCredentials(mockCredentials); + + doThrow(new RuntimeException("Simulated reload failure")).when(mockCredentials).reload(false); + + assertThatThrownBy(() -> config.onCertificateFileChanged()) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("Failed to reload SSL credentials"); + + assertThat(callbackCount.get()).isEqualTo(0); + } + + @Test + public void givenCertificateChanged_whenCredentialsReloadSucceeds_thenShouldCallReload() throws Exception { + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + verify(mockCredentials).reload(false); + } + + @Test + public void givenTrustsOnlyConfig_whenCertificateChanged_thenShouldReloadWithTrustsOnlyTrue() throws Exception { + SslCredentialsConfig trustsOnlyConfig = new SslCredentialsConfig("Trust Config", true); + trustsOnlyConfig.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(true); + + trustsOnlyConfig.onCertificateFileChanged(); + + verify(mockCredentials).reload(true); + } + + @Test + public void givenConcurrentCallbackRegistrations_whenCertificateChanged_thenShouldHandleSafely() throws Exception { + AtomicInteger totalCallbacks = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(10); + + for (int i = 0; i < 10; i++) { + new Thread(() -> { + try { + startLatch.await(); + config.registerReloadCallback(totalCallbacks::incrementAndGet); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + assertThat(completed).isTrue(); + + config.setCredentials(mockCredentials); + doNothing().when(mockCredentials).reload(false); + + config.onCertificateFileChanged(); + + assertThat(totalCallbacks.get()).isEqualTo(10); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java new file mode 100644 index 0000000000..b03e1f2edc --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizerTest.java @@ -0,0 +1,277 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.config.ssl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.springframework.boot.autoconfigure.web.ServerProperties; +import org.springframework.boot.ssl.SslBundle; +import org.springframework.boot.ssl.SslBundles; +import org.springframework.test.util.ReflectionTestUtils; + +import java.security.KeyStore; +import java.security.cert.X509Certificate; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class SslCredentialsWebServerCustomizerTest { + + @Mock + private ServerProperties mockServerProperties; + + @Mock + private SslCredentialsConfig mockCredentialsConfig; + + @Mock + private SslCredentials mockCredentials; + + @Mock + private KeyStore mockKeyStore; + + private SslCredentialsWebServerCustomizer customizer; + + @BeforeEach + public void setup() throws Exception { + customizer = new SslCredentialsWebServerCustomizer(mockServerProperties); + ReflectionTestUtils.setField(customizer, "httpServerSslCredentialsConfig", mockCredentialsConfig); + + when(mockCredentialsConfig.getCredentials()).thenReturn(mockCredentials); + when(mockCredentials.getKeyStore()).thenReturn(mockKeyStore); + when(mockCredentials.getKeyPassword()).thenReturn("password"); + when(mockCredentials.getKeyAlias()).thenReturn("server"); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getEncoded()).thenReturn("TEST_CERT_DATA".getBytes()); + when(mockCredentials.getCertificateChain()).thenReturn(new X509Certificate[]{mockCert}); + } + + @Test + public void givenInitialized_whenAfterSingletonsInstantiated_thenShouldRegisterReloadCallback() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + assertThat(callbackCaptor.getValue()).isNotNull(); + } + + @Test + public void givenReloadCallback_whenInvoked_thenShouldReloadCertificates() { + customizer.afterSingletonsInstantiated(); + + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + + verify(mockCredentialsConfig, times(1)).getCredentials(); + } + + @Test + public void givenSslBundles_whenGetBundle_thenShouldReturnValidBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle = sslBundles.getBundle("default"); + + assertThat(bundle).isNotNull(); + } + + @Test + public void givenSslBundles_whenGetBundleNames_thenShouldReturnDefault() { + SslBundles sslBundles = customizer.sslBundles(); + + List bundleNames = sslBundles.getBundleNames(); + + assertThat(bundleNames).containsExactly("default"); + } + + @Test + public void givenSslBundles_whenAddUpdateHandler_thenShouldRegisterHandler() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("default", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(1); + } + + @Test + public void givenSslBundles_whenAddUpdateHandlerForWrongBundle_thenShouldNotRegister() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + Consumer handler = bundle -> handlerCallCount.incrementAndGet(); + + sslBundles.addBundleUpdateHandler("wrong-bundle", handler); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + callbackCaptor.getValue().run(); + + assertThat(handlerCallCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleUpdateHandlers_whenReload_thenShouldNotifyAll() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + AtomicInteger handler3CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> handler1CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + sslBundles.addBundleUpdateHandler("default", bundle -> handler3CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + assertThat(handler3CallCount.get()).isEqualTo(1); + } + + @Test + public void givenMultipleReloads_whenTriggered_thenShouldNotifyHandlersEachTime() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + reloadCallback.run(); + reloadCallback.run(); + reloadCallback.run(); + + assertThat(handlerCallCount.get()).isEqualTo(3); + } + + @Test + public void givenUpdateHandlerThrowsException_whenReload_thenShouldContinueNotifyingOtherHandlers() { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handler1CallCount = new AtomicInteger(0); + AtomicInteger handler2CallCount = new AtomicInteger(0); + + sslBundles.addBundleUpdateHandler("default", bundle -> { + handler1CallCount.incrementAndGet(); + throw new RuntimeException("Handler 1 failed"); + }); + sslBundles.addBundleUpdateHandler("default", bundle -> handler2CallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + + assertThat(handler1CallCount.get()).isEqualTo(1); + assertThat(handler2CallCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentReloads_whenTriggered_thenShouldHandleThreadSafely() throws Exception { + SslBundles sslBundles = customizer.sslBundles(); + AtomicInteger handlerCallCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + sslBundles.addBundleUpdateHandler("default", bundle -> handlerCallCount.incrementAndGet()); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + Runnable reloadCallback = callbackCaptor.getValue(); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + reloadCallback.run(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(handlerCallCount.get()).isEqualTo(5); + } + + @Test + public void givenReloadWithFailingCredentials_whenInvoked_thenShouldHandleGracefully() { + when(mockCredentialsConfig.getCredentials()).thenThrow(new RuntimeException("Failed to load credentials")); + + customizer.afterSingletonsInstantiated(); + ArgumentCaptor callbackCaptor = ArgumentCaptor.forClass(Runnable.class); + verify(mockCredentialsConfig).registerReloadCallback(callbackCaptor.capture()); + + callbackCaptor.getValue().run(); + } + + @Test + public void givenSslBundle_whenGetBundleMultipleTimes_thenShouldReturnFreshBundle() { + SslBundles sslBundles = customizer.sslBundles(); + + SslBundle bundle1 = sslBundles.getBundle("default"); + SslBundle bundle2 = sslBundles.getBundle("default"); + + assertThat(bundle1).isNotNull(); + assertThat(bundle2).isNotNull(); + } + + @Test + public void givenHttpServerSslCredentials_whenCreateBean_thenShouldReturnConfig() { + SslCredentialsConfig config = customizer.httpServerSslCredentials(); + + assertThat(config).isNotNull(); + assertThat(config.getName()).isEqualTo("HTTP Server SSL Credentials"); + assertThat(config.isTrustsOnly()).isFalse(); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java new file mode 100644 index 0000000000..6f78eaefae --- /dev/null +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/CertificateReloadManagerTest.java @@ -0,0 +1,355 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.common.transport.service; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.test.util.ReflectionTestUtils; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.FileTime; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static java.util.concurrent.TimeUnit.MILLISECONDS; +import static java.util.concurrent.TimeUnit.SECONDS; +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +public class CertificateReloadManagerTest { + + @TempDir + Path tempDir; + + private CertificateReloadManager certificateReloadManager; + private Path certFile; + + @BeforeEach + public void setup() throws IOException { + certificateReloadManager = new CertificateReloadManager(); + + certFile = tempDir.resolve("test-cert.pem"); + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V1\n-----END CERTIFICATE-----\n"); + } + + @AfterEach + public void teardown() throws Exception { + if (certificateReloadManager != null) { + certificateReloadManager.destroy(); + } + } + + private void writeFileAndAwaitMtimeChange(Path path, String content, long baselineMtime) throws IOException { + Files.writeString(path, content); + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> Files.getLastModifiedTime(path).toMillis() != baselineMtime); + } + + private long mtime(Path path) throws IOException { + return Files.getLastModifiedTime(path).toMillis(); + } + + @Test + public void givenCertificateFileChanged_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nTEST_CERT_V2_MODIFIED\n-----END CERTIFICATE-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenCertificateFileUnchanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenOnlyTimestampChanged_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long bumpedMtime = Files.getLastModifiedTime(certFile).toMillis() + 5_000L; + Files.setLastModifiedTime(certFile, FileTime.fromMillis(bumpedMtime)); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenWatcherRegistered_whenFileDeleted_thenShouldNotCrash() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Files.delete(certFile); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenWatcherRegistered_whenShutdown_thenShouldStopScheduler() throws Exception { + certificateReloadManager.registerWatcher("test-cert", certFile, () -> {}); + ScheduledExecutorService scheduler = Executors.newSingleThreadScheduledExecutor(); + ReflectionTestUtils.setField(certificateReloadManager, "scheduler", scheduler); + + certificateReloadManager.destroy(); + + assertThat(scheduler.isShutdown()).isTrue(); + assertThat(scheduler.isTerminated()).isTrue(); + } + + @Test + public void givenMultipleCertificateFiles_whenOneChanges_thenShouldTriggerReload() throws Exception { + Path keyFile = tempDir.resolve("test-key.pem"); + Files.writeString(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V1\n-----END PRIVATE KEY-----\n"); + + AtomicInteger certReloadCount = new AtomicInteger(0); + AtomicInteger keyReloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, certReloadCount::incrementAndGet); + certificateReloadManager.registerWatcher("test-key", keyFile, keyReloadCount::incrementAndGet); + + long baseline = mtime(keyFile); + writeFileAndAwaitMtimeChange(keyFile, "-----BEGIN PRIVATE KEY-----\nTEST_KEY_V2_MODIFIED\n-----END PRIVATE KEY-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(keyReloadCount.get()).isEqualTo(1); + assertThat(certReloadCount.get()).isEqualTo(0); + } + + @Test + public void givenMultipleWatchers_whenCheckCertificates_thenShouldCheckAll() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload1Count = new AtomicInteger(0); + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, reload1Count::incrementAndGet); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reload1Count.get()).isEqualTo(1); + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenCallbackThrowsException_whenCheckForChanges_thenShouldContinueWithOtherWatchers() throws Exception { + Path cert2File = tempDir.resolve("test-cert2.pem"); + Files.writeString(cert2File, "-----BEGIN CERTIFICATE-----\nTEST_CERT2_V1\n-----END CERTIFICATE-----\n"); + + AtomicInteger reload2Count = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert1", certFile, () -> { + throw new RuntimeException("Simulated reload failure"); + }); + certificateReloadManager.registerWatcher("test-cert2", cert2File, reload2Count::incrementAndGet); + + long baseline1 = mtime(certFile); + long baseline2 = mtime(cert2File); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED1\n-----END CERTIFICATE-----\n", baseline1); + writeFileAndAwaitMtimeChange(cert2File, "-----BEGIN CERTIFICATE-----\nMODIFIED2\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reload2Count.get()).isEqualTo(1); + } + + @Test + public void givenFileDeletedAndRecreated_whenCheckForChanges_thenShouldTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + Files.delete(certFile); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(1); + + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nNEW_CERT\n-----END CERTIFICATE-----\n"); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadCount.get()).isEqualTo(2); + } + + @Test + public void givenRapidFileModifications_whenCheckForChanges_thenShouldDetectLatestChange() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + for (int i = 0; i < 5; i++) { + Files.writeString(certFile, "-----BEGIN CERTIFICATE-----\nCERT_VERSION_" + i + "\n-----END CERTIFICATE-----\n"); + } + await().atMost(2, SECONDS) + .pollInterval(10, MILLISECONDS) + .until(() -> mtime(certFile) != baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenConcurrentChecks_whenCheckForChanges_thenShouldReloadExactlyOnce() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch doneLatch = new CountDownLatch(5); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nMODIFIED\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 5; i++) { + new Thread(() -> { + try { + startLatch.await(); + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + doneLatch.countDown(); + } + }).start(); + } + + startLatch.countDown(); + boolean completed = doneLatch.await(5, TimeUnit.SECONDS); + + assertThat(completed).isTrue(); + assertThat(reloadCount.get()).isEqualTo(1); + } + + @Test + public void givenSameContentRewritten_whenCheckForChanges_thenShouldNotTriggerReload() throws Exception { + AtomicInteger reloadCount = new AtomicInteger(0); + String originalContent = Files.readString(certFile); + + certificateReloadManager.registerWatcher("test-cert", certFile, reloadCount::incrementAndGet); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, originalContent, baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + + assertThat(reloadCount.get()).isEqualTo(0); + } + + @Test + public void givenCallbackFailsRepeatedly_whenMaxFailuresReached_thenShouldStopRetrying() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + throw new RuntimeException("Persistent failure"); + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + + assertThat(reloadAttempts.get()).isEqualTo(10); + } + + @Test + public void givenCallbackFailedPreviously_whenFileChangesAgain_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Transient failure"); + } + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(1); + + shouldFail.set(0); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nGOOD_CERT\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(2); + } + + @Test + public void givenCallbackHitMaxFailures_whenFileChangesToNewContent_thenShouldResetAndRetry() throws Exception { + AtomicInteger reloadAttempts = new AtomicInteger(0); + AtomicInteger shouldFail = new AtomicInteger(1); + + certificateReloadManager.registerWatcher("test-cert", certFile, () -> { + reloadAttempts.incrementAndGet(); + if (shouldFail.get() == 1) { + throw new RuntimeException("Persistent failure"); + } + }); + + long baseline = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nBAD_CERT\n-----END CERTIFICATE-----\n", baseline); + + for (int i = 0; i < 15; i++) { + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + } + assertThat(reloadAttempts.get()).isEqualTo(10); + + shouldFail.set(0); + long baseline2 = mtime(certFile); + writeFileAndAwaitMtimeChange(certFile, "-----BEGIN CERTIFICATE-----\nFIXED_CERT\n-----END CERTIFICATE-----\n", baseline2); + + ReflectionTestUtils.invokeMethod(certificateReloadManager, "checkCertificates"); + assertThat(reloadAttempts.get()).isEqualTo(11); + } + +} diff --git a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java index 3b3ce6e600..a656b22241 100644 --- a/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java +++ b/common/transport/transport-api/src/test/java/org/thingsboard/server/common/transport/service/TransportActivityManagerTest.java @@ -26,12 +26,14 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.transport.SessionMsgListener; import org.thingsboard.server.common.transport.TransportServiceCallback; import org.thingsboard.server.common.transport.activity.ActivityReportCallback; import org.thingsboard.server.common.transport.activity.ActivityState; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategy; import org.thingsboard.server.common.transport.activity.strategy.ActivityStrategyType; +import org.thingsboard.server.common.transport.limits.TransportRateLimitService; import org.thingsboard.server.gen.transport.TransportProtos; import java.util.UUID; @@ -41,7 +43,12 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.ArgumentMatchers.nullable; import static org.mockito.Mockito.doCallRealMethod; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -514,4 +521,55 @@ public class TransportActivityManagerTest { verify(transportServiceMock, never()).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); } + @Test + void givenSessionClosedEvent_whenProcessingSessionEvent_thenShouldNotRecordActivity() { + // GIVEN - simulates the session-expiry path: session already removed from sessions map, + // then process(SESSION_CLOSED) is called. Activity must NOT be recorded to avoid active + // status update from false to true + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // WHEN + transportServiceMock.process(sessionInfo, SESSION_EVENT_MSG_CLOSED, null); + + // THEN + verify(transportServiceMock, never()).onActivity(any(), any(), anyLong()); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + + @Test + void givenSessionOpenEvent_whenProcessingSessionEvent_thenShouldRecordActivity() { + // GIVEN + var rateLimitServiceMock = mock(TransportRateLimitService.class); + ReflectionTestUtils.setField(transportServiceMock, "rateLimitService", rateLimitServiceMock); + when(rateLimitServiceMock.checkLimits(any(), nullable(DeviceId.class), any(), anyInt(), anyBoolean())).thenReturn(null); + + TransportProtos.SessionInfoProto sessionInfo = TransportProtos.SessionInfoProto.newBuilder() + .setSessionIdMSB(SESSION_ID.getMostSignificantBits()) + .setSessionIdLSB(SESSION_ID.getLeastSignificantBits()) + .build(); + var sessionOpenMsg = TransportProtos.SessionEventMsg.newBuilder() + .setSessionType(TransportProtos.SessionType.ASYNC) + .setEvent(TransportProtos.SessionEvent.OPEN) + .build(); + when(transportServiceMock.toSessionId(sessionInfo)).thenReturn(SESSION_ID); + long expectedActivityTime = 100L; + when(transportServiceMock.getCurrentTimeMillis()).thenReturn(expectedActivityTime); + doCallRealMethod().when(transportServiceMock).process(sessionInfo, sessionOpenMsg, null); + + // WHEN + transportServiceMock.process(sessionInfo, sessionOpenMsg, null); + + // THEN + verify(transportServiceMock).onActivity(SESSION_ID, sessionInfo, expectedActivityTime); + verify(transportServiceMock).sendToDeviceActor(eq(sessionInfo), any(), isNull()); + } + } diff --git a/common/util/pom.xml b/common/util/pom.xml index c540f756a7..f3077d49c3 100644 --- a/common/util/pom.xml +++ b/common/util/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java b/common/util/src/main/java/org/thingsboard/common/util/DirectListeningExecutor.java similarity index 87% rename from rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java rename to common/util/src/main/java/org/thingsboard/common/util/DirectListeningExecutor.java index a1d4a935e9..0021aa5849 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/TestDbCallbackExecutor.java +++ b/common/util/src/main/java/org/thingsboard/common/util/DirectListeningExecutor.java @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.rule.engine; +package org.thingsboard.common.util; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; -import org.thingsboard.common.util.ListeningExecutor; import java.util.concurrent.Callable; -public class TestDbCallbackExecutor implements ListeningExecutor { +public enum DirectListeningExecutor implements ListeningExecutor { + + INSTANCE; @Override public ListenableFuture executeAsync(Callable task) { diff --git a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java index 2cf758885f..cf547cb9d4 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java +++ b/common/util/src/main/java/org/thingsboard/common/util/JacksonUtil.java @@ -243,6 +243,14 @@ public class JacksonUtil { } } + public static T treeToValue(JsonNode node, TypeReference type) { + try { + return OBJECT_MAPPER.treeToValue(node, type); + } catch (IOException e) { + throw new IllegalArgumentException("Can't convert value: " + node.toString(), e); + } + } + public static JsonNode toJsonNode(String value) { return toJsonNode(value, OBJECT_MAPPER); } diff --git a/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java b/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java new file mode 100644 index 0000000000..9132d117ae --- /dev/null +++ b/common/util/src/main/java/org/thingsboard/common/util/SsrfProtectionValidator.java @@ -0,0 +1,293 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.common.util; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.net.InetAddress; +import java.net.URI; +import java.net.UnknownHostException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +@Slf4j +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public class SsrfProtectionValidator { + + private static volatile boolean enabled; + private static final Set ALLOWED_SCHEMES = Set.of("http", "https"); + private static final Set BLOCKED_HOSTNAMES = Set.of("localhost"); + private static final Set BLOCKED_HOSTNAME_SUFFIXES = Set.of(".internal", ".local"); + + private static volatile AdditionalBlockedHosts additionalBlocked = AdditionalBlockedHosts.EMPTY; + private static volatile AllowedHosts allowedHosts = AllowedHosts.EMPTY; + + // Well-known cloud metadata endpoints not covered by the JDK checks (isLoopback, isSiteLocal, isLinkLocal) + private static final List CLOUD_METADATA_RANGES = List.of( + CidrRange.of("100.64.0.0", 10), // Carrier-Grade NAT (RFC 6598); Alibaba Cloud and Tencent Cloud metadata + CidrRange.of("192.0.0.0", 24), // IANA reserved (RFC 6890); Oracle Cloud alternate metadata endpoint + CidrRange.of("168.63.129.16", 32) // Azure WireServer + ); + + public static void validateUri(URI uri) { + validateUri(uri, enabled); + } + + static void validateUri(URI uri, boolean ssrfProtectionEnabled) { + if (!ssrfProtectionEnabled) { + return; + } + + String scheme = uri.getScheme(); + if (scheme == null || !ALLOWED_SCHEMES.contains(scheme.toLowerCase())) { + throw new RuntimeException("URI is invalid: only HTTP and HTTPS schemes are allowed, got: " + scheme); + } + + String host = uri.getHost(); + if (host == null || host.isEmpty()) { + throw new RuntimeException("URI is invalid: hostname is missing"); + } + + String hostLower = host.toLowerCase(); + + // Allow-listed hostnames bypass all hostname and IP checks + AllowedHosts currentAllowed = allowedHosts; + if (currentAllowed.hostnames.contains(hostLower)) { + return; + } + + if (BLOCKED_HOSTNAMES.contains(hostLower) || additionalBlocked.hostnames.contains(hostLower)) { + throwBlockedHost(host); + } + for (String suffix : BLOCKED_HOSTNAME_SUFFIXES) { + if (hostLower.endsWith(suffix)) { + throwBlockedHost(host); + } + } + // Block IPv6 loopback literal in URL (e.g. http://[::1]/) + if ("[::1]".equals(host) || "::1".equals(host)) { + throwBlockedHost(host); + } + + validateResolvedAddresses(host); + } + + private static void validateResolvedAddresses(String host) { + InetAddress[] addresses; + try { + addresses = InetAddress.getAllByName(host); + } catch (UnknownHostException e) { + throw new RuntimeException("URI is invalid: unable to resolve hostname '" + host + "'", e); + } + + for (InetAddress address : addresses) { + if (isBlockedAddress(address)) { + log.debug("Blocked request to host '{}' resolved to '{}'", host, address.getHostAddress()); + throwBlockedHost(host); + } + } + } + + public static boolean isBlockedAddress(InetAddress address) { + // Check allow-list first: allowed addresses bypass all block checks + AllowedHosts currentAllowed = allowedHosts; + for (CidrRange cidr : currentAllowed.cidrRanges) { + if (cidr.contains(address)) { + return false; + } + } + + // Covers 127.0.0.0/8 and ::1 + if (address.isLoopbackAddress()) { + return true; + } + // Covers 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 + if (address.isSiteLocalAddress()) { + return true; + } + // Covers 169.254.0.0/16 and fe80::/10 + if (address.isLinkLocalAddress()) { + return true; + } + // Covers 0.0.0.0 + if (address.isAnyLocalAddress()) { + return true; + } + // Additional check for IPv6 unique local addresses (fc00::/7) + byte[] addr = address.getAddress(); + if (addr.length == 16) { + int firstByte = addr[0] & 0xFF; + // fc00::/7 means first 7 bits are 1111110, so first byte is 0xFC or 0xFD + if (firstByte == 0xFC || firstByte == 0xFD) { + return true; + } + } + for (CidrRange cidr : CLOUD_METADATA_RANGES) { + if (cidr.contains(address)) { + return true; + } + } + // Check additional configured CIDR ranges + for (CidrRange cidr : additionalBlocked.cidrRanges) { + if (cidr.contains(address)) { + return true; + } + } + return false; + } + + private static void throwBlockedHost(String host) { + throw new RuntimeException("URI is invalid: host '" + host + "' is not allowed"); + } + + public static boolean isEnabled() { + return enabled; + } + + public static void setEnabled(boolean enabled) { + SsrfProtectionValidator.enabled = enabled; + } + + public static void setAdditionalBlockedHosts(List entries) { + ParsedHostEntries parsed = parseHostEntries(entries); + additionalBlocked = new AdditionalBlockedHosts(parsed.cidrRanges, parsed.hostnames); + if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) { + log.info("SSRF additional blocked hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size()); + } + } + + public static void setAllowedHosts(List entries) { + ParsedHostEntries parsed = parseHostEntries(entries); + allowedHosts = new AllowedHosts(parsed.cidrRanges, parsed.hostnames); + if (!parsed.cidrRanges.isEmpty() || !parsed.hostnames.isEmpty()) { + log.info("SSRF allowed hosts configured: {} CIDR range(s), {} hostname(s)", parsed.cidrRanges.size(), parsed.hostnames.size()); + } + } + + public static boolean isHostnameAllowed(String hostname) { + return allowedHosts.hostnames.contains(hostname.toLowerCase()); + } + + private static ParsedHostEntries parseHostEntries(List entries) { + if (entries == null || entries.isEmpty()) { + return ParsedHostEntries.EMPTY; + } + List cidrRanges = new ArrayList<>(); + Set hostnames = new HashSet<>(); + for (String entry : entries) { + String trimmed = entry.trim(); + if (trimmed.isEmpty()) { + continue; + } + if (trimmed.contains("/") || isIpLiteral(trimmed)) { + try { + cidrRanges.add(CidrRange.parse(trimmed)); + } catch (Exception e) { + log.warn("Failed to parse CIDR/IP entry '{}': {}", trimmed, e.getMessage()); + } + } else { + hostnames.add(trimmed.toLowerCase()); + } + } + return new ParsedHostEntries( + Collections.unmodifiableList(cidrRanges), + Collections.unmodifiableSet(hostnames)); + } + + private static boolean isIpLiteral(String entry) { + // IPv4 starts with a digit, IPv6 contains ':' + return !entry.isEmpty() && (Character.isDigit(entry.charAt(0)) || entry.contains(":")); + } + + private record ParsedHostEntries(List cidrRanges, Set hostnames) { + static final ParsedHostEntries EMPTY = new ParsedHostEntries(Collections.emptyList(), Collections.emptySet()); + } + + record AdditionalBlockedHosts(List cidrRanges, Set hostnames) { + static final AdditionalBlockedHosts EMPTY = new AdditionalBlockedHosts(Collections.emptyList(), Collections.emptySet()); + } + + record AllowedHosts(List cidrRanges, Set hostnames) { + static final AllowedHosts EMPTY = new AllowedHosts(Collections.emptyList(), Collections.emptySet()); + } + + record CidrRange(byte[] network, int prefixLength) { + + static CidrRange of(String ip, int prefixLength) { + try { + byte[] addr = InetAddress.getByName(ip).getAddress(); + if (prefixLength < 0 || prefixLength > addr.length * 8) { + throw new IllegalArgumentException("Invalid prefix length: " + prefixLength + " for " + ip); + } + return new CidrRange(addr, prefixLength); + } catch (UnknownHostException e) { + throw new IllegalArgumentException("Invalid IP: " + ip, e); + } + } + + static CidrRange parse(String entry) throws UnknownHostException { + int slashIndex = entry.indexOf('/'); + if (slashIndex >= 0) { + String ip = entry.substring(0, slashIndex); + int prefix = Integer.parseInt(entry.substring(slashIndex + 1)); + byte[] addr = InetAddress.getByName(ip).getAddress(); + if (prefix < 0 || prefix > addr.length * 8) { + throw new IllegalArgumentException("Invalid prefix length: " + prefix + " for " + entry); + } + return new CidrRange(addr, prefix); + } else { + byte[] addr = InetAddress.getByName(entry).getAddress(); + return new CidrRange(addr, addr.length * 8); + } + } + + boolean contains(InetAddress address) { + byte[] addr = address.getAddress(); + if (addr.length != network.length) { + return false; + } + int fullBytes = prefixLength / 8; + int remainingBits = prefixLength % 8; + for (int i = 0; i < fullBytes; i++) { + if (addr[i] != network[i]) { + return false; + } + } + if (remainingBits > 0 && fullBytes < addr.length) { + int mask = 0xFF << (8 - remainingBits); + if ((addr[fullBytes] & mask) != (network[fullBytes] & mask)) { + return false; + } + } + return true; + } + + @Override + public String toString() { + try { + return InetAddress.getByAddress(network).getHostAddress() + "/" + prefixLength; + } catch (UnknownHostException e) { + return "invalid/" + prefixLength; + } + } + } + +} diff --git a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java index a6218b2eb8..272e44ba7e 100644 --- a/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java +++ b/common/util/src/main/java/org/thingsboard/common/util/ThingsBoardThreadFactory.java @@ -33,9 +33,7 @@ public class ThingsBoardThreadFactory implements ThreadFactory { } private ThingsBoardThreadFactory(String name) { - SecurityManager s = System.getSecurityManager(); - group = (s != null) ? s.getThreadGroup() : - Thread.currentThread().getThreadGroup(); + group = Thread.currentThread().getThreadGroup(); namePrefix = name + "-" + poolNumber.getAndIncrement() + "-thread-"; diff --git a/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java new file mode 100644 index 0000000000..77f915c09c --- /dev/null +++ b/common/util/src/test/java/org/thingsboard/common/util/SsrfProtectionValidatorTest.java @@ -0,0 +1,580 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.common.util; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.net.InetAddress; +import java.net.URI; +import java.util.Collections; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration +public class SsrfProtectionValidatorTest { + + @ParameterizedTest + @ValueSource(strings = { + "http://example.com", + "https://example.com:8443/path", + "https://8.8.8.8/dns-query" + }) + void testAllowedUrls(String url) { + URI uri = URI.create(url); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://127.0.0.1", + "http://127.0.0.1:8080/path", + "http://127.1.2.3" + }) + void testBlockedLoopbackIpv4(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void testBlockedLocalhost() { + URI uri = URI.create("http://localhost/path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void testBlockedIpv6Loopback() { + URI uri = URI.create("http://[::1]/path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://169.254.169.254/latest/meta-data/", + "http://169.254.169.254/latest/meta-data/iam/security-credentials/", + "http://169.254.1.1" + }) + void testBlockedLinkLocalImds(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://10.0.0.1", + "http://10.255.255.255", + "http://172.16.0.1", + "http://172.31.255.255", + "http://192.168.1.1", + "http://192.168.0.100:8080/api" + }) + void testBlockedPrivateRfc1918(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @ParameterizedTest + @ValueSource(strings = { + // 100.64.0.0/10 — Carrier-Grade NAT (RFC 6598): Alibaba Cloud metadata (100.100.100.200), Tencent Cloud (100.88.222.5) + "http://100.100.100.200", + "http://100.88.222.5", + "http://100.64.0.1", + "http://100.127.255.255", + // 192.0.0.0/24 — IANA reserved (RFC 6890): Oracle Cloud alternate metadata (192.0.0.192) + "http://192.0.0.192", + "http://192.0.0.1", + // 168.63.129.16 — Azure WireServer + "http://168.63.129.16" + }) + void testBlockedCloudMetadataEndpoints(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @ParameterizedTest + @ValueSource(strings = { + // Just outside 100.64.0.0/10 + "http://100.128.0.1", + // Just outside 192.0.0.0/24 + "http://192.0.1.1", + // Adjacent to Azure WireServer + "http://168.63.129.17" + }) + void testAllowedNearCloudMetadataBoundaries(String url) { + URI uri = URI.create(url); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); + } + + @ParameterizedTest + @ValueSource(strings = { + "file:///etc/passwd", + "ftp://internal.host/file" + }) + void testBlockedSchemes(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("only HTTP and HTTPS schemes are allowed"); + } + + @Test + void testBlockedZeroAddress() { + URI uri = URI.create("http://0.0.0.0"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @ParameterizedTest + @ValueSource(strings = { + "http://server.internal", + "http://app.local" + }) + void testBlockedHostnameSuffixes(String url) { + URI uri = URI.create(url); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void testBlockedNullScheme() { + URI uri = URI.create("//example.com/path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("only HTTP and HTTPS schemes are allowed"); + } + + @Test + void testBlockedEmptyHost() { + URI uri = URI.create("http:///path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("hostname is missing"); + } + + @Test + void testBlockedUnresolvableHostname() { + URI uri = URI.create("http://host.invalid.tld.that.does.not.exist/path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("unable to resolve hostname"); + } + + @Test + void testBlockedLocalhostCaseInsensitive() { + URI uri = URI.create("http://LOCALHOST/path"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } + + @Test + void testDisabledAllowsPrivateAddresses() { + URI uri = URI.create("http://127.0.0.1"); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, false)); + } + + @Test + void testAdditionalBlockedSingleIp() { + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.8")); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8/dns-query"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Adjacent IP is not blocked + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true)); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testAdditionalBlockedCidrSlash10() { + try { + // Use 44.0.0.0/10 (not blocked by default) to verify CIDR /10 matching + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("44.0.0.0/10")); + // Inside the range + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.0.1.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Last address in the range + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.63.255.255"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Outside the range + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://44.64.0.1"), true)); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testAdditionalBlockedCidrSlash24() { + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("198.51.100.0/24")); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.0"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.100.255"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Outside the range + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://198.51.101.0"), true)); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testAdditionalBlockedHostnameViaValidateUri() { + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("evil.corp")); + URI uri = URI.create("http://evil.corp/api"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testAdditionalBlockedHostnameCaseInsensitive() { + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("My-Service.Corp")); + URI uri = URI.create("http://my-service.corp/api"); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testSetAdditionalBlockedHostsEmptyAndNull() { + // Should not throw + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + SsrfProtectionValidator.setAdditionalBlockedHosts(null); + } + + @Test + void testCidrRangeInvalidPrefixLength() { + assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/999")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid prefix length"); + assertThatThrownBy(() -> SsrfProtectionValidator.CidrRange.parse("10.0.0.0/-1")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Invalid prefix length"); + } + + @Test + void testAdditionalBlockedCidrViaValidateUri() { + // 203.0.113.0/24 (TEST-NET-3) is not blocked by default + URI uri = URI.create("http://203.0.113.1"); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)); + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24")); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(uri, true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + @Test + void testAdditionalBlockedMixedConfig() { + try { + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("203.0.113.0/24", "evil.corp", "8.8.8.8")); + // CIDR range + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://203.0.113.50"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Hostname + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://evil.corp/api"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Single IP + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Not in any additional block list + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://1.1.1.1"), true)); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + } + } + + // --- Allow-list tests --- + + @Test + void testAllowListCidrAllowsPrivateAddress() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + // 192.168.1.1 is normally blocked (site-local), but allow-listed + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + // Other private ranges remain blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.0.0.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListHostnameBypassesSuffixCheck() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local")); + // .local suffix is normally blocked, but allow-listed hostname passes + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true)); + // Other .local hostnames remain blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://other-device.local/api"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListPrecedenceOverBlockList() { + try { + // Block 8.8.8.0/24 via additional-blocked, but allow 8.8.8.8 via allow-list + SsrfProtectionValidator.setAdditionalBlockedHosts(List.of("8.8.8.0/24")); + SsrfProtectionValidator.setAllowedHosts(List.of("8.8.8.8")); + // Allow-list should win + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.8"), true)); + // Adjacent IP still blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("https://8.8.8.9"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAdditionalBlockedHosts(Collections.emptyList()); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsBlockedAddressPublicApi() throws Exception { + InetAddress loopback = InetAddress.getByName("127.0.0.1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(loopback)).isTrue(); + + InetAddress publicIp = InetAddress.getByName("8.8.8.8"); + assertThat(SsrfProtectionValidator.isBlockedAddress(publicIp)).isFalse(); + + // Allow-listed private address + try { + SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8")); + InetAddress privateIp = InetAddress.getByName("10.1.2.3"); + assertThat(SsrfProtectionValidator.isBlockedAddress(privateIp)).isFalse(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsEnabledAccessor() { + boolean original = SsrfProtectionValidator.isEnabled(); + try { + SsrfProtectionValidator.setEnabled(true); + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + SsrfProtectionValidator.setEnabled(false); + assertThat(SsrfProtectionValidator.isEnabled()).isFalse(); + } finally { + SsrfProtectionValidator.setEnabled(original); + } + } + + @Test + void testSetAllowedHostsEmptyAndNull() { + // Should not throw + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + SsrfProtectionValidator.setAllowedHosts(null); + } + + @Test + void testIsHostnameAllowed() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("my-device.local", "Internal-Server.Corp")); + assertThat(SsrfProtectionValidator.isHostnameAllowed("my-device.local")).isTrue(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("MY-DEVICE.LOCAL")).isTrue(); // case-insensitive + assertThat(SsrfProtectionValidator.isHostnameAllowed("internal-server.corp")).isTrue(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("other-device.local")).isFalse(); + assertThat(SsrfProtectionValidator.isHostnameAllowed("example.com")).isFalse(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testIsHostnameAllowedEmptyList() { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + assertThat(SsrfProtectionValidator.isHostnameAllowed("anything")).isFalse(); + } + + @Test + void testValidateUriUsesStaticEnabledFlag() { + boolean original = SsrfProtectionValidator.isEnabled(); + try { + // When enabled, loopback is blocked via the public one-arg overload + SsrfProtectionValidator.setEnabled(true); + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"))) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + + // When disabled, loopback passes + SsrfProtectionValidator.setEnabled(false); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"))); + } finally { + SsrfProtectionValidator.setEnabled(original); + } + } + + @Test + void testAllowListHostnameCaseInsensitive() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("My-Device.LOCAL")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://my-device.local/api"), true)); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://MY-DEVICE.LOCAL/api"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListOverridesCloudMetadataRange() { + try { + // 169.254.169.254 is link-local (blocked by default), allow-list should override + SsrfProtectionValidator.setAllowedHosts(List.of("169.254.169.254")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.169.254/latest/meta-data/"), true)); + // Other link-local still blocked + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://169.254.1.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListOverridesLoopback() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("127.0.0.0/8")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.0.0.1"), true)); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://127.1.2.3"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListCidrBoundary() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + // Last address in range + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.255"), true)); + // First address outside range + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.2.0"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // Different subnet entirely + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.0.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testBlockedIpv6UniqueLocal() throws Exception { + // fc00::/7 covers fc00:: through fdff:: + InetAddress fc00 = InetAddress.getByName("fc00::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(fc00)).isTrue(); + + InetAddress fdAddr = InetAddress.getByName("fd12:3456:789a::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(fdAddr)).isTrue(); + + // fe00:: is NOT in fc00::/7 (it's in fe80::/10 link-local, but fe00:: without the 80 bits is different) + // 2001:db8:: is a public documentation prefix, not blocked + InetAddress publicV6 = InetAddress.getByName("2001:db8::1"); + assertThat(SsrfProtectionValidator.isBlockedAddress(publicV6)).isFalse(); + } + + @Test + void testParseHostEntriesWithWhitespaceAndBlanks() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of(" 192.168.1.0/24 ", "", " ", "my-host.corp")); + // Trimmed CIDR works + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + // Trimmed hostname works + assertThat(SsrfProtectionValidator.isHostnameAllowed("my-host.corp")).isTrue(); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testSetAllowedHostsReplacePrevious() { + try { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)); + + // Replace with different range + SsrfProtectionValidator.setAllowedHosts(List.of("10.0.0.0/8")); + // Old range no longer allowed + assertThatThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://192.168.1.1"), true)) + .isInstanceOf(RuntimeException.class) + .hasMessageContaining("URI is invalid"); + // New range allowed + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://10.1.2.3"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + + @Test + void testAllowListHostnameBypassesBlockedHostname() { + try { + // "localhost" is in BLOCKED_HOSTNAMES; allow-listing it should let it through + SsrfProtectionValidator.setAllowedHosts(List.of("localhost")); + assertThatNoException().isThrownBy(() -> SsrfProtectionValidator.validateUri(URI.create("http://localhost/path"), true)); + } finally { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + } + +} diff --git a/common/version-control/pom.xml b/common/version-control/pom.xml index ce7102d3f2..d9e134128d 100644 --- a/common/version-control/pom.xml +++ b/common/version-control/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT common org.thingsboard.common diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java index 2abf025cda..4ffb0940b6 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/DefaultGitSyncService.java @@ -18,6 +18,7 @@ package org.thingsboard.server.service.sync; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; +import org.eclipse.jgit.revwalk.RevCommit; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.thingsboard.common.util.ThingsBoardExecutors; @@ -47,6 +48,8 @@ public class DefaultGitSyncService implements GitSyncService { private final Map repositories = new ConcurrentHashMap<>(); private final Map updateListeners = new ConcurrentHashMap<>(); + private final Map lastCommits = new ConcurrentHashMap<>(); + @Override public void registerSync(String key, String repoUri, String branch, long fetchFrequencyMs, Runnable onUpdate) { RepositorySettings settings = new RepositorySettings(); @@ -84,7 +87,7 @@ public class DefaultGitSyncService implements GitSyncService { @Override public List listFiles(String key, String path, int depth, FileType type) { GitRepository repository = getRepository(key); - return repository.listFilesAtCommit(getBranchRef(repository), path, depth).stream() + return repository.listFilesAtCommit(getLastCommit(key), path, depth).stream() .filter(file -> type == null || file.type() == type) .toList(); } @@ -93,7 +96,7 @@ public class DefaultGitSyncService implements GitSyncService { @Override public byte[] getFileContent(String key, String path) { GitRepository repository = getRepository(key); - return repository.getFileContentAtCommit(path, getBranchRef(repository)); + return repository.getFileContentAtCommit(path, getLastCommit(key)); } @Override @@ -137,6 +140,15 @@ public class DefaultGitSyncService implements GitSyncService { } private void onUpdate(String key) { + GitRepository repository = getRepository(key); + String branchRef = getBranchRef(repository); + try { + lastCommits.put(key, repository.resolveCommit(branchRef)); + } catch (Throwable e) { + log.error("[{}] Failed to resolve commit for ref {}", key, branchRef, e); + return; + } + Runnable listener = updateListeners.get(key); if (listener != null) { log.debug("[{}] Handling repository update", key); @@ -154,6 +166,14 @@ public class DefaultGitSyncService implements GitSyncService { return Path.of(repositoriesFolder, name); } + private RevCommit getLastCommit(String key) { + RevCommit commit = lastCommits.get(key); + if (commit == null) { + throw new IllegalStateException(key + " repository has no resolved commit"); + } + return commit; + } + private String getBranchRef(GitRepository repository) { return "refs/remotes/origin/" + repository.getSettings().getDefaultBranch(); } diff --git a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java index 434d52a4ca..3602dd55ca 100644 --- a/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java +++ b/common/version-control/src/main/java/org/thingsboard/server/service/sync/vc/GitRepository.java @@ -277,13 +277,17 @@ public class GitRepository { return listFilesAtCommit(commitId, path, -1).stream().map(RepoFile::path).toList(); } - @SneakyThrows public List listFilesAtCommit(String commitId, String path, int depth) { - log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, path); + RevCommit commit = resolveCommit(commitId); + return listFilesAtCommit(commit, path, depth); + } + + @SneakyThrows + public List listFilesAtCommit(RevCommit commit, String path, int depth) { + log.debug("Executing listFilesAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, path); List files = new ArrayList<>(); - RevCommit revCommit = resolveCommit(commitId); try (TreeWalk treeWalk = new TreeWalk(git.getRepository())) { - treeWalk.reset(revCommit.getTree().getId()); + treeWalk.reset(commit.getTree().getId()); if (StringUtils.isNotEmpty(path)) { treeWalk.setFilter(PathFilter.create(path)); } @@ -301,11 +305,15 @@ public class GitRepository { return files; } - @SneakyThrows public byte[] getFileContentAtCommit(String file, String commitId) { - log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commitId, file); - RevCommit revCommit = resolveCommit(commitId); - try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, revCommit.getTree())) { + RevCommit commit = resolveCommit(commitId); + return getFileContentAtCommit(file, commit); + } + + @SneakyThrows + public byte[] getFileContentAtCommit(String file, RevCommit commit) { + log.debug("Executing getFileContentAtCommit [{}][{}][{}]", settings.getRepositoryUri(), commit, file); + try (TreeWalk treeWalk = TreeWalk.forPath(git.getRepository(), file, commit.getTree())) { if (treeWalk == null) { throw new IllegalArgumentException("File not found"); } @@ -373,9 +381,9 @@ public class GitRepository { for (RemoteRefUpdate update : pushResult.getRemoteUpdates()) { RemoteRefUpdate.Status status = update.getStatus(); if (status == REJECTED_NONFASTFORWARD || status == REJECTED_NODELETE || - status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) { + status == REJECTED_REMOTE_CHANGED || status == REJECTED_OTHER_REASON) { throw new RuntimeException("Remote repository answered with error: " + - Optional.ofNullable(update.getMessage()).orElseGet(status::name)); + Optional.ofNullable(update.getMessage()).orElseGet(status::name)); } } }); @@ -450,7 +458,8 @@ public class GitRepository { revCommit.getFullMessage(), revCommit.getAuthorIdent().getName(), revCommit.getAuthorIdent().getEmailAddress()); } - private RevCommit resolveCommit(String id) throws IOException { + @SneakyThrows + public RevCommit resolveCommit(String id) { return git.getRepository().parseCommit(resolve(id)); } @@ -481,8 +490,8 @@ public class GitRepository { private static final Function> revCommitComparatorFunction = pageLink -> { SortOrder sortOrder = pageLink.getSortOrder(); if (sortOrder != null - && sortOrder.getProperty().equals("timestamp") - && SortOrder.Direction.ASC.equals(sortOrder.getDirection())) { + && sortOrder.getProperty().equals("timestamp") + && SortOrder.Direction.ASC.equals(sortOrder.getDirection())) { return Comparator.comparingInt(RevCommit::getCommitTime); } return null; diff --git a/dao/pom.xml b/dao/pom.xml index 4ec8d13fd0..d526eeed4f 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard dao @@ -280,7 +280,6 @@ org.apache.maven.plugins maven-jar-plugin - ${jar-plugin.version} diff --git a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java index a19ba6c0e7..4b469851cd 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java +++ b/dao/src/main/java/org/thingsboard/server/dao/DaoUtil.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao; +import org.hibernate.exception.ConstraintViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -202,4 +203,13 @@ public final class DaoUtil { .collect(Collectors.toList()); } + public static ConstraintViolationException extractConstraintViolation(Throwable t) { + if (t instanceof ConstraintViolationException cve) { + return cve; + } else if (t != null && t.getCause() instanceof ConstraintViolationException cve) { + return cve; + } + return null; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java index 505a319baa..1dec2bbe04 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/alarm/BaseAlarmCommentService.java @@ -48,13 +48,13 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al @Override public AlarmComment createOrUpdateAlarmComment(TenantId tenantId, AlarmComment alarmComment) { - alarmCommentDataValidator.validate(alarmComment, c -> tenantId); + AlarmComment oldAlarmComment = alarmCommentDataValidator.validate(alarmComment, c -> tenantId); boolean isCreated = alarmComment.getId() == null; AlarmComment result; if (isCreated) { result = createAlarmComment(tenantId, alarmComment); } else { - result = updateAlarmComment(tenantId, alarmComment); + result = updateAlarmComment(tenantId, alarmComment, oldAlarmComment); } if (result != null) { eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(result) @@ -101,18 +101,17 @@ public class BaseAlarmCommentService extends AbstractEntityService implements Al return alarmCommentDao.save(tenantId, alarmComment); } - private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment) { + private AlarmComment updateAlarmComment(TenantId tenantId, AlarmComment newAlarmComment, AlarmComment oldAlarmComment) { log.debug("Update Alarm comment : {}", newAlarmComment); - AlarmComment existing = alarmCommentDao.findAlarmCommentById(tenantId, newAlarmComment.getId().getId()); - if (existing != null) { + if (oldAlarmComment != null) { if (newAlarmComment.getComment() != null) { JsonNode comment = newAlarmComment.getComment(); ((ObjectNode) comment).put("edited", "true"); ((ObjectNode) comment).put("editedOn", System.currentTimeMillis()); - existing.setComment(comment); + oldAlarmComment.setComment(comment); } - return alarmCommentDao.save(tenantId, existing); + return alarmCommentDao.save(tenantId, oldAlarmComment); } return null; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java index 2241b20e14..7c563debd6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/AttributesDao.java @@ -55,6 +55,12 @@ public interface AttributesDao { List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + + List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope); + + ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope); + List> removeAllByEntityId(TenantId tenantId, EntityId entityId); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java index 117c47b61c..ca4c937d4d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/BaseAttributesService.java @@ -45,9 +45,6 @@ import java.util.Optional; import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; -/** - * @author Andrew Shvayka - */ @Service @ConditionalOnProperty(prefix = "cache.attributes", value = "enabled", havingValue = "false", matchIfMissing = true) @Primary @@ -92,7 +89,7 @@ public class BaseAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + public List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { @@ -100,6 +97,21 @@ public class BaseAttributesService implements AttributesService { } } + @Override + public ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java index c150eabfa2..1c99dfc495 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/attributes/CachedAttributesService.java @@ -66,6 +66,7 @@ import static org.thingsboard.server.dao.attributes.AttributeUtils.validate; @Primary @Slf4j public class CachedAttributesService implements AttributesService { + private static final String STATS_NAME = "attributes.cache"; public static final String LOCAL_CACHE_TYPE = "caffeine"; @@ -212,7 +213,7 @@ public class CachedAttributesService implements AttributesService { } @Override - public List findAllKeysByEntityIds(TenantId tenantId, List entityIds, AttributeScope scope) { + public List findAllKeysByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { if (scope == null) { return attributesDao.findAllKeysByEntityIds(tenantId, entityIds); } else { @@ -220,6 +221,21 @@ public class CachedAttributesService implements AttributesService { } } + @Override + public ListenableFuture> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findAllKeysByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScope(tenantId, entityIds, scope); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, entityIds, scope); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope scope, AttributeKvEntry attribute) { validate(entityId, scope); diff --git a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java index 27a52e1cc7..8aef814657 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/audit/AuditLogServiceImpl.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ArrayNode; import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.ListenableFuture; +import jakarta.validation.constraints.NotNull; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -117,9 +118,9 @@ public class AuditLogServiceImpl implements AuditLogService { @Override public ListenableFuture - logEntityAction(TenantId tenantId, CustomerId customerId, UserId userId, String userName, I entityId, E entity, + logEntityAction(TenantId tenantId, CustomerId customerId, UserId userId, String userName, @NotNull I entityId, E entity, ActionType actionType, Exception e, Object... additionalInfo) { - if (canLog(entityId.getEntityType(), actionType)) { + if (canLog(entityId.getEntityType(), actionType) || (tenantId != null && tenantId.isSysTenantId())) { JsonNode actionData = constructActionData(entityId, entity, actionType, additionalInfo); ActionStatus actionStatus = ActionStatus.SUCCESS; String failureDetails = ""; diff --git a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java index 00c5ec372e..075e2d8a9d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/cf/BaseCalculatedFieldService.java @@ -26,18 +26,21 @@ import org.thingsboard.server.common.data.cf.CalculatedFieldFilter; import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.CalculatedFieldConfiguration; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.id.CalculatedFieldId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.entity.AbstractEntityService; import org.thingsboard.server.dao.entity.EntityService; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.exception.IncorrectParameterException; import org.thingsboard.server.dao.service.validator.CalculatedFieldDataValidator; +import org.thingsboard.server.dao.usagerecord.ApiLimitService; import java.util.EnumSet; import java.util.List; @@ -62,6 +65,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements private final EntityService entityService; private final CalculatedFieldDao calculatedFieldDao; private final CalculatedFieldDataValidator calculatedFieldDataValidator; + private final ApiLimitService apiLimitService; @Override public CalculatedField save(CalculatedField calculatedField) { @@ -70,6 +74,7 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements @Override public CalculatedField save(CalculatedField calculatedField, boolean doValidate) { + setConfigurationDefaults(calculatedField); CalculatedField oldCalculatedField = null; if (doValidate) { oldCalculatedField = calculatedFieldDataValidator.validate(calculatedField, CalculatedField::getTenantId); @@ -79,6 +84,15 @@ public class BaseCalculatedFieldService extends AbstractEntityService implements return doSave(calculatedField, oldCalculatedField); } + private void setConfigurationDefaults(CalculatedField calculatedField) { + if (calculatedField.getConfiguration() instanceof RelatedEntitiesAggregationCalculatedFieldConfiguration config + && config.getScheduledUpdateInterval() == null) { + int minScheduledUpdateInterval = (int) apiLimitService.getLimit( + calculatedField.getTenantId(), DefaultTenantProfileConfiguration::getMinAllowedScheduledUpdateIntervalInSecForCF + ); + config.setScheduledUpdateInterval(minScheduledUpdateInterval); + } + } private CalculatedField doSave(CalculatedField calculatedField, CalculatedField oldCalculatedField) { try { diff --git a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java index fa955fa72e..70f5e94d92 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/edge/EdgeServiceImpl.java @@ -66,7 +66,6 @@ import org.thingsboard.server.dao.entity.EntityCountService; import org.thingsboard.server.dao.eventsourcing.ActionEntityEvent; import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; -import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.rule.RuleChainService; import org.thingsboard.server.dao.service.DataValidator; @@ -75,6 +74,7 @@ import org.thingsboard.server.dao.service.Validator; import org.thingsboard.server.dao.sql.JpaExecutorService; import org.thingsboard.server.dao.timeseries.TimeseriesService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; import java.util.Collections; @@ -239,9 +239,10 @@ public class EdgeServiceImpl extends AbstractCachedEntityService EXCLUDED_TYPES_FROM_OPTIMIZATION = Set.of( EntityFilterType.ENTITY_LIST, EntityFilterType.SINGLE_ENTITY, EntityFilterType.RELATIONS_QUERY); + @Value("${sql.query.key-filters-or-conditions.enabled:true}") + private boolean keyFiltersOrConditionsEnabled; + @Autowired private EntityQueryDao entityQueryDao; @@ -104,6 +113,9 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe @Autowired private EdqsStatsService edqsStatsService; + @Autowired + private JpaExecutorService jpaExecutorService; + @Override public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { log.trace("Executing countEntitiesByQuery, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query); @@ -142,41 +154,78 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe EdqsResponse response = processEdqsRequest(tenantId, customerId, request); result = response.getEntityDataQueryResult(); } else { - if (!isValidForOptimization(query)) { - result = entityQueryDao.findEntityDataByQuery(tenantId, customerId, query); - } else { - // 1 step - find entity data by filter and sort columns - PageData entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query); - if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) { - result = entityDataByQuery; - } else { - // 2 step - find entity data by entity ids from the 1st step - List entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData()); - result = new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); - } - } + result = findEntityDataByQueryInternal(tenantId, customerId, query); } edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); return result; } + @Override + public ListenableFuture> findEntityDataByQueryAsync(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { + log.trace("Executing findEntityDataByQueryAsync, tenantId [{}], customerId [{}], query [{}]", tenantId, customerId, query); + + try { + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + validateId(customerId, id -> INCORRECT_CUSTOMER_ID + id); + validateEntityDataQuery(query); + } catch (Exception e) { + return Futures.immediateFailedFuture(e); + } + + if (edqsService.isApiEnabled() && validForEdqs(query) && !tenantId.isSysTenantId()) { + EdqsRequest request = EdqsRequest.builder() + .entityDataQuery(query) + .build(); + long startNs = System.nanoTime(); + return Futures.transform(processEdqsRequestAsync(tenantId, customerId, request), response -> { + edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); + return response.getEntityDataQueryResult(); + }, MoreExecutors.directExecutor()); + } + + return jpaExecutorService.submit(() -> { + long startNs = System.nanoTime(); + PageData result = findEntityDataByQueryInternal(tenantId, customerId, query); + edqsStatsService.reportEntityDataQuery(tenantId, query, System.nanoTime() - startNs); + return result; + }); + } + + private PageData findEntityDataByQueryInternal(TenantId tenantId, CustomerId customerId, EntityDataQuery query) { + if (!isValidForOptimization(query)) { + return entityQueryDao.findEntityDataByQuery(tenantId, customerId, query); + } + // 1 step - find entity data by filter and sort columns + PageData entityDataByQuery = findEntityIdsByFilterAndSorterColumns(tenantId, customerId, query); + if (entityDataByQuery == null || entityDataByQuery.getData().isEmpty()) { + return entityDataByQuery; + } + // 2 step - find entity data by entity ids from the 1st step + List entities = fetchEntityDataByIdsFromInitialQuery(tenantId, customerId, query, entityDataByQuery.getData()); + return new PageData<>(entities, entityDataByQuery.getTotalPages(), entityDataByQuery.getTotalElements(), entityDataByQuery.hasNext()); + } + private boolean validForEdqs(EntityCountQuery query) { // for compatibility with PE return true; } private EdqsResponse processEdqsRequest(TenantId tenantId, CustomerId customerId, EdqsRequest request) { - EdqsResponse response; try { - log.debug("[{}] Sending request to EDQS: {}", tenantId, request); - response = edqsApiService.processRequest(tenantId, customerId, request).get(); + return processEdqsRequestAsync(tenantId, customerId, request).get(); } catch (InterruptedException | ExecutionException e) { throw new RuntimeException(e); } - log.debug("[{}] Received response from EDQS: {}", tenantId, response); - if (response.getError() != null) { - throw new RuntimeException(response.getError()); - } - return response; + } + + private ListenableFuture processEdqsRequestAsync(TenantId tenantId, CustomerId customerId, EdqsRequest request) { + log.debug("[{}] Sending request to EDQS: {}", tenantId, request); + return Futures.transform(edqsApiService.processRequest(tenantId, customerId, request), response -> { + log.debug("[{}] Received response from EDQS: {}", tenantId, response); + if (response.getError() != null) { + throw new RuntimeException(response.getError()); + } + return response; + }, MoreExecutors.directExecutor()); } @Override @@ -284,7 +333,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe return new NameLabelAndCustomerDetails(getName(entity), getLabel(entity), getCustomerId(entity)); } - private static void validateEntityCountQuery(EntityCountQuery query) { + private void validateEntityCountQuery(EntityCountQuery query) { if (query == null) { throw new IncorrectParameterException("Query must be specified."); } else if (query.getEntityFilter() == null) { @@ -298,9 +347,14 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe } else if (query.getEntityFilter().getType().equals(ENTITY_NAME)) { validateEntityNameQuery((EntityNameFilter) query.getEntityFilter()); } + // Intentionally using the nullable getKeyFiltersOperation() (not getKeyFiltersOperationOrDefault()): + // a null value encodes "classic AND" and must pass this guard even when the OR feature flag is off. + if (!keyFiltersOrConditionsEnabled && query.getKeyFiltersOperation() == ComplexOperation.OR) { + throw new IncorrectParameterException("OR conditions between key filters are disabled by the system administrator."); + } } - private static void validateEntityDataQuery(EntityDataQuery query) { + private void validateEntityDataQuery(EntityDataQuery query) { validateEntityCountQuery(query); validateEntityDataPageLink(query.getPageLink()); } @@ -365,7 +419,7 @@ public class BaseEntityService extends AbstractEntityService implements EntitySe .collect(Collectors.toList()); } } - EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters()); + EntityDataQuery entityQuery = new EntityDataQuery(query.getEntityFilter(), query.getPageLink(), entityFields, latestValues, query.getKeyFilters(), query.getKeyFiltersOperation()); return this.entityQueryDao.findEntityDataByQuery(tenantId, customerId, entityQuery); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java index 9f9e4a67e9..6f971e61f7 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/ModelConstants.java @@ -420,6 +420,7 @@ public class ModelConstants { public static final String RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY = "first_rule_node_id"; public static final String RULE_CHAIN_ROOT_PROPERTY = "root"; public static final String RULE_CHAIN_CONFIGURATION_PROPERTY = "configuration"; + public static final String RULE_CHAIN_NOTES_PROPERTY = "notes"; /** * Rule node constants. @@ -730,6 +731,7 @@ public class ModelConstants { public static final String CALCULATED_FIELD_CONFIGURATION_VERSION = "configuration_version"; public static final String CALCULATED_FIELD_CONFIGURATION = "configuration"; public static final String CALCULATED_FIELD_VERSION = "version"; + public static final String CALCULATED_FIELD_ADDITIONAL_INFO = ADDITIONAL_INFO_PROPERTY; /** * Tasks constants. diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java new file mode 100644 index 0000000000..71b729ae76 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/AbstractRuleChainEntity.java @@ -0,0 +1,127 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.MappedSuperclass; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainType; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.BaseVersionedEntity; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +import java.util.UUID; + +@Data +@EqualsAndHashCode(callSuper = true) +@MappedSuperclass +public abstract class AbstractRuleChainEntity extends BaseVersionedEntity { + + @Column(name = ModelConstants.RULE_CHAIN_TENANT_ID_PROPERTY) + private UUID tenantId; + + @Column(name = ModelConstants.RULE_CHAIN_NAME_PROPERTY) + private String name; + + @Enumerated(EnumType.STRING) + @Column(name = ModelConstants.RULE_CHAIN_TYPE_PROPERTY) + private RuleChainType type; + + @Column(name = ModelConstants.RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY) + private UUID firstRuleNodeId; + + @Column(name = ModelConstants.RULE_CHAIN_ROOT_PROPERTY) + private boolean root; + + @Column(name = ModelConstants.DEBUG_MODE) + private boolean debugMode; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.RULE_CHAIN_CONFIGURATION_PROPERTY) + private JsonNode configuration; + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY) + private JsonNode additionalInfo; + + @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) + private UUID externalId; + + public AbstractRuleChainEntity() { + super(); + } + + public AbstractRuleChainEntity(T ruleChain) { + super(ruleChain); + this.tenantId = DaoUtil.getId(ruleChain.getTenantId()); + this.name = ruleChain.getName(); + this.type = ruleChain.getType(); + if (ruleChain.getFirstRuleNodeId() != null) { + this.firstRuleNodeId = ruleChain.getFirstRuleNodeId().getId(); + } + this.root = ruleChain.isRoot(); + this.debugMode = ruleChain.isDebugMode(); + this.configuration = ruleChain.getConfiguration(); + this.additionalInfo = ruleChain.getAdditionalInfo(); + if (ruleChain.getExternalId() != null) { + this.externalId = ruleChain.getExternalId().getId(); + } + } + + public AbstractRuleChainEntity(AbstractRuleChainEntity ruleChainEntity) { + super(ruleChainEntity); + this.tenantId = ruleChainEntity.getTenantId(); + this.name = ruleChainEntity.getName(); + this.type = ruleChainEntity.getType(); + this.firstRuleNodeId = ruleChainEntity.getFirstRuleNodeId(); + this.root = ruleChainEntity.isRoot(); + this.debugMode = ruleChainEntity.isDebugMode(); + this.configuration = ruleChainEntity.getConfiguration(); + this.additionalInfo = ruleChainEntity.getAdditionalInfo(); + this.externalId = ruleChainEntity.getExternalId(); + } + + protected RuleChain toRuleChain() { + RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid())); + ruleChain.setCreatedTime(createdTime); + ruleChain.setVersion(version); + ruleChain.setTenantId(TenantId.fromUUID(tenantId)); + ruleChain.setName(name); + ruleChain.setType(type); + if (firstRuleNodeId != null) { + ruleChain.setFirstRuleNodeId(new RuleNodeId(firstRuleNodeId)); + } + ruleChain.setRoot(root); + ruleChain.setDebugMode(debugMode); + ruleChain.setConfiguration(configuration); + ruleChain.setAdditionalInfo(additionalInfo); + if (externalId != null) { + ruleChain.setExternalId(new RuleChainId(externalId)); + } + return ruleChain; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java index 5a507b7fd3..47c1520ac0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/CalculatedFieldEntity.java @@ -36,6 +36,7 @@ import org.thingsboard.server.dao.util.mapping.JsonConverter; import java.util.UUID; +import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ADDITIONAL_INFO; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_CONFIGURATION_VERSION; import static org.thingsboard.server.dao.model.ModelConstants.CALCULATED_FIELD_ENTITY_ID; @@ -81,9 +82,11 @@ public class CalculatedFieldEntity extends BaseVersionedEntity @Column(name = DEBUG_SETTINGS) private String debugSettings; - public CalculatedFieldEntity() { - super(); - } + @Convert(converter = JsonConverter.class) + @Column(name = CALCULATED_FIELD_ADDITIONAL_INFO) + private JsonNode additionalInfo; + + public CalculatedFieldEntity() {} public CalculatedFieldEntity(CalculatedField calculatedField) { this.setUuid(calculatedField.getUuidId()); @@ -97,6 +100,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity this.configuration = JacksonUtil.valueToTree(calculatedField.getConfiguration()); this.version = calculatedField.getVersion(); this.debugSettings = JacksonUtil.toString(calculatedField.getDebugSettings()); + this.additionalInfo = calculatedField.getAdditionalInfo(); } @Override @@ -111,6 +115,7 @@ public class CalculatedFieldEntity extends BaseVersionedEntity calculatedField.setConfiguration(JacksonUtil.treeToValue(configuration, CalculatedFieldConfiguration.class)); calculatedField.setVersion(version); calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + calculatedField.setAdditionalInfo(additionalInfo); return calculatedField; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java new file mode 100644 index 0000000000..bb7680e476 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainDetailsEntity.java @@ -0,0 +1,63 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.model.sql; + +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.persistence.Column; +import jakarta.persistence.Convert; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.server.common.data.rule.RuleChain; +import org.thingsboard.server.common.data.rule.RuleChainDetails; +import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.util.mapping.JsonConverter; + +@Data +@EqualsAndHashCode(callSuper = true) +@Entity +@Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME) +public class RuleChainDetailsEntity extends AbstractRuleChainEntity { + + @Convert(converter = JsonConverter.class) + @Column(name = ModelConstants.RULE_CHAIN_NOTES_PROPERTY) + private JsonNode notes; + + public RuleChainDetailsEntity() { + super(); + } + + public RuleChainDetailsEntity(RuleChainDetails ruleChainDetails) { + super(ruleChainDetails); + if (ruleChainDetails.getNotes() != null) { + this.notes = JacksonUtil.valueToTree(ruleChainDetails.getNotes()); + } + } + + @Override + public RuleChainDetails toData() { + RuleChain ruleChain = super.toRuleChain(); + RuleChainDetails details = new RuleChainDetails(ruleChain); + if (notes != null && notes.isArray()) { + details.setNotes(JacksonUtil.treeToValue(notes, new TypeReference<>() {})); + } + return details; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java index 5b83eb6f72..ca6651a965 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sql/RuleChainEntity.java @@ -15,102 +15,30 @@ */ package org.thingsboard.server.dao.model.sql; -import com.fasterxml.jackson.databind.JsonNode; -import jakarta.persistence.Column; -import jakarta.persistence.Convert; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.Table; import lombok.Data; import lombok.EqualsAndHashCode; -import org.thingsboard.server.common.data.id.RuleChainId; -import org.thingsboard.server.common.data.id.RuleNodeId; -import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.rule.RuleChain; -import org.thingsboard.server.common.data.rule.RuleChainType; -import org.thingsboard.server.dao.DaoUtil; -import org.thingsboard.server.dao.model.BaseVersionedEntity; import org.thingsboard.server.dao.model.ModelConstants; -import org.thingsboard.server.dao.util.mapping.JsonConverter; - -import java.util.UUID; @Data @EqualsAndHashCode(callSuper = true) @Entity @Table(name = ModelConstants.RULE_CHAIN_TABLE_NAME) -public class RuleChainEntity extends BaseVersionedEntity { - - @Column(name = ModelConstants.RULE_CHAIN_TENANT_ID_PROPERTY) - private UUID tenantId; - - @Column(name = ModelConstants.RULE_CHAIN_NAME_PROPERTY) - private String name; - - @Enumerated(EnumType.STRING) - @Column(name = ModelConstants.RULE_CHAIN_TYPE_PROPERTY) - private RuleChainType type; - - @Column(name = ModelConstants.RULE_CHAIN_FIRST_RULE_NODE_ID_PROPERTY) - private UUID firstRuleNodeId; - - @Column(name = ModelConstants.RULE_CHAIN_ROOT_PROPERTY) - private boolean root; - - @Column(name = ModelConstants.DEBUG_MODE) - private boolean debugMode; - - @Convert(converter = JsonConverter.class) - @Column(name = ModelConstants.RULE_CHAIN_CONFIGURATION_PROPERTY) - private JsonNode configuration; - - @Convert(converter = JsonConverter.class) - @Column(name = ModelConstants.ADDITIONAL_INFO_PROPERTY) - private JsonNode additionalInfo; - - @Column(name = ModelConstants.EXTERNAL_ID_PROPERTY) - private UUID externalId; +public class RuleChainEntity extends AbstractRuleChainEntity { public RuleChainEntity() { + super(); } public RuleChainEntity(RuleChain ruleChain) { super(ruleChain); - this.tenantId = DaoUtil.getId(ruleChain.getTenantId()); - this.name = ruleChain.getName(); - this.type = ruleChain.getType(); - if (ruleChain.getFirstRuleNodeId() != null) { - this.firstRuleNodeId = ruleChain.getFirstRuleNodeId().getId(); - } - this.root = ruleChain.isRoot(); - this.debugMode = ruleChain.isDebugMode(); - this.configuration = ruleChain.getConfiguration(); - this.additionalInfo = ruleChain.getAdditionalInfo(); - if (ruleChain.getExternalId() != null) { - this.externalId = ruleChain.getExternalId().getId(); - } } @Override public RuleChain toData() { - RuleChain ruleChain = new RuleChain(new RuleChainId(this.getUuid())); - ruleChain.setCreatedTime(createdTime); - ruleChain.setVersion(version); - ruleChain.setTenantId(TenantId.fromUUID(tenantId)); - ruleChain.setName(name); - ruleChain.setType(type); - if (firstRuleNodeId != null) { - ruleChain.setFirstRuleNodeId(new RuleNodeId(firstRuleNodeId)); - } - ruleChain.setRoot(root); - ruleChain.setDebugMode(debugMode); - ruleChain.setConfiguration(configuration); - ruleChain.setAdditionalInfo(additionalInfo); - if (externalId != null) { - ruleChain.setExternalId(new RuleChainId(externalId)); - } - return ruleChain; + return super.toRuleChain(); } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java index 44a38bf445..971c06c7a0 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java +++ b/dao/src/main/java/org/thingsboard/server/dao/model/sqlts/latest/TsKvLatestEntity.java @@ -65,6 +65,12 @@ import static org.thingsboard.server.dao.model.ModelConstants.VERSION_COLUMN; query = SearchTsKvLatestRepository.FIND_ALL_BY_ENTITY_ID_QUERY, resultSetMapping = "tsKvLatestFindMapping", resultClass = TsKvLatestEntity.class + ), + @NamedNativeQuery( + name = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS, + query = SearchTsKvLatestRepository.FIND_LATEST_BY_ENTITY_IDS_QUERY, + resultSetMapping = "tsKvLatestFindMapping", + resultClass = TsKvLatestEntity.class ) }) public final class TsKvLatestEntity extends AbstractTsKvEntity { diff --git a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java index 900958dcba..3d2ebfd1fb 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/nosql/CassandraAbstractAsyncDao.java @@ -37,6 +37,9 @@ public abstract class CassandraAbstractAsyncDao extends CassandraAbstractDao { @Value("${cassandra.query.result_processing_threads:50}") private int threadPoolSize; + @Value("${cassandra.query.max_result_set_size_in_bytes:52428800}") + protected long maxResultSetSizeBytes; + @PostConstruct public void startExecutor() { readResultsProcessingExecutor = ThingsBoardExecutors.newWorkStealingPool(threadPoolSize, "cassandra-callback"); diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java index b46540aa55..fb8d5eb1d8 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/DefaultNotifications.java @@ -511,12 +511,10 @@ public class DefaultNotifications { rule.setTriggerConfig(defaultRule.getTriggerConfig()); if (rule.getTriggerType() == NotificationRuleTriggerType.ALARM) { EscalatedNotificationRuleRecipientsConfig recipientsConfig = new EscalatedNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); recipientsConfig.setEscalationTable(Map.of(0, toUUIDs(List.of(targets)))); rule.setRecipientsConfig(recipientsConfig); } else { - DefaultNotificationRuleRecipientsConfig recipientsConfig = new DefaultNotificationRuleRecipientsConfig(); - recipientsConfig.setTriggerType(rule.getTriggerType()); + DefaultNotificationRuleRecipientsConfig recipientsConfig = DefaultNotificationRuleRecipientsConfig.forTriggerType(rule.getTriggerType()); recipientsConfig.setTargets(toUUIDs(List.of(targets))); rule.setRecipientsConfig(recipientsConfig); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java index 96a86073d4..8e1408f4fc 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/notification/NotificationRequestDao.java @@ -50,7 +50,7 @@ public interface NotificationRequestDao extends Dao { boolean existsByTenantIdAndStatusAndTemplateId(TenantId tenantId, NotificationRequestStatus status, NotificationTemplateId templateId); - int removeAllByCreatedTimeBefore(long ts); + int removeByTenantIdAndCreatedTimeBeforeBatch(TenantId tenantId, long ts, int batchSize); NotificationRequestInfo findInfoById(TenantId tenantId, NotificationRequestId id); diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java index 484b3c49b3..2e30c0d598 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/BaseOtaPackageService.java @@ -196,7 +196,9 @@ public class BaseOtaPackageService extends AbstractCachedEntityService INCORRECT_OTA_PACKAGE_ID + id); try { + Long oid = getDataOidById(tenantId, otaPackageId); otaPackageDao.removeById(tenantId, otaPackageId.getId()); + unlinkDataIfPresent(tenantId, otaPackageId, oid); publishEvictEvent(new OtaPackageCacheEvictEvent(otaPackageId)); eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(otaPackageId).build()); } catch (Exception t) { @@ -215,6 +217,30 @@ public class BaseOtaPackageService extends AbstractCachedEntityService tenantOtaPackageRemover = - new PaginatedRemover<>() { - - @Override - protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { - return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink); - } + private final PaginatedRemover tenantOtaPackageRemover = new PaginatedRemover<>() { + @Override + protected PageData findEntities(TenantId tenantId, TenantId id, PageLink pageLink) { + return otaPackageInfoDao.findOtaPackageInfoByTenantId(id, pageLink); + } - @Override - protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) { - deleteOtaPackage(tenantId, entity.getId()); - } - }; + @Override + protected void removeEntity(TenantId tenantId, OtaPackageInfo entity) { + deleteOtaPackage(tenantId, entity.getId()); + } + }; @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java index c11a13cbe1..875aaea4ed 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ota/OtaPackageDao.java @@ -18,15 +18,20 @@ package org.thingsboard.server.dao.ota; import org.thingsboard.server.common.data.OtaPackage; import org.thingsboard.server.common.data.id.OtaPackageId; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.dao.Dao; import org.thingsboard.server.dao.ExportableEntityDao; import org.thingsboard.server.dao.TenantEntityWithDataDao; +import java.util.UUID; + public interface OtaPackageDao extends Dao, TenantEntityWithDataDao, ExportableEntityDao { Long sumDataSizeByTenantId(TenantId tenantId); OtaPackage findOtaPackageByTenantIdAndTitleAndVersion(TenantId tenantId, String title, String version); + Long getDataOidById(UUID id); + + Integer unlinkLargeObject(Long dataOid); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java index d71d6502cf..f5ff79fb52 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyDao.java @@ -17,15 +17,22 @@ package org.thingsboard.server.dao.pat; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.Dao; +import java.util.List; import java.util.Set; public interface ApiKeyDao extends Dao { ApiKey findByValue(String value); + PageData findByTenantId(TenantId tenantId, PageLink pageLink); + + List findByTenantIdAndUserId(TenantId tenantId, UserId userId); + Set deleteByTenantId(TenantId tenantId); Set deleteByUserId(TenantId tenantId, UserId userId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java index 17a3e2bda3..36d8736f78 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/pat/ApiKeyServiceImpl.java @@ -24,6 +24,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.event.TransactionalEventListener; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.common.data.id.ApiKeyId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; @@ -34,9 +35,11 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.dao.entity.AbstractCachedEntityService; +import org.thingsboard.server.dao.eventsourcing.DeleteEntityEvent; import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.validator.ApiKeyDataValidator; +import java.util.List; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -73,13 +76,20 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService findApiKeysByUserId(TenantId tenantId, UserId userId) { + log.trace("Executing findApiKeysByUserId [{}][{}]", tenantId, userId); + validateId(userId, id -> INCORRECT_USER_ID + id); + return apiKeyDao.findByTenantIdAndUserId(tenantId, userId); + } + @Override public Optional> findEntity(TenantId tenantId, EntityId entityId) { return Optional.ofNullable(findApiKeyById(tenantId, new ApiKeyId(entityId.getId()))); @@ -126,6 +143,20 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService INCORRECT_API_KEY_ID + id); apiKeyDao.removeById(tenantId, apiKeyId); publishEvictEvent(new ApiKeyEvictEvent(apiKey.getValue())); + eventPublisher.publishEvent(DeleteEntityEvent.builder().tenantId(tenantId).entityId(apiKey.getId()).build()); + } + + @Override + public void deleteEntity(TenantId tenantId, EntityId id, boolean force) { + ApiKey apiKey = findApiKeyById(tenantId, new ApiKeyId(id.getId())); + if (apiKey == null) { + if (force) { + return; + } else { + throw new DataValidationException("Unable to delete non-existent API key."); + } + } + deleteApiKey(tenantId, apiKey, force); } @Override @@ -144,6 +175,13 @@ public class ApiKeyServiceImpl extends AbstractCachedEntityService publishEvictEvent(new ApiKeyEvictEvent(value))); } + @Override + public PageData findApiKeysByTenantId(TenantId tenantId, PageLink pageLink) { + log.trace("Executing findApiKeysByTenantId [{}]", tenantId); + validateId(tenantId, id -> INCORRECT_TENANT_ID + id); + return apiKeyDao.findByTenantId(tenantId, pageLink); + } + @Override public ApiKey findApiKeyByValue(String value) { log.trace("Executing findApiKeyByValue [{}]", value); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java index 639c60e1d6..11bbe797cf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/BaseImageService.java @@ -188,6 +188,12 @@ public class BaseImageService extends BaseResourceService implements ImageServic return findResourceInfoByTenantIdAndKey(tenantId, ResourceType.IMAGE, key); } + @Override + public Set getAllImageKeysByTenantId(TenantId tenantId) { + log.trace("Executing getAllImageKeysByTenantId [{}]", tenantId); + return resourceInfoDao.findKeysByTenantIdAndResourceTypeAndResourceKeyPrefix(tenantId, ResourceType.IMAGE, ""); + } + @Override public TbResourceInfo getPublicImageInfoByKey(String publicResourceKey) { return resourceInfoDao.findPublicResourceByKey(ResourceType.IMAGE, publicResourceKey); diff --git a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java index 343c37299e..925b8c2912 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java +++ b/dao/src/main/java/org/thingsboard/server/dao/resource/DefaultTbResourceDataCache.java @@ -19,6 +19,7 @@ import com.github.benmanes.caffeine.cache.AsyncLoadingCache; import com.github.benmanes.caffeine.cache.Caffeine; import com.google.common.util.concurrent.FluentFuture; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -55,6 +56,12 @@ public class DefaultTbResourceDataCache implements TbResourceDataCache { .buildAsync((key, executor) -> CompletableFuture.supplyAsync(() -> resourceService.getResourceDataInfo(key.tenantId(), key.resourceId()), executor)); } + @PreDestroy + private void destroy() { + cache.synchronous().invalidateAll(); + cache = null; + } + @Override public FluentFuture getResourceDataInfoAsync(TenantId tenantId, TbResourceId resourceId) { log.trace("Retrieving resource data info by id [{}], tenant id [{}] from cache", resourceId, tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java b/dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java index f88b672a34..37fe2950b4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rpc/RpcDao.java @@ -24,12 +24,13 @@ import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.dao.Dao; public interface RpcDao extends Dao { + PageData findAllByDeviceId(TenantId tenantId, DeviceId deviceId, PageLink pageLink); PageData findAllByDeviceIdAndStatus(TenantId tenantId, DeviceId deviceId, RpcStatus rpcStatus, PageLink pageLink); PageData findAllRpcByTenantId(TenantId tenantId, PageLink pageLink); - int deleteOutdatedRpcByTenantId(TenantId tenantId, Long expirationTime); + int deleteOutdatedRpcByTenantIdBatch(TenantId tenantId, Long expirationTime, int batchSize); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java index 6b90b6fac6..fce0cec732 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/BaseRuleChainService.java @@ -51,6 +51,7 @@ import org.thingsboard.server.common.data.rule.NodeConnectionInfo; import org.thingsboard.server.common.data.rule.RuleChain; import org.thingsboard.server.common.data.rule.RuleChainConnectionInfo; import org.thingsboard.server.common.data.rule.RuleChainData; +import org.thingsboard.server.common.data.rule.RuleChainDetails; import org.thingsboard.server.common.data.rule.RuleChainImportResult; import org.thingsboard.server.common.data.rule.RuleChainMetaData; import org.thingsboard.server.common.data.rule.RuleChainType; @@ -104,6 +105,9 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC @Autowired private RuleChainDao ruleChainDao; + @Autowired + private RuleChainDetailsDao ruleChainDetailsDao; + @Autowired private RuleNodeDao ruleNodeDao; @@ -316,9 +320,11 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC if (!relations.isEmpty()) { relationService.saveRelations(tenantId, relations); } - ruleChain = ruleChainDao.save(tenantId, ruleChain); - eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChain) - .entityId(ruleChain.getId()).broadcastEvent(publishSaveEvent).build()); + RuleChainDetails ruleChainDetails = new RuleChainDetails(ruleChain); + ruleChainDetails.setNotes(ruleChainMetaData.getNotes()); + ruleChainDetails = ruleChainDetailsDao.save(tenantId, ruleChainDetails); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(tenantId).entity(ruleChainDetails) + .entityId(ruleChainDetails.getId()).broadcastEvent(publishSaveEvent).build()); return RuleChainUpdateResult.successful(updatedRuleNodes); } @@ -341,13 +347,13 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC @Override public RuleChainMetaData loadRuleChainMetaData(TenantId tenantId, RuleChainId ruleChainId) { Validator.validateId(ruleChainId, "Incorrect rule chain id."); - RuleChain ruleChain = findRuleChainById(tenantId, ruleChainId); - if (ruleChain == null) { + RuleChainDetails ruleChainDetails = ruleChainDetailsDao.findById(tenantId, ruleChainId.getId()); + if (ruleChainDetails == null) { return null; } RuleChainMetaData ruleChainMetaData = new RuleChainMetaData(); ruleChainMetaData.setRuleChainId(ruleChainId); - ruleChainMetaData.setVersion(ruleChain.getVersion()); + ruleChainMetaData.setVersion(ruleChainDetails.getVersion()); List ruleNodes = getRuleChainNodes(tenantId, ruleChainId); Collections.sort(ruleNodes, Comparator.comparingLong(RuleNode::getCreatedTime).thenComparing(RuleNode::getId, Comparator.comparing(RuleNodeId::getId))); Map ruleNodeIndexMap = new HashMap<>(); @@ -355,8 +361,8 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC ruleNodeIndexMap.put(node.getId(), ruleNodes.indexOf(node)); } ruleChainMetaData.setNodes(ruleNodes); - if (ruleChain.getFirstRuleNodeId() != null) { - ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChain.getFirstRuleNodeId())); + if (ruleChainDetails.getFirstRuleNodeId() != null) { + ruleChainMetaData.setFirstNodeIndex(ruleNodeIndexMap.get(ruleChainDetails.getFirstRuleNodeId())); } for (RuleNode node : ruleNodes) { int fromIndex = ruleNodeIndexMap.get(node.getId()); @@ -376,6 +382,7 @@ public class BaseRuleChainService extends AbstractEntityService implements RuleC Collections.sort(ruleChainMetaData.getConnections(), Comparator.comparingInt(NodeConnectionInfo::getFromIndex) .thenComparing(NodeConnectionInfo::getToIndex).thenComparing(NodeConnectionInfo::getType)); } + ruleChainMetaData.setNotes(ruleChainDetails.getNotes()); return ruleChainMetaData; } diff --git a/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java new file mode 100644 index 0000000000..f7ab9d8662 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/rule/RuleChainDetailsDao.java @@ -0,0 +1,23 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.rule; + +import org.thingsboard.server.common.data.rule.RuleChainDetails; +import org.thingsboard.server.dao.Dao; + +public interface RuleChainDetailsDao extends Dao { + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java index b955bed043..14e1300856 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AiModelDataValidator.java @@ -17,13 +17,19 @@ package org.thingsboard.server.dao.service.validator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.ai.AiModel; +import org.thingsboard.server.common.data.ai.provider.AiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.AzureOpenAiProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OllamaProviderConfig; +import org.thingsboard.server.common.data.ai.provider.OpenAiProviderConfig; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.dao.ai.AiModelDao; import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.tenant.TenantService; +import java.net.URI; import java.util.Optional; @Component @@ -64,6 +70,26 @@ class AiModelDataValidator extends DataValidator { if (!tenantService.tenantExists(tenantId)) { throw new DataValidationException("AI model reference a non-existent tenant!"); } + + // provider URL SSRF validation + if (model.getConfiguration() != null) { + AiProviderConfig providerConfig = model.getConfiguration().providerConfig(); + String url = null; + if (providerConfig instanceof OpenAiProviderConfig c) { + url = c.baseUrl(); + } else if (providerConfig instanceof AzureOpenAiProviderConfig c) { + url = c.endpoint(); + } else if (providerConfig instanceof OllamaProviderConfig c) { + url = c.baseUrl(); + } + if (url != null) { + try { + SsrfProtectionValidator.validateUri(URI.create(url)); + } catch (Exception e) { + throw new DataValidationException("AI model provider URL is not allowed: " + e.getMessage()); + } + } + } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java index ea2060a6a4..b70251027d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/AlarmCommentDataValidator.java @@ -18,14 +18,18 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.alarm.AlarmComment; +import org.thingsboard.server.common.data.alarm.AlarmCommentType; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.dao.alarm.AlarmCommentDao; import org.thingsboard.server.dao.service.DataValidator; +import org.thingsboard.server.exception.DataValidationException; @Component @AllArgsConstructor public class AlarmCommentDataValidator extends DataValidator { + private final AlarmCommentDao alarmCommentDao; + @Override protected void validateDataImpl(TenantId tenantId, AlarmComment alarmComment) { if (alarmComment.getComment() == null) { @@ -35,4 +39,20 @@ public class AlarmCommentDataValidator extends DataValidator { throw new DataValidationException("Alarm id should be specified!"); } } + + @Override + protected AlarmComment validateUpdate(TenantId tenantId, AlarmComment alarmComment) { + AlarmComment oldAlarmComment = null; + if (alarmComment.getId() != null) { + oldAlarmComment = alarmCommentDao.findAlarmCommentById(tenantId, alarmComment.getId().getId()); + if (oldAlarmComment == null) { + throw new DataValidationException("Can't update non existing alarm comment!"); + } + if (oldAlarmComment.getType() == AlarmCommentType.SYSTEM) { + throw new DataValidationException("System alarm comment can't be updated!"); + } + } + return oldAlarmComment; + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java index 8006793b87..74fa56dc87 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/DeviceProfileDataValidator.java @@ -100,6 +100,18 @@ public class DeviceProfileDataValidator extends AbstractHasOtaPackageValidator { @@ -35,12 +38,17 @@ public class Oauth2ClientDataValidator extends DataValidator { @Override protected void validateDataImpl(TenantId tenantId, OAuth2Client oAuth2Client) { OAuth2MapperConfig mapperConfig = oAuth2Client.getMapperConfig(); - if (mapperConfig.getType() == MapperType.BASIC) { + MapperType type = mapperConfig.getType(); + if (type == MapperType.BASIC || type == MapperType.GITHUB || type == MapperType.APPLE) { OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic(); if (basicConfig == null) { throw new DataValidationException("Basic config should be specified!"); } - if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) { + if (type == MapperType.GITHUB) { + if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) { + throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!"); + } + } else if (StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) { throw new DataValidationException("Email attribute key should be specified!"); } if (basicConfig.getTenantNameStrategy() == null) { @@ -51,23 +59,7 @@ public class Oauth2ClientDataValidator extends DataValidator { throw new DataValidationException("Tenant name pattern should be specified!"); } } - if (mapperConfig.getType() == MapperType.GITHUB) { - OAuth2BasicMapperConfig basicConfig = mapperConfig.getBasic(); - if (basicConfig == null) { - throw new DataValidationException("Basic config should be specified!"); - } - if (!StringUtils.isEmpty(basicConfig.getEmailAttributeKey())) { - throw new DataValidationException("Email attribute key cannot be configured for GITHUB mapper type!"); - } - if (basicConfig.getTenantNameStrategy() == null) { - throw new DataValidationException("Tenant name strategy should be specified!"); - } - if (basicConfig.getTenantNameStrategy() == TenantNameStrategyType.CUSTOM - && StringUtils.isEmpty(basicConfig.getTenantNamePattern())) { - throw new DataValidationException("Tenant name pattern should be specified!"); - } - } - if (mapperConfig.getType() == MapperType.CUSTOM) { + if (type == MapperType.CUSTOM) { OAuth2CustomMapperConfig customConfig = mapperConfig.getCustom(); if (customConfig == null) { throw new DataValidationException("Custom config should be specified!"); @@ -75,6 +67,11 @@ public class Oauth2ClientDataValidator extends DataValidator { if (StringUtils.isEmpty(customConfig.getUrl())) { throw new DataValidationException("Custom mapper URL should be specified!"); } + try { + SsrfProtectionValidator.validateUri(new URI(customConfig.getUrl())); + } catch (Exception e) { + throw new DataValidationException("Custom mapper URL is not allowed: " + e.getMessage()); + } } } } diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java index 2c814ef50a..ee8510a851 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsDao.java @@ -17,6 +17,8 @@ package org.thingsboard.server.dao.settings; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.Dao; import java.util.UUID; @@ -27,6 +29,8 @@ public interface AdminSettingsDao extends Dao { AdminSettings findByTenantIdAndKey(UUID tenantId, String key); + PageData findAllByTenantId(TenantId tenantId, PageLink pageLink); + boolean removeByTenantIdAndKey(UUID tenantId, String key); void removeByTenantId(UUID tenantId); diff --git a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java index d6708aabfe..71f0eb268e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/settings/AdminSettingsServiceImpl.java @@ -20,6 +20,7 @@ import com.fasterxml.jackson.databind.node.ObjectNode; import com.google.common.util.concurrent.FluentFuture; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.EntityType; @@ -27,6 +28,9 @@ import org.thingsboard.server.common.data.id.AdminSettingsId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.HasId; import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; +import org.thingsboard.server.dao.eventsourcing.SaveEntityEvent; import org.thingsboard.server.dao.service.DataValidator; import org.thingsboard.server.dao.service.Validator; @@ -44,6 +48,9 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { @Autowired private DataValidator adminSettingsValidator; + @Autowired + protected ApplicationEventPublisher eventPublisher; + @Override public AdminSettings findAdminSettingsById(TenantId tenantId, AdminSettingsId adminSettingsId) { log.trace("Executing findAdminSettingsById [{}]", adminSettingsId); @@ -63,10 +70,15 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { return adminSettingsDao.findByTenantIdAndKey(tenantId.getId(), key); } + @Override + public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { + return adminSettingsDao.findAllByTenantId(tenantId, pageLink); + } + @Override public AdminSettings saveAdminSettings(TenantId tenantId, AdminSettings adminSettings) { log.trace("Executing saveAdminSettings [{}]", adminSettings); - adminSettingsValidator.validate(adminSettings, data -> tenantId); + AdminSettings oldAdminSettings = adminSettingsValidator.validate(adminSettings, data -> tenantId); if (adminSettings.getKey().equals("mail")) { AdminSettings mailSettings = findAdminSettingsByKey(tenantId, "mail"); if (mailSettings != null) { @@ -84,7 +96,10 @@ public class AdminSettingsServiceImpl implements AdminSettingsService { if (adminSettings.getTenantId() == null) { adminSettings.setTenantId(TenantId.SYS_TENANT_ID); } - return adminSettingsDao.save(tenantId, adminSettings); + AdminSettings savedAdminSettings = adminSettingsDao.save(tenantId, adminSettings); + eventPublisher.publishEvent(SaveEntityEvent.builder().tenantId(savedAdminSettings.getTenantId()).entityId(savedAdminSettings.getId()) + .entity(savedAdminSettings).oldEntity(oldAdminSettings).created(adminSettings.getId() == null).build()); + return savedAdminSettings; } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java index bee3896a5b..d8be34372e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/JpaAbstractDaoListeningExecutorService.java @@ -17,8 +17,10 @@ package org.thingsboard.server.dao.sql; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.transaction.support.TransactionTemplate; +import org.thingsboard.common.util.ListeningExecutor; import javax.sql.DataSource; import java.sql.SQLException; @@ -29,7 +31,8 @@ import java.sql.Statement; public abstract class JpaAbstractDaoListeningExecutorService { @Autowired - protected JpaExecutorService service; + @Qualifier("jpaExecutorService") + protected ListeningExecutor service; @Autowired protected DataSource dataSource; diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java index f812d13f65..67e71ebcdf 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/alarm/AlarmRepository.java @@ -98,10 +98,8 @@ public interface AlarmRepository extends JpaRepository { "AND ea.entityType = :affectedEntityType " + "AND (:startTime IS NULL OR (a.createdTime >= :startTime AND ea.createdTime >= :startTime)) " + "AND (:endTime IS NULL OR (a.createdTime <= :endTime AND ea.createdTime <= :endTime)) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + @@ -119,10 +117,8 @@ public interface AlarmRepository extends JpaRepository { "AND ea.entityType = :affectedEntityType " + "AND (:startTime IS NULL OR (a.createdTime >= :startTime AND ea.createdTime >= :startTime)) " + "AND (:endTime IS NULL OR (a.createdTime <= :endTime AND ea.createdTime <= :endTime)) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + @@ -183,10 +179,8 @@ public interface AlarmRepository extends JpaRepository { "WHERE a.tenantId = :tenantId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + @@ -199,10 +193,8 @@ public interface AlarmRepository extends JpaRepository { "WHERE a.tenantId = :tenantId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + @@ -263,10 +255,8 @@ public interface AlarmRepository extends JpaRepository { "WHERE a.tenantId = :tenantId AND a.customerId = :customerId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + @@ -280,10 +270,8 @@ public interface AlarmRepository extends JpaRepository { "WHERE a.tenantId = :tenantId AND a.customerId = :customerId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#alarmTypes == null} = true) OR a.type IN (:alarmTypes)) " + //HHH-15968 - "AND ((:#{#alarmSeverities == null} = true) OR a.severity IN (:alarmSeverities)) " + //HHH-15968 -// "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + -// "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + + "AND ((:alarmTypes) IS NULL OR a.type IN (:alarmTypes)) " + + "AND ((:alarmSeverities) IS NULL OR a.severity IN (:alarmSeverities)) " + "AND ((:clearFilterEnabled) = FALSE OR a.cleared = :clearFilter) " + "AND ((:ackFilterEnabled) = FALSE OR a.acknowledged = :ackFilter) " + "AND (:assigneeId IS NULL OR a.assigneeId = :assigneeId) " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java index aa7f0490d7..8a3405549e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/attributes/AttributeKvRepository.java @@ -20,6 +20,14 @@ import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; +import org.thingsboard.server.common.data.kv.AttributeKvEntry; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.JsonDataEntry; +import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; +import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.dao.model.sql.AttributeKvCompositeKey; import org.thingsboard.server.dao.model.sql.AttributeKvEntity; @@ -60,6 +68,19 @@ public interface AttributeKvRepository extends JpaRepository findAllKeysByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, @Param("attributeType") int attributeType); + @Query(value = """ + SELECT DISTINCT ON (a.attribute_key) + kd.key AS strKey, + a.bool_v AS boolV, a.str_v AS strV, a.long_v AS longV, + a.dbl_v AS dblV, a.json_v AS jsonV, + a.last_update_ts AS lastUpdateTs, a.version AS version + FROM attribute_kv a + INNER JOIN key_dictionary kd ON a.attribute_key = kd.key_id + WHERE a.entity_id IN :entityIds AND a.attribute_type = :attributeType + ORDER BY a.attribute_key, a.last_update_ts DESC""", nativeQuery = true) + List findLatestByEntityIdsAndAttributeType(@Param("entityIds") List entityIds, + @Param("attributeType") int attributeType); + @Query(value = "SELECT attribute_key, attribute_type, entity_id, bool_v, dbl_v, json_v, last_update_ts, long_v, str_v, version FROM attribute_kv WHERE (entity_id, attribute_type, attribute_key) > " + "(:entityId, :attributeType, :attributeKey) ORDER BY entity_id, attribute_type, attribute_key LIMIT :batchSize", nativeQuery = true) List findNextBatch(@Param("entityId") UUID entityId, @@ -67,4 +88,40 @@ public interface AttributeKvRepository extends JpaRepository> findAllKeysByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return service.submit(() -> findAllKeysByEntityIdsAndScope(tenantId, entityIds, scope)); + } + + @Override + public List findLatestByEntityIdsAndScope(TenantId tenantId, List entityIds, AttributeScope scope) { + if (CollectionUtils.isEmpty(entityIds)) { + return Collections.emptyList(); + } + var uniqueIds = entityIds.stream().map(EntityId::getId).distinct().toList(); + return attributeKvRepository.findLatestByEntityIdsAndAttributeType(uniqueIds, scope.getId()) + .stream() + .map(AttributeKvRepository.AttributeKvProjection::toAttributeKvEntry) + .toList(); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAndScopeAsync(TenantId tenantId, List entityIds, AttributeScope scope) { + return service.submit(() -> findLatestByEntityIdsAndScope(tenantId, entityIds, scope)); + } + @Override public ListenableFuture save(TenantId tenantId, EntityId entityId, AttributeScope attributeScope, AttributeKvEntry attribute) { AttributeKvEntity entity = new AttributeKvEntity(); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java index d685d9ee78..4266003706 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/audit/AuditLogRepository.java @@ -33,8 +33,7 @@ public interface AuditLogRepository extends JpaRepository "a.tenantId = :tenantId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#actionTypes == null} = true) OR a.actionType IN (:actionTypes)) " + //HHH-15968 -// "AND ((:actionTypes) IS NULL OR a.actionType in (:actionTypes)) " + + "AND ((:actionTypes) IS NULL OR a.actionType IN (:actionTypes)) " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + @@ -54,8 +53,7 @@ public interface AuditLogRepository extends JpaRepository "AND a.entityType = :entityType AND a.entityId = :entityId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#actionTypes == null} = true) OR a.actionType IN (:actionTypes)) " + //HHH-15968 -// "AND ((:actionTypes) IS NULL OR a.actionType in (:actionTypes)) " + + "AND ((:actionTypes) IS NULL OR a.actionType IN (:actionTypes)) " + "AND (:textSearch IS NULL OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " + @@ -75,8 +73,7 @@ public interface AuditLogRepository extends JpaRepository "AND a.customerId = :customerId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#actionTypes == null} = true) OR a.actionType IN (:actionTypes)) " + //HHH-15968 -// "AND ((:actionTypes) IS NULL OR a.actionType in (:actionTypes)) " + + "AND ((:actionTypes) IS NULL OR a.actionType IN (:actionTypes)) " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.userName, CONCAT('%', :textSearch, '%')) = true " + @@ -96,8 +93,7 @@ public interface AuditLogRepository extends JpaRepository "AND a.userId = :userId " + "AND (:startTime IS NULL OR a.createdTime >= :startTime) " + "AND (:endTime IS NULL OR a.createdTime <= :endTime) " + - "AND ((:#{#actionTypes == null} = true) OR a.actionType IN (:actionTypes)) " + //HHH-15968 -// "AND ((:actionTypes) IS NULL OR a.actionType in (:actionTypes)) " + + "AND ((:actionTypes) IS NULL OR a.actionType IN (:actionTypes)) " + "AND (:textSearch IS NULL OR ilike(a.entityType, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.entityName, CONCAT('%', :textSearch, '%')) = true " + "OR ilike(a.actionType, CONCAT('%', :textSearch, '%')) = true " + diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java index f6113155ca..0f9dcc20be 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/cf/DefaultNativeCalculatedFieldRepository.java @@ -74,6 +74,7 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF JsonNode configuration = JacksonUtil.toJsonNode((String) row.get("configuration")); long version = row.get("version") != null ? (long) row.get("version") : 0; String debugSettings = (String) row.get("debug_settings"); + JsonNode additionalInfo = JacksonUtil.toJsonNode((String) row.get("additional_info")); CalculatedField calculatedField = new CalculatedField(); calculatedField.setId(new CalculatedFieldId(id)); @@ -91,6 +92,7 @@ public class DefaultNativeCalculatedFieldRepository implements NativeCalculatedF } calculatedField.setVersion(version); calculatedField.setDebugSettings(JacksonUtil.fromString(debugSettings, DebugSettings.class)); + calculatedField.setAdditionalInfo(additionalInfo); return calculatedField; }).collect(Collectors.toList()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java index 7a648cea3c..4775d5d35b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeAssetRepository.java @@ -50,7 +50,8 @@ public class DefaultNativeAssetRepository extends AbstractNativeRepository imple @Override public PageData findProfileEntityIdInfosByTenantId(UUID tenantId, Pageable pageable) { String PROFILE_ASSET_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, customer_id as customerId, asset_profile_id as profileId, id as id FROM asset WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); - return find(COUNT_QUERY, PROFILE_ASSET_ID_INFO_QUERY, pageable, DefaultNativeAssetRepository::toInfo); + String COUNT_QUERY_BY_TENANT = String.format("SELECT count(id) FROM asset WHERE tenant_id = '%s';", tenantId); + return find(COUNT_QUERY_BY_TENANT, PROFILE_ASSET_ID_INFO_QUERY, pageable, DefaultNativeAssetRepository::toInfo); } private static ProfileEntityIdInfo toInfo(Map row) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java index 275729f1e9..2a15aee984 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/device/DefaultNativeDeviceRepository.java @@ -62,7 +62,8 @@ public class DefaultNativeDeviceRepository extends AbstractNativeRepository impl @Override public PageData findProfileEntityIdInfosByTenantId(UUID tenantId, Pageable pageable) { String PROFILE_DEVICE_ID_INFO_QUERY = String.format("SELECT tenant_id as tenantId, customer_id as customerId, device_profile_id as profileId, id as id FROM device WHERE tenant_id = '%s' ORDER BY created_time ASC LIMIT %%s OFFSET %%s", tenantId); - return find(COUNT_QUERY, PROFILE_DEVICE_ID_INFO_QUERY, pageable, DefaultNativeDeviceRepository::toInfo); + String COUNT_QUERY_BY_TENANT = String.format("SELECT count(id) FROM device WHERE tenant_id = '%s';", tenantId); + return find(COUNT_QUERY_BY_TENANT, PROFILE_DEVICE_ID_INFO_QUERY, pageable, DefaultNativeDeviceRepository::toInfo); } private static ProfileEntityIdInfo toInfo(Map row) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java index 9d32e91ca2..f4cc406a75 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDao.java @@ -98,8 +98,8 @@ public class JpaNotificationRequestDao extends JpaAbstractDao findIdsByTenantId(@Param("tenantId") UUID tenantId, Pageable pageable); + // The 'data' column is of type OID (PostgreSQL large object reference), so it returns the OID as Long + @Query(value = "SELECT data FROM ota_package WHERE id = :id AND data IS NOT NULL", nativeQuery = true) + Long getDataOidById(@Param("id") UUID id); + + @Transactional + @Query(value = "SELECT lo_unlink(:oid)", nativeQuery = true) + Integer unlinkLargeObject(@Param("oid") Long oid); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java index e3e560725c..a83223d726 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/ApiKeyRepository.java @@ -15,6 +15,8 @@ */ package org.thingsboard.server.dao.sql.pat; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -22,6 +24,7 @@ import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -29,6 +32,10 @@ public interface ApiKeyRepository extends JpaRepository { ApiKeyEntity findByValue(String value); + Page findByTenantId(UUID tenantId, Pageable pageable); + + List findByTenantIdAndUserId(UUID tenantId, UUID userId); + @Transactional @Modifying @Query(value = """ diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java index 61ca7bd142..69de36384e 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/pat/JpaApiKeyDao.java @@ -22,6 +22,8 @@ import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.id.UserId; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.pat.ApiKey; import org.thingsboard.server.dao.DaoUtil; import org.thingsboard.server.dao.model.sql.ApiKeyEntity; @@ -29,6 +31,7 @@ import org.thingsboard.server.dao.pat.ApiKeyDao; import org.thingsboard.server.dao.sql.JpaAbstractDao; import org.thingsboard.server.dao.util.SqlDao; +import java.util.List; import java.util.Set; import java.util.UUID; @@ -45,6 +48,16 @@ public class JpaApiKeyDao extends JpaAbstractDao implement return DaoUtil.getData(apiKeyRepository.findByValue(value)); } + @Override + public PageData findByTenantId(TenantId tenantId, PageLink pageLink) { + return DaoUtil.toPageData(apiKeyRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + } + + @Override + public List findByTenantIdAndUserId(TenantId tenantId, UserId userId) { + return DaoUtil.convertDataList(apiKeyRepository.findByTenantIdAndUserId(tenantId.getId(), userId.getId())); + } + @Override public Set deleteByTenantId(TenantId tenantId) { return apiKeyRepository.deleteByTenantId(tenantId.getId()); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java index a9b04618c8..28a144459d 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/DefaultEntityQueryRepository.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.dao.sql.query; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -30,6 +31,7 @@ import org.thingsboard.server.common.data.page.PageData; import org.thingsboard.server.common.data.permission.QueryContext; import org.thingsboard.server.common.data.query.ApiUsageStateFilter; import org.thingsboard.server.common.data.query.AssetSearchQueryFilter; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.AssetTypeFilter; import org.thingsboard.server.common.data.query.DeviceSearchQueryFilter; import org.thingsboard.server.common.data.query.DeviceTypeFilter; @@ -54,12 +56,14 @@ import org.thingsboard.server.common.data.query.SingleEntityFilter; import org.thingsboard.server.common.data.relation.EntitySearchDirection; import org.thingsboard.server.common.data.relation.RelationEntityTypeFilter; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.stream.Collectors; @@ -318,10 +322,19 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { .replace("$in", "from").replace("$out", "to") .replace("$rootIdCondition", "in (:relation_root_ids)"); + private static final String NULLS_ORDER_DEFAULT = "default"; + private static final String NULLS_ORDER_FIRST = "nulls_first"; + private static final String NULLS_ORDER_LAST = "nulls_last"; + private static final Set ACCEPTED_NULLS_ORDER_STRATEGIES = Set.of(NULLS_ORDER_DEFAULT, NULLS_ORDER_FIRST, NULLS_ORDER_LAST); + @Getter @Value("${sql.relations.max_level:50}") int maxLevelAllowed; //This value has to be reasonable small to prevent infinite recursion as early as possible + @Getter + @Value("${sql.entity_data_query_nulls_order_strategy:default}") + String nullsOrderStrategy; + private final NamedParameterJdbcTemplate jdbcTemplate; private final TransactionTemplate transactionTemplate; private final DefaultQueryLogComponent queryLog; @@ -332,6 +345,15 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { this.queryLog = queryLog; } + @PostConstruct + void validateNullsOrderStrategy() { + if (!ACCEPTED_NULLS_ORDER_STRATEGIES.contains(nullsOrderStrategy)) { + log.error("Invalid value '{}' for sql.entity_data_query_nulls_order_strategy. Accepted values are: {}. Falling back to '{}'.", + nullsOrderStrategy, ACCEPTED_NULLS_ORDER_STRATEGIES, NULLS_ORDER_DEFAULT); + nullsOrderStrategy = NULLS_ORDER_DEFAULT; + } + } + @Override public long countEntitiesByQuery(TenantId tenantId, CustomerId customerId, EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); @@ -351,25 +373,56 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { } }); } else { - List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List mappings = EntityKeyMapping.prepareEntityCountKeyMapping(query); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List filterMapping = mappings.stream().filter(EntityKeyMapping::hasFilter) .collect(Collectors.toList()); List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mirror the ignore=true fix from findEntityDataByQuery so the inner subquery still emits the + // extra column but downstream response shape (benign for count) stays symmetric with the data path. + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + m.setIgnore(true); + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + if (!combinedFilterQuery.isEmpty()) { + latestJoinsCnt += " where (" + combinedFilterQuery + ")"; + } + aliasWhereQuery = ""; + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, ""); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { @@ -419,13 +472,13 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { EntityType entityType = resolveEntityType(query.getEntityFilter()); SqlQueryContext ctx = new SqlQueryContext(new QueryContext(tenantId, customerId, entityType, ignorePermissionCheck)); EntityDataPageLink pageLink = query.getPageLink(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); + boolean isOr = operation == ComplexOperation.OR; List mappings = EntityKeyMapping.prepareKeyMapping(entityType, query); - List selectionMapping = mappings.stream().filter(EntityKeyMapping::isSelection) - .collect(Collectors.toList()); - List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) - .collect(Collectors.toList()); + List selectionMapping = new ArrayList<>(mappings.stream().filter(EntityKeyMapping::isSelection) + .collect(Collectors.toList())); List latestSelectionMapping = selectionMapping.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); @@ -434,14 +487,52 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { List entityFieldsFiltersMapping = filterMapping.stream().filter(mapping -> !mapping.isLatest() && mapping.getEntityKeyColumn() != null) .collect(Collectors.toList()); + // Under OR: entity field filter columns must be in inner SELECT for outer WHERE reference. + // Mark force-added filter-only mappings as ignored so EntityDataAdapter does not expose + // them in EntityData.latest — keeps the response shape identical to AND. + if (isOr) { + for (EntityKeyMapping m : entityFieldsFiltersMapping) { + if (!selectionMapping.contains(m)) { + m.setIgnore(true); + selectionMapping.add(m); + } + } + } + + List entityFieldsSelectionMapping = selectionMapping.stream().filter(mapping -> !mapping.isLatest()) + .collect(Collectors.toList()); + List allLatestMappings = mappings.stream().filter(EntityKeyMapping::isLatest) .collect(Collectors.toList()); + // Under OR: entity field filters move to outer WHERE (not inner WHERE) + List innerEntityFieldsFilters = isOr ? Collections.emptyList() : entityFieldsFiltersMapping; + + String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), innerEntityFieldsFilters); + String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true, isOr); + String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false, isOr); + + // Under OR: combine ALL filter mappings (entity fields + aliases) with OR joiner + // Place at the middle layer (after JOINs) where alias table references are visible. + // Under AND: place at the outer result layer (existing behavior). + String aliasWhereQuery; + if (isOr) { + String combinedFilterQuery = EntityKeyMapping.buildQuery(ctx, filterMapping, query.getEntityFilter().getType(), ComplexOperation.OR); + String middleWhere = ""; + if (!combinedFilterQuery.isEmpty()) { + middleWhere = " where (" + combinedFilterQuery + ")"; + } + String searchTextQuery = buildTextSearchQuery(ctx, selectionMapping, pageLink.getTextSearch()); + if (!searchTextQuery.isEmpty()) { + middleWhere += (middleWhere.isEmpty() ? " where " : " and ") + "(" + searchTextQuery + ") "; + } + latestJoinsCnt += middleWhere; + latestJoinsData += middleWhere; + aliasWhereQuery = ""; + } else { + aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); + } - String entityWhereClause = DefaultEntityQueryRepository.this.buildEntityWhere(ctx, query.getEntityFilter(), entityFieldsFiltersMapping); - String latestJoinsCnt = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, true); - String latestJoinsData = EntityKeyMapping.buildLatestJoins(ctx, query.getEntityFilter(), entityType, allLatestMappings, false); - String aliasWhereQuery = DefaultEntityQueryRepository.this.buildAliasWhereQuery(ctx, query.getEntityFilter(), selectionMapping, pageLink.getTextSearch()); String entityFieldsSelection = EntityKeyMapping.buildSelections(entityFieldsSelectionMapping, query.getEntityFilter().getType(), entityType); String entityTypeStr; if (query.getEntityFilter().getType().equals(EntityFilterType.RELATIONS_QUERY)) { @@ -502,11 +593,12 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { if (sortOrderMappingOpt.isPresent()) { EntityKeyMapping sortOrderMapping = sortOrderMappingOpt.get(); String direction = sortOrder.getDirection() == EntityDataSortOrder.Direction.ASC ? "asc" : "desc"; + String nullsOrder = resolveNullsOrder(); if (sortOrderMapping.getEntityKey().getType() == EntityKeyType.ENTITY_FIELD) { - dataQuery = String.format("%s order by %s %s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, direction); + dataQuery = String.format("%s order by %s %s%s, result.id %s", dataQuery, sortOrderMapping.getValueAlias(), direction, nullsOrder, direction); } else { - dataQuery = String.format("%s order by %s %s, %s %s, result.id %s", dataQuery, - sortOrderMapping.getSortOrderNumAlias(), direction, sortOrderMapping.getSortOrderStrAlias(), direction, direction); + dataQuery = String.format("%s order by %s %s%s, %s %s, result.id %s", dataQuery, + sortOrderMapping.getSortOrderNumAlias(), direction, nullsOrder, sortOrderMapping.getSortOrderStrAlias(), direction, direction); } } } @@ -525,6 +617,14 @@ public class DefaultEntityQueryRepository implements EntityQueryRepository { }); } + private String resolveNullsOrder() { + return switch (nullsOrderStrategy) { + case NULLS_ORDER_FIRST -> " NULLS FIRST"; + case NULLS_ORDER_LAST -> " NULLS LAST"; + default -> ""; + }; + } + private String buildEntityWhere(SqlQueryContext ctx, EntityFilter entityFilter, List entityFieldsFilters) { String permissionQuery = this.buildPermissionQuery(ctx, entityFilter); String entityFilterQuery = this.buildEntityFilterQuery(ctx, entityFilter); diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java index b9a134e713..7fe002e156 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/query/EntityKeyMapping.java @@ -21,6 +21,7 @@ import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityDataQuery; import org.thingsboard.server.common.data.query.EntityDataSortOrder; @@ -283,16 +284,39 @@ public class EntityKeyMapping { } public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType) { - if (hasFilter()) { - String keyAlias = (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) ? "e" : alias; - return keyFilters.stream().map(keyFilter -> - this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType)); - } else { + return toQueries(ctx, filterType, false); + } + + public Stream toQueries(SqlQueryContext ctx, EntityFilterType filterType, boolean outerContext) { + if (!hasFilter()) { return Stream.empty(); } + String keyAlias; + boolean useAliasDirectly = false; + if (entityKey.getType().equals(EntityKeyType.ENTITY_FIELD) && getEntityKeyColumn() != null) { + if (outerContext) { + // In the middle layer (OR relocation), entity field columns are exposed + // by their alias name from the inner subquery SELECT (e.g., "alias2" from + // "cast(e.name as varchar) as alias2"), so buildSimplePredicateQuery must + // use the alias directly as the field instead of appending entityKeyColumn. + keyAlias = alias; + useAliasDirectly = true; + } else { + keyAlias = "e"; + } + } else { + keyAlias = alias; + } + final boolean aliasAsField = useAliasDirectly; + return keyFilters.stream().map(keyFilter -> + this.buildKeyQuery(ctx, keyAlias, keyFilter, filterType, aliasAsField)); } public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType) { + return toLatestJoin(ctx, entityFilter, entityType, false); + } + + public String toLatestJoin(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, boolean forceLeftJoin) { String entityTypeStr; if (entityFilter.getType().equals(EntityFilterType.RELATIONS_QUERY)) { entityTypeStr = "entities.entity_type"; @@ -300,20 +324,23 @@ public class EntityKeyMapping { entityTypeStr = "'" + entityType.name() + "'"; } ctx.addStringParameter(getKeyId(), entityKey.getKey()); - String filterQuery = toQueries(ctx, entityFilter.getType()) + // Under OR (forceLeftJoin=true) the filter predicate is re-emitted by buildQuery at the middle + // layer with disjunction semantics. Inlining it in the ON clause here would duplicate the + // predicate with a different bound parameter name and double-filter the LEFT JOIN. + String filterQuery = forceLeftJoin ? "" : toQueries(ctx, entityFilter.getType()) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" and ")); if (StringUtils.isNotEmpty(filterQuery)) { filterQuery = " AND (" + filterQuery + ")"; } if (entityKey.getType().equals(EntityKeyType.TIME_SERIES)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; return String.format("%s ts_kv_latest %s ON %s.entity_id=entities.id AND %s.key = (select key_id from key_dictionary where key = :%s_key_id) %s", join, alias, alias, alias, alias, filterQuery); } else { String query; if (!entityKey.getType().equals(EntityKeyType.ATTRIBUTE)) { - String join = (hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "inner join" : "left join"; query = String.format("%s attribute_kv %s ON %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) ", join, alias, alias, alias, alias); int scope; @@ -326,7 +353,7 @@ public class EntityKeyMapping { } query = String.format("%s AND %s.attribute_type=%s %s", query, alias, scope, filterQuery); } else { - String join = (hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; + String join = (!forceLeftJoin && hasFilter() && hasFilterValues(ctx)) ? "join LATERAL" : "left join LATERAL"; query = String.format("%s (select * from attribute_kv %s WHERE %s.entity_id=entities.id AND %s.attribute_key=(select key_id from key_dictionary where key = :%s_key_id) %s " + "ORDER BY %s.last_update_ts DESC limit 1) as %s ON true", join, alias, alias, alias, alias, filterQuery, alias, alias); @@ -351,27 +378,47 @@ public class EntityKeyMapping { } public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, List latestMappings, boolean countQuery) { + return buildLatestJoins(ctx, entityFilter, entityType, latestMappings, countQuery, false); + } + + public static String buildLatestJoins(SqlQueryContext ctx, EntityFilter entityFilter, EntityType entityType, + List latestMappings, boolean countQuery, boolean forceLeftJoin) { return latestMappings.stream() .filter(mapping -> !countQuery || mapping.hasFilter()) - .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType)) + .map(mapping -> mapping.toLatestJoin(ctx, entityFilter, entityType, forceLeftJoin)) .collect(Collectors.joining(" ")); } public static String buildQuery(SqlQueryContext ctx, List mappings, EntityFilterType filterType) { + return buildQuery(ctx, mappings, filterType, ComplexOperation.AND); + } + + public static String buildQuery(SqlQueryContext ctx, List mappings, + EntityFilterType filterType, ComplexOperation operation) { + String joiner = (operation == ComplexOperation.OR) ? " OR " : " AND "; + // Vacuously-true predicates (e.g. a ComplexFilterPredicate with zero nested predicates producing + // an empty string) are dropped here. Under AND this is safe — TRUE is the identity for AND — but + // under OR it silently narrows the disjunction. Callers must not emit meaningful empty predicates. return mappings.stream() - .flatMap(mapping -> mapping.toQueries(ctx, filterType)) + .flatMap(mapping -> mapping.toQueries(ctx, filterType, operation == ComplexOperation.OR)) .filter(StringUtils::isNotEmpty) - .collect(Collectors.joining(" AND ")); + .collect(Collectors.joining(joiner)); } public static List prepareKeyMapping(EntityType entityType, EntityDataQuery query) { EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); List entityFields = query.getEntityFields() != null ? query.getEntityFields() : Collections.emptyList(); List latestValues = query.getLatestValues() != null ? query.getLatestValues() : Collections.emptyList(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + Map> filters; + if (operation == ComplexOperation.OR) { + // Under OR, don't group same-key filters; handle individually below + filters = Collections.emptyMap(); + } else { + filters = query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + } EntityDataSortOrder sortOrder = query.getPageLink().getSortOrder(); EntityKey sortOrderKey = sortOrder != null ? sortOrder.getKey() : null; int index = 2; @@ -428,14 +475,37 @@ public class EntityKeyMapping { for (EntityKeyMapping mapping : mappings) { mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.remove(mapping.entityKey)); + if (operation != ComplexOperation.OR) { + // Under AND: assign grouped filters to matching selection mappings + mapping.setKeyFilters(filters.remove(mapping.entityKey)); + } if (mapping.getEntityKey().getType().equals(EntityKeyType.ENTITY_FIELD)) { index++; } else { index += 2; } } - if (!filters.isEmpty()) { + if (operation == ComplexOperation.OR && query.getKeyFilters() != null) { + // Under OR: each KeyFilter gets its own EntityKeyMapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + if (filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)) { + index += 1; + } else { + index += 2; + } + } + } else if (!filters.isEmpty()) { + // Under AND: create mappings for filter-only keys not in selection for (EntityKey filterField : filters.keySet()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); @@ -464,25 +534,45 @@ public class EntityKeyMapping { public static List prepareEntityCountKeyMapping(EntityCountQuery query) { EntityType entityType = resolveEntityType(query.getEntityFilter()); EntityFilterType entityFilterType = query.getEntityFilter().getType(); + ComplexOperation operation = query.getKeyFiltersOperationOrDefault(); - Map> filters = - query.getKeyFilters() != null ? - query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); int index = 2; List mappings = new ArrayList<>(); - if (!filters.isEmpty()) { - for (EntityKey filterField : filters.keySet()) { + + if (operation == ComplexOperation.OR && query.getKeyFilters() != null && !query.getKeyFilters().isEmpty()) { + // Under OR: each KeyFilter gets its own mapping (no same-key grouping) + for (KeyFilter keyFilter : query.getKeyFilters()) { EntityKeyMapping mapping = new EntityKeyMapping(); mapping.setIndex(index); mapping.setAlias(String.format("alias%s", index)); - mapping.setKeyFilters(filters.get(filterField)); - mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); - mapping.setEntityKey(filterField); + mapping.setKeyFilters(Collections.singletonList(keyFilter)); + EntityKey filterKey = keyFilter.getKey(); + mapping.setLatest(!filterKey.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterKey); mapping.setEntityKeyColumn(entityType, entityFilterType); mapping.setSelection(mapping.getEntityKeyColumn() == null); mappings.add(mapping); index += 1; } + } else { + // Under AND (or null/default): group same-key filters together + Map> filters = + query.getKeyFilters() != null ? + query.getKeyFilters().stream().collect(Collectors.groupingBy(KeyFilter::getKey)) : Collections.emptyMap(); + if (!filters.isEmpty()) { + for (EntityKey filterField : filters.keySet()) { + EntityKeyMapping mapping = new EntityKeyMapping(); + mapping.setIndex(index); + mapping.setAlias(String.format("alias%s", index)); + mapping.setKeyFilters(filters.get(filterField)); + mapping.setLatest(!filterField.getType().equals(EntityKeyType.ENTITY_FIELD)); + mapping.setEntityKey(filterField); + mapping.setEntityKeyColumn(entityType, entityFilterType); + mapping.setSelection(mapping.getEntityKeyColumn() == null); + mappings.add(mapping); + index += 1; + } + } } return mappings; @@ -511,8 +601,8 @@ public class EntityKeyMapping { String attrNumAlias = getSortOrderNumAlias(); String attrVarcharAlias = getSortOrderStrAlias(); String attrSortOrderSelection = - String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v then 1 else 0 end)) %s," + - "coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias); + String.format("coalesce(%s.dbl_v, cast(%s.long_v as double precision), (case when %s.bool_v is null then null when %s.bool_v then 1 else 0 end)) %s," + + "coalesce(%s.str_v, cast(%s.json_v as varchar), '') %s", alias, alias, alias, alias, attrNumAlias, alias, alias, attrVarcharAlias); return String.join(", ", attrValSelection, attrTsSelection, attrSortOrderSelection); } else { return String.join(", ", attrValSelection, attrTsSelection); @@ -529,22 +619,27 @@ public class EntityKeyMapping { private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, EntityFilterType filterType) { - return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType); + return this.buildKeyQuery(ctx, alias, keyFilter, filterType, false); + } + + private String buildKeyQuery(SqlQueryContext ctx, String alias, KeyFilter keyFilter, + EntityFilterType filterType, boolean useAliasAsField) { + return this.buildPredicateQuery(ctx, alias, keyFilter.getKey(), keyFilter.getPredicate(), filterType, useAliasAsField); } private String buildPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (predicate.getType().equals(FilterPredicateType.COMPLEX)) { - return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType); + return this.buildComplexPredicateQuery(ctx, alias, key, (ComplexFilterPredicate) predicate, filterType, useAliasAsField); } else { - return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType); + return this.buildSimplePredicateQuery(ctx, alias, key, predicate, filterType, useAliasAsField); } } private String buildComplexPredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - ComplexFilterPredicate predicate, EntityFilterType filterType) { + ComplexFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { String result = predicate.getPredicates().stream() - .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType)) + .map(keyFilterPredicate -> this.buildPredicateQuery(ctx, alias, key, keyFilterPredicate, filterType, useAliasAsField)) .filter(StringUtils::isNotEmpty) .collect(Collectors.joining(" " + predicate.getOperation().name() + " ")); if (!result.trim().isEmpty()) { @@ -554,9 +649,9 @@ public class EntityKeyMapping { } private String buildSimplePredicateQuery(SqlQueryContext ctx, String alias, EntityKey key, - KeyFilterPredicate predicate, EntityFilterType filterType) { + KeyFilterPredicate predicate, EntityFilterType filterType, boolean useAliasAsField) { if (key.getType().equals(EntityKeyType.ENTITY_FIELD)) { - String field = (getEntityKeyColumn() != null) ? alias + "." + getEntityKeyColumn() : alias; + String field = useAliasAsField || getEntityKeyColumn() == null ? alias : alias + "." + getEntityKeyColumn(); if (predicate.getType().equals(FilterPredicateType.NUMERIC)) { return this.buildNumericPredicateQuery(ctx, field, (NumericFilterPredicate) predicate); } else if (predicate.getType().equals(FilterPredicateType.STRING)) { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java index 9daae69d2a..a929b6d1e4 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDao.java @@ -71,8 +71,8 @@ public class JpaRpcDao extends JpaAbstractDao implements RpcDao, @Transactional @Override - public int deleteOutdatedRpcByTenantId(TenantId tenantId, Long expirationTime) { - return rpcRepository.deleteOutdatedRpcByTenantId(tenantId.getId(), expirationTime); + public int deleteOutdatedRpcByTenantIdBatch(TenantId tenantId, Long expirationTime, int batchSize) { + return rpcRepository.deleteOutdatedRpcByTenantIdBatch(tenantId.getId(), expirationTime, batchSize); } @Override diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java index 8a9489333f..3f76170c84 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rpc/RpcRepository.java @@ -21,20 +21,27 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import org.springframework.transaction.annotation.Transactional; import org.thingsboard.server.common.data.rpc.RpcStatus; import org.thingsboard.server.dao.model.sql.RpcEntity; import java.util.UUID; public interface RpcRepository extends JpaRepository { + Page findAllByTenantIdAndDeviceId(UUID tenantId, UUID deviceId, Pageable pageable); Page findAllByTenantIdAndDeviceIdAndStatus(UUID tenantId, UUID deviceId, RpcStatus status, Pageable pageable); Page findAllByTenantId(UUID tenantId, Pageable pageable); + @Transactional @Modifying - @Query(value = "DELETE FROM rpc WHERE tenant_id = :tenantId AND created_time < :expirationTime", + @Query(value = "DELETE FROM rpc WHERE id IN " + + "(SELECT id FROM rpc WHERE tenant_id = :tenantId AND created_time < :expirationTime LIMIT :batchSize)", nativeQuery = true) - int deleteOutdatedRpcByTenantId(@Param("tenantId") UUID tenantId, @Param("expirationTime") Long expirationTime); + int deleteOutdatedRpcByTenantIdBatch(@Param("tenantId") UUID tenantId, + @Param("expirationTime") Long expirationTime, + @Param("batchSize") int batchSize); + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java new file mode 100644 index 0000000000..3381bae386 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/JpaRuleChainDetailsDao.java @@ -0,0 +1,77 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.rule; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.exception.ExceptionUtils; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Component; +import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.rule.RuleChainDetails; +import org.thingsboard.server.dao.DaoUtil; +import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity; +import org.thingsboard.server.dao.rule.RuleChainDetailsDao; +import org.thingsboard.server.dao.sql.JpaAbstractDao; +import org.thingsboard.server.dao.util.SqlDao; +import org.thingsboard.server.exception.DataValidationException; + +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Component +@SqlDao +@RequiredArgsConstructor +public class JpaRuleChainDetailsDao extends JpaAbstractDao implements RuleChainDetailsDao { + + private final RuleChainDetailsRepository ruleChainDetailsRepository; + + @Override + public RuleChainDetails findById(TenantId tenantId, UUID key) { + Optional entity = getRepository().findById(key); + // Detaching to avoid stale version conflict with RuleChainEntity which maps to the same table. + // Without detach, a loaded RuleChainDetailsEntity stays in the persistence context and becomes stale + // when the same row is updated via RuleChainEntity (e.g. during rule chain import with circular references). + entity.ifPresent(e -> getEntityManager().detach(e)); + return DaoUtil.getData(entity); + } + + @Override + protected RuleChainDetailsEntity doSave(RuleChainDetailsEntity entity, boolean isNew, boolean flush) { + try { + return super.doSave(entity, isNew, flush); + } catch (Exception e) { + String rootMsg = ExceptionUtils.getRootCauseMessage(e); + if (StringUtils.contains(rootMsg, "value too long")) { + throw new DataValidationException("Rule chain notes data is too large. Please reduce the number or size of notes."); + } + throw e; + } + } + + @Override + protected Class getEntityClass() { + return RuleChainDetailsEntity.class; + } + + @Override + protected JpaRepository getRepository() { + return ruleChainDetailsRepository; + } + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java new file mode 100644 index 0000000000..df54720984 --- /dev/null +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/rule/RuleChainDetailsRepository.java @@ -0,0 +1,25 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.rule; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.thingsboard.server.dao.model.sql.RuleChainDetailsEntity; + +import java.util.UUID; + +public interface RuleChainDetailsRepository extends JpaRepository { + +} diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java index 0d35e08b5b..d9e77a34e1 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDao.java @@ -120,7 +120,7 @@ public abstract class AbstractChunkedAggregationTimeseriesDao extends AbstractSq public ListenableFuture findAllAsync(TenantId tenantId, EntityId entityId, ReadTsKvQuery query) { var aggParams = query.getAggParameters(); if (Aggregation.NONE.equals(aggParams.getAggregation()) || aggParams.getInterval() < 1) { - return Futures.immediateFuture(findAllAsyncWithLimit(entityId, query)); + return service.submit(() -> findAllWithLimit(entityId, query)); } else { List>> futures = new ArrayList<>(); var intervalType = aggParams.getIntervalType(); @@ -144,7 +144,7 @@ public abstract class AbstractChunkedAggregationTimeseriesDao extends AbstractSq } } - ReadTsKvQueryResult findAllAsyncWithLimit(EntityId entityId, ReadTsKvQuery query) { + ReadTsKvQueryResult findAllWithLimit(EntityId entityId, ReadTsKvQuery query) { Integer keyId = keyDictionaryDao.getOrSaveKeyId(query.getKey()); List tsKvEntities = tsKvRepository.findAllWithLimit( entityId.getId(), diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java index eabbdfed80..143ab53e18 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/CachedRedisSqlTimeseriesLatestDao.java @@ -172,4 +172,14 @@ public class CachedRedisSqlTimeseriesLatestDao extends BaseAbstractSqlTimeseries return sqlDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return sqlDao.findLatestByEntityIds(tenantId, entityIds); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return sqlDao.findLatestByEntityIdsAsync(tenantId, entityIds); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java index d807794ddc..b33ceca63b 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDao.java @@ -22,6 +22,7 @@ import com.google.common.util.concurrent.MoreExecutors; import jakarta.annotation.PostConstruct; import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.collections4.CollectionUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -54,6 +55,7 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import org.thingsboard.server.dao.util.SqlTsLatestAnyDao; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.List; @@ -189,6 +191,21 @@ public class SqlTimeseriesLatestDao extends BaseAbstractSqlTimeseriesDao impleme return service.submit(() -> findAllKeysByEntityIds(tenantId, entityIds)); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + if (CollectionUtils.isEmpty(entityIds)) { + return Collections.emptyList(); + } + return DaoUtil.convertDataList( + searchTsKvLatestRepository.findLatestByEntityIds(entityIds.stream().map(EntityId::getId).toList()) + ); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return service.submit(() -> findLatestByEntityIds(tenantId, entityIds)); + } + private ListenableFuture getNewLatestEntryFuture(TenantId tenantId, EntityId entityId, DeleteTsKvQuery query, Long version) { ListenableFuture> future = findNewLatestEntryFuture(tenantId, entityId, query); return Futures.transformAsync(future, entryList -> { diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java index 0ab1994059..8b3a66612c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/latest/SearchTsKvLatestRepository.java @@ -34,6 +34,20 @@ public class SearchTsKvLatestRepository { " ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, ts_kv_latest.ts AS ts, ts_kv_latest.version AS version FROM ts_kv_latest " + "INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id WHERE ts_kv_latest.entity_id = cast(:id AS uuid)"; + public static final String FIND_LATEST_BY_ENTITY_IDS = "findLatestByEntityIds"; + + public static final String FIND_LATEST_BY_ENTITY_IDS_QUERY = """ + SELECT DISTINCT ON (ts_kv_latest.key) + ts_kv_latest.entity_id AS entityId, ts_kv_latest.key AS key, + key_dictionary.key AS strKey, ts_kv_latest.str_v AS strValue, + ts_kv_latest.bool_v AS boolValue, ts_kv_latest.long_v AS longValue, + ts_kv_latest.dbl_v AS doubleValue, ts_kv_latest.json_v AS jsonValue, + ts_kv_latest.ts AS ts, ts_kv_latest.version AS version + FROM ts_kv_latest + INNER JOIN key_dictionary ON ts_kv_latest.key = key_dictionary.key_id + WHERE ts_kv_latest.entity_id IN (:entityIds) + ORDER BY ts_kv_latest.key, ts_kv_latest.ts DESC"""; + @PersistenceContext private EntityManager entityManager; @@ -43,4 +57,10 @@ public class SearchTsKvLatestRepository { .getResultList(); } + public List findLatestByEntityIds(List entityIds) { + return entityManager.createNamedQuery(FIND_LATEST_BY_ENTITY_IDS, TsKvLatestEntity.class) + .setParameter("entityIds", entityIds) + .getResultList(); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java index ae1a95c9ec..c273dd646a 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/timescale/TimescaleTimeseriesDao.java @@ -152,7 +152,7 @@ public class TimescaleTimeseriesDao extends AbstractSqlTimeseriesDao implements var aggParams = query.getAggParameters(); var intervalType = aggParams.getIntervalType(); if (query.getAggregation() == Aggregation.NONE) { - return Futures.immediateFuture(findAllAsyncWithLimit(entityId, query)); + return service.submit(() -> findAllWithLimit(entityId, query)); } else if (IntervalType.MILLISECONDS.equals(intervalType)) { long startTs = query.getStartTs(); long endTs = Math.max(query.getStartTs() + 1, query.getEndTs()); @@ -179,7 +179,7 @@ public class TimescaleTimeseriesDao extends AbstractSqlTimeseriesDao implements super.cleanup(systemTtl); } - private ReadTsKvQueryResult findAllAsyncWithLimit(EntityId entityId, ReadTsKvQuery query) { + private ReadTsKvQueryResult findAllWithLimit(EntityId entityId, ReadTsKvQuery query) { String strKey = query.getKey(); Integer keyId = keyDictionaryDao.getOrSaveKeyId(strKey); List timescaleTsKvEntities = tsKvRepository.findAllWithLimit( diff --git a/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java index 6cfe1c7b36..10c5ae4185 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sqlts/ts/TsKvRepository.java @@ -61,8 +61,10 @@ public interface TsKvRepository extends JpaRepository INCORRECT_TENANT_ID + id); validateId(tenantProfileId, id -> INCORRECT_TENANT_PROFILE_ID + id); @@ -198,22 +198,18 @@ public class TenantProfileServiceImpl extends AbstractCachedEntityService processMinOrMaxResult(AggregationResult aggResult) { if (aggResult.dataType == DataType.DOUBLE || aggResult.dataType == DataType.LONG) { if (aggResult.hasDouble) { - double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(Double.MIN_VALUE); + double currentD = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.dValue).orElse(Double.MAX_VALUE) : Optional.ofNullable(aggResult.dValue).orElse(-Double.MAX_VALUE); double currentL = aggregation == Aggregation.MIN ? Optional.ofNullable(aggResult.lValue).orElse(Long.MAX_VALUE) : Optional.ofNullable(aggResult.lValue).orElse(Long.MIN_VALUE); return Optional.of(new BasicTsKvEntry(ts, new DoubleDataEntry(key, aggregation == Aggregation.MIN ? Math.min(currentD, currentL) : Math.max(currentD, currentL)))); } else { diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java index 747197470c..70e04eb5e3 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/BaseTimeseriesService.java @@ -55,9 +55,6 @@ import java.util.stream.Collectors; import static org.thingsboard.server.common.data.StringUtils.isBlank; -/** - * @author Andrew Shvayka - */ @Service @Slf4j public class BaseTimeseriesService implements TimeseriesService { @@ -161,6 +158,16 @@ public class BaseTimeseriesService implements TimeseriesService { return timeseriesLatestDao.findAllKeysByEntityIdsAsync(tenantId, entityIds); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return timeseriesLatestDao.findLatestByEntityIds(tenantId, entityIds); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, entityIds); + } + @Override public void cleanup(long systemTtl) { timeseriesDao.cleanup(systemTtl); diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java index e68e315b4b..a421cd6842 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesDao.java @@ -52,6 +52,7 @@ import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.common.data.kv.TsKvEntryAggWrapper; import org.thingsboard.server.common.data.kv.TsKvQuery; import org.thingsboard.server.dao.model.ModelConstants; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.dao.nosql.TbResultSet; import org.thingsboard.server.dao.nosql.TbResultSetFuture; import org.thingsboard.server.dao.sqlts.AggregationTimeseriesDao; @@ -256,7 +257,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), minPartition, maxPartition, t); + log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), minPartition, maxPartition, t); + resultFuture.setException(t); } }, readResultsProcessingExecutor); return resultFuture; @@ -330,7 +332,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to fetch partitions for interval {}-{}", entityId.getEntityType().name(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t); + log.error("[{}][{}][{}] Failed to fetch partitions for interval {}-{}", tenantId, entityId.getEntityType(), entityId.getId(), toPartitionTs(query.getStartTs()), toPartitionTs(query.getEndTs()), t); + resultFuture.setException(t); } }, readResultsProcessingExecutor); @@ -372,8 +375,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD cursor.addData(convertResultToTsKvEntryList(Collections.emptyList())); findAllAsyncSequentiallyWithLimit(tenantId, cursor, resultFuture); } else { - Futures.addCallback(result.allRows(readResultsProcessingExecutor), new FutureCallback>() { - + Futures.addCallback(result.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes), new FutureCallback>() { @Override public void onSuccess(@Nullable List result) { cursor.addData(convertResultToTsKvEntryList(result == null ? Collections.emptyList() : result)); @@ -382,7 +384,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t); + if (t instanceof ResultSetSizeLimitExceededException e) { + log.warn("[{}][{}][{}] Result set size limit exceeded for key [{}], query [{}]: {} bytes, limit {} bytes", + tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), e.getActualBytes(), e.getLimitBytes()); + } else { + log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t); + } + resultFuture.setException(t); } }, readResultsProcessingExecutor); @@ -392,7 +400,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to fetch data for query {}-{}", stmt, t); + log.error("[{}][{}][{}] Failed to fetch data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t); + resultFuture.setException(t); } }, readResultsProcessingExecutor); } @@ -425,7 +434,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD } if (!isUseTsKeyValuePartitioningOnRead()) { final long estimatedPartitionCount = estimatePartitionCount(minPartition, maxPartition); - if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) { + if (estimatedPartitionCount <= useTsKeyValuePartitioningOnReadMaxEstimatedPartitionCount) { return Futures.immediateFuture(calculatePartitions(minPartition, maxPartition, (int) estimatedPartitionCount)); } } @@ -446,7 +455,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD } List calculatePartitions(long minPartition, long maxPartition) { - return calculatePartitions(minPartition, maxPartition, 0); + return calculatePartitions(minPartition, maxPartition, 0); } List calculatePartitions(long minPartition, long maxPartition, int estimatedPartitionCount) { @@ -533,6 +542,7 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD public void onFailure(Throwable t) { } + } private long computeTtl(long ttl) { @@ -569,7 +579,8 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD @Override public void onFailure(Throwable t) { - log.error("[{}][{}] Failed to delete data for query {}-{}", stmt, t); + log.error("[{}][{}][{}] Failed to delete data for key [{}], query [{}]", tenantId, cursor.getEntityType(), cursor.getEntityId(), cursor.getKey(), stmt.getPreparedStatement().getQuery(), t); + resultFuture.setException(t); } }, readResultsProcessingExecutor); } @@ -581,12 +592,12 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD try { if (deleteStmt == null) { deleteStmt = prepare("DELETE FROM " + ModelConstants.TS_KV_CF + - " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.TS_COLUMN + " >= ? " - + "AND " + ModelConstants.TS_COLUMN + " < ?"); + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.TS_COLUMN + " >= ? " + + "AND " + ModelConstants.TS_COLUMN + " < ?"); } } finally { stmtCreationLock.unlock(); @@ -661,13 +672,13 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD private String getPreparedStatementQuery(DataType type) { return INSERT_INTO + ModelConstants.TS_KV_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.KEY_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.TS_COLUMN + - "," + getColumnName(type) + ")" + - " VALUES(?, ?, ?, ?, ?, ?)"; + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.KEY_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.TS_COLUMN + + "," + getColumnName(type) + ")" + + " VALUES(?, ?, ?, ?, ?, ?)"; } private String getPreparedStatementQueryWithTtl(DataType type) { @@ -680,11 +691,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD try { if (partitionInsertStmt == null) { partitionInsertStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?)"); + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?)"); } } finally { stmtCreationLock.unlock(); @@ -699,11 +710,11 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD try { if (partitionInsertTtlStmt == null) { partitionInsertTtlStmt = prepare(INSERT_INTO + ModelConstants.TS_KV_PARTITIONS_CF + - "(" + ModelConstants.ENTITY_TYPE_COLUMN + - "," + ModelConstants.ENTITY_ID_COLUMN + - "," + ModelConstants.PARTITION_COLUMN + - "," + ModelConstants.KEY_COLUMN + ")" + - " VALUES(?, ?, ?, ?) USING TTL ?"); + "(" + ModelConstants.ENTITY_TYPE_COLUMN + + "," + ModelConstants.ENTITY_ID_COLUMN + + "," + ModelConstants.PARTITION_COLUMN + + "," + ModelConstants.KEY_COLUMN + ")" + + " VALUES(?, ?, ?, ?) USING TTL ?"); } } finally { stmtCreationLock.unlock(); @@ -809,16 +820,17 @@ public class CassandraBaseTimeseriesDao extends AbstractCassandraBaseTimeseriesD fetchStmts[type.ordinal()] = fetchStmts[Aggregation.SUM.ordinal()]; } else { fetchStmts[type.ordinal()] = prepare(SELECT_PREFIX + - String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF - + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM - + "AND " + ModelConstants.TS_COLUMN + " >= ? " - + "AND " + ModelConstants.TS_COLUMN + " < ?" - + (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : "")); + String.join(", ", ModelConstants.getFetchColumnNames(type)) + " FROM " + ModelConstants.TS_KV_CF + + " WHERE " + ModelConstants.ENTITY_TYPE_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.ENTITY_ID_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.KEY_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.PARTITION_COLUMN + EQUALS_PARAM + + "AND " + ModelConstants.TS_COLUMN + " >= ? " + + "AND " + ModelConstants.TS_COLUMN + " < ?" + + (type == Aggregation.NONE ? " ORDER BY " + ModelConstants.TS_COLUMN + " " + orderBy + " LIMIT ?" : "")); } } return fetchStmts; } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java index 6bcc88c2df..e9bf3db134 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/CassandraBaseTimeseriesLatestDao.java @@ -104,6 +104,16 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes return Futures.immediateFuture(Collections.emptyList()); } + @Override + public List findLatestByEntityIds(TenantId tenantId, List entityIds) { + return Collections.emptyList(); + } + + @Override + public ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds) { + return Futures.immediateFuture(Collections.emptyList()); + } + @Override public ListenableFuture saveLatest(TenantId tenantId, EntityId entityId, TsKvEntry tsKvEntry) { BoundStatementBuilder stmtBuilder = new BoundStatementBuilder(getLatestStmt().bind()); @@ -184,7 +194,7 @@ public class CassandraBaseTimeseriesLatestDao extends AbstractCassandraBaseTimes } private ListenableFuture> convertAsyncResultSetToTsKvEntryList(TbResultSet rs) { - return Futures.transform(rs.allRows(readResultsProcessingExecutor), + return Futures.transform(rs.allRows(readResultsProcessingExecutor, maxResultSetSizeBytes), rows -> this.convertResultToTsKvEntryList(rows), readResultsProcessingExecutor); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java index 01581026d6..ded8c74b98 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/SimpleListenableFuture.java @@ -26,4 +26,8 @@ public class SimpleListenableFuture extends AbstractFuture { return super.set(value); } + public boolean setException(Throwable throwable) { + return super.setException(throwable); + } + } diff --git a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java index 26e784b760..c7582944a9 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java +++ b/dao/src/main/java/org/thingsboard/server/dao/timeseries/TimeseriesLatestDao.java @@ -52,4 +52,13 @@ public interface TimeseriesLatestDao { ListenableFuture> findAllKeysByEntityIdsAsync(TenantId tenantId, List entityIds); + /** + * For each unique timeseries key across the given entities, returns the single most recent {@link TsKvEntry} + * (i.e. the entry with the highest timestamp). If the same key exists on multiple entities, + * only the freshest value is kept. Useful for discovering available keys together with a representative sample value. + */ + List findLatestByEntityIds(TenantId tenantId, List entityIds); + + ListenableFuture> findLatestByEntityIdsAsync(TenantId tenantId, List entityIds); + } diff --git a/dao/src/main/resources/sql/schema-entities.sql b/dao/src/main/resources/sql/schema-entities.sql index 3d9c9f17e6..6a66b203a4 100644 --- a/dao/src/main/resources/sql/schema-entities.sql +++ b/dao/src/main/resources/sql/schema-entities.sql @@ -162,6 +162,7 @@ CREATE TABLE IF NOT EXISTS rule_chain ( created_time bigint NOT NULL, additional_info varchar, configuration varchar(10000000), + notes varchar(1000000), name varchar(255), type varchar(255), first_rule_node_id uuid, @@ -935,6 +936,7 @@ CREATE TABLE IF NOT EXISTS calculated_field ( configuration varchar(1000000), version BIGINT DEFAULT 1, debug_settings varchar(1024), + additional_info varchar, CONSTRAINT calculated_field_unq_key UNIQUE (entity_id, type, name) ); diff --git a/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java new file mode 100644 index 0000000000..8afcaa1c22 --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/TbTimescaleDBContainerProvider.java @@ -0,0 +1,47 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao; + +import org.testcontainers.containers.JdbcDatabaseContainer; +import org.testcontainers.containers.TimescaleDBContainerProvider; + +/** + * Extends the upstream {@link TimescaleDBContainerProvider} to disable the + * timescaledb-tune entrypoint script via NO_TS_TUNE=true. + * + * Works around a shell bug in /docker-entrypoint-initdb.d/001_timescaledb_tune.sh + * that crashes the container entrypoint on cgroup v2 hosts (including CI agents) + * when the kernel reports the 64-bit max for memory.max. + * + * Activated by the jdbc:tc:tbtimescaledb:<tag>:///... URL prefix + * registered via META-INF/services. + */ +public class TbTimescaleDBContainerProvider extends TimescaleDBContainerProvider { + + private static final String NAME = "tbtimescaledb"; + + @Override + public boolean supports(String databaseType) { + return NAME.equals(databaseType); + } + + @Override + public JdbcDatabaseContainer newInstance(String tag) { + JdbcDatabaseContainer container = super.newInstance(tag); + container.withEnv("NO_TS_TUNE", "true"); + return container; + } +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java index 9a467071ef..5ee9af6292 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AbstractServiceTest.java @@ -93,11 +93,13 @@ public abstract class AbstractServiceTest { @Autowired protected EntityServiceRegistry entityServiceRegistry; + protected Tenant tenant; protected TenantId tenantId; @Before public void beforeAbstractService() { - tenantId = createTenant().getId(); + tenant = createTenant(); + tenantId = tenant.getId(); } @After diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java index 8423599c90..50d0b852f1 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AdminSettingsServiceTest.java @@ -25,9 +25,12 @@ import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AdminSettings; import org.thingsboard.server.common.data.id.TenantId; -import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.common.data.page.PageData; +import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.dao.settings.AdminSettingsService; +import org.thingsboard.server.exception.DataValidationException; +import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; @@ -113,4 +116,24 @@ public class AdminSettingsServiceTest extends AbstractServiceTest { }).hasMessageContaining("already exists"); } + @Test + public void testFindAllByTenantId() { + int pageSize = 10; + int totalElements = 100; + + for (int i = 0; i < totalElements; i++) { + AdminSettings settings = new AdminSettings(); + settings.setTenantId(tenantId); + String key = RandomStringUtils.randomAlphanumeric(15); + settings.setKey(key); + settings.setJsonValue(JacksonUtil.newObjectNode().put("value", i)); + adminSettingsService.saveAdminSettings(tenantId, settings); + } + + PageData pageData = adminSettingsService.findAllByTenantId(tenantId, new PageLink(pageSize)); + assertThat(pageData.getData().size()).isEqualTo(pageSize); + assertThat(pageData.getTotalElements()).isEqualTo(totalElements); + + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java index c951c58060..46254fbe2b 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/AlarmServiceTest.java @@ -18,6 +18,7 @@ package org.thingsboard.server.dao.service; import com.datastax.oss.driver.api.core.uuid.Uuids; import org.junit.Assert; import org.junit.Test; +import org.junit.jupiter.api.Assertions; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.Customer; @@ -59,11 +60,14 @@ import org.thingsboard.server.dao.customer.CustomerService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.relation.RelationService; import org.thingsboard.server.dao.user.UserService; +import org.thingsboard.server.exception.DataValidationException; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; +import static org.assertj.core.api.Assertions.assertThat; + @DaoSqlTest public class AlarmServiceTest extends AbstractServiceTest { @@ -990,4 +994,25 @@ public class AlarmServiceTest extends AbstractServiceTest { Assert.assertEquals(1, alarmsCount); } + @Test + public void testShouldFailToCreateAlarmWithBadType() { + AssetId originatorId = new AssetId(Uuids.timeBased()); + + long ts = System.currentTimeMillis(); + AlarmCreateOrUpdateActiveRequest request = AlarmCreateOrUpdateActiveRequest.builder() + .tenantId(tenantId) + .originator(originatorId) + .type("") + .severity(AlarmSeverity.CRITICAL) + .startTs(ts).build(); + + Assertions.assertThrows(DataValidationException.class, () -> { + alarmService.createAlarm(request); + }); + + request.setType(TEST_ALARM); + AlarmApiCallResult result = alarmService.createAlarm(request); + assertThat(result.getAlarm().getId()).isNotNull(); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index ba914c6d35..3caf4bb5f4 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java @@ -15,10 +15,13 @@ */ package org.thingsboard.server.dao.service; +import org.apache.commons.lang3.RandomUtils; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.cf.CalculatedField; import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.cf.configuration.Argument; @@ -29,6 +32,10 @@ import org.thingsboard.server.common.data.cf.configuration.ReferencedEntityKey; import org.thingsboard.server.common.data.cf.configuration.RelationPathQueryDynamicSourceConfiguration; import org.thingsboard.server.common.data.cf.configuration.SimpleCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.TimeSeriesOutput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggFunction; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggKeyInput; +import org.thingsboard.server.common.data.cf.configuration.aggregation.AggMetric; +import org.thingsboard.server.common.data.cf.configuration.aggregation.RelatedEntitiesAggregationCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.EntityCoordinates; import org.thingsboard.server.common.data.cf.configuration.geofencing.GeofencingCalculatedFieldConfiguration; import org.thingsboard.server.common.data.cf.configuration.geofencing.ZoneGroupConfiguration; @@ -39,6 +46,7 @@ import org.thingsboard.server.common.data.relation.RelationPathLevel; import org.thingsboard.server.dao.cf.CalculatedFieldService; import org.thingsboard.server.dao.device.DeviceService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; +import org.thingsboard.server.dao.tenant.TenantProfileService; import org.thingsboard.server.exception.DataValidationException; import java.util.ArrayList; @@ -59,6 +67,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { private DeviceService deviceService; @Autowired private TbTenantProfileCache tbTenantProfileCache; + @Autowired + private TenantProfileService tenantProfileService; @Test public void testSaveCalculatedField() { @@ -74,6 +84,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(savedCalculatedField.getType()).isEqualTo(calculatedField.getType()); assertThat(savedCalculatedField.getName()).isEqualTo(calculatedField.getName()); assertThat(savedCalculatedField.getConfiguration()).isEqualTo(calculatedField.getConfiguration()); + assertThat(savedCalculatedField.getAdditionalInfo()).isEqualTo(calculatedField.getAdditionalInfo()); assertThat(savedCalculatedField.getVersion()).isEqualTo(1L); savedCalculatedField.setName("Test CF"); @@ -81,9 +92,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { CalculatedField updatedCalculatedField = calculatedFieldService.save(savedCalculatedField); assertThat(updatedCalculatedField.getName()).isEqualTo(savedCalculatedField.getName()); + assertThat(updatedCalculatedField.getAdditionalInfo()).isEqualTo(savedCalculatedField.getAdditionalInfo()); assertThat(updatedCalculatedField.getVersion()).isEqualTo(savedCalculatedField.getVersion() + 1); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -113,11 +123,11 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int min = tbTenantProfileCache.get(tenantId) .getDefaultProfileConfiguration() .getMinAllowedScheduledUpdateIntervalInSecForCF(); - int valueFromConfig = min - 10; // Enable scheduling with an interval below tenant min cfg.setScheduledUpdateEnabled(true); - cfg.setScheduledUpdateInterval(valueFromConfig); + int invalidInterval = RandomUtils.insecure().randomInt(1, min); + cfg.setScheduledUpdateInterval(invalidInterval); // Create & save Calculated Field CalculatedField cf = new CalculatedField(); @@ -131,8 +141,8 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThatThrownBy(() -> calculatedFieldService.save(cf)) .isInstanceOf(DataValidationException.class) .hasCauseInstanceOf(IllegalArgumentException.class) - .hasMessageStartingWith("Scheduled update interval is less than configured " + - "minimum allowed interval in tenant profile: "); + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + min + " seconds"); } @Test @@ -233,8 +243,67 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { int savedInterval = geofencingCalculatedFieldConfiguration.getScheduledUpdateInterval(); assertThat(savedInterval).isEqualTo(valueFromConfig); + } - calculatedFieldService.deleteCalculatedField(tenantId, saved.getId()); + @Test + public void testSaveGeofencingCalculatedField_shouldAcceptZeroScheduledUpdateIntervalWhenTenantProfileAllows() { + // GIVEN + var device = createTestDevice(); + + // Store original value and update tenant profile to allow 0 as min scheduled update interval + TenantProfile tenantProfile = tenantProfileService.findTenantProfileById(tenantId, tenant.getTenantProfileId()); + int originalMinScheduledUpdateInterval = tenantProfile.getDefaultProfileConfiguration().getMinAllowedScheduledUpdateIntervalInSecForCF(); + tenantProfile.getDefaultProfileConfiguration().setMinAllowedScheduledUpdateIntervalInSecForCF(0); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + + try { + // Build a valid Geofencing configuration + var cfg = new GeofencingCalculatedFieldConfiguration(); + + // Coordinates: TS_LATEST, no dynamic source + var entityCoordinates = new EntityCoordinates("latitude", "longitude"); + cfg.setEntityCoordinates(entityCoordinates); + + // Zone-group argument (ATTRIBUTE) — make it DYNAMIC so scheduling is enabled + var zoneGroupConfiguration = new ZoneGroupConfiguration("allowed", REPORT_TRANSITION_EVENTS_AND_PRESENCE_STATUS, false); + var dynamicSourceConfiguration = new RelationPathQueryDynamicSourceConfiguration(); + dynamicSourceConfiguration.setLevels(List.of(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE))); + zoneGroupConfiguration.setRefDynamicSourceConfiguration(dynamicSourceConfiguration); + cfg.setZoneGroups(Map.of("allowed", zoneGroupConfiguration)); + + // Enable scheduling with interval = 0 + cfg.setScheduledUpdateEnabled(true); + cfg.setScheduledUpdateInterval(0); + + // Create Calculated Field + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.GEOFENCING); + cf.setName("GF zero scheduled update interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + var out = new AttributesOutput(); + out.setScope(AttributeScope.SERVER_SCOPE); + cfg.setOutput(out); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(GeofencingCalculatedFieldConfiguration.class); + + var savedConfig = (GeofencingCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(0); + } finally { + // Restore original tenant profile value + tenantProfile.getProfileConfiguration().orElseThrow().setMinAllowedScheduledUpdateIntervalInSecForCF(originalMinScheduledUpdateInterval); + tenantProfileService.saveTenantProfile(tenantId, tenantProfile); + tbTenantProfileCache.evict(tenantProfile.getId()); + } } @Test @@ -254,8 +323,6 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { CalculatedField fetchedCalculatedField = calculatedFieldService.findById(tenantId, savedCalculatedField.getId()); assertThat(fetchedCalculatedField).isEqualTo(savedCalculatedField); - - calculatedFieldService.deleteCalculatedField(tenantId, savedCalculatedField.getId()); } @Test @@ -267,6 +334,156 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { assertThat(calculatedFieldService.findById(tenantId, savedCalculatedField.getId())).isNull(); } + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldUseMinScheduledUpdateIntervalFromTenantProfileWhenNotSet() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + // Do NOT set scheduledUpdateInterval - it should default to tenant profile min value + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - default scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + int expectedMinScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(expectedMinScheduledUpdateInterval); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldThrowWhenScheduledUpdateIntervalLessThanMinAllowed() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int invalidInterval = RandomUtils.insecure().randomInt(1, minScheduledUpdateInterval); + cfg.setScheduledUpdateInterval(invalidInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - invalid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN-THEN + assertThatThrownBy(() -> calculatedFieldService.save(cf)) + .isInstanceOf(DataValidationException.class) + .hasCauseInstanceOf(IllegalArgumentException.class) + .hasMessage("Scheduled update interval (" + invalidInterval + + " seconds) is less than minimum allowed interval in tenant profile: " + minScheduledUpdateInterval + " seconds"); + } + + @Test + public void testSaveRelatedEntitiesAggregationCF_shouldAcceptValidScheduledUpdateInterval() { + // GIVEN + var device = createTestDevice(); + + var cfg = new RelatedEntitiesAggregationCalculatedFieldConfiguration(); + cfg.setRelation(new RelationPathLevel(EntitySearchDirection.FROM, EntityRelation.CONTAINS_TYPE)); + + var argument = new Argument(); + argument.setRefEntityKey(new ReferencedEntityKey("temperature", ArgumentType.TS_LATEST, null)); + cfg.setArguments(Map.of("temp", argument)); + + var metric = new AggMetric(); + metric.setFunction(AggFunction.AVG); + metric.setInput(new AggKeyInput("temp")); + cfg.setMetrics(Map.of("avgTemp", metric)); + + var output = new TimeSeriesOutput(); + output.setName("avgTemperature"); + cfg.setOutput(output); + + int minDeduplicationInterval = (int) tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedDeduplicationIntervalInSecForCF(); + cfg.setDeduplicationIntervalInSec(minDeduplicationInterval); + + int minScheduledUpdateInterval = tbTenantProfileCache.get(tenantId) + .getDefaultProfileConfiguration() + .getMinAllowedScheduledUpdateIntervalInSecForCF(); + int customScheduledUpdateInterval = minScheduledUpdateInterval + 100; + cfg.setScheduledUpdateInterval(customScheduledUpdateInterval); + + var cf = new CalculatedField(); + cf.setTenantId(tenantId); + cf.setEntityId(device.getId()); + cf.setType(CalculatedFieldType.RELATED_ENTITIES_AGGREGATION); + cf.setName("Related Entities Aggregation CF - valid scheduled interval test"); + cf.setConfigurationVersion(0); + cf.setConfiguration(cfg); + + // WHEN + CalculatedField saved = calculatedFieldService.save(cf); + + // THEN + assertThat(saved).isNotNull(); + assertThat(saved.getConfiguration()).isInstanceOf(RelatedEntitiesAggregationCalculatedFieldConfiguration.class); + + var savedConfig = (RelatedEntitiesAggregationCalculatedFieldConfiguration) saved.getConfiguration(); + assertThat(savedConfig.getScheduledUpdateInterval()).isEqualTo(customScheduledUpdateInterval); + } + private CalculatedField saveValidCalculatedField() { Device device = createTestDevice(); CalculatedField calculatedField = getCalculatedField(device.getId(), device.getId()); @@ -281,6 +498,7 @@ public class CalculatedFieldServiceTest extends AbstractServiceTest { calculatedField.setName("Test Calculated Field"); calculatedField.setConfigurationVersion(1); calculatedField.setConfiguration(getCalculatedFieldConfig(referencedEntityId)); + calculatedField.setAdditionalInfo(JacksonUtil.newObjectNode()); return calculatedField; } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java index 668400423d..ca54775ea2 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/ConstraintValidatorTest.java @@ -16,16 +16,12 @@ package org.thingsboard.server.dao.service; import org.junit.Assert; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.exception.DataValidationException; class ConstraintValidatorTest { - private static final int MIN_IN_MS = 60000; - private static final int _1M = 1_000_000; - @Test void validateFields() { StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); @@ -35,16 +31,4 @@ class ConstraintValidatorTest { ConstraintValidator.validateFields(stringDataEntryValid); } - @Test - void validatePerMinute() { - StringDataEntry stringDataEntryValid = new StringDataEntry("key", "value"); - - long start = System.currentTimeMillis(); - for (int i = 0; i < _1M; i++) { - ConstraintValidator.validateFields(stringDataEntryValid); - } - long end = System.currentTimeMillis(); - - Assertions.assertTrue(MIN_IN_MS > end - start); - } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java index 002a5a7845..69fa3fbc5d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/OtaPackageServiceTest.java @@ -36,14 +36,14 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.dao.device.DeviceProfileService; import org.thingsboard.server.dao.device.DeviceService; -import org.thingsboard.server.exception.DataValidationException; +import org.thingsboard.server.dao.ota.OtaPackageDao; import org.thingsboard.server.dao.ota.OtaPackageService; import org.thingsboard.server.dao.tenant.TbTenantProfileCache; import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.exception.DataValidationException; import java.nio.ByteBuffer; import java.util.ArrayList; -import java.util.Collections; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -77,6 +77,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest { @Autowired TenantProfileService tenantProfileService; @Autowired + OtaPackageDao otaPackageDao; + @Autowired TbTenantProfileCache tenantProfileCache; @Before @@ -118,10 +120,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest { Assert.assertEquals(1, otaPackageService.sumDataSizeByTenantId(tenantId)); int maxSumDataSize = 8; - List packages = new ArrayList<>(maxSumDataSize); - for (int i = 2; i <= maxSumDataSize; i++) { - packages.add(createAndSaveFirmware(tenantId, "0." + i)); + createAndSaveFirmware(tenantId, "0." + i); Assert.assertEquals(i, otaPackageService.sumDataSizeByTenantId(tenantId)); } @@ -533,6 +533,39 @@ public class OtaPackageServiceTest extends AbstractServiceTest { Assert.assertNull(foundFirmware); } + @Test + public void testDeleteOtaPackageWithoutData() { + OtaPackageInfo firmwareInfo = new OtaPackageInfo(); + firmwareInfo.setTenantId(tenantId); + firmwareInfo.setDeviceProfileId(deviceProfileId); + firmwareInfo.setType(FIRMWARE); + firmwareInfo.setTitle(TITLE); + firmwareInfo.setVersion(VERSION); + OtaPackageInfo savedFirmwareInfo = otaPackageService.saveOtaPackageInfo(firmwareInfo, false); + + Assert.assertNotNull(savedFirmwareInfo); + Assert.assertNotNull(savedFirmwareInfo.getId()); + + // Should not throw NPE when deleting package without data (OID is null) + otaPackageService.deleteOtaPackage(tenantId, savedFirmwareInfo.getId()); + + OtaPackageInfo foundFirmware = otaPackageService.findOtaPackageInfoById(tenantId, savedFirmwareInfo.getId()); + Assert.assertNull(foundFirmware); + } + + @Test + public void testDeleteOtaPackageUnlinksLargeObject() { + OtaPackage savedFirmware = createAndSaveFirmware(tenantId, VERSION); + + Long oid = otaPackageDao.getDataOidById(savedFirmware.getId().getId()); + Assert.assertNotNull(oid); + + otaPackageService.deleteOtaPackage(tenantId, savedFirmware.getId()); + + // Verify the large object was unlinked - PostgreSQL throws an exception when the object doesn't exist + assertThatThrownBy(() -> otaPackageDao.unlinkLargeObject(oid)).hasMessageContaining("large object " + oid + " does not exist"); + } + @Test public void testFindTenantFirmwaresByTenantId() { List firmwares = new ArrayList<>(); @@ -567,8 +600,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest { } } while (pageData.hasNext()); - Collections.sort(firmwares, idComparator); - Collections.sort(loadedFirmwares, idComparator); + firmwares.sort(idComparator); + loadedFirmwares.sort(idComparator); assertThat(firmwares).isEqualTo(loadedFirmwares); @@ -622,8 +655,8 @@ public class OtaPackageServiceTest extends AbstractServiceTest { } } while (pageData.hasNext()); - Collections.sort(firmwares, idComparator); - Collections.sort(loadedFirmwares, idComparator); + firmwares.sort(idComparator); + loadedFirmwares.sort(idComparator); assertThat(firmwares).isEqualTo(loadedFirmwares); @@ -726,4 +759,5 @@ public class OtaPackageServiceTest extends AbstractServiceTest { firmware.setDataSize(DATA_SIZE); return firmware; } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java index d3436fa9d1..1d8e77f8ce 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/TenantProfileServiceTest.java @@ -164,13 +164,13 @@ public class TenantProfileServiceTest extends AbstractServiceTest { TenantProfile savedTenantProfile1 = tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile1); TenantProfile savedTenantProfile2 = tenantProfileService.saveTenantProfile(TenantId.SYS_TENANT_ID, tenantProfile2); - boolean result = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile1.getId()); - Assert.assertTrue(result); + TenantProfile setDefaultTenantProfile1 = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile1.getId()); + Assert.assertNotEquals(savedTenantProfile1.isDefault(), setDefaultTenantProfile1.isDefault()); TenantProfile defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); Assert.assertNotNull(defaultTenantProfile); Assert.assertEquals(savedTenantProfile1.getId(), defaultTenantProfile.getId()); - result = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile2.getId()); - Assert.assertTrue(result); + TenantProfile setDefaultTenantProfile2 = tenantProfileService.setDefaultTenantProfile(TenantId.SYS_TENANT_ID, savedTenantProfile2.getId()); + Assert.assertNotEquals(savedTenantProfile2.isDefault(), setDefaultTenantProfile2.isDefault()); defaultTenantProfile = tenantProfileService.findDefaultTenantProfile(TenantId.SYS_TENANT_ID); Assert.assertNotNull(defaultTenantProfile); Assert.assertEquals(savedTenantProfile2.getId(), defaultTenantProfile.getId()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java index d3aa0fde0f..373112293d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/attributes/BaseAttributesServiceTest.java @@ -23,7 +23,6 @@ import com.google.common.util.concurrent.MoreExecutors; import lombok.extern.slf4j.Slf4j; import org.awaitility.Awaitility; import org.junit.Assert; -import org.junit.Before; import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.AttributeScope; @@ -31,8 +30,12 @@ import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.kv.AttributeKvEntry; import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; import org.thingsboard.server.common.data.kv.KvEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.dao.attributes.AttributesDao; import org.thingsboard.server.dao.attributes.AttributesService; import org.thingsboard.server.dao.service.AbstractServiceTest; @@ -40,6 +43,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.Executors; @@ -48,9 +52,6 @@ import java.util.stream.Collectors; import static org.assertj.core.api.Assertions.assertThat; -/** - * @author Andrew Shvayka - */ @Slf4j public abstract class BaseAttributesServiceTest extends AbstractServiceTest { @@ -60,9 +61,8 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { @Autowired private AttributesService attributesService; - @Before - public void before() { - } + @Autowired + private AttributesDao attributesDao; @Test public void saveAndFetch() throws Exception { @@ -223,7 +223,7 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { saveAttribute(tenantId, deviceId, AttributeScope.SERVER_SCOPE, "key2", "123"); Awaitility.await().atMost(30, TimeUnit.SECONDS).untilAsserted(() -> { - List keys = attributesService.findAllKeysByEntityIds(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); + List keys = attributesService.findAllKeysByEntityIdsAndScope(tenantId, List.of(deviceId), AttributeScope.SERVER_SCOPE); assertThat(keys).containsOnly("key1", "key2"); }); } @@ -241,6 +241,84 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { }); } + @Test + public void findLatestByEntityIdsAndScope_returnsOneEntryPerKey() { + var device1 = new DeviceId(UUID.randomUUID()); + var device2 = new DeviceId(UUID.randomUUID()); + + // Both devices have "temperature", device2 has a newer ts + saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1000, new DoubleDataEntry("temperature", 20.0)); + saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 2000, new DoubleDataEntry("temperature", 25.0)); + // Only device1 has "humidity" + saveAttribute(tenantId, device1, AttributeScope.SERVER_SCOPE, 1500, new LongDataEntry("humidity", 60L)); + // Only device2 has "active" + saveAttribute(tenantId, device2, AttributeScope.SERVER_SCOPE, 3000, new BooleanDataEntry("active", true)); + + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device1, device2), AttributeScope.SERVER_SCOPE); + Map byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e)); + + Assert.assertEquals(3, byKey.size()); + + // "temperature" should pick device2's value (ts=2000 > ts=1000) + AttributeKvEntry temp = byKey.get("temperature"); + Assert.assertNotNull(temp); + Assert.assertEquals(25.0, temp.getDoubleValue().orElseThrow(), 0.0); + Assert.assertEquals(2000, temp.getLastUpdateTs()); + + // "humidity" — only device1 has it + AttributeKvEntry humidity = byKey.get("humidity"); + Assert.assertNotNull(humidity); + Assert.assertEquals(60L, (long) humidity.getLongValue().orElseThrow()); + Assert.assertEquals(1500, humidity.getLastUpdateTs()); + + // "active" — only device2 has it + AttributeKvEntry active = byKey.get("active"); + Assert.assertNotNull(active); + Assert.assertEquals(true, active.getBooleanValue().orElseThrow()); + Assert.assertEquals(3000, active.getLastUpdateTs()); + } + + @Test + public void findLatestByEntityIdsAndScope_emptyList() { + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(), AttributeScope.SERVER_SCOPE); + Assert.assertTrue(results.isEmpty()); + } + + @Test + public void findLatestByEntityIdsAndScope_singleEntity() throws Exception { + var device = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("key1", "value1")); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 2000, new StringDataEntry("key2", "value2")); + + // sync + List results = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE); + Assert.assertEquals(2, results.size()); + Map byKey = results.stream().collect(Collectors.toMap(AttributeKvEntry::getKey, e -> e)); + Assert.assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow()); + Assert.assertEquals(1000, byKey.get("key1").getLastUpdateTs()); + Assert.assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow()); + Assert.assertEquals(2000, byKey.get("key2").getLastUpdateTs()); + + // async — same result + List asyncResults = attributesDao.findLatestByEntityIdsAndScopeAsync(tenantId, List.of(device), AttributeScope.SERVER_SCOPE).get(); + Assert.assertEquals(results, asyncResults); + } + + @Test + public void findLatestByEntityIdsAndScope_filtersScope() { + var device = new DeviceId(UUID.randomUUID()); + saveAttribute(tenantId, device, AttributeScope.SERVER_SCOPE, 1000, new StringDataEntry("serverKey", "sv")); + saveAttribute(tenantId, device, AttributeScope.CLIENT_SCOPE, 1000, new StringDataEntry("clientKey", "cv")); + + List serverResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.SERVER_SCOPE); + Assert.assertEquals(1, serverResults.size()); + Assert.assertEquals("serverKey", serverResults.get(0).getKey()); + + List clientResults = attributesDao.findLatestByEntityIdsAndScope(tenantId, List.of(device), AttributeScope.CLIENT_SCOPE); + Assert.assertEquals(1, clientResults.size()); + Assert.assertEquals("clientKey", clientResults.get(0).getKey()); + } + private void testConcurrentFetchAndUpdate(TenantId tenantId, DeviceId deviceId, ListeningExecutorService pool) throws Exception { var scope = AttributeScope.SERVER_SCOPE; var key = "TEST"; @@ -313,6 +391,15 @@ public abstract class BaseAttributesServiceTest extends AbstractServiceTest { } } + private void saveAttribute(TenantId tenantId, DeviceId deviceId, AttributeScope scope, long ts, KvEntry value) { + try { + attributesService.save(tenantId, deviceId, scope, Collections.singletonList(new BaseAttributeKvEntry(ts, value))).get(10, TimeUnit.SECONDS); + } catch (Exception e) { + log.warn("Failed to save attribute", e.getCause()); + throw new RuntimeException(e); + } + } + private void equalsIgnoreVersion(AttributeKvEntry expected, AttributeKvEntry actual) { Assert.assertEquals(expected.getKey(), actual.getKey()); Assert.assertEquals(expected.getValue(), actual.getValue()); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java index 221684c10c..e7d2d6a8fc 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/BaseTimeseriesServiceTest.java @@ -710,6 +710,31 @@ public abstract class BaseTimeseriesServiceTest extends AbstractServiceTest { assertEquals(java.util.Optional.of(2L), list.get(2).getLongValue()); } + @Test + public void testFindDeviceMaxAggregationOverNegativeMixedLongAndDoubleTsData() throws Exception { + save(deviceId, 5000, -100L); + save(deviceId, 15000, -50.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + + @Test + public void testFindDeviceMaxAggregationOverAllNegativeDoubleTsData() throws Exception { + save(deviceId, 5000, -50.0); + save(deviceId, 15000, -100.0); + save(deviceId, 25000, -75.0); + + List list = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery(LONG_KEY, 0, + 60000, 60000, 1, Aggregation.MAX))).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + assertEquals(1, list.size()); + assertEquals(java.util.Optional.of(-50.0), list.get(0).getDoubleValue()); + } + @Test public void testSaveTs_RemoveTs_AndSaveTsAgain() throws Exception { save(deviceId, 2000000L, 95); diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java index 495ffc131b..b66cdb4363 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/nosql/TimeseriesServiceNoSqlTest.java @@ -16,7 +16,10 @@ package org.thingsboard.server.dao.service.timeseries.nosql; import com.datastax.oss.driver.api.core.uuid.Uuids; +import org.apache.commons.lang3.exception.ExceptionUtils; import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.kv.Aggregation; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; @@ -27,9 +30,12 @@ import org.thingsboard.server.common.data.kv.JsonDataEntry; import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; +import org.thingsboard.server.dao.nosql.ResultSetSizeLimitExceededException; import org.thingsboard.server.dao.service.DaoNoSqlTest; import org.thingsboard.server.dao.service.timeseries.BaseTimeseriesServiceTest; +import org.thingsboard.server.dao.timeseries.CassandraBaseTimeseriesDao; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.concurrent.ExecutionException; @@ -37,6 +43,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -44,6 +51,9 @@ import static org.junit.Assert.assertTrue; @DaoNoSqlTest public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { + @Autowired + private CassandraBaseTimeseriesDao cassandraBaseTimeseriesDao; + @Test public void shouldSaveEntryOfEachTypeWithTtl() throws ExecutionException, InterruptedException, TimeoutException { long ttlInSec = TimeUnit.SECONDS.toSeconds(3); @@ -55,7 +65,7 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(5), new JsonDataEntry("test", "{\"test\":\"testValue\"}"))); DeviceId deviceId = new DeviceId(Uuids.timeBased()); - tsService.save(tenantId, deviceId, timeseries, ttlInSec); + tsService.save(tenantId, deviceId, timeseries, ttlInSec).get(MAX_TIMEOUT, TimeUnit.SECONDS); List fullList = tsService.findAll(tenantId, deviceId, Collections.singletonList(new BaseReadTsKvQuery("test", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE))).get(MAX_TIMEOUT, TimeUnit.SECONDS); @@ -94,4 +104,36 @@ public class TimeseriesServiceNoSqlTest extends BaseTimeseriesServiceTest { double expectedValue = (doubleValue + longValue)/ 2; assertThat(listWithAgg.get(0).getDoubleValue().get()).isEqualTo(expectedValue); } + + @Test + public void testResultSetSizeLimitExceeded() throws Exception { + DeviceId deviceId = new DeviceId(Uuids.timeBased()); + + String value = "x".repeat(500); + List entries = new ArrayList<>(); + for (int i = 0; i < 5; i++) { + entries.add(new BasicTsKvEntry(TimeUnit.MINUTES.toMillis(i + 1), new StringDataEntry("bigKey", value))); + } + tsService.save(tenantId, deviceId, entries, 0).get(MAX_TIMEOUT, TimeUnit.SECONDS); + + long originalLimit = (long) ReflectionTestUtils.getField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes"); + try { + // Set a very low limit to trigger the exception + ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", 1024L); + + assertThatThrownBy(() -> tsService.findAll(tenantId, deviceId, Collections.singletonList( + new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE) + )).get(MAX_TIMEOUT, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .satisfies(e -> assertThat(ExceptionUtils.getRootCause(e)).isInstanceOf(ResultSetSizeLimitExceededException.class)); + } finally { + ReflectionTestUtils.setField(cassandraBaseTimeseriesDao, "maxResultSetSizeBytes", originalLimit); + } + + // Verify query succeeds with original limit restored + List result = tsService.findAll(tenantId, deviceId, Collections.singletonList( + new BaseReadTsKvQuery("bigKey", 0L, TimeUnit.MINUTES.toMillis(6), 1000, 10, Aggregation.NONE) + )).get(MAX_TIMEOUT, TimeUnit.SECONDS); + assertThat(result).hasSize(5); + } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java deleted file mode 100644 index 100ef1c11e..0000000000 --- a/dao/src/test/java/org/thingsboard/server/dao/service/timeseries/sql/LatestTimeseriesPerformanceTest.java +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Copyright © 2016-2026 The Thingsboard Authors - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.thingsboard.server.dao.service.timeseries.sql; - -import com.google.common.util.concurrent.Futures; -import com.google.common.util.concurrent.ListenableFuture; -import com.google.common.util.concurrent.ListeningExecutorService; -import com.google.common.util.concurrent.MoreExecutors; -import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.After; -import org.junit.Assert; -import org.junit.Before; -import org.junit.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.thingsboard.common.util.ThingsBoardThreadFactory; -import org.thingsboard.server.common.data.Tenant; -import org.thingsboard.server.common.data.id.DeviceId; -import org.thingsboard.server.common.data.id.EntityId; -import org.thingsboard.server.common.data.kv.BasicTsKvEntry; -import org.thingsboard.server.common.data.kv.BooleanDataEntry; -import org.thingsboard.server.common.data.kv.DoubleDataEntry; -import org.thingsboard.server.common.data.kv.LongDataEntry; -import org.thingsboard.server.common.data.kv.StringDataEntry; -import org.thingsboard.server.common.data.kv.TsKvEntry; -import org.thingsboard.server.dao.service.AbstractServiceTest; -import org.thingsboard.server.dao.service.DaoSqlTest; -import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; - -import java.util.ArrayList; -import java.util.List; -import java.util.Random; -import java.util.UUID; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicLong; - -@DaoSqlTest -@Slf4j -public class LatestTimeseriesPerformanceTest extends AbstractServiceTest { - - private static final String STRING_KEY = "stringKey"; - private static final String LONG_KEY = "longKey"; - private static final String DOUBLE_KEY = "doubleKey"; - private static final String BOOLEAN_KEY = "booleanKey"; - private static final int AMOUNT_OF_UNIQ_KEY = 10000; - private static final int TIMEOUT = 100; - - private final Random random = new Random(); - - @Autowired - private TimeseriesLatestDao timeseriesLatestDao; - - private ListeningExecutorService testExecutor; - - private EntityId entityId; - - private AtomicLong saveCounter; - - @Before - public void before() { - Tenant tenant = new Tenant(); - tenant.setTitle("My tenant"); - Tenant savedTenant = tenantService.saveTenant(tenant); - Assert.assertNotNull(savedTenant); - tenantId = savedTenant.getId(); - entityId = new DeviceId(UUID.randomUUID()); - saveCounter = new AtomicLong(0); - testExecutor = MoreExecutors.listeningDecorator(Executors.newFixedThreadPool(200, ThingsBoardThreadFactory.forName(getClass().getSimpleName() + "-test-scope"))); - } - - @After - public void after() { - tenantService.deleteTenant(tenantId); - if (testExecutor != null) { - testExecutor.shutdownNow(); - } - } - - @Test - public void test_save_latest_timeseries() throws Exception { - warmup(); - saveCounter.set(0); - - long startTime = System.currentTimeMillis(); - List> futures = new ArrayList<>(); - for (int i = 0; i < 25_000; i++) { - futures.add(save(generateStrEntry(getRandomKey()))); - futures.add(save(generateLngEntry(getRandomKey()))); - futures.add(save(generateDblEntry(getRandomKey()))); - futures.add(save(generateBoolEntry(getRandomKey()))); - } - Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); - long endTime = System.currentTimeMillis(); - - long totalTime = endTime - startTime; - - log.info("Total time: {}", totalTime); - log.info("Saved count: {}", saveCounter.get()); - log.warn("Saved per 1 sec: {}", saveCounter.get() * 1000 / totalTime); - } - - private void warmup() throws Exception { - List> futures = new ArrayList<>(); - for (int i = 0; i < AMOUNT_OF_UNIQ_KEY; i++) { - futures.add(save(generateStrEntry(i))); - futures.add(save(generateLngEntry(i))); - futures.add(save(generateDblEntry(i))); - futures.add(save(generateBoolEntry(i))); - } - Futures.allAsList(futures).get(TIMEOUT, TimeUnit.SECONDS); - } - - private ListenableFuture save(TsKvEntry tsKvEntry) { - return Futures.transformAsync(testExecutor.submit(() -> timeseriesLatestDao.saveLatest(tenantId, entityId, tsKvEntry)), result -> { - saveCounter.incrementAndGet(); - return result; - }, testExecutor); - } - - private TsKvEntry generateStrEntry(int keyIndex) { - return new BasicTsKvEntry(System.currentTimeMillis(), new StringDataEntry(STRING_KEY + keyIndex, RandomStringUtils.random(10))); - } - - private TsKvEntry generateLngEntry(int keyIndex) { - return new BasicTsKvEntry(System.currentTimeMillis(), new LongDataEntry(LONG_KEY + keyIndex, random.nextLong())); - } - - private TsKvEntry generateDblEntry(int keyIndex) { - return new BasicTsKvEntry(System.currentTimeMillis(), new DoubleDataEntry(DOUBLE_KEY + keyIndex, random.nextDouble())); - } - - private TsKvEntry generateBoolEntry(int keyIndex) { - return new BasicTsKvEntry(System.currentTimeMillis(), new BooleanDataEntry(BOOLEAN_KEY + keyIndex, random.nextBoolean())); - } - - private int getRandomKey() { - return random.nextInt(AMOUNT_OF_UNIQ_KEY); - } - -} diff --git a/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java index 170b452fee..373344cb6d 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/service/validator/TenantProfileDataValidatorTest.java @@ -16,29 +16,35 @@ package org.thingsboard.server.dao.service.validator; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.test.context.SpringBootTest; -import org.springframework.boot.test.mock.mockito.MockBean; -import org.springframework.boot.test.mock.mockito.SpyBean; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.tenant.profile.DefaultTenantProfileConfiguration; import org.thingsboard.server.common.data.tenant.profile.TenantProfileData; import org.thingsboard.server.dao.tenant.TenantProfileDao; import org.thingsboard.server.dao.tenant.TenantProfileService; +import org.thingsboard.server.exception.DataValidationException; import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.verify; @SpringBootTest(classes = TenantProfileDataValidator.class) class TenantProfileDataValidatorTest { - @MockBean + @MockitoBean TenantProfileDao tenantProfileDao; - @MockBean + @MockitoBean TenantProfileService tenantProfileService; - @SpyBean + @MockitoSpyBean TenantProfileDataValidator validator; + TenantId tenantId = TenantId.fromUUID(UUID.fromString("9ef79cdf-37a8-4119-b682-2e7ed4e018da")); @Test @@ -53,4 +59,44 @@ class TenantProfileDataValidatorTest { verify(validator).validateString("Tenant profile name", tenantProfile.getName()); } + @ParameterizedTest + @ValueSource(ints = {-1, -100, Integer.MIN_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldRejectNegativeValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatThrownBy(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("minAllowedScheduledUpdateIntervalInSecForCF") + .hasMessageContaining("must be greater than or equal to 0"); + } + + @ParameterizedTest + @ValueSource(ints = {0, 1, 60, Integer.MAX_VALUE}) + void minAllowedScheduledUpdateIntervalInSecForCF_shouldAcceptValidValues(int value) { + // GIVEN + var config = new DefaultTenantProfileConfiguration(); + config.setMinAllowedScheduledUpdateIntervalInSecForCF(value); + + var tenantProfileData = new TenantProfileData(); + tenantProfileData.setConfiguration(config); + + var tenantProfile = new TenantProfile(); + tenantProfile.setName("Test"); + tenantProfile.setProfileData(tenantProfileData); + + // WHEN/THEN + assertThatCode(() -> validator.validate(tenantProfile, __ -> TenantId.SYS_TENANT_ID)) + .doesNotThrowAnyException(); + } + } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDaoTest.java new file mode 100644 index 0000000000..5e2fe2308b --- /dev/null +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/notification/JpaNotificationRequestDaoTest.java @@ -0,0 +1,133 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.dao.sql.notification; + +import org.junit.After; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.thingsboard.server.common.data.id.NotificationRequestId; +import org.thingsboard.server.common.data.id.TenantId; +import org.thingsboard.server.common.data.notification.NotificationRequest; +import org.thingsboard.server.common.data.notification.NotificationRequestStatus; +import org.thingsboard.server.dao.AbstractJpaDaoTest; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +public class JpaNotificationRequestDaoTest extends AbstractJpaDaoTest { + + @Autowired + JpaNotificationRequestDao notificationRequestDao; + + private final List createdRequests = new ArrayList<>(); + + @After + public void tearDown() { + for (NotificationRequest request : createdRequests) { + notificationRequestDao.removeById(request.getTenantId(), request.getId().getId()); + } + createdRequests.clear(); + } + + @Test + public void testBatchDeletion() { + TenantId sysTenantId = TenantId.SYS_TENANT_ID; + long now = System.currentTimeMillis(); + long oldTimestamp = now - TimeUnit.DAYS.toMillis(30); + + NotificationRequest oldRequest1 = createNotificationRequest(sysTenantId, oldTimestamp); + notificationRequestDao.save(sysTenantId, oldRequest1); + + NotificationRequest oldRequest2 = createNotificationRequest(sysTenantId, oldTimestamp); + notificationRequestDao.save(sysTenantId, oldRequest2); + + NotificationRequest freshRequest = createNotificationRequest(sysTenantId, now); + notificationRequestDao.save(sysTenantId, freshRequest); + + TenantId tenant2Id = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); + NotificationRequest tenant2Request = createNotificationRequest(tenant2Id, oldTimestamp); + notificationRequestDao.save(tenant2Id, tenant2Request); + + int batchSize = 10_000; + + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, oldTimestamp - 1, batchSize)).isEqualTo(0); + + long expirationTime = now - TimeUnit.DAYS.toMillis(15); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(sysTenantId, expirationTime, batchSize)).isEqualTo(2); + + assertThat(notificationRequestDao.findById(sysTenantId, freshRequest.getId().getId())).isNotNull(); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2Id, now + 1, batchSize)).isEqualTo(1); + } + + @Test + public void testBatchDeletionWithSmallBatchSize() { + TenantId tenantId = TenantId.SYS_TENANT_ID; + long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); + + for (int i = 0; i < 10; i++) { + NotificationRequest request = createNotificationRequest(tenantId, oldTimestamp); + notificationRequestDao.save(tenantId, request); + } + + int batchSize = 3; + long expirationTime = System.currentTimeMillis(); + + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(3); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(1); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenantId, expirationTime, batchSize)).isEqualTo(0); + } + + @Test + public void testBatchDeletionIsolationBetweenTenants() { + TenantId tenant1 = TenantId.SYS_TENANT_ID; + TenantId tenant2 = TenantId.fromUUID(UUID.fromString("3d193a7a-774b-4c05-84d5-f7fdcf7a37cf")); + long oldTimestamp = System.currentTimeMillis() - TimeUnit.DAYS.toMillis(30); + + for (int i = 0; i < 5; i++) { + NotificationRequest request = createNotificationRequest(tenant1, oldTimestamp); + notificationRequestDao.save(tenant1, request); + } + + for (int i = 0; i < 3; i++) { + NotificationRequest request = createNotificationRequest(tenant2, oldTimestamp); + notificationRequestDao.save(tenant2, request); + } + + int batchSize = 10_000; + long expirationTime = System.currentTimeMillis(); + + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant1, expirationTime, batchSize)).isEqualTo(5); + assertThat(notificationRequestDao.removeByTenantIdAndCreatedTimeBeforeBatch(tenant2, expirationTime, batchSize)).isEqualTo(3); + } + + private NotificationRequest createNotificationRequest(TenantId tenantId, long createdTime) { + NotificationRequest request = new NotificationRequest(); + request.setId(new NotificationRequestId(UUID.randomUUID())); + request.setTenantId(tenantId); + request.setCreatedTime(createdTime); + request.setTargets(List.of(UUID.randomUUID())); + request.setStatus(NotificationRequestStatus.SENT); + createdRequests.add(request); + return request; + } + +} diff --git a/dao/src/test/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDaoTest.java index 1629922685..921339a92b 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sql/rpc/JpaRpcDaoTest.java @@ -51,9 +51,10 @@ public class JpaRpcDaoTest extends AbstractJpaDaoTest { rpc.setDeviceId(new DeviceId(UUID.randomUUID())); rpcDao.saveAndFlush(rpc.getTenantId(), rpc); - assertThat(rpcDao.deleteOutdatedRpcByTenantId(TenantId.SYS_TENANT_ID, 0L)).isEqualTo(0); - assertThat(rpcDao.deleteOutdatedRpcByTenantId(TenantId.SYS_TENANT_ID, Long.MAX_VALUE)).isEqualTo(2); - assertThat(rpcDao.deleteOutdatedRpcByTenantId(tenantId, System.currentTimeMillis() + 1)).isEqualTo(1); + int batchSize = 10_000; + assertThat(rpcDao.deleteOutdatedRpcByTenantIdBatch(TenantId.SYS_TENANT_ID, 0L, batchSize)).isEqualTo(0); + assertThat(rpcDao.deleteOutdatedRpcByTenantIdBatch(TenantId.SYS_TENANT_ID, Long.MAX_VALUE, batchSize)).isEqualTo(2); + assertThat(rpcDao.deleteOutdatedRpcByTenantIdBatch(tenantId, System.currentTimeMillis() + 1, batchSize)).isEqualTo(1); } } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java index 583fc4116d..c3d8394bd3 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sqlts/AbstractChunkedAggregationTimeseriesDaoTest.java @@ -18,6 +18,8 @@ package org.thingsboard.server.dao.sqlts; import com.google.common.util.concurrent.Futures; import org.junit.Before; import org.junit.Test; +import org.springframework.test.util.ReflectionTestUtils; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.server.common.data.kv.BaseReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQuery; import org.thingsboard.server.common.data.kv.ReadTsKvQueryResult; @@ -48,10 +50,11 @@ public class AbstractChunkedAggregationTimeseriesDaoTest { @Before public void setUp() throws Exception { tsDao = spy(AbstractChunkedAggregationTimeseriesDao.class); + ReflectionTestUtils.setField(tsDao, "service", DirectListeningExecutor.INSTANCE); Optional optionalListenableFuture = Optional.of(mock(TsKvEntry.class)); willReturn(Futures.immediateFuture(optionalListenableFuture)).given(tsDao).findAndAggregateAsync(any(), anyString(), anyLong(), anyLong(), anyLong(), any()); willReturn(Futures.immediateFuture(mock(ReadTsKvQueryResult.class))).given(tsDao).getReadTsKvQueryResultFuture(any(), any()); - willReturn(mock(ReadTsKvQueryResult.class)).given(tsDao).findAllAsyncWithLimit(any(), any()); + willReturn(mock(ReadTsKvQueryResult.class)).given(tsDao).findAllWithLimit(any(), any()); } @Test @@ -161,7 +164,7 @@ public class AbstractChunkedAggregationTimeseriesDaoTest { ReadTsKvQuery query = new BaseReadTsKvQuery(TEMP, 1, 3000, interval, LIMIT, COUNT, DESC); willCallRealMethod().given(tsDao).findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); tsDao.findAllAsync(SYS_TENANT_ID, SYS_TENANT_ID, query); - verify(tsDao, times(1)).findAllAsyncWithLimit(any(), any()); + verify(tsDao, times(1)).findAllWithLimit(any(), any()); verify(tsDao, times(0)).findAndAggregateAsync(any(), any(), anyLong(), anyLong(), anyLong(), any()); } diff --git a/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java b/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java index 96f3390b13..02abf1e5fd 100644 --- a/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java +++ b/dao/src/test/java/org/thingsboard/server/dao/sqlts/SqlTimeseriesLatestDaoTest.java @@ -22,6 +22,9 @@ import org.junit.Test; import org.springframework.beans.factory.annotation.Autowired; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; +import org.thingsboard.server.common.data.kv.DoubleDataEntry; +import org.thingsboard.server.common.data.kv.LongDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; import org.thingsboard.server.common.data.kv.TsKvEntry; import org.thingsboard.server.dao.service.AbstractServiceTest; @@ -30,7 +33,9 @@ import org.thingsboard.server.dao.timeseries.TimeseriesLatestDao; import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.UUID; +import java.util.stream.Collectors; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -102,6 +107,69 @@ public class SqlTimeseriesLatestDaoTest extends AbstractServiceTest { } } + @Test + public void findLatestByEntityIds_returnsOneEntryPerKey() throws Exception { + DeviceId device1 = new DeviceId(UUID.randomUUID()); + DeviceId device2 = new DeviceId(UUID.randomUUID()); + + // Both devices have "temperature" key, device2 has a newer ts + timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1000, new DoubleDataEntry("temperature", 20.0))).get(); + timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(2000, new DoubleDataEntry("temperature", 25.0))).get(); + // Only device1 has "humidity" + timeseriesLatestDao.saveLatest(tenantId, device1, new BasicTsKvEntry(1500, new LongDataEntry("humidity", 60L))).get(); + // Only device2 has "active" + timeseriesLatestDao.saveLatest(tenantId, device2, new BasicTsKvEntry(3000, new BooleanDataEntry("active", true))).get(); + + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device1, device2)); + Map byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e)); + + assertEquals(3, byKey.size()); + + // "temperature" should pick device2's value (ts=2000 > ts=1000) + TsKvEntry temp = byKey.get("temperature"); + assertNotNull(temp); + assertEquals(25.0, temp.getDoubleValue().orElseThrow()); + assertEquals(2000, temp.getTs()); + + // "humidity" — only device1 has it + TsKvEntry humidity = byKey.get("humidity"); + assertNotNull(humidity); + assertEquals(60L, humidity.getLongValue().orElseThrow()); + assertEquals(1500, humidity.getTs()); + + // "active" — only device2 has it + TsKvEntry active = byKey.get("active"); + assertNotNull(active); + assertEquals(true, active.getBooleanValue().orElseThrow()); + assertEquals(3000, active.getTs()); + } + + @Test + public void findLatestByEntityIds_emptyList() { + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of()); + assertTrue(results.isEmpty()); + } + + @Test + public void findLatestByEntityIds_singleEntity() throws Exception { + DeviceId device = new DeviceId(UUID.randomUUID()); + timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(1000, new StringDataEntry("key1", "value1"))).get(); + timeseriesLatestDao.saveLatest(tenantId, device, new BasicTsKvEntry(2000, new StringDataEntry("key2", "value2"))).get(); + + // sync + List results = timeseriesLatestDao.findLatestByEntityIds(tenantId, List.of(device)); + assertEquals(2, results.size()); + Map byKey = results.stream().collect(Collectors.toMap(TsKvEntry::getKey, e -> e)); + assertEquals("value1", byKey.get("key1").getStrValue().orElseThrow()); + assertEquals(1000, byKey.get("key1").getTs()); + assertEquals("value2", byKey.get("key2").getStrValue().orElseThrow()); + assertEquals(2000, byKey.get("key2").getTs()); + + // async — same result + List asyncResults = timeseriesLatestDao.findLatestByEntityIdsAsync(tenantId, List.of(device)).get(); + assertEquals(results, asyncResults); + } + private TsKvEntry createEntry(String key, long ts) { return new BasicTsKvEntry(ts, new StringDataEntry(key, RandomStringUtils.random(10))); } diff --git a/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider new file mode 100644 index 0000000000..ab36744aa9 --- /dev/null +++ b/dao/src/test/resources/META-INF/services/org.testcontainers.containers.JdbcDatabaseContainerProvider @@ -0,0 +1 @@ +org.thingsboard.server.dao.TbTimescaleDBContainerProvider diff --git a/dao/src/test/resources/application-test.properties b/dao/src/test/resources/application-test.properties index de4b342af5..821317b6b4 100644 --- a/dao/src/test/resources/application-test.properties +++ b/dao/src/test/resources/application-test.properties @@ -1,3 +1,4 @@ +spring.main.banner-mode=off zk.enabled=false zk.url=localhost:2181 zk.zk_dir=/thingsboard diff --git a/dao/src/test/resources/logback-test.xml b/dao/src/test/resources/logback-test.xml index fe0887c678..92cd10f2ca 100644 --- a/dao/src/test/resources/logback-test.xml +++ b/dao/src/test/resources/logback-test.xml @@ -19,6 +19,9 @@ + + + diff --git a/dao/src/test/resources/nosql-test.properties b/dao/src/test/resources/nosql-test.properties index b688c3c40f..921aebd5fe 100644 --- a/dao/src/test/resources/nosql-test.properties +++ b/dao/src/test/resources/nosql-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql-test.properties b/dao/src/test/resources/sql-test.properties index e3f4861aa9..0639c461a3 100644 --- a/dao/src/test/resources/sql-test.properties +++ b/dao/src/test/resources/sql-test.properties @@ -14,7 +14,7 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:postgresql:16.6:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb +spring.datasource.url=jdbc:tc:postgresql:18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.PostgreSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize=16 diff --git a/dao/src/test/resources/sql/system-data.sql b/dao/src/test/resources/sql/system-data.sql index 0b17d4f108..2cd038aae7 100644 --- a/dao/src/test/resources/sql/system-data.sql +++ b/dao/src/test/resources/sql/system-data.sql @@ -66,3 +66,5 @@ VALUES ( '6eaaefa6-4612-11e7-a919-92ebcb67fe33', 1592576748000 ,'13814000-1dd2-1 '{"type": "BURST", "batchSize": 1000}', '{"type": "SKIP_ALL_FAILURES", "retries": 3, "failurePercentage": 0.0, "pauseBetweenRetries": 3, "maxPauseBetweenRetries": 3}' ); + +INSERT INTO tb_schema_settings (schema_version, product) VALUES (999999000000, 'CE'); diff --git a/dao/src/test/resources/timescale-test.properties b/dao/src/test/resources/timescale-test.properties index 2c5552cb75..e0c0bef25e 100644 --- a/dao/src/test/resources/timescale-test.properties +++ b/dao/src/test/resources/timescale-test.properties @@ -13,6 +13,6 @@ spring.jpa.show-sql=false spring.jpa.hibernate.ddl-auto=none spring.datasource.username=postgres spring.datasource.password=postgres -spring.datasource.url=jdbc:tc:timescaledb:latest-pg12:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb +spring.datasource.url=jdbc:tc:tbtimescaledb:latest-pg18:///thingsboard?TC_DAEMON=true&TC_TMPFS=/testtmpfs:rw&?TC_INITFUNCTION=org.thingsboard.server.dao.TimescaleSqlInitializer::initDb spring.datasource.driverClassName=org.testcontainers.jdbc.ContainerDatabaseDriver spring.datasource.hikari.maximumPoolSize = 50 diff --git a/docker/monitoring/grafana/provisioning/dashboards/transport_connections.json b/docker/monitoring/grafana/provisioning/dashboards/transport_connections.json new file mode 100644 index 0000000000..f092315e94 --- /dev/null +++ b/docker/monitoring/grafana/provisioning/dashboards/transport_connections.json @@ -0,0 +1,458 @@ +{ + "annotations": { + "list": [ + { + "builtIn": 1, + "datasource": { + "type": "datasource", + "uid": "grafana" + }, + "enable": true, + "hide": true, + "iconColor": "rgba(0, 211, 255, 1)", + "name": "Annotations & Alerts", + "type": "dashboard" + } + ] + }, + "editable": true, + "fiscalYearStartMonth": 0, + "graphTooltip": 0, + "id": null, + "links": [], + "panels": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "dark-orange", + "value": null + } + ] + }, + "unit": "short" + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Total connections" + }, + "properties": [ + { + "id": "color", + "value": { + "fixedColor": "light-orange", + "mode": "fixed" + } + } + ] + } + ] + }, + "gridPos": { + "h": 15, + "w": 19, + "x": 0, + "y": 0 + }, + "id": 19, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": false, + "expr": "sum by (protocol) (thingsboard_transport_connections_active{})", + "format": "time_series", + "instant": false, + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{protocol}}", + "refId": "A" + } + ], + "title": "Active Connections by Protocol", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(209, 138, 78, 1)", + "value": null + } + ] + } + }, + "overrides": [] + }, + "gridPos": { + "h": 15, + "w": 5, + "x": 19, + "y": 0 + }, + "id": 21, + "options": { + "colorMode": "value", + "graphMode": "none", + "justifyMode": "auto", + "orientation": "auto", + "percentChangeColorMode": "standard", + "reduceOptions": { + "calcs": [ + "lastNotNull" + ], + "fields": "", + "values": false + }, + "showPercentChange": false, + "text": {}, + "textMode": "auto", + "wideLayout": true + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum (thingsboard_transport_connections_active)", + "instant": true, + "interval": "", + "legendFormat": "", + "refId": "A" + } + ], + "title": "Total Connections", + "type": "stat" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "description": "", + "fieldConfig": { + "defaults": { + "color": { + "mode": "palette-classic" + }, + "custom": { + "axisBorderShow": false, + "axisCenteredZero": false, + "axisColorMode": "text", + "axisLabel": "", + "axisPlacement": "auto", + "barAlignment": 0, + "drawStyle": "line", + "fillOpacity": 10, + "gradientMode": "none", + "hideFrom": { + "legend": false, + "tooltip": false, + "viz": false + }, + "insertNulls": false, + "lineInterpolation": "linear", + "lineWidth": 1, + "pointSize": 5, + "scaleDistribution": { + "type": "linear" + }, + "showPoints": "never", + "spanNulls": true, + "stacking": { + "group": "A", + "mode": "none" + }, + "thresholdsStyle": { + "mode": "off" + } + }, + "mappings": [], + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "green", + "value": null + }, + { + "color": "red", + "value": 80 + } + ] + }, + "unit": "short" + }, + "overrides": [] + }, + "gridPos": { + "h": 15, + "w": 19, + "x": 0, + "y": 15 + }, + "id": 22, + "options": { + "legend": { + "calcs": [], + "displayMode": "list", + "placement": "bottom", + "showLegend": true + }, + "tooltip": { + "mode": "multi", + "sort": "none" + } + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "thingsboard_transport_connections_active", + "format": "time_series", + "interval": "", + "intervalFactor": 1, + "legendFormat": "{{protocol}} {{pod_name}}", + "range": true, + "refId": "A" + } + ], + "title": "Active Connections by Pod", + "type": "timeseries" + }, + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "color": { + "mode": "thresholds" + }, + "custom": { + "align": "auto", + "cellOptions": { + "type": "auto" + }, + "filterable": false, + "inspect": false + }, + "mappings": [], + "min": 0, + "thresholds": { + "mode": "absolute", + "steps": [ + { + "color": "rgba(209, 138, 78, 1)", + "value": null + } + ] + } + }, + "overrides": [ + { + "matcher": { + "id": "byName", + "options": "Connections" + }, + "properties": [ + { + "id": "custom.cellOptions", + "value": { + "mode": "gradient", + "type": "gauge" + } + } + ] + } + ] + }, + "gridPos": { + "h": 15, + "w": 5, + "x": 19, + "y": 15 + }, + "id": 23, + "options": { + "cellHeight": "sm", + "footer": { + "countRows": false, + "fields": "", + "reducer": [ + "sum" + ], + "show": false + }, + "showHeader": true, + "sortBy": [] + }, + "targets": [ + { + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "editorMode": "code", + "exemplar": true, + "expr": "sum by (pod_name) (thingsboard_transport_connections_active)", + "format": "table", + "instant": true, + "interval": "", + "legendFormat": "__auto", + "queryType": "randomWalk", + "refId": "A" + } + ], + "title": "Connection Count by Pod", + "transformations": [ + { + "id": "organize", + "options": { + "excludeByName": { + "Time": true, + "__name__": true, + "app": true, + "controller_revision_hash": true, + "instance": true, + "job": true, + "kubernetes_name": true, + "kubernetes_namespace": true, + "node_name": true, + "pod_name": false, + "statefulset_kubernetes_io_pod_name": true + }, + "indexByName": {}, + "renameByName": { + "Time": "", + "Value": "Connections", + "pod_name": "Pod" + } + } + }, + { + "id": "sortBy", + "options": { + "fields": {}, + "sort": [ + { + "field": "Pod" + } + ] + } + } + ], + "type": "table" + } + ], + "refresh": "30s", + "schemaVersion": 39, + "tags": [ + "thingsboard" + ], + "templating": { + "list": [] + }, + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ] + }, + "timezone": "", + "title": "Transport Connections", + "uid": "lewbrlwjerwkj3", + "version": 1, + "weekStart": "" +} diff --git a/edqs/pom.xml b/edqs/pom.xml index 25c6125d82..abb4fbe68b 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard edqs @@ -33,7 +33,6 @@ UTF-8 ${basedir}/.. java - false process-resources package tb-edqs @@ -179,10 +178,6 @@ org.apache.maven.plugins maven-assembly-plugin - - org.apache.maven.plugins - maven-install-plugin - org.xolstice.maven.plugins protobuf-maven-plugin diff --git a/edqs/src/main/resources/edqs.yml b/edqs/src/main/resources/edqs.yml index a2a6ca6814..cad0fcaccc 100644 --- a/edqs/src/main/resources/edqs.yml +++ b/edqs/src/main/resources/edqs.yml @@ -25,19 +25,19 @@ app: # Application version version: "@project.version@" -# Zookeeper connection parameters +# ZooKeeper connection parameters zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:true}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -103,8 +103,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -210,7 +210,7 @@ management: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" health: elasticsearch: # Enable the org.springframework.boot.actuate.elasticsearch.ElasticsearchRestClientHealthIndicator.doHealthCheck diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java index 5d4905f0ef..11423d7484 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/DeviceTypeFilterTest.java @@ -19,15 +19,20 @@ import org.junit.After; import org.junit.Assert; import org.junit.Before; import org.junit.Test; +import org.thingsboard.server.common.data.AttributeScope; import org.thingsboard.server.common.data.Device; import org.thingsboard.server.common.data.DeviceProfile; import org.thingsboard.server.common.data.DeviceProfileType; import org.thingsboard.server.common.data.EntityType; +import org.thingsboard.server.common.data.edqs.AttributeKv; import org.thingsboard.server.common.data.edqs.LatestTsKv; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.DeviceProfileId; +import org.thingsboard.server.common.data.kv.BaseAttributeKvEntry; import org.thingsboard.server.common.data.kv.BasicTsKvEntry; +import org.thingsboard.server.common.data.kv.BooleanDataEntry; import org.thingsboard.server.common.data.kv.StringDataEntry; +import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.DeviceTypeFilter; import org.thingsboard.server.common.data.query.EntityDataPageLink; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -39,8 +44,10 @@ import org.thingsboard.server.common.data.query.FilterPredicateValue; import org.thingsboard.server.common.data.query.KeyFilter; import org.thingsboard.server.common.data.query.StringFilterPredicate; +import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.List; import java.util.UUID; public class DeviceTypeFilterTest extends AbstractEDQTest { @@ -119,7 +126,50 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { Assert.assertEquals("42", first.getLatest().get(EntityKeyType.ENTITY_FIELD).get("createdTime").getValue()); } + @Test + public void testFindDeviceByBooleanAttributeWithMixedTypes() { + DeviceId device1Id = createLoraDevice("LoRa-1"); + DeviceId device2Id = createLoraDevice("LoRa-2"); + DeviceId device3Id = createLoraDevice("LoRa-3"); + + long ts = System.currentTimeMillis(); + addOrUpdate(new AttributeKv(device1Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", true), ts), 1L)); + addOrUpdate(new AttributeKv(device2Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new BooleanDataEntry("active", false), ts), 1L)); + addOrUpdate(new AttributeKv(device3Id, AttributeScope.SERVER_SCOPE, + new BaseAttributeKvEntry(new StringDataEntry("active", "true"), ts), 1L)); + + KeyFilter activeFilter = new KeyFilter(); + activeFilter.setKey(new EntityKey(EntityKeyType.SERVER_ATTRIBUTE, "active")); + activeFilter.setValueType(EntityKeyValueType.BOOLEAN); + BooleanFilterPredicate predicate = new BooleanFilterPredicate(); + predicate.setOperation(BooleanFilterPredicate.BooleanOperation.EQUAL); + predicate.setValue(FilterPredicateValue.fromBoolean(true)); + activeFilter.setPredicate(predicate); + + var result = repository.countEntitiesByQuery(tenantId, null, + getDeviceTypeQuery("LoRa", List.of(activeFilter)), false); + Assert.assertEquals(2, result); + } + + private DeviceId createLoraDevice(String name) { + DeviceId deviceId = new DeviceId(UUID.randomUUID()); + Device device = new Device(); + device.setId(deviceId); + device.setTenantId(tenantId); + device.setDeviceProfileId(loraProfileId); + device.setName(name); + device.setCreatedTime(42L); + addOrUpdate(EntityType.DEVICE, device); + return deviceId; + } + private static EntityDataQuery getDeviceTypeQuery(String deviceType) { + return getDeviceTypeQuery(deviceType, null); + } + + private static EntityDataQuery getDeviceTypeQuery(String deviceType, List extraFilters) { DeviceTypeFilter filter = new DeviceTypeFilter(); filter.setDeviceTypes(Collections.singletonList(deviceType)); var pageLink = new EntityDataPageLink(20, 0, null, new EntityDataSortOrder(new EntityKey(EntityKeyType.TIME_SERIES, "state"), EntityDataSortOrder.Direction.DESC), false); @@ -135,7 +185,12 @@ public class DeviceTypeFilterTest extends AbstractEDQTest { nameFilter.setPredicate(predicate); nameFilter.setValueType(EntityKeyValueType.STRING); - return new EntityDataQuery(filter, pageLink, entityFields, latestValues, Arrays.asList(nameFilter)); + List keyFilters = new ArrayList<>(); + keyFilters.add(nameFilter); + if (extraFilters != null) { + keyFilters.addAll(extraFilters); + } + return new EntityDataQuery(filter, pageLink, entityFields, latestValues, keyFilters); } } diff --git a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java index a3691a0889..ab1b6c4060 100644 --- a/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java +++ b/edqs/src/test/java/org/thingsboard/server/edqs/repo/RepositoryUtilsTest.java @@ -15,6 +15,7 @@ */ package org.thingsboard.server.edqs.repo; +import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -26,7 +27,7 @@ import org.thingsboard.server.common.data.edqs.fields.DeviceProfileFields; import org.thingsboard.server.common.data.query.BooleanFilterPredicate; import org.thingsboard.server.common.data.query.BooleanFilterPredicate.BooleanOperation; import org.thingsboard.server.common.data.query.ComplexFilterPredicate; -import org.thingsboard.server.common.data.query.ComplexFilterPredicate.ComplexOperation; +import org.thingsboard.server.common.data.query.ComplexOperation; import org.thingsboard.server.common.data.query.EntityKeyType; import org.thingsboard.server.common.data.query.EntityKeyValueType; import org.thingsboard.server.common.data.query.FilterPredicateValue; @@ -44,6 +45,7 @@ import org.thingsboard.server.edqs.query.DataKey; import org.thingsboard.server.edqs.query.EdqsFilter; import org.thingsboard.server.edqs.util.RepositoryUtils; +import java.util.Collections; import java.util.List; import java.util.UUID; import java.util.stream.Stream; @@ -469,4 +471,163 @@ public class RepositoryUtilsTest { return complexFilterPredicate; } + // --- OR operation tests for checkKeyFilters --- + + @Test + public void testCheckKeyFiltersWithOrOperation() { + // Entity with temperature=60; filters: temperature>50 (match) and humidity>80 (no data) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // OR: temperature>50 matches, short-circuits to true + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationNoMatch() { + // Entity with temperature=30; filters: temperature>50 (no match) and temperature<10 (no match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice2").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 30.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR: neither filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrOperationSameKey() { + // Entity with temperature=5; filters: temperature>50 (no match) and temperature<10 (match) + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("orDevice3").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 5.0)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter tempLt10 = getTemperatureFilter(NumericOperation.LESS, 10); + + // OR on same key: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, tempLt10), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithAndOperationUnchanged() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("andDevice1").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // AND: both match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isTrue(); + + // Now with humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("andDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + // AND: humidity>80 fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithNullOperationDefaultsToAnd() { + // Entity with temperature=60 and humidity=90 + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("nullOpDevice").build()); + deviceData.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData.putTs(6, new LongDataPoint(System.currentTimeMillis(), 90)); + + EdqsFilter tempGt50 = getTemperatureFilter(NumericOperation.GREATER, 50); + EdqsFilter humidityGt80 = getHumidityFilter(NumericOperation.GREATER, 80); + + // null operation should behave like AND + boolean nullResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), null); + boolean andResult = RepositoryUtils.checkKeyFilters(deviceData, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult).isEqualTo(andResult); + assertThat(nullResult).isTrue(); + + // Entity with temperature=60 and humidity=70 -- humidity>80 fails + DeviceData deviceData2 = new DeviceData(UUID.randomUUID()); + deviceData2.setCustomerId(UUID.randomUUID()); + deviceData2.setFields(DeviceFields.builder().name("nullOpDevice2").build()); + deviceData2.putTs(5, new DoubleDataPoint(System.currentTimeMillis(), 60.0)); + deviceData2.putTs(6, new LongDataPoint(System.currentTimeMillis(), 70)); + + boolean nullResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), null); + boolean andResult2 = RepositoryUtils.checkKeyFilters(deviceData2, List.of(tempGt50, humidityGt80), ComplexOperation.AND); + assertThat(nullResult2).isEqualTo(andResult2); + assertThat(nullResult2).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEmptyFilters() { + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("emptyFilterDevice").build()); + + // OR with empty filters = include all (matches SQL behavior) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, Collections.emptyList(), ComplexOperation.OR)).isTrue(); + } + + @Test + public void testCheckKeyFiltersWithOrStringEntityFieldFilters() { + // Entity with name="loranet device 123" + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name("loranet device 123").build()); + + // Filter 1: name STARTS_WITH "mqtt" (no match) + EdqsFilter nameStartsMqtt = getStringEntityFieldFilter("name", StringOperation.STARTS_WITH, "mqtt"); + // Filter 2: name CONTAINS "lora" (match) + EdqsFilter nameContainsLora = getStringEntityFieldFilter("name", StringOperation.CONTAINS, "lora"); + + // OR: second filter matches + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.OR)).isTrue(); + + // AND: first filter fails + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(nameStartsMqtt, nameContainsLora), ComplexOperation.AND)).isFalse(); + } + + @Test + public void testCheckKeyFiltersWithOrEntityFieldNullStringSpecialCase() { + // Entity with name=null — tests the special case where null string + entity field = match + DeviceData deviceData = new DeviceData(UUID.randomUUID()); + deviceData.setCustomerId(UUID.randomUUID()); + deviceData.setFields(DeviceFields.builder().name(null).build()); + + // Filter on entity field "label" which is not set (null) + EdqsFilter labelFilter = getStringEntityFieldFilter("label", StringOperation.STARTS_WITH, "test"); + // Filter on timeseries (non-entity-field) that doesn't exist + EdqsFilter tempFilter = getTemperatureFilter(NumericOperation.GREATER, 50); + + // Entity field with null string = match (special case in evaluateSingleFilter) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.OR)).isTrue(); + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter), ComplexOperation.AND)).isTrue(); + + // Non-entity-field with missing data = no match + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(tempFilter), ComplexOperation.OR)).isFalse(); + + // OR: entity field null match + missing timeseries = true (first matches) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.OR)).isTrue(); + + // AND: entity field null match + missing timeseries = false (second fails) + assertThat(RepositoryUtils.checkKeyFilters(deviceData, List.of(labelFilter, tempFilter), ComplexOperation.AND)).isFalse(); + } + } diff --git a/monitoring/pom.xml b/monitoring/pom.xml index a1addced5d..c3dfaf00c7 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -21,7 +21,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard @@ -33,7 +33,6 @@ UTF-8 ${basedir}/.. java - false process-resources package tb-monitoring @@ -80,6 +79,10 @@ org.apache.httpcomponents httpclient + + com.slack.api + slack-api-client + org.eclipse.leshan leshan-client-cf @@ -119,6 +122,11 @@ ch.qos.logback logback-classic + + org.springframework.boot + spring-boot-starter-test + test + @@ -154,8 +162,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java index 251b5e1d85..cc9801dcad 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/config/transport/TransportInfo.java @@ -16,13 +16,21 @@ package org.thingsboard.monitoring.config.transport; import lombok.Data; +import org.thingsboard.monitoring.data.notification.ShortNameProvider; @Data -public class TransportInfo { +public class TransportInfo implements ShortNameProvider { private final TransportType type; private final TransportMonitoringTarget target; + public String getShortName() { + if (target.getQueue().equals("Main")) { + return type.getName(); + } + return type.getName() + " " + target.getQueue(); + } + @Override public String toString() { if (target.getQueue().equals("Main")) { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java index 28fa6a9e18..c566e8f896 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/MonitoredServiceKey.java @@ -18,6 +18,9 @@ package org.thingsboard.monitoring.data; public class MonitoredServiceKey { public static final String GENERAL = "Monitoring"; + public static final String LOGIN = "Login"; + public static final String WS_CONNECT = "WS Connect"; + public static final String WS_SUBSCRIBE = "WS Subscribe"; public static final String EDQS = "*EDQS*"; } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java new file mode 100644 index 0000000000..b30a398223 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/AffectedService.java @@ -0,0 +1,34 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +public record AffectedService(String name, Status status, int failureCount) { + + public enum Status { FAILING, RECOVERED, HIGH_LATENCY } + + public static AffectedService failing(String name, int failureCount) { + return new AffectedService(name, Status.FAILING, failureCount); + } + + public static AffectedService recovered(String name) { + return new AffectedService(name, Status.RECOVERED, 0); + } + + public static AffectedService highLatency(String name) { + return new AffectedService(name, Status.HIGH_LATENCY, 0); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java index 9a319eb6c9..3e739ec4dc 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/HighLatencyNotification.java @@ -18,6 +18,8 @@ package org.thingsboard.monitoring.data.notification; import org.thingsboard.monitoring.data.Latency; import java.util.Collection; +import java.util.List; +import java.util.stream.Collectors; public class HighLatencyNotification implements Notification { @@ -39,4 +41,11 @@ public class HighLatencyNotification implements Notification { return text.toString(); } + @Override + public List getAffectedServices() { + return highLatencies.stream() + .map(latency -> AffectedService.highLatency(latency.getKey())) + .collect(Collectors.toList()); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java index b2b73414de..6906e8c732 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/InfoNotification.java @@ -24,4 +24,9 @@ public class InfoNotification implements Notification { public String getText() { return message; } + + @Override + public boolean isIncident() { + return false; + } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java index 33ed7f8328..7734fc62ee 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/Notification.java @@ -15,8 +15,18 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public interface Notification { String getText(); + default boolean isIncident() { + return true; + } + + default List getAffectedServices() { + return List.of(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java index dd93f232a6..69e336f334 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotification.java @@ -18,6 +18,10 @@ package org.thingsboard.monitoring.data.notification; import lombok.Getter; import org.apache.commons.lang3.exception.ExceptionUtils; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + @Getter public class ServiceFailureNotification implements Notification { @@ -43,7 +47,65 @@ public class ServiceFailureNotification implements Notification { if (errorMsg == null) { errorMsg = error.getClass().getSimpleName(); } + errorMsg = stripResponseBody(errorMsg); + errorMsg = linkifyRequestUrl(errorMsg); return String.format("%s - Failure: %s (number of subsequent failures: %s)", serviceKey, errorMsg, failuresCount); } + // Spring RestClient: '... request for ""' + private static final Pattern REQUEST_FOR_URL_PATTERN = Pattern.compile("request for \"(https?://[^\"\\s]+)\""); + // Apache HttpClient wrapped by Spring: 'I/O error on POST request: Connect to failed: ' + private static final Pattern REQUEST_CONNECT_PATTERN = Pattern.compile("request: Connect to (https?://\\S+?) failed:"); + + static String linkifyRequestUrl(String msg) { + if (msg == null) { + return null; + } + // Slack mrkdwn link: + Matcher m = REQUEST_FOR_URL_PATTERN.matcher(msg); + if (m.find()) { + return m.replaceAll("<$1|request>"); + } + Matcher m2 = REQUEST_CONNECT_PATTERN.matcher(msg); + if (m2.find()) { + return m2.replaceAll("<$1|request>:"); + } + return msg; + } + + static String stripResponseBody(String msg) { + if (msg == null) { + return null; + } + int htmlIdx = -1; + for (String marker : new String[]{"= 0 && (htmlIdx < 0 || idx < htmlIdx)) { + htmlIdx = idx; + } + } + if (htmlIdx > 0) { + msg = msg.substring(0, htmlIdx).stripTrailing(); + if (msg.endsWith("\"")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + if (msg.endsWith(":")) { + msg = msg.substring(0, msg.length() - 1).stripTrailing(); + } + } + return msg; + } + + @Override + public List getAffectedServices() { + return List.of(AffectedService.failing(shortName(serviceKey), failuresCount)); + } + + static String shortName(Object serviceKey) { + if (serviceKey instanceof ShortNameProvider provider) { + return provider.getShortName(); + } + return serviceKey.toString(); + } + } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java index b6e0c7c695..31b32422a1 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ServiceRecoveryNotification.java @@ -15,6 +15,8 @@ */ package org.thingsboard.monitoring.data.notification; +import java.util.List; + public class ServiceRecoveryNotification implements Notification { private final Object serviceKey; @@ -28,4 +30,9 @@ public class ServiceRecoveryNotification implements Notification { return String.format("%s is OK", serviceKey); } + @Override + public List getAffectedServices() { + return List.of(AffectedService.recovered(ServiceFailureNotification.shortName(serviceKey))); + } + } diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java similarity index 83% rename from common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java rename to monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java index 492fc683dd..96c63023af 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/condition/expression/ComplexOperation.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/data/notification/ShortNameProvider.java @@ -13,9 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package org.thingsboard.server.common.data.alarm.rule.condition.expression; +package org.thingsboard.monitoring.data.notification; + +public interface ShortNameProvider { + + String getShortName(); -public enum ComplexOperation { - AND, - OR } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java index e813b3205d..091a331438 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/NotificationService.java @@ -51,7 +51,7 @@ public class NotificationService { return notificationChannels.stream().map(notificationChannel -> notificationExecutor.submit(() -> { try { - notificationChannel.sendNotification(message); + notificationChannel.sendNotification(message, notification); } catch (Exception e) { log.error("Failed to send notification to {}", notificationChannel.getClass().getSimpleName(), e); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java index 126942cb3f..614fddda31 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/NotificationChannel.java @@ -15,8 +15,10 @@ */ package org.thingsboard.monitoring.notification.channels; +import org.thingsboard.monitoring.data.notification.Notification; + public interface NotificationChannel { - void sendNotification(String message); + void sendNotification(String message, Notification notification); } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java new file mode 100644 index 0000000000..4ad74d7811 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackApiClient.java @@ -0,0 +1,125 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import com.slack.api.Slack; +import com.slack.api.SlackConfig; +import com.slack.api.methods.MethodsClient; +import com.slack.api.methods.SlackApiTextResponse; +import com.slack.api.methods.request.chat.ChatPostMessageRequest; +import com.slack.api.methods.request.chat.ChatUpdateRequest; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; +import com.slack.api.methods.response.chat.ChatUpdateResponse; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +public class SlackApiClient { + + private static final int DEFAULT_CALL_TIMEOUT_MS = 5000; + + private final Slack slack; + private final String botToken; + + public SlackApiClient(String botToken) { + this(botToken, DEFAULT_CALL_TIMEOUT_MS); + } + + public SlackApiClient(String botToken, int callTimeoutMs) { + this.botToken = botToken; + SlackConfig config = new SlackConfig(); + config.setHttpClientCallTimeoutMillis(callTimeoutMs); + config.setHttpClientReadTimeoutMillis(callTimeoutMs); + config.setHttpClientWriteTimeoutMillis(callTimeoutMs); + this.slack = Slack.getInstance(config); + } + + public String postMessage(String channelId, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public String postThreadReply(String channelId, String threadTs, String text) { + ChatPostMessageRequest request = ChatPostMessageRequest.builder() + .channel(channelId) + .text(text) + .threadTs(threadTs) + .build(); + ChatPostMessageResponse response = sendRequest(request); + return response.getTs(); + } + + public void close() { + try { + slack.close(); + } catch (Exception e) { + log.warn("Failed to close Slack client", e); + } + } + + public void updateMessage(String channelId, String ts, String text) { + ChatUpdateRequest request = ChatUpdateRequest.builder() + .channel(channelId) + .ts(ts) + .text(text) + .build(); + MethodsClient client = slack.methods(botToken); + ChatUpdateResponse response; + try { + response = client.chatUpdate(request); + } catch (Exception e) { + throw new RuntimeException("Failed to update Slack message: " + e.getMessage(), e); + } + checkResponse(response); + } + + private ChatPostMessageResponse sendRequest(ChatPostMessageRequest request) { + MethodsClient client = slack.methods(botToken); + ChatPostMessageResponse response; + try { + response = client.chatPostMessage(request); + } catch (Exception e) { + throw new RuntimeException("Failed to send Slack message: " + e.getMessage(), e); + } + checkResponse(response); + return response; + } + + private void checkResponse(SlackApiTextResponse response) { + if (response.isOk()) { + return; + } + String error = response.getError(); + if (error != null) { + switch (error) { + case "missing_scope" -> { + String neededScope = response.getNeeded(); + error = "bot token scope '" + neededScope + "' is needed"; + } + case "not_in_channel" -> error = "app needs to be added to the channel"; + } + } else if (response.getWarning() != null) { + error = "warning: " + response.getWarning(); + } else { + error = "unknown error"; + } + throw new RuntimeException("Slack API error: " + error); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java new file mode 100644 index 0000000000..8812714d62 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackIncidentTransport.java @@ -0,0 +1,45 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.channels.impl; + +import org.thingsboard.monitoring.notification.incident.IncidentTransport; + +public class SlackIncidentTransport implements IncidentTransport { + + private final SlackApiClient slackApiClient; + private final String channelId; + + public SlackIncidentTransport(SlackApiClient slackApiClient, String channelId) { + this.slackApiClient = slackApiClient; + this.channelId = channelId; + } + + @Override + public String postIncident(String text) { + return slackApiClient.postMessage(channelId, text); + } + + @Override + public void postThreadReply(String threadId, String text) { + slackApiClient.postThreadReply(channelId, threadId, text); + } + + @Override + public void updateIncident(String threadId, String text) { + slackApiClient.updateMessage(channelId, threadId, text); + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java index c1d25c43c0..06990c5a51 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/channels/impl/SlackNotificationChannel.java @@ -16,13 +16,16 @@ package org.thingsboard.monitoring.notification.channels.impl; import jakarta.annotation.PostConstruct; +import jakarta.annotation.PreDestroy; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.stereotype.Component; import org.springframework.web.client.RestTemplate; +import org.thingsboard.monitoring.data.notification.Notification; import org.thingsboard.monitoring.notification.channels.NotificationChannel; +import org.thingsboard.monitoring.notification.incident.IncidentManager; import java.time.Duration; import java.util.Map; @@ -35,19 +38,73 @@ public class SlackNotificationChannel implements NotificationChannel { @Value("${monitoring.notifications.slack.webhook_url}") private String webhookUrl; + @Value("${monitoring.notifications.slack.bot_token:}") + private String botToken; + + @Value("${monitoring.notifications.slack.channel_id:}") + private String channelId; + + @Value("${monitoring.notifications.incident.enabled:}") + private boolean incidentEnabled; + + @Value("${monitoring.notifications.incident.resolution_timeout_s:}") + private long resolutionTimeoutSeconds; + + @Value("${monitoring.notifications.incident.tag_channel:}") + private boolean tagChannel; + + @Value("${monitoring.notifications.message_prefix:}") + private String messagePrefix; + private RestTemplate restTemplate; + private SlackApiClient slackApiClient; + private IncidentManager incidentManager; @PostConstruct private void init() { - restTemplate = new RestTemplateBuilder() - .setConnectTimeout(Duration.ofSeconds(5)) - .setReadTimeout(Duration.ofSeconds(2)) - .build(); + boolean hasBotConfig = botToken != null && !botToken.isEmpty() && channelId != null && !channelId.isEmpty(); + if (hasBotConfig) { + slackApiClient = new SlackApiClient(botToken); + log.info("Slack API mode enabled (channel: {})", channelId); + if (incidentEnabled) { + incidentManager = new IncidentManager(new SlackIncidentTransport(slackApiClient, channelId), + resolutionTimeoutSeconds, messagePrefix, tagChannel); + log.info("Incident grouping enabled via Slack (resolution timeout: {}s)", resolutionTimeoutSeconds); + } + } else { + if (incidentEnabled) { + log.warn("Incident grouping is enabled but Slack bot_token/channel_id are not set; " + + "falling back to plain webhook mode without incident support"); + } + restTemplate = new RestTemplateBuilder() + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(2)) + .build(); + log.info("Slack webhook mode enabled"); + } } @Override - public void sendNotification(String message) { - restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + public void sendNotification(String message, Notification notification) { + if (incidentManager != null && notification.isIncident()) { + // Pass the raw notification text: IncidentManager already puts the prefix into the + // incident header, so pre-prefixing the thread reply would double it up. + incidentManager.sendAlert(notification.getText(), notification.getAffectedServices()); + } else if (slackApiClient != null) { + slackApiClient.postMessage(channelId, message); + } else { + restTemplate.postForObject(webhookUrl, Map.of("text", message), String.class); + } + } + + @PreDestroy + private void destroy() { + if (incidentManager != null) { + incidentManager.shutdown(); + } + if (slackApiClient != null) { + slackApiClient.close(); + } } } diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java new file mode 100644 index 0000000000..c25abfc081 --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentManager.java @@ -0,0 +1,292 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +import lombok.extern.slf4j.Slf4j; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +/** + * Thread-safety: all public entry points and scheduled callbacks are {@code synchronized} on the + * manager instance. Transport I/O is performed while the monitor is held. This is safe under the + * assumptions that (a) the transport enforces a short per-call timeout, and (b) notification + * producers are single-threaded; see the Slack client (default 5s) for the Slack-based transport. + */ +@Slf4j +public class IncidentManager { + + private final IncidentTransport transport; + private final long resolutionTimeoutSeconds; + private final String messagePrefix; + private final boolean tagChannel; + private final ScheduledExecutorService scheduler; + + private String activeIncidentThreadId; + private ScheduledFuture resolutionTask; + private ScheduledFuture durationUpdateTask; + private Instant incidentStartTime; + private Instant lastAlertTime; + private final Map failingServices = new LinkedHashMap<>(); + private final Map recoveredServices = new LinkedHashMap<>(); + private final Set highLatencyServices = new LinkedHashSet<>(); + + public IncidentManager(IncidentTransport transport, long resolutionTimeoutSeconds, + String messagePrefix, boolean tagChannel) { + this.transport = transport; + this.resolutionTimeoutSeconds = resolutionTimeoutSeconds; + this.messagePrefix = messagePrefix; + this.tagChannel = tagChannel; + this.scheduler = Executors.newSingleThreadScheduledExecutor(r -> { + Thread t = new Thread(r, "incident-manager"); + t.setDaemon(true); + return t; + }); + } + + public synchronized void sendAlert(String message, List affectedServices) { + try { + if (activeIncidentThreadId == null) { + if (affectedServices.stream().allMatch(s -> s.status() == AffectedService.Status.RECOVERED)) { + return; + } + incidentStartTime = Instant.now(); + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + applyAffectedServices(affectedServices); + activeIncidentThreadId = transport.postIncident(buildOngoingMessageText()); + startDurationUpdater(); + log.info("New incident created, thread id: {}", activeIncidentThreadId); + } else if (applyAffectedServices(affectedServices)) { + safeUpdateHeader(); + } + + try { + transport.postThreadReply(activeIncidentThreadId, message); + log.debug("Alert added to incident thread {}", activeIncidentThreadId); + } catch (Exception e) { + log.error("Failed to post alert to incident thread {}", activeIncidentThreadId, e); + } + } finally { + if (activeIncidentThreadId != null) { + lastAlertTime = Instant.now(); + // High latency is a warning only — it has no explicit recovery signal + // (HighLatencyNotification fires only when something is above threshold), + // so resolution hinges on failing services alone. + if (failingServices.isEmpty()) { + resetResolutionTimer(); + } else { + cancelResolutionTimer(); + } + } + } + } + + private boolean applyAffectedServices(List affectedServices) { + boolean changed = false; + Set latencySnapshot = null; + for (AffectedService service : affectedServices) { + String name = service.name(); + switch (service.status()) { + case FAILING -> { + Integer prev = failingServices.put(name, service.failureCount()); + if (prev == null || prev.intValue() != service.failureCount()) { + changed = true; + } + if (recoveredServices.remove(name) != null) { + changed = true; + } + } + case RECOVERED -> { + Integer lastFailureCount = failingServices.remove(name); + if (lastFailureCount != null) { + recoveredServices.put(name, lastFailureCount); + changed = true; + } + } + case HIGH_LATENCY -> { + if (latencySnapshot == null) { + latencySnapshot = new LinkedHashSet<>(); + } + latencySnapshot.add(name); + } + } + } + // HighLatencyNotification carries the full current set of high latencies, so treat it as a + // snapshot: replace highLatencyServices entirely. Without this, a brief spike would stay + // yellow in the header until the incident resolves. + if (latencySnapshot != null && !latencySnapshot.equals(highLatencyServices)) { + highLatencyServices.clear(); + highLatencyServices.addAll(latencySnapshot); + changed = true; + } + return changed; + } + + private String buildOngoingMessageText() { + StringBuilder sb = new StringBuilder(); + if (tagChannel) { + sb.append(" "); + } + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :rotating_light:"); + Duration elapsed = Duration.between(incidentStartTime, Instant.now()); + if (elapsed.toMinutes() >= 1) { + sb.append(" (").append(formatDuration(elapsed)).append(")"); + } + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()); + } + return sb.toString(); + } + + private boolean hasAffected() { + return !failingServices.isEmpty() || !recoveredServices.isEmpty() || !highLatencyServices.isEmpty(); + } + + private void safeUpdateHeader() { + try { + transport.updateIncident(activeIncidentThreadId, buildOngoingMessageText()); + } catch (Exception e) { + log.error("Failed to update incident message", e); + } + } + + private void resetResolutionTimer() { + cancelResolutionTimer(); + resolutionTask = scheduler.schedule(this::resolveIncident, resolutionTimeoutSeconds, TimeUnit.SECONDS); + } + + private void cancelResolutionTimer() { + if (resolutionTask != null) { + resolutionTask.cancel(false); + resolutionTask = null; + } + } + + private void startDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + } + durationUpdateTask = scheduler.scheduleAtFixedRate(this::updateDuration, 60, 60, TimeUnit.SECONDS); + } + + private synchronized void updateDuration() { + if (activeIncidentThreadId == null) { + return; + } + safeUpdateHeader(); + } + + private void stopDurationUpdater() { + if (durationUpdateTask != null) { + durationUpdateTask.cancel(false); + durationUpdateTask = null; + } + } + + static String formatDuration(Duration duration) { + long totalMinutes = duration.toMinutes(); + if (totalMinutes < 60) { + return totalMinutes + "m"; + } + long hours = totalMinutes / 60; + long minutes = totalMinutes % 60; + return minutes > 0 ? hours + "h" + minutes + "m" : hours + "h"; + } + + synchronized void resolveIncident() { + if (activeIncidentThreadId == null) { + return; + } + String threadId = activeIncidentThreadId; + stopDurationUpdater(); + String resolutionMessage = buildResolutionMessage(); + activeIncidentThreadId = null; + resolutionTask = null; + failingServices.clear(); + recoveredServices.clear(); + highLatencyServices.clear(); + try { + transport.updateIncident(threadId, resolutionMessage); + log.info("Incident resolved (thread was {})", threadId); + } catch (Exception e) { + log.error("Failed to send incident resolution message", e); + } + } + + private String buildResolutionMessage() { + Duration totalDuration = lastAlertTime != null + ? Duration.between(incidentStartTime, lastAlertTime) + : Duration.between(incidentStartTime, Instant.now()); + StringBuilder sb = new StringBuilder(); + if (messagePrefix != null && !messagePrefix.isEmpty()) { + sb.append("*").append(messagePrefix).append("*"); + } + sb.append(" :white_check_mark:"); + sb.append(" (").append(formatDuration(totalDuration)).append(")"); + if (hasAffected()) { + sb.append(" | ").append(formatAffectedServices()).append("\n"); + } + return sb.toString(); + } + + private String formatAffectedServices() { + StringBuilder sb = new StringBuilder(); + boolean first = true; + for (Map.Entry entry : failingServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":red_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + for (String name : highLatencyServices) { + if (!first) sb.append(", "); + sb.append(":large_yellow_circle: ").append(name); + first = false; + } + for (Map.Entry entry : recoveredServices.entrySet()) { + if (!first) sb.append(", "); + sb.append(":large_green_circle: ").append(entry.getKey()).append(" (").append(entry.getValue()).append(")"); + first = false; + } + return sb.toString(); + } + + public void shutdown() { + scheduler.shutdownNow(); + try { + if (!scheduler.awaitTermination(5, TimeUnit.SECONDS)) { + log.warn("Incident scheduler did not terminate in time"); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java new file mode 100644 index 0000000000..0d6b4cac2a --- /dev/null +++ b/monitoring/src/main/java/org/thingsboard/monitoring/notification/incident/IncidentTransport.java @@ -0,0 +1,26 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +public interface IncidentTransport { + + String postIncident(String text); + + void postThreadReply(String threadId, String text); + + void updateIncident(String threadId, String text); + +} diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java index b73194469f..c0e85da1ac 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseHealthChecker.java @@ -89,11 +89,10 @@ public abstract class BaseHealthChecker { diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java index ea154e49c0..9c8c8fc533 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/BaseMonitoringService.java @@ -117,28 +117,60 @@ public abstract class BaseMonitoringService, T ext } try { log.info("Starting {}", getName()); - stopWatch.start(); - String accessToken = tbClient.logIn(); - reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); - try (WsClient wsClient = wsClientFactory.createClient(accessToken)) { + String accessToken; + try { stopWatch.start(); - wsClient.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); - reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + accessToken = tbClient.logIn(); + reporter.reportLatency(Latencies.LOG_IN, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.LOGIN); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.LOGIN, e); + return; + } + + WsClient wsClient; + try { + wsClient = wsClientFactory.createClient(accessToken); + reporter.serviceIsOk(MonitoredServiceKey.WS_CONNECT); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_CONNECT, e); + return; + } + + try (WsClient ws = wsClient) { + try { + stopWatch.start(); + ws.subscribeForTelemetry(devices, getTestTelemetryKeys()).waitForReply(); + reporter.reportLatency(Latencies.WS_SUBSCRIBE, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.WS_SUBSCRIBE); + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.WS_SUBSCRIBE, e); + return; + } for (BaseHealthChecker healthChecker : healthCheckers) { - check(healthChecker, wsClient); + check(healthChecker, ws); } } if (checkEdqs) { - stopWatch.start(); - checkEdqs(); - reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); - reporter.serviceIsOk(MonitoredServiceKey.EDQS); + try { + stopWatch.start(); + checkEdqs(); + reporter.reportLatency(Latencies.EDQS_QUERY, stopWatch.getTime()); + reporter.serviceIsOk(MonitoredServiceKey.EDQS); + } catch (ServiceFailureException e) { + reporter.serviceFailure(e.getServiceKey(), e); + return; + } catch (Exception e) { + reporter.serviceFailure(MonitoredServiceKey.EDQS, e); + return; + } } reporter.reportLatencies(); + reporter.serviceIsOk(MonitoredServiceKey.GENERAL); log.debug("Finished {}", getName()); } catch (ServiceFailureException e) { reporter.serviceFailure(e.getServiceKey(), e); diff --git a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java index b13394dbdc..75a8a819c0 100644 --- a/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java +++ b/monitoring/src/main/java/org/thingsboard/monitoring/service/MonitoringReporter.java @@ -25,7 +25,6 @@ import org.springframework.stereotype.Component; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.monitoring.client.TbClient; import org.thingsboard.monitoring.data.Latency; -import org.thingsboard.monitoring.data.MonitoredServiceKey; import org.thingsboard.monitoring.data.notification.HighLatencyNotification; import org.thingsboard.monitoring.data.notification.ServiceFailureNotification; import org.thingsboard.monitoring.data.notification.ServiceRecoveryNotification; @@ -119,9 +118,7 @@ public class MonitoringReporter { public void serviceIsOk(Object serviceKey) { ServiceRecoveryNotification notification = new ServiceRecoveryNotification(serviceKey); - if (!serviceKey.equals(MonitoredServiceKey.GENERAL)) { - log.info(notification.getText()); - } + log.info(notification.getText()); AtomicInteger failuresCounter = failuresCounters.get(serviceKey); if (failuresCounter != null) { if (failuresCounter.get() >= failuresThreshold) { diff --git a/monitoring/src/main/resources/tb-monitoring.yml b/monitoring/src/main/resources/tb-monitoring.yml index 053e42bb9d..768fd2e4c7 100644 --- a/monitoring/src/main/resources/tb-monitoring.yml +++ b/monitoring/src/main/resources/tb-monitoring.yml @@ -121,11 +121,24 @@ monitoring: notifications: message_prefix: '${NOTIFICATION_MESSAGE_PREFIX:}' + # Incident grouping (threads alerts into incidents, auto-resolves after timeout). + # Requires a channel that supports it — currently only Slack API (bot_token + channel_id). + incident: + # Enable incident grouping + enabled: '${INCIDENT_ENABLED:false}' + # Incident resolution timeout in seconds + resolution_timeout_s: '${INCIDENT_RESOLUTION_TIMEOUT_S:90}' + # Tag @channel in incident messages + tag_channel: '${INCIDENT_TAG_CHANNEL:false}' slack: # Enable notifying via Slack enabled: '${SLACK_NOTIFICATION_CHANNEL_ENABLED:false}' # Slack webhook url webhook_url: '${SLACK_WEBHOOK_URL:}' + # Slack Bot OAuth token (xoxb-...) for API-based messaging with incident support, requires chat:write:bot scope + bot_token: '${SLACK_BOT_TOKEN:}' + # Slack channel ID (e.g. C01234ABCDE) - required when incident feature is enabled + channel_id: '${SLACK_CHANNEL_ID:}' latency: # Enable latencies reporting diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java new file mode 100644 index 0000000000..500a713f7b --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/data/notification/ServiceFailureNotificationTest.java @@ -0,0 +1,97 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.data.notification; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class ServiceFailureNotificationTest { + + @Test + void stripResponseBodyRemovesNginxErrorHtml() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\": \"" + + "503 Service Temporarily Unavailable" + + "

503 Service Temporarily Unavailable


nginx
\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized) + .isEqualTo("503 Service Temporarily Unavailable on POST request for \"https://domain/api/auth/login\""); + } + + @Test + void stripResponseBodyRemovesDoctypeHtml() { + String msg = "500 Internal Server Error: \"...\""; + + String sanitized = ServiceFailureNotification.stripResponseBody(msg); + + assertThat(sanitized).isEqualTo("500 Internal Server Error"); + } + + @Test + void stripResponseBodyLeavesPlainMessagesUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.stripResponseBody(msg)).isEqualTo(msg); + } + + @Test + void stripResponseBodyHandlesNull() { + assertThat(ServiceFailureNotification.stripResponseBody(null)).isNull(); + } + + @Test + void linkifyReplacesRequestForUrlWithSlackMrkdwnLink() { + String msg = "503 Service Temporarily Unavailable on POST request for \"https://example.com/api/auth/login\""; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("503 Service Temporarily Unavailable on POST "); + } + + @Test + void linkifyReplacesRequestConnectToUrlFailed() { + String msg = "I/O error on POST request: Connect to https://example.com:443 failed: Connect timed out"; + + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)) + .isEqualTo("I/O error on POST : Connect timed out"); + } + + @Test + void linkifyLeavesMessagesWithoutRequestUrlUntouched() { + String msg = "Connection refused"; + assertThat(ServiceFailureNotification.linkifyRequestUrl(msg)).isEqualTo(msg); + } + + @Test + void linkifyHandlesNull() { + assertThat(ServiceFailureNotification.linkifyRequestUrl(null)).isNull(); + } + + @Test + void shortNameUsesShortNameProviderWhenAvailable() { + ShortNameProvider provider = () -> "MQTT"; + assertThat(ServiceFailureNotification.shortName(provider)).isEqualTo("MQTT"); + } + + @Test + void shortNameFallsBackToToStringForOtherKeys() { + Object key = new Object() { + @Override public String toString() { return "LOGIN"; } + }; + assertThat(ServiceFailureNotification.shortName(key)).isEqualTo("LOGIN"); + } + +} diff --git a/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java new file mode 100644 index 0000000000..b7051db534 --- /dev/null +++ b/monitoring/src/test/java/org/thingsboard/monitoring/notification/incident/IncidentManagerTest.java @@ -0,0 +1,183 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.monitoring.notification.incident; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.thingsboard.monitoring.data.notification.AffectedService; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class IncidentManagerTest { + + private RecordingTransport transport; + private IncidentManager manager; + + @BeforeEach + void setUp() { + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 3600L, "tbqa", false); + } + + @AfterEach + void tearDown() { + manager.shutdown(); + } + + @Test + void formatDurationRendersMinutesAndHours() { + assertThat(IncidentManager.formatDuration(Duration.ofSeconds(30))).isEqualTo("0m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(5))).isEqualTo("5m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(59))).isEqualTo("59m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(60))).isEqualTo("1h"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(75))).isEqualTo("1h15m"); + assertThat(IncidentManager.formatDuration(Duration.ofMinutes(120))).isEqualTo("2h"); + } + + @Test + void firstFailureOpensIncidentAndPostsHeaderAndReply() { + manager.sendAlert("CoAP failure message", + List.of(AffectedService.failing("CoAP", 1))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.incidents.get(0)).contains(":rotating_light:").contains(":red_circle: CoAP (1)"); + assertThat(transport.replies).hasSize(1); + assertThat(transport.replies.get(0).text()).isEqualTo("CoAP failure message"); + } + + @Test + void isolatedRecoveryWithoutActiveIncidentIsIgnored() { + manager.sendAlert("Login is OK", + List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).isEmpty(); + assertThat(transport.replies).isEmpty(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void subsequentFailureUpdatesHeaderAndPostsReply() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + manager.sendAlert("CoAP repeat", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.incidents).hasSize(1); + assertThat(transport.replies).hasSize(2); + assertThat(transport.updates).hasSize(1); + assertThat(transport.updates.get(0).text()).contains(":red_circle: CoAP (3)"); + } + + @Test + void recoveryAfterFailureMovesServiceToGreenAndKeepsFailureCount() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 4))); + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + + assertThat(transport.updates).hasSize(1); + String updated = transport.updates.get(0).text(); + assertThat(updated).contains(":large_green_circle: CoAP (4)").doesNotContain(":red_circle:"); + } + + @Test + void highLatencyIsTrackedAsYellow() { + manager.sendAlert("high latency", + List.of(AffectedService.highLatency("logInLatency"))); + + assertThat(transport.incidents.get(0)).contains(":large_yellow_circle: logInLatency"); + } + + @Test + void repeatingSameFailureCountDoesNotTriggerRedundantUpdate() { + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 3))); + manager.sendAlert("CoAP still failing", List.of(AffectedService.failing("CoAP", 3))); + + assertThat(transport.updates).isEmpty(); + assertThat(transport.replies).hasSize(2); + } + + @Test + void fullLifecycleStartFailRecoverResolve() { + manager.sendAlert("Login failure", List.of(AffectedService.failing("Login", 1))); + manager.sendAlert("WS failure", List.of(AffectedService.failing("WS Connect", 1))); + manager.sendAlert("Login is OK", List.of(AffectedService.recovered("Login"))); + + assertThat(transport.incidents).hasSize(1); + + manager.resolveIncident(); + + assertThat(transport.updates).last() + .extracting(RecordingTransport.Message::text) + .asString() + .contains(":white_check_mark:") + .contains(":red_circle: WS Connect") + .contains(":large_green_circle: Login (1)"); + } + + @Test + void resolveWithoutActiveIncidentIsNoOp() { + manager.resolveIncident(); + assertThat(transport.updates).isEmpty(); + } + + @Test + void doesNotAutoResolveWhileServicesAreStillFailing() throws Exception { + manager.shutdown(); + transport = new RecordingTransport(); + manager = new IncidentManager(transport, 1L, "tbqa", false); + + manager.sendAlert("CoAP failure", List.of(AffectedService.failing("CoAP", 1))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .noneMatch(t -> t.contains(":white_check_mark:")); + + manager.sendAlert("CoAP is OK", List.of(AffectedService.recovered("CoAP"))); + Thread.sleep(1500); + + assertThat(transport.updates) + .extracting(RecordingTransport.Message::text) + .anyMatch(t -> t.contains(":white_check_mark:")); + } + + private static class RecordingTransport implements IncidentTransport { + private final AtomicInteger threadCounter = new AtomicInteger(); + final java.util.List incidents = new java.util.ArrayList<>(); + final java.util.List replies = new java.util.ArrayList<>(); + final java.util.List updates = new java.util.ArrayList<>(); + + @Override + public String postIncident(String text) { + incidents.add(text); + return "thread-" + threadCounter.incrementAndGet(); + } + + @Override + public void postThreadReply(String threadId, String text) { + replies.add(new Message(threadId, text)); + } + + @Override + public void updateIncident(String threadId, String text) { + updates.add(new Message(threadId, text)); + } + + record Message(String threadId, String text) {} + } +} diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index 425948ae43..141ee1adac 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -128,42 +128,50 @@ org.thingsboard.msa js-executor - docker-info + ${project.version} + pom org.thingsboard.msa web-ui - docker-info + ${project.version} + pom org.thingsboard.msa tb-node - docker-info + ${project.version} + pom org.thingsboard.msa.transport coap - docker-info + ${project.version} + pom org.thingsboard.msa.transport http - docker-info + ${project.version} + pom org.thingsboard.msa.transport mqtt - docker-info + ${project.version} + pom org.thingsboard.msa.transport lwm2m - docker-info + ${project.version} + pom org.thingsboard.msa.transport snmp - docker-info + ${project.version} + pom org.thingsboard.common @@ -206,7 +214,7 @@ org.apache.maven.surefire surefire-testng - ${surefire.version} + ${maven-surefire-plugin.version} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractCoapClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractCoapClientTest.java index d4d9d08474..e409a82600 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractCoapClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractCoapClientTest.java @@ -28,7 +28,7 @@ import org.thingsboard.server.common.msg.session.FeatureType; public abstract class AbstractCoapClientTest extends AbstractContainerTest{ - private static final String COAP_BASE_URL = "coap://localhost:5683/api/v1/"; + private static final String CONTAINER_COAP_BASE_URL = "coap://localhost:5683/api/v1/"; private static final long CLIENT_REQUEST_TIMEOUT = 60000L; @@ -54,7 +54,7 @@ public abstract class AbstractCoapClientTest extends AbstractContainerTest{ protected byte[] createCoapClientAndPublish(String deviceName) throws Exception { String provisionRequestMsg = createTestProvisionMessage(deviceName); Configuration.addDefaultModule(MODULE_DEFINITIONS_PROVIDER); - String featureTokenUrl = COAP_BASE_URL + FeatureType.PROVISION.name().toLowerCase(); + String featureTokenUrl = CONTAINER_COAP_BASE_URL + FeatureType.PROVISION.name().toLowerCase(); client = new CoapClient(featureTokenUrl); return client.setTimeout(CLIENT_REQUEST_TIMEOUT) .post(provisionRequestMsg.getBytes(), MediaTypeRegistry.APPLICATION_JSON) diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java index ebf90bb7f0..ec1bdcf658 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/AbstractContainerTest.java @@ -44,7 +44,6 @@ import java.util.Map; import java.util.Random; import java.util.function.Consumer; - @Slf4j @Listeners(TestListener.class) public abstract class AbstractContainerTest { @@ -170,19 +169,14 @@ public abstract class AbstractContainerTest { DeviceProfileProvisionConfiguration provisionConfiguration; String testProvisionDeviceKey = TEST_PROVISION_DEVICE_KEY; deviceProfile.setProvisionType(provisionType); - switch(provisionType) { - case ALLOW_CREATE_NEW_DEVICES: - provisionConfiguration = new AllowCreateNewDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); - break; - case CHECK_PRE_PROVISIONED_DEVICES: - provisionConfiguration = new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); - break; - default: - case DISABLED: + provisionConfiguration = switch (provisionType) { + case ALLOW_CREATE_NEW_DEVICES -> new AllowCreateNewDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + case CHECK_PRE_PROVISIONED_DEVICES -> new CheckPreProvisionedDevicesDeviceProfileProvisionConfiguration(TEST_PROVISION_DEVICE_SECRET); + default -> { testProvisionDeviceKey = null; - provisionConfiguration = new DisabledDeviceProfileProvisionConfiguration(null); - break; - } + yield new DisabledDeviceProfileProvisionConfiguration(null); + } + }; DeviceProfileData deviceProfileData = deviceProfile.getProfileData(); deviceProfileData.setProvisionConfiguration(provisionConfiguration); deviceProfile.setProfileData(deviceProfileData); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/PortFinder.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/PortFinder.java new file mode 100644 index 0000000000..8bd036bf15 --- /dev/null +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/PortFinder.java @@ -0,0 +1,42 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.server.msa; + +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.net.DatagramSocket; +import java.net.SocketException; + +@Slf4j +public class PortFinder { + public static int findAvailableUdpPort() { + try (DatagramSocket socket = new DatagramSocket(0)) { + return socket.getLocalPort(); + } catch (SocketException e) { + throw new IllegalStateException("No available UDP ports found", e); + } + } + + public static boolean isUDPPortAvailable(int port) { + try (DatagramSocket socket = new DatagramSocket(port)) { + return true; + } catch (IOException e) { + log.debug("Failed to open UDP port {}", port, e); + return false; + } + } +} diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java index 4c5b228d3c..af4197d6ec 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/cf/CalculatedFieldTest.java @@ -810,18 +810,27 @@ public class CalculatedFieldTest extends AbstractContainerTest { // --- Build CF: PROPAGATION --- CalculatedField saved = createPropagationCF(device.getId()); + // Wait for INITIALIZED event + waitForDebugEvent(saved, CalculatedFieldEventType.INITIALIZED.name(), 1); + // Create relations FROM asset 2 TO device Asset asset2 = testRestClient.postAsset(createAsset("Propagated Asset 2", null)); EntityRelation rel2 = new EntityRelation(asset2.getId(), device.getId(), EntityRelation.CONTAINS_TYPE); testRestClient.postEntityRelation(rel2); + // Wait for RELATION_ADD_OR_UPDATE event + waitForDebugEvent(saved, CalculatedFieldEventType.RELATION_ADD_OR_UPDATE.name(), 2); + // Telemetry on device testRestClient.postTelemetry(deviceToken, JacksonUtil.toJsonNode("{\"temperature\":25.1}")); + // Wait for POST_TELEMETRY_REQUEST event + waitForDebugEvent(saved, TbMsgType.POST_TELEMETRY_REQUEST.name(), 3); + // Delete relation between asset 1 and device testRestClient.deleteEntityRelation(asset1.getId(), EntityRelation.CONTAINS_TYPE, device.getId()); - // --- Assert propagated calculation (arguments-only mode) --- + // --- Assert all debug events in correct sequence --- await().alias("check debug events") .atMost(TIMEOUT, TimeUnit.SECONDS) .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) @@ -845,6 +854,19 @@ public class CalculatedFieldTest extends AbstractContainerTest { testRestClient.deleteAsset(asset2.getId()); } + private void waitForDebugEvent(CalculatedField cf, String expectedEventType, int expectedTotalEvents) { + await().alias("wait for debug event: " + expectedEventType) + .atMost(TIMEOUT, TimeUnit.SECONDS) + .pollInterval(POLL_INTERVAL, TimeUnit.SECONDS) + .untilAsserted(() -> { + List eventTypes = testRestClient.getEvents(cf.getId(), EventType.DEBUG_CALCULATED_FIELD, tenantId, new TimePageLink(expectedTotalEvents, 0, null, SortOrder.BY_CREATED_TIME_DESC)).getData().stream() + .map(e -> e.getBody().get("msgType").asText()) + .toList(); + assertThat(eventTypes).hasSize(expectedTotalEvents); + assertThat(eventTypes.get(0)).isEqualTo(expectedEventType); + }); + } + private CalculatedField createPropagationCF(EntityId entityId) { CalculatedField cf = new CalculatedField(); cf.setEntityId(entityId); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java index c9ebfad3e2..24e7e78039 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/JavaRestClientTest.java @@ -16,6 +16,7 @@ package org.thingsboard.server.msa.connectivity; import com.google.gson.JsonObject; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; import org.apache.hc.client5.http.impl.classic.HttpClients; import org.apache.hc.client5.http.impl.io.PoolingHttpClientConnectionManagerBuilder; @@ -26,7 +27,6 @@ import org.apache.hc.client5.http.ssl.NoopHostnameVerifier; import org.apache.hc.core5.ssl.SSLContexts; import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; -import org.testcontainers.shaded.org.apache.commons.lang3.RandomStringUtils; import org.testng.annotations.AfterMethod; import org.testng.annotations.BeforeClass; import org.testng.annotations.BeforeMethod; @@ -34,6 +34,10 @@ import org.testng.annotations.Test; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rest.client.RestClient; import org.thingsboard.server.common.data.Device; +import org.thingsboard.server.common.data.DeviceProfile; +import org.thingsboard.server.common.data.DeviceProfileInfo; +import org.thingsboard.server.common.data.DeviceProfileType; +import org.thingsboard.server.common.data.DeviceTransportType; import org.thingsboard.server.common.data.EntityType; import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.User; @@ -41,6 +45,11 @@ import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmInfo; import org.thingsboard.server.common.data.alarm.AlarmSearchStatus; import org.thingsboard.server.common.data.alarm.AlarmSeverity; +import org.thingsboard.server.common.data.asset.AssetProfile; +import org.thingsboard.server.common.data.asset.AssetProfileInfo; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileConfiguration; +import org.thingsboard.server.common.data.device.profile.DefaultDeviceProfileTransportConfiguration; +import org.thingsboard.server.common.data.device.profile.DeviceProfileData; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.domain.DomainInfo; import org.thingsboard.server.common.data.id.NotificationTargetId; @@ -94,6 +103,7 @@ import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -109,8 +119,10 @@ public class JavaRestClientTest extends AbstractContainerTest { public static final String DEFAULT_NOTIFICATION_SUBJECT = "Just a test"; public static final NotificationType DEFAULT_NOTIFICATION_TYPE = NotificationType.GENERAL; private RestClient restClient; - private Tenant tenant; - private User user; + private Tenant tenant1; + private Tenant tenant2; + private User tenantAdmin1; + private User tenantAdmin2; @BeforeClass public void beforeClass() throws Exception { @@ -140,31 +152,45 @@ public class JavaRestClientTest extends AbstractContainerTest { public void setUp() throws Exception { restClient.login("sysadmin@thingsboard.org", "sysadmin"); - // create tenant and tenant admin - tenant = new Tenant(); - tenant.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.randomAlphabetic(5)); - tenant = restClient.saveTenant(tenant); + // create tenant 1 and tenant admin 1 + tenant1 = new Tenant(); + tenant1.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.insecure().randomAlphabetic(5)); + tenant1 = restClient.saveTenant(tenant1); - String email = RandomStringUtils.randomAlphabetic(5) + "@gmail.com"; - user = restClient.saveUser(defaultTenantAdmin(tenant.getId(), email), false); - restClient.activateUser(user.getId(), "password123", false); - restClient.login(email, "password123"); + String email1 = RandomStringUtils.insecure().randomAlphabetic(5) + "@gmail.com"; + tenantAdmin1 = restClient.saveUser(defaultTenantAdmin(tenant1.getId(), email1), false); + restClient.activateUser(tenantAdmin1.getId(), "password123", false); + + // create tenant 2 and tenant admin 2 + tenant2 = new Tenant(); + tenant2.setTitle("Java Rest Client Test Tenant " + RandomStringUtils.insecure().randomAlphabetic(5)); + tenant2 = restClient.saveTenant(tenant2); + + String email2 = RandomStringUtils.insecure().randomAlphabetic(5) + "@gmail.com"; + tenantAdmin2 = restClient.saveUser(defaultTenantAdmin(tenant2.getId(), email2), false); + restClient.activateUser(tenantAdmin2.getId(), "password123", false); + + // tenant 1 tenant admin by default + restClient.login(tenantAdmin1.getEmail(), "password123"); } @AfterMethod public void tearDown() { restClient.login("sysadmin@thingsboard.org", "sysadmin"); - if (tenant != null) { - restClient.deleteTenant(tenant.getId()); + if (tenant1 != null) { + restClient.deleteTenant(tenant1.getId()); + } + if (tenant2 != null) { + restClient.deleteTenant(tenant2.getId()); } } @Test public void testGetAlarmsV2() { - Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.insecure().randomAlphabetic(5))); assertThat(device).isNotNull(); - String type = "High temp" + RandomStringUtils.randomAlphabetic(5); + String type = "High temp" + RandomStringUtils.insecure().randomAlphabetic(5); Alarm alarm = Alarm.builder() .originator(device.getId()) .severity(AlarmSeverity.CRITICAL) @@ -202,7 +228,7 @@ public class JavaRestClientTest extends AbstractContainerTest { @Test public void testTimeSeriesByReadTsKvQueries() { - Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.randomAlphabetic(5))); + Device device = restClient.saveDevice(defaultDevicePrototype(RandomStringUtils.insecure().randomAlphabetic(5))); assertThat(device).isNotNull(); DeviceCredentials deviceCredentials = restClient.getDeviceCredentialsByDeviceId(device.getId()).get(); @@ -230,7 +256,7 @@ public class JavaRestClientTest extends AbstractContainerTest { @Test public void testFindNotifications() { - NotificationTarget notificationTarget = createNotificationTarget(user.getId()); + NotificationTarget notificationTarget = createNotificationTarget(tenantAdmin1.getId()); String notificationText1 = "Notification 1"; NotificationTemplate notificationTemplate = createNotificationTemplate(DEFAULT_NOTIFICATION_TYPE, DEFAULT_NOTIFICATION_SUBJECT, notificationText1, new NotificationDeliveryMethod[]{WEB}); NotificationRequest notificationRequest = submitNotificationRequest(notificationTarget.getId(), notificationTemplate.getId()); @@ -248,7 +274,7 @@ public class JavaRestClientTest extends AbstractContainerTest { NotificationRequestPreview requestPreview = restClient.getNotificationRequestPreview(notificationRequest, 10); assertThat(requestPreview.getTotalRecipientsCount()).isEqualTo(1); - assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(user.getEmail())); + assertThat(requestPreview.getRecipientsPreview()).isEqualTo(List.of(tenantAdmin1.getEmail())); PageData notifications = restClient.getNotifications(false, WEB, new PageLink(30)); assertThat(notifications.getTotalElements()).isEqualTo(2); @@ -316,7 +342,7 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.login("sysadmin@thingsboard.org", "sysadmin"); Domain domain = new Domain(); - String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String prefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); domain.setName(prefix + ".test.com"); Domain savedDomain = restClient.saveDomain(domain); assertThat(savedDomain.getName()).isEqualTo(domain.getName()); @@ -330,10 +356,10 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.login("sysadmin@thingsboard.org", "sysadmin"); MobileApp mobileApp = new MobileApp(); - String prefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String prefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); mobileApp.setPkgName(prefix + "test.app.apple"); mobileApp.setPlatformType(PlatformType.ANDROID); - mobileApp.setAppSecret(RandomStringUtils.randomAlphabetic(20)); + mobileApp.setAppSecret(RandomStringUtils.insecure().randomAlphabetic(20)); mobileApp.setStatus(MobileAppStatus.DRAFT); MobileApp savedMobileApp = restClient.saveMobileApp(mobileApp); @@ -343,7 +369,7 @@ public class JavaRestClientTest extends AbstractContainerTest { assertThat(retrieved.getData()).hasSize(1); MobileAppBundle mobileAppBundle = new MobileAppBundle(); - String bundlePrefix = RandomStringUtils.randomAlphabetic(5).toLowerCase(); + String bundlePrefix = RandomStringUtils.insecure().randomAlphabetic(5).toLowerCase(); mobileAppBundle.setTitle(bundlePrefix + "Test Bundle"); mobileAppBundle.setAndroidAppId(savedMobileApp.getId()); @@ -357,7 +383,7 @@ public class JavaRestClientTest extends AbstractContainerTest { filter.setUsersIds(Arrays.stream(usersIds).map(UUIDBased::getId).toList()); NotificationTarget notificationTarget = new NotificationTarget(); - notificationTarget.setName(filter + RandomStringUtils.randomNumeric(5)); + notificationTarget.setName(filter + RandomStringUtils.insecure().randomNumeric(5)); PlatformUsersNotificationTargetConfig targetConfig = new PlatformUsersNotificationTargetConfig(); targetConfig.setUsersFilter(filter); notificationTarget.setConfiguration(targetConfig); @@ -367,7 +393,7 @@ public class JavaRestClientTest extends AbstractContainerTest { private NotificationTemplate createNotificationTemplate(NotificationType notificationType, String subject, String text, NotificationDeliveryMethod... deliveryMethods) { NotificationTemplate notificationTemplate = new NotificationTemplate(); - notificationTemplate.setName("Notification template: " + RandomStringUtils.randomAlphabetic(5)); + notificationTemplate.setName("Notification template: " + RandomStringUtils.insecure().randomAlphabetic(5)); notificationTemplate.setNotificationType(notificationType); NotificationTemplateConfig config = new NotificationTemplateConfig(); config.setDeliveryMethodsTemplates(new HashMap<>()); @@ -418,9 +444,9 @@ public class JavaRestClientTest extends AbstractContainerTest { public void testApiKeyOperations() { // Create an API key ApiKeyInfo apiKeyInfo = new ApiKeyInfo(); - apiKeyInfo.setDescription("Test API Key " + RandomStringUtils.randomAlphabetic(5)); + apiKeyInfo.setDescription("Test API Key " + RandomStringUtils.insecure().randomAlphabetic(5)); apiKeyInfo.setEnabled(true); - apiKeyInfo.setUserId(user.getId()); + apiKeyInfo.setUserId(tenantAdmin1.getId()); apiKeyInfo.setExpirationTime(0); ApiKey savedApiKey = restClient.saveApiKey(apiKeyInfo); @@ -428,18 +454,18 @@ public class JavaRestClientTest extends AbstractContainerTest { assertThat(savedApiKey.getId()).isNotNull(); assertThat(savedApiKey.getDescription()).isEqualTo(apiKeyInfo.getDescription()); assertThat(savedApiKey.isEnabled()).isTrue(); - assertThat(savedApiKey.getUserId()).isEqualTo(user.getId()); - assertThat(savedApiKey.getTenantId()).isEqualTo(tenant.getId()); + assertThat(savedApiKey.getUserId()).isEqualTo(tenantAdmin1.getId()); + assertThat(savedApiKey.getTenantId()).isEqualTo(tenant1.getId()); assertThat(savedApiKey.getValue()).isNotNull(); // Get user API keys - PageData apiKeys = restClient.getUserApiKeys(user.getId(), new PageLink(10)); + PageData apiKeys = restClient.getUserApiKeys(tenantAdmin1.getId(), new PageLink(10)); assertThat(apiKeys).isNotNull(); assertThat(apiKeys.getData()).hasSize(1); assertThat(apiKeys.getData().get(0).getId()).isEqualTo(savedApiKey.getId()); // Update API key description - String updatedDescription = "Updated description " + RandomStringUtils.randomAlphabetic(5); + String updatedDescription = "Updated description " + RandomStringUtils.insecure().randomAlphabetic(5); ApiKeyInfo updatedApiKeyInfo = restClient.updateApiKeyDescription(savedApiKey.getId(), updatedDescription); assertThat(updatedApiKeyInfo).isNotNull(); assertThat(updatedApiKeyInfo.getDescription()).isEqualTo(updatedDescription); @@ -458,8 +484,96 @@ public class JavaRestClientTest extends AbstractContainerTest { restClient.deleteApiKey(savedApiKey.getId()); // Verify the API key is deleted - PageData apiKeysAfterDelete = restClient.getUserApiKeys(user.getId(), new PageLink(10)); + PageData apiKeysAfterDelete = restClient.getUserApiKeys(tenantAdmin1.getId(), new PageLink(10)); assertThat(apiKeysAfterDelete.getData()).isEmpty(); } + @Test + public void testGetDeviceProfileInfosByIds() { + var profileData = new DeviceProfileData(); + profileData.setConfiguration(new DefaultDeviceProfileConfiguration()); + profileData.setTransportConfiguration(new DefaultDeviceProfileTransportConfiguration()); + + // Create a device profile in tenant1 (current tenant) + var deviceProfile1 = new DeviceProfile(); + deviceProfile1.setTenantId(tenant1.getId()); + deviceProfile1.setName("Device Profile 1"); + deviceProfile1.setType(DeviceProfileType.DEFAULT); + deviceProfile1.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile1.setProfileData(profileData); + deviceProfile1 = restClient.saveDeviceProfile(deviceProfile1); + + var deviceProfile2 = new DeviceProfile(); + deviceProfile2.setTenantId(tenant1.getId()); + deviceProfile2.setName("Device Profile 2"); + deviceProfile2.setType(DeviceProfileType.DEFAULT); + deviceProfile2.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile2.setProfileData(profileData); + deviceProfile2 = restClient.saveDeviceProfile(deviceProfile2); + + // Create two more device profiles in tenant2 (different tenant) + restClient.login(tenantAdmin2.getEmail(), "password123"); + + var deviceProfile3 = new DeviceProfile(); + deviceProfile3.setTenantId(tenant2.getId()); + deviceProfile3.setName("Device Profile 3"); + deviceProfile3.setType(DeviceProfileType.DEFAULT); + deviceProfile3.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile3.setProfileData(profileData); + deviceProfile3 = restClient.saveDeviceProfile(deviceProfile3); + + var deviceProfile4 = new DeviceProfile(); + deviceProfile4.setTenantId(tenant2.getId()); + deviceProfile4.setName("Device Profile 4"); + deviceProfile4.setType(DeviceProfileType.DEFAULT); + deviceProfile4.setTransportType(DeviceTransportType.DEFAULT); + deviceProfile4.setProfileData(profileData); + deviceProfile4 = restClient.saveDeviceProfile(deviceProfile4); + + // Attempt to fetch profiles 1 and 3 while acting as Tenant 2. + // - Profile 1: Filtered out because it belongs to a different tenant. + // - Profile 2: Filtered out because it belongs to a different tenant and was not requested. + // - Profile 3: Should be returned. + // - Profile 4: Filtered out; it belongs to the correct tenant, but was not requested by ID. + List profiles = restClient.getDeviceProfileInfosByIds(Set.of(deviceProfile1.getUuidId(), deviceProfile3.getUuidId())); + assertThat(profiles).hasSize(1); + assertThat(profiles.get(0).getId()).isEqualTo(deviceProfile3.getId()); + } + + @Test + public void testGetAssetProfilesByIds() { + // Create two asset profiles in tenant1 (current tenant) + var assetProfile1 = new AssetProfile(); + assetProfile1.setTenantId(tenant1.getId()); + assetProfile1.setName("Asset Profile 1"); + assetProfile1 = restClient.saveAssetProfile(assetProfile1); + + var assetProfile2 = new AssetProfile(); + assetProfile2.setTenantId(tenant1.getId()); + assetProfile2.setName("Asset Profile 2"); + assetProfile2 = restClient.saveAssetProfile(assetProfile2); + + // Create two more asset profiles in tenant2 (different tenant) + restClient.login(tenantAdmin2.getEmail(), "password123"); + + var assetProfile3 = new AssetProfile(); + assetProfile3.setTenantId(tenant2.getId()); + assetProfile3.setName("Asset Profile 3"); + assetProfile3 = restClient.saveAssetProfile(assetProfile3); + + var assetProfile4 = new AssetProfile(); + assetProfile4.setTenantId(tenant2.getId()); + assetProfile4.setName("Asset Profile 4"); + assetProfile4 = restClient.saveAssetProfile(assetProfile4); + + // Attempt to fetch profiles 1 and 3 while acting as Tenant 2. + // - Profile 1: Filtered out because it belongs to a different tenant. + // - Profile 2: Filtered out because it belongs to a different tenant and was not requested. + // - Profile 3: Should be returned. + // - Profile 4: Filtered out; it belongs to the correct tenant, but was not requested by ID. + List profiles = restClient.getAssetProfilesByIds(Set.of(assetProfile1.getUuidId(), assetProfile3.getUuidId())); + assertThat(profiles).hasSize(1); + assertThat(profiles.get(0).getId()).isEqualTo(assetProfile3.getId()); + } + } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/AbstractLwm2mClientTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/AbstractLwm2mClientTest.java index f991e1b29c..c5673ae667 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/AbstractLwm2mClientTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/AbstractLwm2mClientTest.java @@ -57,12 +57,12 @@ import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.security.DeviceCredentials; import org.thingsboard.server.common.data.security.DeviceCredentialsType; import org.thingsboard.server.msa.AbstractContainerTest; +import org.thingsboard.server.msa.PortFinder; import org.thingsboard.server.msa.WsClient; import org.thingsboard.server.msa.connectivity.lwm2m.client.LwM2MTestClient; import org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.LwM2MClientState; import org.thingsboard.server.msa.mapper.WsTelemetryResponse; -import java.net.ServerSocket; import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.ArrayList; @@ -229,10 +229,8 @@ public class AbstractLwm2mClientTest extends AbstractContainerTest { String endpoint, ScheduledExecutorService executor) throws Exception { this.executor = executor; LwM2MTestClient lwM2MTestClient = new LwM2MTestClient(executor, endpoint); - try (ServerSocket socket = new ServerSocket(0)) { - int clientPort = socket.getLocalPort(); - lwM2MTestClient.init(security, clientPort); - } + int clientPort = PortFinder.findAvailableUdpPort(); + lwM2MTestClient.init(security, clientPort); return lwM2MTestClient; } diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/client/LwM2MTestClient.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/client/LwM2MTestClient.java index 64f4d6f08a..61a763562c 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/client/LwM2MTestClient.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/connectivity/lwm2m/client/LwM2MTestClient.java @@ -65,6 +65,7 @@ import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_CONNECTION_ID_LENGTH; import static org.eclipse.californium.scandium.config.DtlsConfig.DTLS_RECOMMENDED_CIPHER_SUITES_ONLY; import static org.eclipse.leshan.core.LwM2mId.ACCESS_CONTROL; @@ -72,6 +73,7 @@ import static org.eclipse.leshan.core.LwM2mId.DEVICE; import static org.eclipse.leshan.core.LwM2mId.FIRMWARE; import static org.eclipse.leshan.core.LwM2mId.SECURITY; import static org.eclipse.leshan.core.LwM2mId.SERVER; +import static org.thingsboard.server.msa.PortFinder.isUDPPortAvailable; import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.BINARY_APP_DATA_CONTAINER; import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.LwM2MClientState.ON_BOOTSTRAP_FAILURE; import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.LwM2MClientState.ON_BOOTSTRAP_STARTED; @@ -95,7 +97,6 @@ import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelp import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.serverId; import static org.thingsboard.server.msa.connectivity.lwm2m.client.Lwm2mTestHelper.shortServerId; - @Slf4j @Data public class LwM2MTestClient { @@ -115,8 +116,11 @@ public class LwM2MTestClient { private int countUpdateRegistrationSuccess; private int countReadObserveAfterUpdateRegistrationSuccess; + private int clientPort; + public void init(Security security, int clientPort) throws InvalidDDFFileException, IOException { assertThat(leshanClient).as("client already initialized").isNull(); + this.clientPort = clientPort; List models = new ArrayList<>(); for (String resourceName : resources) { @@ -342,12 +346,27 @@ public class LwM2MTestClient { } }); + } + + public void start() { leshanClient.start(); + if (clientPort > 0) { + log.error("Await UDP clientPort {} to be available before leshanClient.start()", clientPort); + await("Await UDP clientPort " + clientPort + " to be available before leshanClient.start()") + .atMost(30, TimeUnit.SECONDS) + .until(() -> isUDPPortAvailable(clientPort)); + } } public void destroy() { if (leshanClient != null) { leshanClient.destroy(true); + if (clientPort > 0) { + log.error("Await UDP clientPort {} to disconnect after leshanClient.stop(deregister)", clientPort); + await("Await client UDP port " + clientPort + " to disconnect after leshanClient.stop(deregister)") + .atMost(30, TimeUnit.SECONDS) + .until(() -> isUDPPortAvailable(clientPort)); + } } if (lwM2MDevice != null) { lwM2MDevice.destroy(); diff --git a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/rule/node/MqttNodeTest.java b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/rule/node/MqttNodeTest.java index 67f6258767..6fff0b4b6a 100644 --- a/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/rule/node/MqttNodeTest.java +++ b/msa/black-box-tests/src/test/java/org/thingsboard/server/msa/rule/node/MqttNodeTest.java @@ -65,6 +65,7 @@ import static org.thingsboard.server.msa.prototypes.DevicePrototypes.defaultDevi public class MqttNodeTest extends AbstractContainerTest { private static final String TOPIC = "tb/mqtt/device"; + private static final String CONTAINER_MQTT_URL = "tcp://localhost:1883"; private Device device; @@ -94,7 +95,7 @@ public class MqttNodeTest extends AbstractContainerTest { responseClient.connect(); responseClient.subscribe(TOPIC, messageListener); - MqttClient mqttClient = new MqttClient("tcp://localhost:1883", StringUtils.randomAlphanumeric(10), new MemoryPersistence()); + MqttClient mqttClient = new MqttClient(CONTAINER_MQTT_URL, StringUtils.randomAlphanumeric(10), new MemoryPersistence()); MqttConnectOptions mqttConnectOptions = new MqttConnectOptions(); mqttConnectOptions.setUserName(deviceCredentials.getCredentialsId()); mqttClient.connect(mqttConnectOptions); diff --git a/msa/edqs/pom.xml b/msa/edqs/pom.xml index 8de67e23b3..b1034afe4e 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../.. + ${maven.multiModuleProjectDirectory} tb-edqs tb-edqs /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard edqs ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-edqs - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard - edqs - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/edqs/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/edqs/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-edqs + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/edqs/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/js-executor/config/custom-environment-variables.yml b/msa/js-executor/config/custom-environment-variables.yml index 6a98361c72..9923fe4ed3 100644 --- a/msa/js-executor/config/custom-environment-variables.yml +++ b/msa/js-executor/config/custom-environment-variables.yml @@ -34,7 +34,7 @@ kafka: partitions_consumed_concurrently: "TB_KAFKA_PARTITIONS_CONSUMED_CONCURRENTLY" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency requestTimeout: "TB_QUEUE_KAFKA_REQUEST_TIMEOUT_MS" connectionTimeout: "TB_KAFKA_CONNECTION_TIMEOUT_MS" - compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip or uncompressed + compression: "TB_QUEUE_KAFKA_COMPRESSION" # gzip, lz4 or none topic_properties: "TB_QUEUE_KAFKA_JE_TOPIC_PROPERTIES" use_confluent_cloud: "TB_QUEUE_KAFKA_USE_CONFLUENT_CLOUD" client_id: "KAFKA_CLIENT_ID" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh diff --git a/msa/js-executor/config/default.yml b/msa/js-executor/config/default.yml index be49ece5a6..8acaaaaba9 100644 --- a/msa/js-executor/config/default.yml +++ b/msa/js-executor/config/default.yml @@ -14,46 +14,56 @@ # limitations under the License. # -queue_type: "kafka" -request_topic: "js_eval.requests" -queue_prefix: "" -http_port: "8888" # /livenessProbe +# Queue configuration +# Defines the messaging queue backend used to dispatch JS evaluation requests to the executor and receive results. +queue_type: "kafka" # Queue type used for JS evaluation requests: kafka or in-memory +request_topic: "js_eval.requests" # Kafka topic name for incoming JS evaluation requests +queue_prefix: "" # Optional prefix applied to all queue/topic names; useful for environment isolation +http_port: "8888" # HTTP port exposing the /livenessProbe health check endpoint +# JavaScript execution configuration +# Controls response polling behavior and output size limits for individual JS script executions. js: - response_poll_interval: "25" - max_result_size: "300000" + response_poll_interval: "25" # Interval in milliseconds to poll for JS execution responses + max_result_size: "300000" # Maximum allowed size in bytes of a JS execution result +# Kafka configuration +# Connection, producer, and topic settings for the Kafka message broker used as the JS evaluation queue. kafka: bootstrap: # Kafka Bootstrap Servers servers: "localhost:9092" - replication_factor: "1" + replication_factor: "1" # Number of replicas for each Kafka topic partition acks: "1" # -1 = all; 0 = no acknowledgments; 1 = only waits for the leader to acknowledge - batch_size: "128" # for producer - linger_ms: "5" # for producer + batch_size: "128" # Maximum number of messages to accumulate before triggering a batch send + linger_ms: "5" # Time in milliseconds the producer waits before sending a batch, allowing more messages to accumulate partitions_consumed_concurrently: "1" # (EXPERIMENTAL) increase this value if you are planning to handle more than one partition (scale up, scale down) - this will decrease the latency - requestTimeout: "30000" # The default value in kafkajs is: 30000 - connectionTimeout: "1000" # The default value in kafkajs is: 1000 - compression: "none" # gzip or uncompressed - topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" - use_confluent_cloud: false - client_id: "kafkajs" #inject pod name to easy identify the client using /opt/kafka/bin/kafka-consumer-groups.sh + requestTimeout: "30000" # Time in milliseconds to wait for a response to a Kafka request before timing out + connectionTimeout: "1000" # Time in milliseconds to wait when establishing a connection to a Kafka broker + compression: "none" # Message compression codec for the producer: gzip, lz4 or none + topic_properties: "retention.ms:604800000;segment.bytes:52428800;retention.bytes:104857600;partitions:100;min.insync.replicas:1" # Semicolon-separated Kafka topic configuration properties applied on topic creation + use_confluent_cloud: false # Set to true to enable Confluent Cloud-specific configuration (SSL + SASL) + client_id: "kafkajs" # Kafka client identifier; inject the pod name to easily identify the client via kafka-consumer-groups.sh ssl: - enabled: false + enabled: false # Enable SSL/TLS for Kafka broker connections confluent: sasl: - mechanism: "PLAIN" + mechanism: "PLAIN" # SASL mechanism for Confluent Cloud authentication: PLAIN, SCRAM-SHA-256, or SCRAM-SHA-512 +# Logging configuration +# Controls log verbosity, output directory, and log file naming pattern for the JS executor service. logger: - level: "info" - path: "logs" - filename: "tb-js-executor-%DATE%.log" + level: "info" # Log level: trace, debug, info, warn, or error + path: "logs" # Directory path where log files are written + filename: "tb-js-executor-%DATE%.log" # Log file name pattern; %DATE% is replaced with the current date +# JavaScript execution and monitoring configuration +# Sandboxing, script caching, and observability settings for the JS script runtime. script: - use_sandbox: "true" - memory_usage_trace_frequency: "1000" - script_body_trace_frequency: "10000" - stat_print_frequency: "10000" - max_active_scripts: "1000" - slow_query_log_ms: "5.000000" #millis - slow_query_log_body: "false" + use_sandbox: "true" # Run scripts inside an isolated sandbox to prevent access to Node.js internals + memory_usage_trace_frequency: "1000" # Log memory usage every N script executions + script_body_trace_frequency: "10000" # Log the script body every N script executions + stat_print_frequency: "10000" # Print execution statistics every N script executions + max_active_scripts: "1000" # Maximum number of compiled scripts kept in the cache + slow_query_log_ms: "5.000000" # Execution time threshold in milliseconds above which a script is considered slow and logged + slow_query_log_body: "false" # Log the script body when execution time exceeds slow_query_log_ms threshold diff --git a/msa/js-executor/docker/Dockerfile b/msa/js-executor/docker/Dockerfile index 1ef157fdd9..736bd56529 100644 --- a/msa/js-executor/docker/Dockerfile +++ b/msa/js-executor/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/js-executor/package.json b/msa/js-executor/package.json index df1d327c1b..7084794b6b 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -1,18 +1,19 @@ { "name": "thingsboard-js-executor", "private": true, - "version": "4.3.0", + "version": "4.4.0", "description": "ThingsBoard JavaScript Executor Microservice", "main": "server.ts", "bin": "server.js", "scripts": { - "pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js", + "pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-js-executor-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-js-executor-win.exe ./target/src && node install.js", "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon --watch '.' --ext 'ts' --exec 'ts-node server.ts'", "start-prod": "nodemon --watch '.' --ext 'ts' --exec 'NODE_ENV=production ts-node server.ts'", "build": "tsc" }, "dependencies": { + "@2l/kafkajs-lz4": "^1.3.2", "config": "^4.1.1", "express": "^5.1.0", "js-yaml": "^4.1.1", @@ -39,11 +40,15 @@ "fs-extra": "^11.3.1", "nodemon": "^3.1.10", "ts-node": "^10.9.2", - "typescript": "5.9.2" + "typescript": "~5.9.3" + }, + "resolutions": { + "@yao-pkg/pkg/tar": ">=7.5.8" }, "pkg": { "assets": [ - "node_modules/config/**/*.*" + "node_modules/config/**/*.*", + "node_modules/@antoniomuso/lz4-napi-*/**/*.node" ] } } diff --git a/msa/js-executor/pom.xml b/msa/js-executor/pom.xml index 6bd15adc43..6b3ed4ab8f 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -37,7 +37,6 @@ tb-js-executor tb-js-executor js - false process-resources package ${project.build.directory}/package/linux @@ -53,6 +52,29 @@ exe provided + + + org.thingsboard.msa + web-ui + ${project.version} + pom + provided + + + * + * + + + @@ -71,7 +93,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 @@ -81,7 +103,7 @@ yarn - install --non-interactive --network-concurrency 4 --network-timeout 100000 --mutex network + install --non-interactive --check-files --network-concurrency 4 --network-timeout 100000 --mutex network @@ -91,11 +113,22 @@ compile - run pkg + --mutex network run pkg + + org.apache.maven.plugins + maven-clean-plugin + + + + ${basedir}/node_modules + + + + org.apache.maven.plugins maven-dependency-plugin @@ -135,33 +168,37 @@ maven-assembly-plugin - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -211,29 +248,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/js-executor/queue/kafkaTemplate.ts b/msa/js-executor/queue/kafkaTemplate.ts index 4527e9e1ce..175abd80f4 100644 --- a/msa/js-executor/queue/kafkaTemplate.ts +++ b/msa/js-executor/queue/kafkaTemplate.ts @@ -21,6 +21,7 @@ import { JsInvokeMessageProcessor } from '../api/jsInvokeMessageProcessor' import { IQueue } from './queue.models'; import { Admin, + CompressionCodecs, CompressionTypes, Consumer, Kafka, @@ -46,7 +47,31 @@ export class KafkaTemplate implements IQueue { private linger = Number(config.get('kafka.linger_ms')); private requestTimeout = Number(config.get('kafka.requestTimeout')); private connectionTimeout = Number(config.get('kafka.connectionTimeout')); - private compressionType = (config.get('kafka.compression') === "gzip") ? CompressionTypes.GZIP : CompressionTypes.None; + private compressionType = this.resolveCompressionType(config.get('kafka.compression')); + + private resolveCompressionType(compression: string): CompressionTypes { + switch (compression) { + case 'gzip': + return CompressionTypes.GZIP; + case 'lz4': { + // Load the LZ4 codec lazily so users who don't enable LZ4 don't take a hard + // dependency on the lz4-napi native binary (e.g. inside pkg-built executables). + // The package re-assigns module.exports = LZ4Codec, which wipes the __esModule + // flag and the .default property — so accept either shape. + const lz4Module = require('@2l/kafkajs-lz4'); + const LZ4Codec = lz4Module.default || lz4Module; + CompressionCodecs[CompressionTypes.LZ4] = new LZ4Codec().codec; + return CompressionTypes.LZ4; + } + case 'none': + return CompressionTypes.None; + default: + if (isNotEmptyStr(compression)) { + this.logger.warn('Unknown kafka.compression value "%s"; falling back to no compression. Supported values: gzip, lz4, none.', compression); + } + return CompressionTypes.None; + } + } private partitionsConsumedConcurrently = Number(config.get('kafka.partitions_consumed_concurrently')); private kafkaClient: Kafka; diff --git a/msa/js-executor/yarn.lock b/msa/js-executor/yarn.lock index 7a54682fa7..5e3330e0d7 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -2,6 +2,78 @@ # yarn lockfile v1 +"@2l/kafkajs-lz4@^1.3.2": + version "1.3.2" + resolved "https://registry.yarnpkg.com/@2l/kafkajs-lz4/-/kafkajs-lz4-1.3.2.tgz#25259f693a709816ef4eb62514c1ce1bab80502f" + integrity sha512-yq5dx4CbL2sofWXKuadyty3ZKRcZyqe5iiuuOWjZgtIed9MBZ2Wqe5hQN18+a5XVfR4SFQGVISb/a0oTjdPjwQ== + dependencies: + lz4-napi "^2.8.0" + +"@antoniomuso/lz4-napi-android-arm-eabi@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm-eabi/-/lz4-napi-android-arm-eabi-2.9.0.tgz#5ea67847f0a761c9aec22a31212d0e429ca01fb2" + integrity sha512-aeT/9SoWq7rnmzssWuCKUPaxVt3fzE9q+xq/ZHbnUSmrm8/EhLOACMvQeCOnL0IZsmPh8EpuwIE1TZyM9iQPRA== + +"@antoniomuso/lz4-napi-android-arm64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-android-arm64/-/lz4-napi-android-arm64-2.9.0.tgz#64cb0aac70267bb071bba2d8e382301e058811ca" + integrity sha512-ibQ0qiEvmljXAM97IgOZfh+PeiSQ0Rqf2HErJlZPVm2v4GVJoB67v21v1TUydqNNV5L8bwufVoZ90nheL8X9ZA== + +"@antoniomuso/lz4-napi-darwin-arm64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-arm64/-/lz4-napi-darwin-arm64-2.9.0.tgz#669e48b165af11cec20581acc2caa0d1c0f84472" + integrity sha512-1su4K1MWa4bcWoZlHajv+luGmFDV1JwIsvjtDF+0HhUveSDPP+8A4Z34zOZidURIr08Sl7M7ViPth6ZQ9SqnAA== + +"@antoniomuso/lz4-napi-darwin-x64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-darwin-x64/-/lz4-napi-darwin-x64-2.9.0.tgz#a00ddf021772e26bd01b3ca5e3025f11d1667edf" + integrity sha512-8Lnbm2MkdJtiJ/nbcRS9zRyGp3G0sG6D+Y/x1vTP8nZs3/f8tBwYNsjxCQyyXNNyHcYWwVGbk68onP/pyDljOA== + +"@antoniomuso/lz4-napi-freebsd-x64@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-freebsd-x64/-/lz4-napi-freebsd-x64-2.9.0.tgz#fdc2b292489bd6d4e44eb7bb059fb9bd4b860f14" + integrity sha512-k04EMVOjntKDPrdR4Tf8WyNseuk9PTtSGw8WHyp4CTjoR1s+YJxtp9SMnThe5o2q0TATwk8WGYb/Howrp5OMxw== + +"@antoniomuso/lz4-napi-linux-arm-gnueabihf@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm-gnueabihf/-/lz4-napi-linux-arm-gnueabihf-2.9.0.tgz#9fcdf93b3ca5aa24469e4bf11dd7100df8c71975" + integrity sha512-H92F8zPZmgy2r8IhCWh3qIBfLp2BQ5cp18RoDXhtGFWwkh+5gVWrZp11IVznrsdgB0QeW0VR7dAMMHg3WLOPfA== + +"@antoniomuso/lz4-napi-linux-arm64-gnu@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-gnu/-/lz4-napi-linux-arm64-gnu-2.9.0.tgz#9d22e075a9cb60c3cb415428138618ae4a79f3c5" + integrity sha512-25crh0qs/3Rj3fMI8ulYD0DoaKsidUhMBki2aeO69ZK+F8bmQ/e2++FlgJ6f3EgMP5CNxJtnZXKhPOraQWjwAw== + +"@antoniomuso/lz4-napi-linux-arm64-musl@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-arm64-musl/-/lz4-napi-linux-arm64-musl-2.9.0.tgz#d957bce3accd49199bb6de522f5163efa2068fe4" + integrity sha512-eJtHp38zuLaYI0/cOV/BKcNQiXUBo4GPx53FTf0Y307yUjLsn48LNeN0vD28Ct9YrbUae3bQvMD5AD86She0ww== + +"@antoniomuso/lz4-napi-linux-x64-gnu@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-gnu/-/lz4-napi-linux-x64-gnu-2.9.0.tgz#c86567d5aa23863059afbee829fb9f45895d3869" + integrity sha512-mDjS4dyjRKaZQcAP71SphkYH5r3kufB30ih/VETVu/br2toCfBk6Zr1xhL1r+L7FaVAFzF62B7h30CiqrN0Awg== + +"@antoniomuso/lz4-napi-linux-x64-musl@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-linux-x64-musl/-/lz4-napi-linux-x64-musl-2.9.0.tgz#7d02612dec7d6247645aa321871b15247da0ce7d" + integrity sha512-pvU7Z7qjkjn17NkddBtBQ7C2iRqjtZ7WJ3Jqrjtj4XxolY3Q0HaYMvWjkWhzb9AKGZbj5y+EHYtbVoZJ2TSQhQ== + +"@antoniomuso/lz4-napi-win32-arm64-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-arm64-msvc/-/lz4-napi-win32-arm64-msvc-2.9.0.tgz#c901bfec718303ed86867129ecc0371eaa883517" + integrity sha512-aioLlbpJl0QPEXLXhh2bzyitc3T7Jot3f1ap6WdKiRa+CIjMHXw1nxJXy07MLXif10r+qVZr86ic8dvwErgqEQ== + +"@antoniomuso/lz4-napi-win32-ia32-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-ia32-msvc/-/lz4-napi-win32-ia32-msvc-2.9.0.tgz#11e48b18b7923c250735e0130998e5968ae91130" + integrity sha512-VaF4XMTdYb59TsPsiqnWwsNaWKHhgxF33z5p4zg4n0tp20eWozl76hn8B+aXthSs40W0W1N97QhxxV4oXGd8cg== + +"@antoniomuso/lz4-napi-win32-x64-msvc@2.9.0": + version "2.9.0" + resolved "https://registry.yarnpkg.com/@antoniomuso/lz4-napi-win32-x64-msvc/-/lz4-napi-win32-x64-msvc-2.9.0.tgz#20d9f71a638f3277cd0b7662e966f90e53d98af8" + integrity sha512-wfA8ShO3eGLxJ1LDwXJo87XL2D4NkMJV1pfHPvLZpD0MWb9u8VfgS+gKK5YhT7XKjzVdeIna9jgFdn2HBnZBxA== + "@babel/generator@^7.23.0": version "7.28.3" resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" @@ -100,6 +172,18 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@napi-rs/triples@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@napi-rs/triples/-/triples-1.2.0.tgz#bcd9c936acb93890e7015818e0181f3db421aafa" + integrity sha512-HAPjR3bnCsdXBsATpDIP5WCrw0JcACwhhrwIAQhiR46n+jm+a2F8kBsfseAuWtSyQ+H3Yebt2k43B5dy+04yMA== + +"@node-rs/helper@^1.3.3": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@node-rs/helper/-/helper-1.6.0.tgz#83e5381de6e898d0b8c92178bb8d897d619e3a3a" + integrity sha512-2OTh/tokcLA1qom1zuCJm2gQzaZljCCbtX1YCrwRVd/toz7KxaDRFeLTAPwhs8m9hWgzrBn5rShRm6IaZofCPw== + dependencies: + "@napi-rs/triples" "^1.2.0" + "@tsconfig/node10@^1.0.7": version "1.0.11" resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.11.tgz#6ee46400685f130e278128c7b38b7e031ff5b2f2" @@ -363,9 +447,9 @@ body-parser@^2.2.0: type-is "^2.0.0" brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + version "1.1.13" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" + integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" @@ -998,6 +1082,27 @@ long@^5.3.2: resolved "https://registry.yarnpkg.com/long/-/long-5.3.2.tgz#1d84463095999262d7d7b7f8bfd4a8cc55167f83" integrity sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA== +lz4-napi@^2.8.0: + version "2.9.0" + resolved "https://registry.yarnpkg.com/lz4-napi/-/lz4-napi-2.9.0.tgz#4a700974a1154f82b3c0e1b7030fe63140051e7d" + integrity sha512-ZOWqxBMIK5768aD20tYn5B6Pp9WPM9UG/LHk8neG9p0gC1DtjdzhTtlkxhAjvTRpmJvMtnnqLKlT+COlqAt9cQ== + dependencies: + "@node-rs/helper" "^1.3.3" + optionalDependencies: + "@antoniomuso/lz4-napi-android-arm-eabi" "2.9.0" + "@antoniomuso/lz4-napi-android-arm64" "2.9.0" + "@antoniomuso/lz4-napi-darwin-arm64" "2.9.0" + "@antoniomuso/lz4-napi-darwin-x64" "2.9.0" + "@antoniomuso/lz4-napi-freebsd-x64" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm-gnueabihf" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm64-gnu" "2.9.0" + "@antoniomuso/lz4-napi-linux-arm64-musl" "2.9.0" + "@antoniomuso/lz4-napi-linux-x64-gnu" "2.9.0" + "@antoniomuso/lz4-napi-linux-x64-musl" "2.9.0" + "@antoniomuso/lz4-napi-win32-arm64-msvc" "2.9.0" + "@antoniomuso/lz4-napi-win32-ia32-msvc" "2.9.0" + "@antoniomuso/lz4-napi-win32-x64-msvc" "2.9.0" + make-error@^1.1.1: version "1.3.6" resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" @@ -1036,9 +1141,9 @@ mimic-response@^3.1.0: integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== 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== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -1052,10 +1157,10 @@ minipass@^7.0.4, minipass@^7.1.2: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -minizlib@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.2.tgz#f33d638eb279f664439aa38dc5f91607468cb574" - integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== +minizlib@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" + integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== dependencies: minipass "^7.1.2" @@ -1064,11 +1169,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== - moment@^2.29.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" @@ -1184,9 +1284,9 @@ path-parse@^1.0.7: integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" - integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== + version "8.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.0.tgz#8e98fcd94826aff01a90c544ef74ffbaca3a78ed" + integrity sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg== picocolors@^1.1.0: version "1.1.1" @@ -1194,14 +1294,14 @@ picocolors@^1.1.0: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== prebuild-install@^7.1.1: version "7.1.3" @@ -1253,9 +1353,9 @@ pump@^3.0.0: once "^1.3.1" qs@^6.14.0: - version "6.14.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" - integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + version "6.14.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" + integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== dependencies: side-channel "^1.1.0" @@ -1553,16 +1653,15 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^7.4.3: - version "7.4.3" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" - integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== +tar@>=7.5.8, tar@^7.4.3: + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" minipass "^7.1.2" - minizlib "^3.0.1" - mkdirp "^3.0.1" + minizlib "^3.1.0" yallist "^5.0.0" text-hex@1.0.x: @@ -1640,10 +1739,10 @@ type-is@^2.0.0, type-is@^2.0.1: media-typer "^1.1.0" mime-types "^3.0.0" -typescript@5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" - integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== +typescript@~5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== undefsafe@^2.0.5: version "2.0.5" diff --git a/msa/monitoring/pom.xml b/msa/monitoring/pom.xml index fb10c11ec0..a968d693df 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa @@ -34,7 +34,7 @@ UTF-8 - ${basedir}/../.. + ${maven.multiModuleProjectDirectory} tb-monitoring tb-monitoring /var/log/${pkg.name} @@ -47,8 +47,6 @@ org.thingsboard monitoring ${project.version} - deb - deb provided @@ -57,25 +55,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-monitoring-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard - monitoring - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/monitoring/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/monitoring/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -100,36 +94,60 @@ + + copy-tb-monitoring-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/monitoring/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -147,29 +165,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/pom.xml b/msa/pom.xml index b5c40015f7..4e09ad0e0c 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard msa @@ -31,16 +31,29 @@ ${basedir}/.. + + package thingsboard thingsboard/openjdk25:trixie-slim true true - 1.4.13 + 3.5.1 none - + tb web-ui vc-executor @@ -53,6 +66,37 @@ + + + skip-deb + + + pkg.skip.deb + true + + + + none + + + + + skip-pkg + + + pkg.skip + true + + + + none + + black-box-tests @@ -133,7 +177,7 @@ - 4.3.0-latest + 4.4.0-latest false @@ -213,19 +257,12 @@ - - - com.spotify - dockerfile-maven-extension - ${dockerfile-maven.version} - - - com.spotify - dockerfile-maven-plugin - ${dockerfile-maven.version} + org.codehaus.mojo + exec-maven-plugin + ${exec-maven-plugin.version} diff --git a/msa/tb-node/pom.xml b/msa/tb-node/pom.xml index 078f2e1756..6f28a25c51 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../.. + ${maven.multiModuleProjectDirectory} thingsboard tb-node /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard application ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard - application - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/application/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/application/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/application/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/tb/docker-cassandra/Dockerfile b/msa/tb/docker-cassandra/Dockerfile index 34a90e6fb7..f3cc0a5d66 100644 --- a/msa/tb/docker-cassandra/Dockerfile +++ b/msa/tb/docker-cassandra/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/openjdk17:bookworm-slim +FROM ${docker.base.image} ENV PG_MAJOR=16 @@ -40,19 +40,39 @@ ENV PATH=$PATH:/usr/lib/postgresql/$PG_MAJOR/bin ENV PGLOG=/var/log/postgres ENV CASSANDRA_LOG=/var/log/cassandra +# For SVG renderer Debian Trixie slim fonts should be installed explicitly +RUN echo "Previously installed font libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz' || echo " (none)") \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libharfbuzz0b fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* \ + && echo "Current fonts libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz') \ + && echo "Font config list:" \ + && fc-list && test -n "$(fc-list)" \ + && (fc-list | grep -i dejavu) \ + && echo "Harfbuzz library test with ldconfig:" \ + && (ldconfig -p | grep harfbuzz) + COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ +# Keep base image's customized conffiles (e.g. /etc/java-17-openjdk/security/java.security) +# when apt upgrades openjdk-17-jre-headless transitively as cassandra's java11-runtime provider; +# without this dpkg blocks on a non-interactive conffile prompt and the build fails. +ENV DEBIAN_FRONTEND=noninteractive RUN apt-get update \ && apt-get install -y --no-install-recommends wget nmap procps gnupg2 \ - && echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \ - && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ - && echo "deb https://debian.cassandra.apache.org 40x main" | tee -a /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ - && wget -q https://downloads.apache.org/cassandra/KEYS -O- | apt-key add - \ + && mkdir -p /etc/apt/keyrings \ + && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list > /dev/null \ + && wget -q https://downloads.apache.org/cassandra/KEYS -O- | gpg --dearmor -o /etc/apt/keyrings/cassandra.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/cassandra.gpg] https://debian.cassandra.apache.org 40x main" | tee /etc/apt/sources.list.d/cassandra.sources.list > /dev/null \ && apt-get update \ - && apt-get install -y --no-install-recommends cassandra cassandra-tools postgresql-${PG_MAJOR} \ + && apt-get install -y --no-install-recommends \ + -o Dpkg::Options::="--force-confdef" -o Dpkg::Options::="--force-confold" \ + cassandra cassandra-tools postgresql-${PG_MAJOR} \ && rm -rf /var/lib/apt/lists/* \ - && update-rc.d cassandra disable \ - && update-rc.d postgresql disable \ && apt-get purge -y --auto-remove \ && sed -i.old '/ulimit/d' /etc/init.d/cassandra \ && chmod a+x /tmp/*.sh \ diff --git a/msa/tb/docker-postgres/Dockerfile b/msa/tb/docker-postgres/Dockerfile index 0a0f61f904..871154a455 100644 --- a/msa/tb/docker-postgres/Dockerfile +++ b/msa/tb/docker-postgres/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/openjdk17:bookworm-slim +FROM ${docker.base.image} ENV PG_MAJOR 12 @@ -32,16 +32,31 @@ ENV SPRING_DATASOURCE_USERNAME=${pkg.user} ENV PGLOG=/var/log/postgres +# For SVG renderer Debian Trixie slim fonts should be installed explicitly +RUN echo "Previously installed font libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz' || echo " (none)") \ + && apt-get update \ + && apt-get install -y --no-install-recommends \ + libharfbuzz0b fontconfig fonts-dejavu-core \ + && rm -rf /var/lib/apt/lists/* \ + && echo "Current fonts libraries:" \ + && (dpkg -l | grep -E 'font|dejavu|harfbuzz') \ + && echo "Font config list:" \ + && fc-list && test -n "$(fc-list)" \ + && (fc-list | grep -i dejavu) \ + && echo "Harfbuzz library test with ldconfig:" \ + && (ldconfig -p | grep harfbuzz) + COPY logback.xml ${pkg.name}.conf start-db.sh stop-db.sh start-tb.sh upgrade-tb.sh install-tb.sh ${pkg.name}.deb /tmp/ RUN apt-get update \ && apt-get install -y --no-install-recommends wget gnupg2 \ - && echo "deb http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee --append /etc/apt/sources.list.d/pgdg.list > /dev/null \ - && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | apt-key add - \ + && mkdir -p /etc/apt/keyrings \ + && wget -q https://www.postgresql.org/media/keys/ACCC4CF8.asc -O- | gpg --dearmor -o /etc/apt/keyrings/postgresql.gpg \ + && echo "deb [signed-by=/etc/apt/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(. /etc/os-release && echo -n $VERSION_CODENAME)-pgdg main" | tee /etc/apt/sources.list.d/pgdg.list > /dev/null \ && apt-get update \ && apt-get install -y --no-install-recommends postgresql-${PG_MAJOR} \ && rm -rf /var/lib/apt/lists/* \ - && update-rc.d postgresql disable \ && apt-get purge -y --auto-remove \ && chmod a+x /tmp/*.sh \ && mv /tmp/start-tb.sh /usr/bin \ diff --git a/msa/tb/pom.xml b/msa/tb/pom.xml index 8825ea7e33..86651cd311 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../.. + ${maven.multiModuleProjectDirectory} thingsboard tb-postgres tb-cassandra @@ -45,8 +45,6 @@ org.thingsboard application ${project.version} - deb - deb provided @@ -55,44 +53,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-postgres-deb - package - - copy - - - - - org.thingsboard - application - deb - deb - ${pkg.name}.deb - ${project.build.directory}/docker-postgres - - - - - - copy-tb-cassandra-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard - application - deb - deb - ${pkg.name}.deb - ${project.build.directory}/docker-cassandra - - + + + + ${main.dir}/application/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/application/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -101,7 +76,7 @@ org.apache.maven.plugins maven-resources-plugin - + copy-docker-tb-postgres-config process-resources @@ -141,64 +116,112 @@ + + copy-tb-postgres-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory}/docker-postgres + true + + + ${main.dir}/application/target + + ${pkg.name}.deb + + false + + + + + + copy-tb-cassandra-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory}/docker-cassandra + true + + + ${main.dir}/application/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-tb-postgres-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${tb-postgres.docker.name} - true - false - ${project.build.directory}/docker-postgres - true + docker + ${project.build.directory}/docker-postgres + + build + --no-cache + -t + ${docker.repo}/${tb-postgres.docker.name}:latest + . + tag-docker-tb-postgres-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${tb-postgres.docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${tb-postgres.docker.name}:latest + ${docker.repo}/${tb-postgres.docker.name}:${project.version} + build-docker-tb-cassandra-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${tb-cassandra.docker.name} - true - false - ${project.build.directory}/docker-cassandra - true + docker + ${project.build.directory}/docker-cassandra + + build + --no-cache + -t + ${docker.repo}/${tb-cassandra.docker.name}:latest + . + tag-docker-tb-cassandra-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${tb-cassandra.docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${tb-cassandra.docker.name}:latest + ${docker.repo}/${tb-cassandra.docker.name}:${project.version} + @@ -216,51 +239,55 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-tb-postgres-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${tb-postgres.docker.name} + docker + + push + ${docker.repo}/${tb-postgres.docker.name}:latest + push-version-docker-tb-postgres-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${tb-postgres.docker.name} + docker + + push + ${docker.repo}/${tb-postgres.docker.name}:${project.version} + push-latest-docker-tb-cassandra-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${tb-cassandra.docker.name} + docker + + push + ${docker.repo}/${tb-cassandra.docker.name}:latest + push-version-docker-tb-cassandra-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${tb-cassandra.docker.name} + docker + + push + ${docker.repo}/${tb-cassandra.docker.name}:${project.version} + diff --git a/msa/transport/coap/pom.xml b/msa/transport/coap/pom.xml index 9222a95ec2..60aa546e1d 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../../.. + ${maven.multiModuleProjectDirectory} tb-coap-transport tb-coap-transport /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard.transport coap ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-coap-transport-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.transport - coap - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/transport/coap/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/transport/coap/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-coap-transport-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/transport/coap/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/transport/http/pom.xml b/msa/transport/http/pom.xml index 4ecf529b2c..b0c0e7b625 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../../.. + ${maven.multiModuleProjectDirectory} tb-http-transport tb-http-transport /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard.transport http ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-http-transport-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.transport - http - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/transport/http/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/transport/http/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-http-transport-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/transport/http/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/transport/lwm2m/pom.xml b/msa/transport/lwm2m/pom.xml index 9aec571309..296a754c38 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../../.. + ${maven.multiModuleProjectDirectory} tb-lwm2m-transport tb-lwm2m-transport /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard.transport lwm2m ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-lwm2m-transport-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.transport - lwm2m - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/transport/lwm2m/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/transport/lwm2m/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-lwm2m-transport-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/transport/lwm2m/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/transport/mqtt/pom.xml b/msa/transport/mqtt/pom.xml index 2ad9d62c9d..dc8fc5b39a 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard.msa - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.msa.transport @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../../.. + ${maven.multiModuleProjectDirectory} tb-mqtt-transport tb-mqtt-transport /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard.transport mqtt ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-mqtt-transport-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.transport - mqtt - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/transport/mqtt/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/transport/mqtt/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-mqtt-transport-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/transport/mqtt/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/transport/pom.xml b/msa/transport/pom.xml index f26c27cb4b..92978fcaa7 100644 --- a/msa/transport/pom.xml +++ b/msa/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa diff --git a/msa/transport/snmp/pom.xml b/msa/transport/snmp/pom.xml index a6bb9ebc8f..ed441a2208 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard.msa transport - 4.3.0-RC + 4.4.0-SNAPSHOT org.thingsboard.msa.transport @@ -34,7 +34,7 @@ UTF-8 - ${basedir}/../../.. + ${maven.multiModuleProjectDirectory} tb-snmp-transport tb-snmp-transport /var/log/${pkg.name} @@ -47,8 +47,6 @@ org.thingsboard.transport snmp ${project.version} - deb - deb provided @@ -57,25 +55,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-snmp-transport-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.transport - snmp - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/transport/snmp/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/transport/snmp/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -100,36 +94,60 @@ + + copy-tb-snmp-transport-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/transport/snmp/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -147,29 +165,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/vc-executor-docker/pom.xml b/msa/vc-executor-docker/pom.xml index a740f99743..2eef220b7d 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -33,7 +33,7 @@ UTF-8 - ${basedir}/../.. + ${maven.multiModuleProjectDirectory} tb-vc-executor tb-vc-executor /var/log/${pkg.name} @@ -46,8 +46,6 @@ org.thingsboard.msa vc-executor ${project.version} - deb - deb provided @@ -56,25 +54,21 @@ org.apache.maven.plugins - maven-dependency-plugin + maven-enforcer-plugin - copy-tb-vc-executor-deb - package - - copy - + enforce-deb-exists + ${pkg.deb.phase} + enforce - - - org.thingsboard.msa - vc-executor - deb - deb - ${pkg.name}.deb - ${project.build.directory} - - + + + + ${main.dir}/msa/vc-executor/target/${pkg.name}.deb + + DEB artifact not found at ${main.dir}/msa/vc-executor/target/${pkg.name}.deb — build the upstream module first or pass -Dpkg.skip.deb=true. + + @@ -99,36 +93,60 @@ + + copy-tb-vc-executor-deb + ${pkg.deb.phase} + + copy-resources + + + ${project.build.directory} + true + + + ${main.dir}/msa/vc-executor/target + + ${pkg.name}.deb + + false + + + + - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -146,29 +164,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/vc-executor/pom.xml b/msa/vc-executor/pom.xml index cb71d86ef4..028b84f98e 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -35,7 +35,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-vc-executor @@ -115,8 +114,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/msa/vc-executor/src/main/resources/tb-vc-executor.yml b/msa/vc-executor/src/main/resources/tb-vc-executor.yml index 4f5dc7ef63..024504f3c1 100644 --- a/msa/vc-executor/src/main/resources/tb-vc-executor.yml +++ b/msa/vc-executor/src/main/resources/tb-vc-executor.yml @@ -15,30 +15,33 @@ # # Spring common parameters +# Controls Spring Boot startup mode, web environment type, and bean wiring behaviour. spring.main.web-environment: "${WEB_APPLICATION_ENABLE:false}" # If you enabled process metrics you should also enable 'web-environment'. spring.main.web-application-type: "${WEB_APPLICATION_TYPE:none}" # If you enabled process metrics you should set 'web-application-type' to 'servlet' value. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. # Server common parameters +# Configures the embedded HTTP server bind address and port used for actuator and metrics endpoints. server: # Server bind address (has no effect if web-environment is disabled). address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port (has no effect if web-environment is disabled). port: "${HTTP_BIND_PORT:8086}" -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters +# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:true}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -46,6 +49,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Queue configuration parameters +# Defines the message queue type (Kafka or in-memory) and all related producer, consumer, and topic settings. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -73,7 +77,7 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -196,6 +200,7 @@ queue: msg-chunk-size: "${TB_QUEUE_VC_MSG_CHUNK_SIZE:250000}" # Version control parameters +# Configures the Git-based version control executor, including thread pools and local repository storage. vc: # Pool size for handling export tasks thread_pool_size: "${TB_VC_POOL_SIZE:6}" @@ -206,6 +211,7 @@ vc: repositories-folder: "${TB_VC_GIT_REPOSITORIES_FOLDER:${java.io.tmpdir}/repositories}" # Usage statistics parameters +# Controls collection and reporting of API usage statistics at system, tenant, and customer levels. usage: stats: report: @@ -215,10 +221,13 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables actuator-based metrics collection and configures histogram/timer percentile exports. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" @@ -227,20 +236,23 @@ metrics: percentiles: "${METRICS_TIMER_PERCENTILES:0.5}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure, including the metrics and info endpoints. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Service common properties +# Identifies this microservice instance by type and optional unique ID for cluster coordination. service: type: "${TB_SERVICE_TYPE:tb-vc-executor}" # service type # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Notification system parameters +# Defines deduplication rules for notification triggers to prevent repeated alerts within a time window. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/msa/web-ui/config/custom-environment-variables.yml b/msa/web-ui/config/custom-environment-variables.yml index 90bce53cb4..6ba9147555 100644 --- a/msa/web-ui/config/custom-environment-variables.yml +++ b/msa/web-ui/config/custom-environment-variables.yml @@ -25,6 +25,20 @@ thingsboard: host: "TB_HOST" # ThingsBoard node port port: "TB_PORT" +security: + headers: + x-content-type-options: + enabled: "SECURITY_HEADERS_X_CONTENT_TYPE_OPTIONS_ENABLED" + referrer-policy: + enabled: "SECURITY_HEADERS_REFERRER_POLICY_ENABLED" + value: "SECURITY_HEADERS_REFERRER_POLICY_VALUE" + x-frame-options: + enabled: "SECURITY_HEADERS_X_FRAME_OPTIONS_ENABLED" + value: "SECURITY_HEADERS_X_FRAME_OPTIONS_VALUE" + content-security-policy: + enabled: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_ENABLED" + value: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_VALUE" + report-only: "SECURITY_HEADERS_CONTENT_SECURITY_POLICY_REPORT_ONLY" logger: level: "LOGGER_LEVEL" path: "LOG_FOLDER" diff --git a/msa/web-ui/config/default.yml b/msa/web-ui/config/default.yml index b26424a8da..6ffa85b3bc 100644 --- a/msa/web-ui/config/default.yml +++ b/msa/web-ui/config/default.yml @@ -25,6 +25,20 @@ thingsboard: host: "localhost" # ThingsBoard node port port: "8080" +security: + headers: + x-content-type-options: + enabled: true + referrer-policy: + enabled: true + value: "strict-origin-when-cross-origin" + x-frame-options: + enabled: false + value: "SAMEORIGIN" + content-security-policy: + enabled: false + value: "" + report-only: false logger: level: "info" path: "logs" diff --git a/msa/web-ui/docker/Dockerfile b/msa/web-ui/docker/Dockerfile index 063374a478..b87885b45d 100644 --- a/msa/web-ui/docker/Dockerfile +++ b/msa/web-ui/docker/Dockerfile @@ -14,7 +14,7 @@ # limitations under the License. # -FROM thingsboard/node:22.18.0-bookworm-slim +FROM thingsboard/node:22.22.2-bookworm-slim ENV NODE_ENV production ENV DOCKER_MODE true diff --git a/msa/web-ui/package.json b/msa/web-ui/package.json index 0c22a38465..f93bc52c9b 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -1,12 +1,12 @@ { "name": "thingsboard-web-ui", "private": true, - "version": "4.3.0", + "version": "4.4.0", "description": "ThingsBoard Web UI Microservice", "main": "server.ts", "bin": "server.js", "scripts": { - "pkg": "tsc && pkg -t node22-linux-x64,node22-win-x64 --out-path ./target ./target/src && node install.js", + "pkg": "tsc && pkg -t node22-linux-x64 --output ./target/thingsboard-web-ui-linux ./target/src && pkg -t node22-win-x64 --no-bytecode --public-packages \"*\" --public --output ./target/thingsboard-web-ui-win.exe ./target/src && node install.js", "test": "echo \"Error: no test specified\" && exit 1", "start": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web ts-node server.ts'", "start-prod": "nodemon --watch '.' --ext 'ts' --exec 'WEB_FOLDER=./target/web NODE_ENV=production ts-node server.ts'", @@ -42,7 +42,10 @@ "fs-extra": "^11.3.1", "nodemon": "^3.1.10", "ts-node": "^10.9.2", - "typescript": "5.9.2" + "typescript": "~5.9.3" + }, + "resolutions": { + "@yao-pkg/pkg/tar": ">=7.5.8" }, "pkg": { "assets": [ diff --git a/msa/web-ui/pom.xml b/msa/web-ui/pom.xml index 40059304ba..fb0eb2c066 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT msa org.thingsboard.msa @@ -39,7 +39,6 @@ tb-web-ui tb-web-ui js - false process-resources package ${project.build.directory}/package/linux @@ -80,7 +79,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 @@ -90,7 +89,7 @@ yarn - install --non-interactive --network-concurrency 4 --network-timeout 100000 --mutex network + install --non-interactive --check-files --network-concurrency 4 --network-timeout 100000 --mutex network @@ -100,11 +99,22 @@ compile - run pkg + --mutex network run pkg + + org.apache.maven.plugins + maven-clean-plugin + + + + ${basedir}/node_modules + + + + org.apache.maven.plugins maven-dependency-plugin @@ -182,33 +192,37 @@ maven-assembly-plugin - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin build-docker-image pre-integration-test - - build - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - true - false - ${project.build.directory} + docker + ${project.build.directory} + + build + -t + ${docker.repo}/${docker.name}:latest + . + tag-docker-image pre-integration-test - - tag - + exec ${dockerfile.skip} - ${docker.repo}/${docker.name} - ${project.version} + docker + + tag + ${docker.repo}/${docker.name}:latest + ${docker.repo}/${docker.name}:${project.version} + @@ -258,29 +272,31 @@ - com.spotify - dockerfile-maven-plugin + org.codehaus.mojo + exec-maven-plugin push-latest-docker-image pre-integration-test - - push - + exec - latest - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:latest + push-version-docker-image pre-integration-test - - push - + exec - ${project.version} - ${docker.repo}/${docker.name} + docker + + push + ${docker.repo}/${docker.name}:${project.version} + diff --git a/msa/web-ui/server.ts b/msa/web-ui/server.ts index 68baf5079f..0a4ddbfa6b 100644 --- a/msa/web-ui/server.ts +++ b/msa/web-ui/server.ts @@ -60,6 +60,36 @@ let connections: Socket[] = []; const app = express(); server = http.createServer(app); + // Build security headers map once at startup. + // node-config passes env var overrides as strings, so enabled can be boolean or string. + const isEnabled = (val: any) => val === true || val === 'true'; + const securityHeaders: Record = {}; + const hc: any = config.get('security.headers'); + if (isEnabled(hc['x-content-type-options']?.enabled)) { + securityHeaders['X-Content-Type-Options'] = 'nosniff'; + } + if (isEnabled(hc['referrer-policy']?.enabled)) { + securityHeaders['Referrer-Policy'] = hc['referrer-policy']?.value || 'strict-origin-when-cross-origin'; + } + if (isEnabled(hc['x-frame-options']?.enabled)) { + securityHeaders['X-Frame-Options'] = hc['x-frame-options']?.value || 'SAMEORIGIN'; + } + if (isEnabled(hc['content-security-policy']?.enabled) && hc['content-security-policy']?.value) { + const csp = hc['content-security-policy']; + const name = isEnabled(csp['report-only']) + ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy'; + securityHeaders[name] = csp.value; + } + logger.info('Security headers: %s', JSON.stringify(securityHeaders)); + + // Apply security headers to all responses + app.use((_req, res, next) => { + for (const [name, value] of Object.entries(securityHeaders)) { + res.setHeader(name, value); + } + next(); + }); + let apiProxy: httpProxy; if (useApiProxy) { apiProxy = httpProxy.createProxyServer({ diff --git a/msa/web-ui/yarn.lock b/msa/web-ui/yarn.lock index 383d64d5f9..033aeac686 100644 --- a/msa/web-ui/yarn.lock +++ b/msa/web-ui/yarn.lock @@ -379,9 +379,9 @@ body-parser@^2.2.0: type-is "^2.0.0" brace-expansion@^1.1.7: - version "1.1.12" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.12.tgz#ab9b454466e5a8cc3a187beaad580412a9c5b843" - integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== + version "1.1.13" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" + integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" @@ -774,9 +774,9 @@ fn.name@1.x.x: integrity sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw== follow-redirects@^1.0.0: - version "1.15.11" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.11.tgz#777d73d72a92f8ec4d2e410eb47352a56b8e8340" - integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== forwarded@0.2.0: version "0.2.0" @@ -1098,9 +1098,9 @@ mimic-response@^3.1.0: integrity sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ== 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== + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: brace-expansion "^1.1.7" @@ -1114,10 +1114,10 @@ minipass@^7.0.4, minipass@^7.1.2: resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -minizlib@^3.0.1: - version "3.0.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.0.2.tgz#f33d638eb279f664439aa38dc5f91607468cb574" - integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== +minizlib@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" + integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== dependencies: minipass "^7.1.2" @@ -1126,11 +1126,6 @@ mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: resolved "https://registry.yarnpkg.com/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz#fa10c9115cc6d8865be221ba47ee9bed78601113" integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== -mkdirp@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-3.0.1.tgz#e44e4c5607fb279c168241713cc6e0fea9adcb50" - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== - moment@^2.29.1: version "2.30.1" resolved "https://registry.yarnpkg.com/moment/-/moment-2.30.1.tgz#f8c91c07b7a786e30c59926df530b4eac96974ae" @@ -1261,9 +1256,9 @@ path-parse@^1.0.7: integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== path-to-regexp@^8.0.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.2.0.tgz#73990cc29e57a3ff2a0d914095156df5db79e8b4" - integrity sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ== + version "8.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.0.tgz#8e98fcd94826aff01a90c544ef74ffbaca3a78ed" + integrity sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg== picocolors@^1.1.0: version "1.1.1" @@ -1271,14 +1266,14 @@ picocolors@^1.1.0: integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== picomatch@^2.0.4, picomatch@^2.2.1: - version "2.3.1" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.2: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== prebuild-install@^7.1.1: version "7.1.3" @@ -1330,9 +1325,9 @@ pump@^3.0.0: once "^1.3.1" qs@^6.14.0: - version "6.14.1" - resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" - integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== + version "6.14.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.2.tgz#b5634cf9d9ad9898e31fba3504e866e8efb6798c" + integrity sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q== dependencies: side-channel "^1.1.0" @@ -1635,16 +1630,15 @@ tar-stream@^2.1.4: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^7.4.3: - version "7.4.3" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.4.3.tgz#88bbe9286a3fcd900e94592cda7a22b192e80571" - integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== +tar@>=7.5.8, tar@^7.4.3: + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== dependencies: "@isaacs/fs-minipass" "^4.0.0" chownr "^3.0.0" minipass "^7.1.2" - minizlib "^3.0.1" - mkdirp "^3.0.1" + minizlib "^3.1.0" yallist "^5.0.0" text-hex@1.0.x: @@ -1722,10 +1716,10 @@ type-is@^2.0.0, type-is@^2.0.1: media-typer "^1.1.0" mime-types "^3.0.0" -typescript@5.9.2: - version "5.9.2" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.2.tgz#d93450cddec5154a2d5cabe3b8102b83316fb2a6" - integrity sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A== +typescript@~5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== undefsafe@^2.0.5: version "2.0.5" diff --git a/netty-mqtt/pom.xml b/netty-mqtt/pom.xml index 4ea598d363..c23aaabf80 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -19,11 +19,11 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard netty-mqtt - 4.3.0-RC + 4.4.0-SNAPSHOT jar Netty MQTT Client @@ -125,7 +125,6 @@ org.apache.maven.plugins maven-jar-plugin - 3.1.1 diff --git a/packaging/java/build.gradle b/packaging/java/build.gradle index 5ef4576a91..a9984683d2 100644 --- a/packaging/java/build.gradle +++ b/packaging/java/build.gradle @@ -16,12 +16,14 @@ import org.apache.tools.ant.filters.ReplaceTokens plugins { - id "nebula.ospackage" version "8.6.3" + id "com.netflix.nebula.ospackage" version "12.1.1" } buildDir = projectBuildDir version = projectVersion -distsDirName = "./" +base { + distsDirectory = layout.buildDirectory.dir("./") +} // OS Package plugin configuration ospackage { @@ -33,8 +35,8 @@ ospackage { into pkgInstallFolder - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the actual .jar file from(mainJar) { @@ -42,19 +44,25 @@ ospackage { rename { String fileName -> "${pkgName}.jar" } - fileMode 0500 + filePermissions { + unix('r-x------') // 0500 + } into "bin" } if("${pkgCopyInstallScripts}".equalsIgnoreCase("true")) { // Copy the install files from("${buildDir}/bin/install/install.sh") { - fileMode 0775 + filePermissions { + unix('rwxrwxr-x') // 0775 + } into "bin/install" } from("${buildDir}/bin/install/upgrade.sh") { - fileMode 0775 + filePermissions { + unix('rwxrwxr-x') // 0775 + } into "bin/install" } @@ -67,14 +75,18 @@ ospackage { from("${buildDir}/conf") { exclude "${pkgName}.conf" fileType CONFIG | NOREPLACE - fileMode 0754 + filePermissions { + unix('rwxr-xr--') // 0754 + } into "conf" } // Copy the data files from("${buildDir}/data") { fileType CONFIG | NOREPLACE - fileMode 0754 + filePermissions { + unix('rwxr-xr--') // 0754 + } into "data" } @@ -92,7 +104,7 @@ buildRpm { archiveVersion = projectVersion.replace('-', '') archiveFileName = "${pkgName}.rpm" - // Support Java 17 (existing), plus Java 21 and Java 25 for RPM-based distros + // Support Java 25 (current), plus Java 21 and Java 17 (backward compatibility) for RPM-based distros // Keep using RPM boolean expression syntax since .or() chaining is for DEB only requires("(java-17 or java-17-headless or jre-17 or jre-17-headless or " + "java-21 or java-21-headless or jre-21 or jre-21-headless or " + @@ -102,7 +114,9 @@ buildRpm { include "${pkgName}.conf" filter(ReplaceTokens, tokens: ['pkg.platform': 'rpm']) fileType CONFIG | NOREPLACE - fileMode 0754 + filePermissions { + unix('rwxr-xr--') // 0754 + } into "${pkgInstallFolder}/conf" } @@ -111,13 +125,15 @@ buildRpm { preUninstall file("${buildDir}/control/rpm/prerm") postUninstall file("${buildDir}/control/rpm/postrm") - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the system unit files from("${buildDir}/control/template.service") { addParentDirs = false - fileMode 0644 + filePermissions { + unix('rw-r--r--') // 0644 + } into "/usr/lib/systemd/system" rename { String filename -> "${pkgName}.service" @@ -143,7 +159,9 @@ buildDeb { include "${pkgName}.conf" filter(ReplaceTokens, tokens: ['pkg.platform': 'deb']) fileType CONFIG | NOREPLACE - fileMode 0754 + filePermissions { + unix('rwxr-xr--') // 0754 + } into "${pkgInstallFolder}/conf" } @@ -157,13 +175,15 @@ buildDeb { preUninstall file("${buildDir}/control/deb/prerm") postUninstall file("${buildDir}/control/deb/postrm") - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the system unit files from("${buildDir}/control/template.service") { addParentDirs = false - fileMode 0644 + filePermissions { + unix('rw-r--r--') // 0644 + } into "/lib/systemd/system" rename { String filename -> "${pkgName}.service" @@ -173,3 +193,6 @@ buildDeb { link("${pkgInstallFolder}/bin/${pkgName}.yml", "${pkgInstallFolder}/conf/${pkgName}.yml") link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") } + +buildDeb.onlyIf { findProperty('skipDeb') != 'true' } +buildRpm.onlyIf { findProperty('skipRpm') != 'true' } diff --git a/packaging/java/scripts/windows/install.bat b/packaging/java/scripts/windows/install.bat index ca17408dfe..566286d4b5 100644 --- a/packaging/java/scripts/windows/install.bat +++ b/packaging/java/scripts/windows/install.bat @@ -7,11 +7,11 @@ setlocal ENABLEEXTENSIONS for /f tokens^=2-5^ delims^=.-_^" %%j in ('java -fullversion 2^>^&1') do set "jver=%%j%%k" @ECHO CurrentVersion %jver% -if %jver% NEQ 170 GOTO JAVA_NOT_INSTALLED +if %jver% NEQ 250 GOTO JAVA_NOT_INSTALLED :JAVA_INSTALLED -@ECHO Java 17 found! +@ECHO Java 25 found! @ECHO Installing thingsboard ... SET loadDemo=false @@ -50,8 +50,8 @@ POPD GOTO END :JAVA_NOT_INSTALLED -@ECHO Java 17 is not installed. Only Java 17 is supported -@ECHO Please go to https://adoptopenjdk.net/index.html and install Java 17. Then retry installation. +@ECHO Java 25 is not installed. Only Java 25 is supported +@ECHO Please go to https://adoptopenjdk.net/index.html and install Java 25. Then retry installation. PAUSE GOTO END diff --git a/packaging/js/build.gradle b/packaging/js/build.gradle index 15b8e546a1..73020cf4f8 100644 --- a/packaging/js/build.gradle +++ b/packaging/js/build.gradle @@ -13,15 +13,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import org.apache.tools.ant.filters.ReplaceTokens plugins { - id "nebula.ospackage" version "8.6.3" + id "com.netflix.nebula.ospackage" version "12.1.1" } buildDir = projectBuildDir version = projectVersion -distsDirName = "./" +base { + distsDirectory = layout.buildDirectory.dir("./") +} // OS Package plugin configuration ospackage { @@ -33,18 +34,22 @@ ospackage { into pkgInstallFolder - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the executable file from("${buildDir}/package/linux/bin/${pkgName}") { - fileMode 0500 + filePermissions { + unix('r-x------') // 0500 + } into "bin" } // Copy the init file from("${buildDir}/package/linux/init/template") { - fileMode 0500 + filePermissions { + unix('r-x------') // 0500 + } into "init" rename { String filename -> "${pkgName}" @@ -54,7 +59,9 @@ ospackage { // Copy the config files from("${buildDir}/package/linux/conf") { fileType CONFIG | NOREPLACE - fileMode 0754 + filePermissions { + unix('rwxr-xr--') // 0754 + } into "conf" } @@ -78,13 +85,15 @@ buildRpm { preUninstall file("${buildDir}/control/rpm/prerm") postUninstall file("${buildDir}/control/rpm/postrm") - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the system unit files from("${buildDir}/control/template.service") { addParentDirs = false - fileMode 0644 + filePermissions { + unix('rw-r--r--') // 0644 + } into "/usr/lib/systemd/system" rename { String filename -> "${pkgName}.service" @@ -111,13 +120,15 @@ buildDeb { preUninstall file("${buildDir}/control/deb/prerm") postUninstall file("${buildDir}/control/deb/postrm") - user pkgUser - permissionGroup pkgUser + user = pkgUser + permissionGroup = pkgUser // Copy the system unit files from("${buildDir}/control/template.service") { addParentDirs = false - fileMode 0644 + filePermissions { + unix('rw-r--r--') // 0644 + } into "/lib/systemd/system" rename { String filename -> "${pkgName}.service" @@ -126,3 +137,6 @@ buildDeb { link("/etc/${pkgName}/conf", "${pkgInstallFolder}/conf") } + +buildDeb.onlyIf { findProperty('skipDeb') != 'true' } +buildRpm.onlyIf { findProperty('skipRpm') != 'true' } diff --git a/pom.xml b/pom.xml index 92a002cde0..cf1259ea5a 100755 --- a/pom.xml +++ b/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT pom Thingsboard @@ -28,26 +28,56 @@ 2016 - 17 - 17 + 25 + 25 ${basedir} - true none none + + + false + + false + + false + + false + + ${pkg.package.phase} thingsboard ${project.name} /var/log/${pkg.name} /usr/share/${pkg.name} - 3.4.10 + 4.4.0 + 3.5.14 + + 3.5.13 + 3.18.0 + 42.7.11 + 4.1.133.Final 2.4.0-b180830.0359 - 5.1.5 0.12.5 0.10 4.17.0 4.2.25 - 5.0.4 + 5.0.7 33.1.0-jre - 3.18.0 2.16.1 1.3.1 1.10.0 @@ -61,21 +91,24 @@ 2.0.0-M15 2.0.1 5.6.0 - 3.9.3 + 3.9.5 3.25.5 1.76.0 - 1.2.8 - 1.18.38 + 1.2.10 + 1.18.46 1.2.5 1.2.5 1.7.1 - 3.2.5 - 3.4.0 + 3.5.0 + 3.5.4 + 3.1.4 + 3.1.4 + 3.5.0 2.8.8TB 2.2.30 0.8 1.19.0 - 1.78.1 + 1.84 2.0.1 org/thingsboard/server/gen/**/*, org/thingsboard/server/extensions/core/plugin/telemetry/gen/**/* @@ -85,11 +118,9 @@ - 3.9.1 - 1.10.1 + 3.9.2 8.10.1 3.5.3 - 2.2 1.12.701 1.128.1 @@ -111,7 +142,9 @@ 4.0.2 1.7.5 3.8.0 + 1.18.4 1.8.0-TB + 2.5.9 2.38.0 1.24 1.11.0 @@ -127,7 +160,7 @@ 5.0.0 7.10.1 - 1.20.6 + 1.21.4 1.0.2 1.12 6.1.0 @@ -139,7 +172,7 @@ 0.4.8 1.0.0 - 1.39.0 + 1.48.0 6.6.0 1.35.0 1.6.1 @@ -147,7 +180,6 @@ 9.2.0 1.1.10.5 9.10.0 - 4.1.128.Final @@ -182,6 +214,43 @@ + + + skip-deb + + + pkg.skip.deb + true + + + + none + + + + + + skip-pkg + + + pkg.skip + true + + + + true + true + true + true + none + + + packaging @@ -487,7 +556,12 @@ org.springframework.boot spring-boot-maven-plugin - ${pkg.disabled} + + ${pkg.skip.bootjar} ${pkg.mainClass} boot ZIP @@ -531,6 +605,10 @@ -PpkgInstallFolder=${pkg.installFolder} -PpkgCopyInstallScripts=${pkg.copyInstallScripts} -PpkgLogFolder=${pkg.unixLogFolder} + -PskipDeb=${pkg.skip.deb} + -PskipRpm=${pkg.skip.rpm} + --project-cache-dir + ${project.build.directory}/.gradle --warning-mode all @@ -560,27 +638,31 @@ single + + ${pkg.skip.zip} + - org.apache.maven.plugins - maven-install-plugin - - ${project.build.directory}/${pkg.name}.deb - ${project.artifactId} - ${project.groupId} - ${project.version} - deb - deb - + org.codehaus.mojo + build-helper-maven-plugin - install-deb - ${pkg.package.phase} + attach-deb + ${pkg.deb.phase} - install-file + attach-artifact + + + + ${project.build.directory}/${pkg.name}.deb + deb + deb + + + @@ -605,7 +687,7 @@ maven-compiler-plugin 3.11.0 - 17 + 25 -Xlint:deprecation -Xlint:removal @@ -634,7 +716,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.1.1 + ${jar-plugin.version} org.apache.maven.plugins @@ -649,33 +731,41 @@ org.thingsboard gradle-maven-plugin - 1.0.12 + 1.0.15 com.github.eirslett frontend-maven-plugin - 1.12.0 + 2.0.0 org.apache.maven.plugins maven-surefire-plugin - ${surefire.version} + ${maven-surefire-plugin.version} - -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=20 - --add-opens java.base/java.lang.reflect=ALL-UNNAMED + -XX:+UseStringDeduplication -XX:MaxGCPauseMillis=200 + -XX:+EnableDynamicAgentLoading + --add-opens=java.base/java.lang.reflect=ALL-UNNAMED + -Dqueue.edqs.local.rocksdb_path="target/rocksdb/fork_${surefire.forkNumber}/edqs" + -Dqueue.calculated_fields.rocks_db_path="target/rocksdb/fork_${surefire.forkNumber}/cf" org.apache.maven.plugins maven-install-plugin - 3.0.0-M1 + ${maven-install-plugin.version} org.apache.maven.plugins maven-deploy-plugin - 3.0.0-M1 + ${maven-deploy-plugin.version} + + + org.apache.maven.plugins + maven-enforcer-plugin + 3.5.0 org.codehaus.mojo @@ -835,6 +925,13 @@ **/test/resources/lwm2m/** **/resources/lwm2m/models/** src/main/data/resources/** + .claude/** + **/lombok.config + **/eslint.config.mjs + **/config.monitoring + **/valkey-certs/** + **/data/certs/** + **/*.otf JAVADOC_STYLE @@ -884,6 +981,22 @@ com.mycila license-maven-plugin + + org.apache.maven.plugins + maven-clean-plugin + ${maven-clean-plugin.version} + false + + + + ${main.dir}/packaging/java/.gradle + + + ${main.dir}/packaging/js/.gradle + + + + @@ -897,6 +1010,7 @@ + io.netty netty-bom @@ -904,6 +1018,7 @@ pom import + org.springframework.boot spring-boot-dependencies @@ -911,6 +1026,16 @@ pom import + + net.bytebuddy + byte-buddy + ${bytebuddy.version} + + + net.bytebuddy + byte-buddy-agent + ${bytebuddy.version} + org.thingsboard.langchain4j langchain4j-bom @@ -1070,6 +1195,12 @@ ${project.version} test + + org.thingsboard.client + thingsboard-ce-client + ${thingsboard.client.version} + test + org.thingsboard.msa js-executor @@ -1150,21 +1281,23 @@ + + + org.springframework.boot + spring-boot-test + ${spring-boot-test.version} + + + org.springframework.boot + spring-boot-test-autoconfigure + ${spring-boot-test.version} + org.apache.kafka kafka-clients ${kafka.version} - - - org.lz4 - lz4-java - - - - - at.yawk.lz4 - lz4-java - ${lz4.version} com.github.springtestdbunit @@ -1177,11 +1310,6 @@ jjwt ${jjwt.version} - - org.yaml - snakeyaml - ${snakeyaml.version} - antlr antlr @@ -1244,6 +1372,16 @@ commons-lang3 ${commons-lang3.version} + + org.postgresql + postgresql + ${postgresql.version} + + + org.apache.opennlp + opennlp-tools + ${opennlp-tools.version} + commons-io commons-io @@ -1445,12 +1583,6 @@ org.apache.cassandra cassandra-all ${cassandra-all.version} - - - org.lz4 - lz4-java - - org.testng @@ -1482,12 +1614,6 @@ ${dbunit.version} test - - org.projectlombok - lombok - ${lombok.version} - provided - org.eclipse.paho org.eclipse.paho.client.mqttv3 @@ -1533,11 +1659,6 @@ bcprov-ext-jdk18on ${bouncycastle.version} - - redis.clients - jedis - ${jedis.version} - com.sun.winsw winsw @@ -1927,7 +2048,7 @@ https://repo1.maven.org/maven2/ - thingsboard-repo + thingsboard-public-repo https://repo.thingsboard.io/artifactory/libs-release-public @@ -1953,4 +2074,10 @@ + + + thingsboard-repo + https://repo.thingsboard.io/artifactory/libs-release-public + + diff --git a/rest-client/pom.xml b/rest-client/pom.xml index 1b33aca512..9996b19d00 100644 --- a/rest-client/pom.xml +++ b/rest-client/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard rest-client @@ -51,6 +51,10 @@ com.auth0 java-jwt + + org.apache.httpcomponents.core5 + httpcore5 + diff --git a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java index bd1d7b05c3..f133527ddc 100644 --- a/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java +++ b/rest-client/src/main/java/org/thingsboard/rest/client/RestClient.java @@ -23,6 +23,7 @@ import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; import org.apache.commons.lang3.concurrent.LazyInitializer; +import org.apache.hc.core5.net.URIBuilder; import org.springframework.core.ParameterizedTypeReference; import org.springframework.core.io.ByteArrayResource; import org.springframework.core.io.Resource; @@ -93,6 +94,8 @@ import org.thingsboard.server.common.data.asset.AssetSearchQuery; import org.thingsboard.server.common.data.audit.ActionType; import org.thingsboard.server.common.data.audit.AuditLog; import org.thingsboard.server.common.data.cf.CalculatedField; +import org.thingsboard.server.common.data.cf.CalculatedFieldInfo; +import org.thingsboard.server.common.data.cf.CalculatedFieldType; import org.thingsboard.server.common.data.device.DeviceSearchQuery; import org.thingsboard.server.common.data.domain.Domain; import org.thingsboard.server.common.data.domain.DomainInfo; @@ -157,17 +160,18 @@ import org.thingsboard.server.common.data.oauth2.PlatformType; import org.thingsboard.server.common.data.ota.ChecksumAlgorithm; import org.thingsboard.server.common.data.ota.OtaPackageType; import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.pat.ApiKey; -import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.page.PageLink; import org.thingsboard.server.common.data.page.SortOrder; import org.thingsboard.server.common.data.page.TimePageLink; +import org.thingsboard.server.common.data.pat.ApiKey; +import org.thingsboard.server.common.data.pat.ApiKeyInfo; import org.thingsboard.server.common.data.plugin.ComponentDescriptor; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.query.AlarmCountQuery; import org.thingsboard.server.common.data.query.AlarmData; import org.thingsboard.server.common.data.query.AlarmDataQuery; import org.thingsboard.server.common.data.query.AvailableEntityKeys; +import org.thingsboard.server.common.data.query.AvailableEntityKeysV2; import org.thingsboard.server.common.data.query.EntityCountQuery; import org.thingsboard.server.common.data.query.EntityData; import org.thingsboard.server.common.data.query.EntityDataQuery; @@ -210,17 +214,21 @@ import org.thingsboard.server.common.data.widget.WidgetsBundle; import java.io.Closeable; import java.io.IOException; import java.net.URI; +import java.net.URISyntaxException; import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.ExecutorService; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +import static java.util.stream.Collectors.joining; import static org.thingsboard.server.common.data.StringUtils.isEmpty; public class RestClient implements Closeable { @@ -1588,6 +1596,33 @@ public class RestClient implements Closeable { }, activeOnly).getBody(); } + public List getDeviceProfileInfosByIds(Set ids) { + URIBuilder builder; + try { + builder = new URIBuilder(baseURL); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid base URL: " + baseURL, e); + } + + builder.appendPath("/api/deviceProfileInfos"); + + String commaSeparatedIds = ids.stream() + .filter(Objects::nonNull) + .map(UUID::toString) + .collect(joining(",")); + + builder.addParameter("deviceProfileIds", commaSeparatedIds); + + URI uri; + try { + uri = builder.build(); + } catch (URISyntaxException e) { + throw new IllegalStateException("Failed to construct API URI from base URL and provided params", e); + } + + return restTemplate.exchange(uri, HttpMethod.GET, null, new ParameterizedTypeReference>() {}).getBody(); + } + public JsonNode claimDevice(String deviceName, ClaimRequest claimRequest) { return restTemplate.exchange( baseURL + "/api/customer/device/{deviceName}/claim", @@ -1824,6 +1859,33 @@ public class RestClient implements Closeable { }, params).getBody(); } + public List getAssetProfilesByIds(Set ids) { + URIBuilder builder; + try { + builder = new URIBuilder(baseURL); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Invalid base URL: " + baseURL, e); + } + + builder.appendPath("/api/assetProfileInfos"); + + String commaSeparatedIds = ids.stream() + .filter(Objects::nonNull) + .map(UUID::toString) + .collect(joining(",")); + + builder.addParameter("assetProfileIds", commaSeparatedIds); + + URI uri; + try { + uri = builder.build(); + } catch (URISyntaxException e) { + throw new IllegalStateException("Failed to construct API URI from base URL and provided params", e); + } + + return restTemplate.exchange(uri, HttpMethod.GET, null, new ParameterizedTypeReference>() {}).getBody(); + } + public Long countEntitiesByQuery(EntityCountQuery query) { return restTemplate.postForObject(baseURL + "/api/entitiesQuery/count", query, Long.class); } @@ -1836,6 +1898,10 @@ public class RestClient implements Closeable { }).getBody(); } + /** + * @deprecated Use {@link #findAvailableEntityKeysV2(EntityDataQuery, boolean, boolean, Set, boolean)} instead. + */ + @Deprecated(forRemoval = true) public AvailableEntityKeys findAvailableEntityKeysByQuery(EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, AttributeScope scope) { var uri = UriComponentsBuilder.fromUriString(baseURL) .path("/api/entitiesQuery/find/keys") @@ -1847,6 +1913,22 @@ public class RestClient implements Closeable { return restTemplate.exchange(uri, HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() {}).getBody(); } + @SneakyThrows(URISyntaxException.class) + public AvailableEntityKeysV2 findAvailableEntityKeysV2( + EntityDataQuery query, boolean includeTimeseries, boolean includeAttributes, Set scopes, boolean includeSamples + ) { + var builder = new URIBuilder(baseURL).appendPath("/api/v2/entitiesQuery/find/keys") + .addParameter("includeTimeseries", String.valueOf(includeTimeseries)) + .addParameter("includeAttributes", String.valueOf(includeAttributes)) + .addParameter("includeSamples", String.valueOf(includeSamples)); + if (scopes != null) { + for (AttributeScope scope : scopes) { + builder.addParameter("scopes", scope.name()); + } + } + return restTemplate.exchange(builder.build(), HttpMethod.POST, new HttpEntity<>(query), new ParameterizedTypeReference() {}).getBody(); + } + public PageData findAlarmDataByQuery(AlarmDataQuery query) { return restTemplate.exchange( baseURL + "/api/alarmsQuery/find", @@ -4309,6 +4391,46 @@ public class RestClient implements Closeable { } + @SneakyThrows(URISyntaxException.class) + public PageData getCalculatedFields(PageLink pageLink, + Set types, + EntityType entityType, + Set entities, + Set names) { + var urlBuilder = new URIBuilder(baseURL).appendPath("/api/calculatedFields"); + urlBuilder.addParameter("pageSize", String.valueOf(pageLink.getPageSize())); + urlBuilder.addParameter("page", String.valueOf(pageLink.getPage())); + if (!isEmpty(pageLink.getTextSearch())) { + urlBuilder.addParameter("textSearch", pageLink.getTextSearch()); + } + if (pageLink.getSortOrder() != null) { + urlBuilder.addParameter("sortProperty", pageLink.getSortOrder().getProperty()); + urlBuilder.addParameter("sortOrder", pageLink.getSortOrder().getDirection().name()); + } + if (!CollectionUtils.isEmpty(types)) { + for (CalculatedFieldType type : types) { + urlBuilder.addParameter("types", type.name()); + } + } + if (entityType != null) { + urlBuilder.addParameter("entityType", entityType.name()); + } + if (!CollectionUtils.isEmpty(entities)) { + for (UUID entity : entities) { + urlBuilder.addParameter("entities", entity.toString()); + } + } + if (!CollectionUtils.isEmpty(names)) { + for (String name : names) { + urlBuilder.addParameter("name", name); + } + } + return restTemplate.exchange( + urlBuilder.build(), + HttpMethod.GET, HttpEntity.EMPTY, + new ParameterizedTypeReference>() {}).getBody(); + } + public void deleteCalculatedField(CalculatedFieldId calculatedFieldId) { restTemplate.delete(baseURL + "/api/calculatedField/{calculatedFieldId}", calculatedFieldId.getId()); } diff --git a/rule-engine/pom.xml b/rule-engine/pom.xml index feb8cbf8f1..bba58e92a8 100644 --- a/rule-engine/pom.xml +++ b/rule-engine/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard rule-engine diff --git a/rule-engine/rule-engine-api/pom.xml b/rule-engine/rule-engine-api/pom.xml index 7523afc0b6..a9f58a08b3 100644 --- a/rule-engine/rule-engine-api/pom.xml +++ b/rule-engine/rule-engine-api/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT rule-engine org.thingsboard.rule-engine diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java index e4cb573cfe..f12d59cd0a 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/JobManager.java @@ -19,11 +19,14 @@ import com.google.common.util.concurrent.ListenableFuture; import org.thingsboard.server.common.data.id.JobId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.job.Job; +import org.thingsboard.server.common.msg.queue.TbCallback; public interface JobManager { ListenableFuture submitJob(Job job); // TODO: rate limits + ListenableFuture submitJob(Job job, TbCallback finishCallback); + void cancelJob(TenantId tenantId, JobId jobId); void reprocessJob(TenantId tenantId, JobId jobId); diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java index d3014ab906..f827dd6e0b 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbContext.java @@ -441,4 +441,10 @@ public interface TbContext { MqttClientSettings getMqttClientSettings(); + // Server-level safety caps for the HTTP client used by the REST API Call rule node (read from thingsboard.yml) + + default TbHttpClientSettings getTbHttpClientSettings() { + return TbHttpClientSettings.DEFAULT; + } + } diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java new file mode 100644 index 0000000000..658f82c0d7 --- /dev/null +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/TbHttpClientSettings.java @@ -0,0 +1,49 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.api; + +/** + * Server-level safety caps for the HTTP client used by the REST API Call rule node. + * Values are read from {@code thingsboard.yml} (or the corresponding environment variables) + * and applied as hard ceilings on top of the per-node tenant configuration. + * A value of {@code 0} means no system-level restriction. + */ +public interface TbHttpClientSettings { + + /** System ceiling for {@code maxParallelRequestsCount}. 0 = no system limit. */ + int getMaxParallelRequests(); + + /** System ceiling for the pending-request queue depth. 0 = no system limit. */ + int getMaxPendingRequests(); + + /** + * Maximum number of TCP connections in the reactor-netty pool per node instance. + * 0 = use reactor-netty's default: {@code max(availableProcessors, 8) * 2}. + */ + int getPoolMaxConnections(); + + TbHttpClientSettings DEFAULT = new TbHttpClientSettings() { + @Override + public int getMaxParallelRequests() { return 0; } + + @Override + public int getMaxPendingRequests() { return 0; } + + @Override + public int getPoolMaxConnections() { return 0; } + }; + +} diff --git a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java index 4a0f1883c0..8fac42e029 100644 --- a/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java +++ b/rule-engine/rule-engine-api/src/main/java/org/thingsboard/rule/engine/api/util/TbNodeUtils.java @@ -15,6 +15,7 @@ */ package org.thingsboard.rule.engine.api.util; +import com.fasterxml.jackson.core.io.JsonStringEncoder; import com.fasterxml.jackson.databind.JsonNode; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -57,8 +58,12 @@ public final class TbNodeUtils { } public static String processPattern(String pattern, TbMsg tbMsg) { + return processPattern(pattern, tbMsg, false); + } + + public static String processPattern(String pattern, TbMsg tbMsg, boolean escapeJsonValues) { try { - String result = processPattern(pattern, tbMsg.getMetaData()); + String result = processPattern(pattern, tbMsg.getMetaData(), escapeJsonValues); JsonNode json = JacksonUtil.toJsonNode(tbMsg.getData()); result = result.replace(ALL_DATA_TEMPLATE, JacksonUtil.toString(json)); @@ -79,7 +84,11 @@ public final class TbNodeUtils { } if (jsonNode != null && jsonNode.isValueNode()) { - result = result.replace(formatDataVarTemplate(group), jsonNode.asText()); + String value = jsonNode.asText(); + if (escapeJsonValues) { + value = escapeJsonValue(value); + } + result = result.replace(formatDataVarTemplate(group), value); } } } @@ -89,22 +98,31 @@ public final class TbNodeUtils { } } - private static String processPattern(String pattern, TbMsgMetaData metaData) { + private static String processPattern(String pattern, TbMsgMetaData metaData, boolean escapeJsonValues) { String replacement = metaData.isEmpty() ? "{}" : JacksonUtil.toString(metaData.getData()); pattern = pattern.replace(ALL_METADATA_TEMPLATE, replacement); - return processTemplate(pattern, metaData.values()); + return processTemplate(pattern, metaData.values(), escapeJsonValues); + } + + private static String processPattern(String pattern, TbMsgMetaData metaData) { + return processPattern(pattern, metaData, false); } public static String processTemplate(String template, Map data) { + return processTemplate(template, data, false); + } + + private static String processTemplate(String template, Map data, boolean escapeJsonValues) { String result = template; for (Map.Entry kv : data.entrySet()) { - result = processVar(result, kv.getKey(), kv.getValue()); + String value = escapeJsonValues ? escapeJsonValue(kv.getValue()) : kv.getValue(); + result = result.replace(formatMetadataVarTemplate(kv.getKey()), value); } return result; } - private static String processVar(String pattern, String key, String val) { - return pattern.replace(formatMetadataVarTemplate(key), val); + private static String escapeJsonValue(String value) { + return new String(JsonStringEncoder.getInstance().quoteAsString(value)); } static String formatDataVarTemplate(String key) { diff --git a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java index 18003d6b70..882e2080d7 100644 --- a/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java +++ b/rule-engine/rule-engine-api/src/test/java/org/thingsboard/rule/engine/api/util/TbNodeUtilsTest.java @@ -305,6 +305,50 @@ public class TbNodeUtilsTest { assertThat(actual, is(expected)); } + @Test + public void testProcessPatternWithJsonEscaping() { + String pattern = "{\"name\":\"${user}\",\"desc\":\"$[description]\"}"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + String result = TbNodeUtils.processPattern(pattern, msg, true); + Assertions.assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", result); + + // Verify the result is valid JSON + Assertions.assertDoesNotThrow(() -> JacksonUtil.toJsonNode(result)); + } + + @Test + public void testProcessPatternWithoutJsonEscaping() { + String pattern = "Hello ${user}, desc: $[description]"; + TbMsgMetaData md = new TbMsgMetaData(); + md.putValue("user", "John \"Doe\""); + + ObjectNode node = JacksonUtil.newObjectNode(); + node.put("description", "line1\nline2"); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(TenantId.SYS_TENANT_ID) + .copyMetaData(md) + .data(JacksonUtil.toString(node)) + .build(); + + // Without escaping, raw values are substituted as-is + String result = TbNodeUtils.processPattern(pattern, msg, false); + Assertions.assertEquals("Hello John \"Doe\", desc: line1\nline2", result); + } + @Test public void testMixedAllDataMetadataAndNormalTemplates() { // GIVEN diff --git a/rule-engine/rule-engine-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index f9218b69de..c1cfcf13c8 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -22,7 +22,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT rule-engine org.thingsboard.rule-engine @@ -96,10 +96,6 @@ org.apache.kafka kafka-clients - - at.yawk.lz4 - lz4-java - com.amazonaws aws-java-sdk-sns @@ -161,6 +157,10 @@ org.thingsboard.langchain4j langchain4j + + org.apache.httpcomponents.core5 + httpcore5 + diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java index b4d2951264..9a29f80b1c 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/deduplication/TbMsgDeduplicationNode.java @@ -41,6 +41,7 @@ import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.concurrent.TimeUnit; @@ -177,15 +178,11 @@ public class TbMsgDeduplicationNode implements TbNode { } } if (resultMsg != null) { - String queueName1 = queueName != null ? queueName : resultMsg.getQueueName(); - deduplicationResults.add(TbMsg.newMsg() - .queueName(queueName1) - .type(resultMsg.getType()) - .originator(resultMsg.getOriginator()) - .customerId(resultMsg.getCustomerId()) - .copyMetaData(resultMsg.getMetaData()) - .data(resultMsg.getData()) - .build()); + var msgBuilder = resultMsg.copyWithNewCtx().id(UUID.randomUUID()); + if (queueName != null) { + msgBuilder.queueName(queueName); + } + deduplicationResults.add(msgBuilder.build()); } } packBoundsOpt = findValidPack(msgList, deduplicationTimeoutMs); diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java index f0e4a3e40e..04288cf7f3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/delay/TbMsgDelayNode.java @@ -62,17 +62,9 @@ public class TbMsgDelayNode implements TbNode { if (msg.isTypeOf(TbMsgType.DELAY_TIMEOUT_SELF_MSG)) { TbMsg pendingMsg = pendingMsgs.remove(UUID.fromString(msg.getData())); if (pendingMsg != null) { - ctx.enqueueForTellNext( - TbMsg.newMsg() - .queueName(pendingMsg.getQueueName()) - .type(pendingMsg.getType()) - .originator(pendingMsg.getOriginator()) - .customerId(pendingMsg.getCustomerId()) - .copyMetaData(pendingMsg.getMetaData()) - .data(pendingMsg.getData()) - .build(), - TbNodeConnectionType.SUCCESS - ); + ctx.enqueueForTellNext(pendingMsg.copyWithNewCtx() + .id(UUID.randomUUID()) + .build(), TbNodeConnectionType.SUCCESS); } } else { if (pendingMsgs.size() < config.getMaxPendingMsgs()) { diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java index e2c218f272..4efa462e37 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/external/TbAbstractExternalNode.java @@ -30,7 +30,7 @@ public abstract class TbAbstractExternalNode implements TbNode { protected void tellSuccess(TbContext ctx, TbMsg tbMsg) { if (forceAck) { - ctx.enqueueForTellNext(tbMsg.copyWithNewCtx(), TbNodeConnectionType.SUCCESS); + ctx.enqueueForTellNext(tbMsg.copyWithNewCtx().build(), TbNodeConnectionType.SUCCESS); } else { ctx.tellSuccess(tbMsg); } @@ -39,9 +39,9 @@ public abstract class TbAbstractExternalNode implements TbNode { protected void tellFailure(TbContext ctx, TbMsg tbMsg, Throwable t) { if (forceAck) { if (t == null) { - ctx.enqueueForTellNext(tbMsg.copyWithNewCtx(), TbNodeConnectionType.FAILURE); + ctx.enqueueForTellNext(tbMsg.copyWithNewCtx().build(), TbNodeConnectionType.FAILURE); } else { - ctx.enqueueForTellFailure(tbMsg.copyWithNewCtx(), t); + ctx.enqueueForTellFailure(tbMsg.copyWithNewCtx().build(), t); } } else { if (t == null) { @@ -55,7 +55,7 @@ public abstract class TbAbstractExternalNode implements TbNode { protected TbMsg ackIfNeeded(TbContext ctx, TbMsg msg) { if (forceAck) { ctx.ack(msg); - return msg.copyWithNewCtx(); + return msg.copyWithNewCtx().build(); } else { return msg; } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java index ae6f69a2d4..78bae6fe75 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNode.java @@ -76,6 +76,13 @@ public class TbRuleChainInputNode implements TbNode { public void onMsg(TbContext ctx, TbMsg msg) throws TbNodeException { RuleChainId targetRuleChainId = forwardMsgToDefaultRuleChain ? getOriginatorDefaultRuleChainId(ctx, msg).orElse(ruleChainId) : ruleChainId; + if (targetRuleChainId.equals(ctx.getSelf().getRuleChainId())) { + ctx.tellFailure(msg, new RuntimeException( + "TbRuleChainInputNode in rule chain [" + targetRuleChainId + + "] is configured to forward messages back to the same rule chain it belongs to. " + + "This would cause an infinite loop. Please check the rule chain configuration.")); + return; + } ctx.input(msg, targetRuleChainId); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java new file mode 100644 index 0000000000..9d15cb9793 --- /dev/null +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroup.java @@ -0,0 +1,174 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.rest; + +import io.netty.resolver.AddressResolver; +import io.netty.resolver.AddressResolverGroup; +import io.netty.resolver.DefaultAddressResolverGroup; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Future; +import io.netty.util.concurrent.Promise; +import org.thingsboard.common.util.SsrfProtectionValidator; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Custom Netty {@link AddressResolverGroup} that validates every resolved IP address + * against the SSRF block-list at connection time. This eliminates the DNS rebinding + * TOCTOU gap where a hostname resolves to a safe IP during validation but to a + * private/metadata IP when the actual connection is made. + *

+ * Only wired into {@link TbHttpClient} when SSRF protection is enabled. + */ +public final class SsrfSafeAddressResolverGroup extends AddressResolverGroup { + + public static final SsrfSafeAddressResolverGroup INSTANCE = new SsrfSafeAddressResolverGroup(); + + private SsrfSafeAddressResolverGroup() { + } + + @Override + protected AddressResolver newResolver(EventExecutor executor) throws Exception { + AddressResolver delegate = DefaultAddressResolverGroup.INSTANCE.getResolver(executor); + return new SsrfValidatingResolver(executor, delegate); + } + + private static final class SsrfValidatingResolver implements AddressResolver { + + private final EventExecutor executor; + private final AddressResolver delegate; + + SsrfValidatingResolver(EventExecutor executor, AddressResolver delegate) { + this.executor = executor; + this.delegate = delegate; + } + + @Override + public boolean isSupported(SocketAddress address) { + return delegate.isSupported(address); + } + + @Override + public boolean isResolved(SocketAddress address) { + return delegate.isResolved(address); + } + + @Override + public Future resolve(SocketAddress address) { + return resolve(address, executor.newPromise()); + } + + @Override + public Future resolve(SocketAddress address, Promise promise) { + delegate.resolve(address).addListener((Future future) -> { + try { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + InetSocketAddress resolved = future.getNow(); + if (isOriginalHostAllowed(address)) { + promise.trySuccess(resolved); + } else if (isBlocked(resolved)) { + promise.tryFailure(new RuntimeException( + "URI is invalid: host '" + getHostString(address) + "' is not allowed")); + } else { + promise.trySuccess(resolved); + } + } catch (Exception e) { + promise.tryFailure(e); + } + }); + return promise; + } + + @Override + public Future> resolveAll(SocketAddress address) { + return resolveAll(address, executor.newPromise()); + } + + @Override + public Future> resolveAll(SocketAddress address, Promise> promise) { + delegate.resolveAll(address).addListener((Future> future) -> { + try { + if (!future.isSuccess()) { + promise.tryFailure(future.cause()); + return; + } + List resolved = future.getNow(); + if (isOriginalHostAllowed(address)) { + promise.trySuccess(resolved); + return; + } + Set blocked = null; + for (InetSocketAddress addr : resolved) { + if (isBlocked(addr)) { + if (blocked == null) { + blocked = new HashSet<>(2); + } + blocked.add(addr); + } + } + if (blocked == null) { + promise.trySuccess(resolved); + } else if (blocked.size() == resolved.size()) { + promise.tryFailure(new RuntimeException( + "URI is invalid: host '" + getHostString(address) + "' is not allowed")); + } else { + List safe = new ArrayList<>(resolved.size() - blocked.size()); + for (InetSocketAddress addr : resolved) { + if (!blocked.contains(addr)) { + safe.add(addr); + } + } + promise.trySuccess(safe); + } + } catch (Exception e) { + promise.tryFailure(e); + } + }); + return promise; + } + + @Override + public void close() { + delegate.close(); + } + + private static boolean isBlocked(InetSocketAddress socketAddress) { + InetAddress addr = socketAddress.getAddress(); + return addr != null && SsrfProtectionValidator.isBlockedAddress(addr); + } + + private static boolean isOriginalHostAllowed(SocketAddress address) { + if (address instanceof InetSocketAddress isa) { + String host = isa.getHostString(); + return host != null && SsrfProtectionValidator.isHostnameAllowed(host); + } + return false; + } + + private static String getHostString(SocketAddress address) { + return address instanceof InetSocketAddress isa ? isa.getHostString() : address.toString(); + } + } +} diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java index 23e7f7853b..8e017e21f3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbHttpClient.java @@ -21,6 +21,7 @@ import io.netty.handler.ssl.SslContext; import io.netty.handler.timeout.ReadTimeoutHandler; import lombok.Data; import lombok.extern.slf4j.Slf4j; +import org.apache.hc.core5.net.URIBuilder; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; @@ -32,6 +33,8 @@ import org.springframework.web.reactive.function.client.WebClient.RequestBodySpe import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.util.UriComponentsBuilder; import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.common.util.SsrfProtectionValidator; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; @@ -39,21 +42,28 @@ import org.thingsboard.rule.engine.credentials.BasicCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials; import org.thingsboard.rule.engine.credentials.CredentialsType; import org.thingsboard.server.common.data.StringUtils; +import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import reactor.core.scheduler.Schedulers; import reactor.netty.http.client.HttpClient; import reactor.netty.resources.ConnectionProvider; import reactor.netty.transport.ProxyProvider; import javax.net.ssl.SSLException; import java.net.URI; +import java.net.URISyntaxException; import java.nio.charset.StandardCharsets; import java.util.Base64; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Properties; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicLong; import java.util.function.BiConsumer; import java.util.function.Consumer; @@ -83,17 +93,48 @@ public class TbHttpClient { public static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "tb.http.maxInMemoryBufferSizeInKb"; + private static final long ANOMALY_REPORT_INTERVAL_MS = 60_000; + private final TbRestApiCallNodeConfiguration config; + private final String tenantId; + private final String nodeId; + private final TbHttpClientSettings settings; private EventLoopGroup eventLoopGroup; private WebClient webClient; private Semaphore semaphore; + private BlockingQueue pendingQueue; + + private final AtomicLong dispatchedCount = new AtomicLong(); + private final AtomicLong successCount = new AtomicLong(); + private final AtomicLong failureCount = new AtomicLong(); + private final AtomicLong droppedFullCount = new AtomicLong(); + private final AtomicLong droppedStaleCount = new AtomicLong(); + private volatile long lastAnomalyReportAt = 0; + + private record PendingTask( + TbContext ctx, + TbMsg msg, + Consumer onSuccess, + BiConsumer onFailure, + long enqueuedNanos) {} TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared) throws TbNodeException { + this(config, eventLoopGroupShared, "n/a", "n/a", TbHttpClientSettings.DEFAULT); + } + + TbHttpClient(TbRestApiCallNodeConfiguration config, EventLoopGroup eventLoopGroupShared, + String tenantId, String nodeId, TbHttpClientSettings settings) throws TbNodeException { try { this.config = config; - if (config.getMaxParallelRequestsCount() > 0) { - semaphore = new Semaphore(config.getMaxParallelRequestsCount()); + this.tenantId = tenantId; + this.nodeId = nodeId; + this.settings = settings; + int effectiveParallel = effectiveMax(config.getMaxParallelRequestsCount(), settings.getMaxParallelRequests()); + if (effectiveParallel > 0) { + semaphore = new Semaphore(effectiveParallel); + int effectivePending = effectiveMax(0, settings.getMaxPendingRequests()); + pendingQueue = effectivePending > 0 ? new LinkedBlockingQueue<>(effectivePending) : new LinkedBlockingQueue<>(); } ConnectionProvider connectionProvider = ConnectionProvider @@ -102,6 +143,7 @@ public class TbHttpClient { .build(); HttpClient httpClient = HttpClient.create(connectionProvider) + .followRedirect(false) .runOn(getSharedOrCreateEventLoopGroup(eventLoopGroupShared)) .doOnConnected(c -> c.addHandlerLast(new ReadTimeoutHandler(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS))); @@ -128,15 +170,15 @@ public class TbHttpClient { SslContext sslContext = config.getCredentials().initSslContext(); httpClient = httpClient.secure(t -> t.sslContext(sslContext)); } - } else if (config.isUseSimpleClientHttpFactory()) { - if (CredentialsType.CERT_PEM == config.getCredentials().getType()) { - throw new TbNodeException("Simple HTTP Factory does not support CERT PEM credentials!"); - } } else { SslContext sslContext = config.getCredentials().initSslContext(); httpClient = httpClient.secure(t -> t.sslContext(sslContext)); } + if (SsrfProtectionValidator.isEnabled()) { + httpClient = httpClient.resolver(SsrfSafeAddressResolverGroup.INSTANCE); + } + validateMaxInMemoryBufferSize(config); this.webClient = WebClient.builder() @@ -149,16 +191,20 @@ public class TbHttpClient { } } - private int getPoolMaxConnections() { - String poolMaxConnectionsEnv = System.getenv("TB_RE_HTTP_CLIENT_POOL_MAX_CONNECTIONS"); + /** + * Returns the effective limit: {@code min(userMax, systemMax)} when both are positive, + * {@code systemMax} when only the system ceiling is set, or {@code userMax} otherwise. + * A value of {@code 0} means unlimited. + */ + private static int effectiveMax(int userMax, int systemMax) { + if (systemMax <= 0) return userMax; + if (userMax <= 0) return systemMax; + return Math.min(userMax, systemMax); + } - int poolMaxConnections; - if (poolMaxConnectionsEnv != null) { - poolMaxConnections = Integer.parseInt(poolMaxConnectionsEnv); - } else { - poolMaxConnections = ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; - } - return poolMaxConnections; + private int getPoolMaxConnections() { + int configured = settings.getPoolMaxConnections(); + return configured > 0 ? configured : ConnectionProvider.DEFAULT_POOL_MAX_CONNECTIONS; } private void validateMaxInMemoryBufferSize(TbRestApiCallNodeConfiguration config) throws TbNodeException { @@ -177,10 +223,7 @@ public class TbHttpClient { } EventLoopGroup getSharedOrCreateEventLoopGroup(EventLoopGroup eventLoopGroupShared) { - if (eventLoopGroupShared != null) { - return eventLoopGroupShared; - } - return this.eventLoopGroup = new NioEventLoopGroup(); + return Objects.requireNonNullElseGet(eventLoopGroupShared, () -> this.eventLoopGroup = new NioEventLoopGroup()); } private void checkSystemProxyProperties() throws TbNodeException { @@ -201,54 +244,164 @@ public class TbHttpClient { if (this.eventLoopGroup != null) { this.eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); } + long full = droppedFullCount.get(); + long stale = droppedStaleCount.get(); + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + if (full > 0 || stale > 0) { + log.warn("[{}][{}] REST API call node destroyed with anomalies: " + + "droppedQueueFull={}, droppedStale={}, dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, full, stale, + dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } else { + log.debug("[{}][{}] REST API call node destroyed. dispatched={}, success={}, failure={}, semaphorePermits={}.", + tenantId, nodeId, dispatchedCount.get(), successCount.get(), failureCount.get(), availablePermits); + } } public void processMessage(TbContext ctx, TbMsg msg, Consumer onSuccess, BiConsumer onFailure) { + if (semaphore == null) { + doHttpCall(new PendingTask(ctx, msg, onSuccess, onFailure, 0L)); + return; + } + if (!pendingQueue.offer(new PendingTask(ctx, msg, onSuccess, onFailure, System.nanoTime()))) { + droppedFullCount.incrementAndGet(); + log.debug("[{}][{}] REST API call queue full, dropping msg {}.", tenantId, nodeId, msg.getId()); + maybeReportAnomalies(); + onFailure.accept(msg, new RuntimeException("Max pending requests limit exceeded!")); + return; + } + tryProcess(); + } + + /** + * Tries to acquire one concurrency slot and fire the next queued task. + * Stale messages (whose message pack has expired) are silently dropped. + * Safe to call from any thread under high concurrency. + */ + private void tryProcess() { + while (true) { + if (!semaphore.tryAcquire()) { + return; // all slots are in use; a callback will call tryProcess() when one frees up + } + PendingTask next = pendingQueue.poll(); + if (next == null) { + semaphore.release(); + return; // queue is empty; slot released + } + if (!next.msg().isValid()) { + semaphore.release(); + droppedStaleCount.incrementAndGet(); + log.debug("[{}][{}] Dropping stale msg {} from REST API call queue (queueDepth={}).", + tenantId, nodeId, next.msg().getId(), pendingQueue.size()); + next.onFailure().accept(next.msg(), new RuntimeException("Message is no longer valid. Dropped from queue.")); + maybeReportAnomalies(); + continue; // slot released — loop to check if there's a valid next item + } + dispatchedCount.incrementAndGet(); + if (doHttpCall(next)) { + return; // async HTTP call started — its callback will call tryProcess() + } + // synchronous failure — semaphore already released in doHttpCall, loop to try next task + } + } + + private void maybeReportAnomalies() { + long now = System.currentTimeMillis(); + if (now - lastAnomalyReportAt < ANOMALY_REPORT_INTERVAL_MS) { + return; + } + lastAnomalyReportAt = now; + int queueSize = pendingQueue != null ? pendingQueue.size() : 0; + int queueRemaining = pendingQueue != null ? pendingQueue.remainingCapacity() : Integer.MAX_VALUE; + int availablePermits = semaphore != null ? semaphore.availablePermits() : -1; + log.warn("[{}][{}] REST API call node anomalies: droppedQueueFull={}, droppedStale={} " + + "(dispatched={}, success={}, failure={}, queueDepth={}, queueRemaining={}, semaphorePermits={}).", + tenantId, nodeId, + droppedFullCount.get(), droppedStaleCount.get(), + dispatchedCount.get(), successCount.get(), failureCount.get(), + queueSize, queueRemaining, availablePermits); + } + + /** + * Initiates an async HTTP call for the given task. + * + * @return {@code true} if the async subscription was started and the semaphore slot is now + * owned by the callback (which will release it and call {@link #tryProcess()}). + * {@code false} if a synchronous exception occurred before the subscription was + * registered; the semaphore slot has already been released and the caller should + * loop rather than recurse to avoid stack overflow when many queued tasks fail + * synchronously (e.g. misconfigured URL pattern). + */ + private boolean doHttpCall(PendingTask task) { + boolean asyncStarted = false; try { - if (semaphore != null && !semaphore.tryAcquire(config.getReadTimeoutMs(), TimeUnit.MILLISECONDS)) { - onFailure.accept(msg, new RuntimeException("Timeout during waiting for reply!")); - return; + String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), task.msg()); + + List> processedQueryParams; + if (config.getQueryParams() != null) { + processedQueryParams = config.getQueryParams().stream() + .map(param -> { + var processedParamName = TbNodeUtils.processPattern(param.key(), task.msg()); + var processedParamValue = TbNodeUtils.processPattern(param.value(), task.msg()); + return new KeyValueEntry<>(processedParamName, processedParamValue); + }) + .toList(); + } else { + processedQueryParams = null; } - String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); - URI uri = buildEncodedUri(endpointUrl); + URI uri = buildEncodedUri(endpointUrl, processedQueryParams); - RequestBodySpec request = webClient + RequestBodySpec req = webClient .method(method) .uri(uri) - .headers(headers -> prepareHeaders(headers, msg)); + .headers(headers -> prepareHeaders(headers, task.msg())); if ((HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || HttpMethod.PATCH.equals(method) || HttpMethod.DELETE.equals(method)) && !config.isIgnoreRequestBody()) { - request.body(BodyInserters.fromValue(getData(msg, config.isParseToPlainText()))); + req.body(BodyInserters.fromValue(getRequestBody(task.msg()))); } - request - .retrieve() + req.retrieve() .toEntity(String.class) - .subscribe(responseEntity -> { + .publishOn(Schedulers.fromExecutor(task.ctx().getExternalCallExecutor())) + .doFinally(signalType -> { + // Runs exactly once after onComplete, onError, or cancel — the only + // place that releases the permit for the async path. if (semaphore != null) { semaphore.release(); + tryProcess(); } - + }) + .subscribe(responseEntity -> { if (responseEntity.getStatusCode().is2xxSuccessful()) { - onSuccess.accept(processResponse(ctx, msg, responseEntity)); + successCount.incrementAndGet(); + task.onSuccess().accept(processResponse(task.ctx(), task.msg(), responseEntity)); } else { - onFailure.accept(processFailureResponse(msg, responseEntity), null); + failureCount.incrementAndGet(); + task.onFailure().accept(processFailureResponse(task.msg(), responseEntity), null); } }, throwable -> { - if (semaphore != null) { - semaphore.release(); - } - - onFailure.accept(processException(msg, throwable), processThrowable(throwable)); + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), throwable), processThrowable(throwable)); }); - } catch (InterruptedException e) { - log.warn("Timeout during waiting for reply!", e); + asyncStarted = true; + return true; + } catch (Exception e) { + failureCount.incrementAndGet(); + task.onFailure().accept(processException(task.msg(), e), processThrowable(e)); + return false; + } finally { + // Synchronous permit release: only when the async pipeline was never registered + // (asyncStarted=false). If it was, doFinally owns the release. + // tryProcess() is intentionally not called here — the caller loops iteratively. + if (!asyncStarted && semaphore != null) { + semaphore.release(); + } } } @@ -256,13 +409,13 @@ public class TbHttpClient { if (origin instanceof WebClientResponseException restClientResponseException && restClientResponseException.getStatusCode().is2xxSuccessful()) { // return cause instead of original exception in case 2xx status code - // this will provide meaningful error message to the user + // this will provide a meaningful error message to the user return new RuntimeException(restClientResponseException.getCause()); } return origin; } - public URI buildEncodedUri(String endpointUrl) { + public URI buildEncodedUri(String endpointUrl, List> queryParams) { if (endpointUrl == null) { throw new RuntimeException("Url string cannot be null!"); } @@ -270,7 +423,13 @@ public class TbHttpClient { throw new RuntimeException("Url string cannot be empty!"); } - URI uri = UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); + URI uri; + if (queryParams != null) { + uri = buildEncodedUriNew(endpointUrl, queryParams); + } else { + uri = buildEncodedUriLegacy(endpointUrl); + } + if (uri.getScheme() == null || uri.getScheme().isEmpty()) { throw new RuntimeException("Transport scheme(protocol) must be provided!"); } @@ -281,9 +440,56 @@ public class TbHttpClient { throw new RuntimeException("Url string is invalid!"); } + SsrfProtectionValidator.validateUri(uri); + return uri; } + private URI buildEncodedUriNew(String endpointUrl, List> queryParams) { + try { + URIBuilder builder = new URIBuilder(endpointUrl); + queryParams.forEach(param -> builder.addParameter(param.key(), param.value())); + return builder.build(); + } catch (URISyntaxException e) { + throw new IllegalArgumentException( + "Invalid request URL: '" + endpointUrl + "'. The URL must be valid and properly encoded. " + + "If URL contains special characters (e.g., spaces), they must be percent-encoded (e.g., '/my device/' should be '/my%20device/').", e); + } + } + + /** + * @deprecated Query params embedded in the URL string are not encoded correctly. + *

+ * In query strings, {@code +} is interpreted as a space (URL form encoding), + * so {@code email=user+tag@test.com} becomes {@code email=user tag@test.com} on the server. + *

+ * Pre-encoding the URL (e.g., {@code email=user%2Btag@test.com}) doesn't help + * because {@code .encode()} will double-encode it to {@code email=user%252Btag@test.com}. + *

+ * Use {@link #buildEncodedUriNew} with a separate query params list, + * where values are encoded exactly once: {@code +} → {@code %2B}, {@code @} → {@code %40}. + */ + @Deprecated(forRemoval = true) + private URI buildEncodedUriLegacy(String endpointUrl) { + return UriComponentsBuilder.fromUriString(endpointUrl).build().encode().toUri(); + } + + private Object getRequestBody(TbMsg msg) { + if (StringUtils.isNotBlank(config.getRequestBodyTemplate())) { + boolean escapeJson = !config.isParseToPlainText(); + String processedTemplate = TbNodeUtils.processPattern(config.getRequestBodyTemplate(), msg, escapeJson); + if (config.isParseToPlainText()) { + return processedTemplate; + } + try { + return JacksonUtil.toJsonNode(processedTemplate); + } catch (Exception e) { + throw new RuntimeException("Request body template produced invalid JSON: " + processedTemplate, e); + } + } + return getData(msg, config.isParseToPlainText()); + } + private Object getData(TbMsg tbMsg, boolean parseToPlainText) { String data = tbMsg.getData(); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); @@ -307,7 +513,7 @@ public class TbHttpClient { headers.forEach((key, values) -> { if (values != null && !values.isEmpty()) { if (values.size() == 1) { - consumer.accept(key, values.get(0)); + consumer.accept(key, values.getFirst()); } else { consumer.accept(key, JacksonUtil.toString(values)); } diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java index 0217edf5e0..4120dd7ad8 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNode.java @@ -17,6 +17,7 @@ package org.thingsboard.rule.engine.rest; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.node.ObjectNode; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.RuleNode; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -26,17 +27,24 @@ import org.thingsboard.rule.engine.external.TbAbstractExternalNode; import org.thingsboard.server.common.data.plugin.ComponentType; import org.thingsboard.server.common.data.util.TbPair; import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.exception.DataValidationException; +import java.util.Iterator; import java.util.List; +import java.util.Set; + +import static org.thingsboard.server.dao.service.ConstraintValidator.validateFields; @RuleNode( type = ComponentType.EXTERNAL, name = "rest api call", configClazz = TbRestApiCallNodeConfiguration.class, - version = 3, + version = 4, nodeDescription = "Invoke REST API calls to external REST server", nodeDetails = "Will invoke REST API call GET | POST | PUT | DELETE to external REST server. " + - "Message payload added into Request body. Configured attributes can be added into Headers from Message Metadata." + + "Message payload is used as the request body by default. " + + "Optionally, a request body template can be configured with ${metadataKey} and $[messageKey] placeholders. " + + "Configured attributes can be added into Headers from Message Metadata." + " Outbound message will contain response fields " + "(status, statusCode, statusReason and response headers) in the Message Metadata." + " Response body saved in outbound Message payload. " + @@ -51,14 +59,28 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { static final String PARSE_TO_PLAIN_TEXT = "parseToPlainText"; static final String MAX_IN_MEMORY_BUFFER_SIZE_IN_KB = "maxInMemoryBufferSizeInKb"; + static final String REQUEST_BODY_TEMPLATE = "requestBodyTemplate"; static final String TRIM_DOUBLE_QUOTES = "trimDoubleQuotes"; protected TbHttpClient httpClient; @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); + TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); - httpClient = new TbHttpClient(config, ctx.getSharedEventLoop()); + + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + + TbHttpClientSettings httpClientSettings = ctx.getTbHttpClientSettings(); + httpClient = new TbHttpClient(config, ctx.getSharedEventLoop(), + ctx.getTenantId() != null ? ctx.getTenantId().getId().toString() : "n/a", + ctx.getSelfId() != null ? ctx.getSelfId().getId().toString() : "n/a", + httpClientSettings != null ? httpClientSettings : TbHttpClientSettings.DEFAULT); } @Override @@ -96,6 +118,25 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { hasChanges = true; ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } + case 3: + if (!oldConfiguration.has(REQUEST_BODY_TEMPLATE)) { + hasChanges = true; + ((ObjectNode) oldConfiguration).putNull(REQUEST_BODY_TEMPLATE); + } + Set knownProperties = Set.of( + "restEndpointUrlPattern", "requestMethod", "headers", + "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText", + "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort", + "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody", + "requestBodyTemplate", "maxInMemoryBufferSizeInKb" + ); + Iterator fieldNames = oldConfiguration.fieldNames(); + while (fieldNames.hasNext()) { + if (!knownProperties.contains(fieldNames.next())) { + hasChanges = true; + fieldNames.remove(); + } + } break; default: break; diff --git a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java index 8413c49c7d..0994ec19e3 100644 --- a/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java +++ b/rule-engine/rule-engine-components/src/main/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeConfiguration.java @@ -15,25 +15,31 @@ */ package org.thingsboard.rule.engine.rest; -import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; import lombok.Data; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.thingsboard.rule.engine.api.NodeConfiguration; import org.thingsboard.rule.engine.credentials.AnonymousCredentials; import org.thingsboard.rule.engine.credentials.ClientCredentials; +import org.thingsboard.server.common.data.util.KeyValueEntry; import java.util.Collections; +import java.util.List; import java.util.Map; +import java.util.Objects; -@JsonIgnoreProperties(ignoreUnknown = true) @Data public class TbRestApiCallNodeConfiguration implements NodeConfiguration { private String restEndpointUrlPattern; private String requestMethod; + // null for legacy configs - triggers different URL encoding logic in TbHttpClient to preserve backward compatibility + // all new/modified configs since introduction of this property are forced to have non-null value by UI (and use new encoding logic) + private List<@NotNull KeyValueEntry> queryParams; private Map headers; - private boolean useSimpleClientHttpFactory; private int readTimeoutMs; private int maxParallelRequestsCount; private boolean parseToPlainText; @@ -43,18 +49,32 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration queryParam : queryParams) { + if (queryParam == null || queryParam.key() == null || queryParam.value() == null) { + return false; + } + } + return true; + } + @Override public TbRestApiCallNodeConfiguration defaultConfiguration() { - TbRestApiCallNodeConfiguration configuration = new TbRestApiCallNodeConfiguration(); + var configuration = new TbRestApiCallNodeConfiguration(); configuration.setRestEndpointUrlPattern("http://localhost/api"); configuration.setRequestMethod("POST"); configuration.setHeaders(Collections.singletonMap(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)); - configuration.setUseSimpleClientHttpFactory(false); + configuration.setQueryParams(Collections.emptyList()); configuration.setReadTimeoutMs(0); configuration.setMaxParallelRequestsCount(0); configuration.setParseToPlainText(false); @@ -66,10 +86,7 @@ public class TbRestApiCallNodeConfiguration implements NodeConfiguration givenUnsupportedOriginatorType_whenOnMsg_thenVerifyExceptionThrown() { return unsupportedEntityTypes.stream().flatMap(type -> Stream.of(Arguments.of(type))); diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java index 6acf820fc1..e8aad50463 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/ai/TbAiNodeTest.java @@ -39,7 +39,7 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbJsonSchemaResponseFormat; import org.thingsboard.rule.engine.ai.TbResponseFormat.TbTextResponseFormat; @@ -164,7 +164,7 @@ class TbAiNodeTest { lenient().when(ctxMock.getTenantId()).thenReturn(tenantId); lenient().when(ctxMock.getAiModelService()).thenReturn(aiModelServiceMock); lenient().when(ctxMock.getAiChatModelService()).thenReturn(aiChatModelServiceMock); - lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(new TestDbCallbackExecutor()); + lenient().when(ctxMock.getDbCallbackExecutor()).thenReturn(DirectListeningExecutor.INSTANCE); lenient().when(ctxMock.getTbResourceDataCache()).thenReturn(tbResourceDataCacheMock); lenient().when(ctxMock.getResourceService()).thenReturn(resourceServiceMock); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java index 850e2b4bdf..847a7f077c 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sns/TbSnsNodeTest.java @@ -32,7 +32,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.server.common.data.id.DeviceId; @@ -59,7 +59,7 @@ import static org.mockito.BDDMockito.verifyNoMoreInteractions; class TbSnsNodeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("fccfdf2e-6a88-4a94-81dd-5cbb557019cf")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private TbSnsNode node; private TbSnsNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java index a029a0bfbc..9f6649cb0b 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/aws/sqs/TbSqsNodeTest.java @@ -33,7 +33,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.util.TbNodeUtils; import org.thingsboard.rule.engine.aws.sqs.TbSqsNodeConfiguration.QueueType; @@ -63,7 +63,7 @@ import static org.mockito.BDDMockito.verifyNoMoreInteractions; class TbSqsNodeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("764de824-929f-4114-95ea-0ea0401ffa3d")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private final String messageId = "msgId-1d186a16-80c7-44b3-a245-a1fc835f20c7"; private final String requestId = "reqId-bef0799b-dde9-4aa0-855b-86bbafaeaf31"; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java new file mode 100644 index 0000000000..7e3220cdfb --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/delay/TbMsgDelayNodeTest.java @@ -0,0 +1,106 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.delay; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.JacksonUtil; +import org.thingsboard.rule.engine.api.TbContext; +import org.thingsboard.rule.engine.api.TbNodeConfiguration; +import org.thingsboard.rule.engine.api.TbNodeException; +import org.thingsboard.server.common.data.id.DeviceId; +import org.thingsboard.server.common.data.id.RuleNodeId; +import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.msg.TbMsg; +import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.TbMsgProcessingCtx; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.lenient; + +@ExtendWith(MockitoExtension.class) +class TbMsgDelayNodeTest { + + final DeviceId deviceId = new DeviceId(UUID.fromString("5770153d-6ca2-4447-8a54-5d8a4538e052")); + final RuleNodeId ruleNodeId = new RuleNodeId(UUID.fromString("ee682a85-7f5a-4182-91bc-46e555138fe2")); + + TbMsgDelayNode node; + + @Mock + TbContext ctxMock; + + @BeforeEach + void setUp() throws TbNodeException { + node = new TbMsgDelayNode(); + var config = new TbMsgDelayNodeConfiguration().defaultConfiguration(); + node.init(ctxMock, new TbNodeConfiguration(JacksonUtil.valueToTree(config))); + + lenient().when(ctxMock.getSelfId()).thenReturn(ruleNodeId); + } + + @Test + void shouldPreserveRuleNodeCounterAndResetCallbackWhenEnqueuingDelayedMsg() { + // GIVEN + int ruleNodeExecCounter = 5; + var originalMsg = TbMsg.newMsg() + .id(UUID.randomUUID()) + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(deviceId) + .metaData(TbMsgMetaData.EMPTY) + .data("{\"temperature\":42}") + .ctx(new TbMsgProcessingCtx(ruleNodeExecCounter)) + .build(); + + String originalMsgId = originalMsg.getId().toString(); + var tickMsg = TbMsg.newMsg() + .type(TbMsgType.DELAY_TIMEOUT_SELF_MSG) + .originator(ruleNodeId) + .metaData(TbMsgMetaData.EMPTY) + .data(originalMsgId) + .build(); + given(ctxMock.newMsg(null, TbMsgType.DELAY_TIMEOUT_SELF_MSG, ruleNodeId, null, TbMsgMetaData.EMPTY, originalMsgId)).willReturn(tickMsg); + + node.onMsg(ctxMock, originalMsg); + + // WHEN + node.onMsg(ctxMock, tickMsg); + + // THEN + var msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + then(ctxMock).should().enqueueForTellNext(msgCaptor.capture(), eq(TbNodeConnectionType.SUCCESS)); + + var enqueuedMsg = msgCaptor.getValue(); + assertThat(enqueuedMsg).usingRecursiveComparison() + .ignoringFields("id", "ts", "callback") + .isEqualTo(originalMsg); + + assertThat(enqueuedMsg.getId()).isNotNull().isNotEqualTo(originalMsg.getId()); + assertThat(enqueuedMsg.getAndIncrementRuleNodeCounter()).isEqualTo(ruleNodeExecCounter); + assertThat(enqueuedMsg.getCallback()).isSameAs(TbMsgCallback.EMPTY); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNodeTest.java index ac94fcef29..d8a302ae92 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckAlarmStatusNodeTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -53,7 +53,7 @@ class TbCheckAlarmStatusNodeTest { private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); private static final DeviceId DEVICE_ID = new DeviceId(UUID.randomUUID()); private static final AlarmId ALARM_ID = new AlarmId(UUID.randomUUID()); - private static final TestDbCallbackExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final DirectListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; private TbCheckAlarmStatusNode node; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java index a38cfa2a4e..e6011c74d8 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/filter/TbCheckRelationNodeTest.java @@ -24,7 +24,7 @@ import org.junit.jupiter.params.provider.Arguments; import org.mockito.ArgumentCaptor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -65,7 +65,7 @@ class TbCheckRelationNodeTest extends AbstractRuleNodeUpgradeTest { private final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); private final DeviceId ORIGINATOR_ID = new DeviceId(UUID.randomUUID()); - private final TestDbCallbackExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private final DirectListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; private final TbMsg EMPTY_POST_ATTRIBUTES_MSG = TbMsg.newMsg() .type(TbMsgType.POST_ATTRIBUTES_REQUEST) .originator(ORIGINATOR_ID) diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java index 95028e7368..3ae26e6560 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/flow/TbRuleChainInputNodeTest.java @@ -41,8 +41,10 @@ import org.thingsboard.server.common.data.id.AssetId; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; +import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.rule.RuleNode; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; @@ -52,8 +54,9 @@ import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.any; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -64,6 +67,8 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { private final TenantId TENANT_ID = new TenantId(UUID.fromString("4ba69ea5-6b27-42df-ab66-e7a727a67027")); private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("97731954-2147-4176-8f1a-d14f1b73e4e6")); private final AssetId ASSET_ID = new AssetId(UUID.fromString("841a47bd-4e8e-4ea5-88e6-420da0d70e51")); + // A stable "current" rule chain ID: this node lives here. Must differ from any target used in tests. + private final RuleChainId CURRENT_RULE_CHAIN_ID = new RuleChainId(UUID.fromString("11111111-1111-1111-1111-111111111111")); private TbRuleChainInputNode node; private TbRuleChainInputNodeConfiguration config; @@ -149,6 +154,7 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { when(ctxMock.getTenantId()).thenReturn(TENANT_ID); when(ctxMock.getDeviceProfileCache()).thenReturn(deviceProfileCacheMock); when(deviceProfileCacheMock.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); node.init(ctxMock, nodeConfiguration); @@ -182,6 +188,7 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { when(ctxMock.getTenantId()).thenReturn(TENANT_ID); when(ctxMock.getAssetProfileCache()).thenReturn(assetProfileCacheMock); when(assetProfileCacheMock.get(any(TenantId.class), any(AssetId.class))).thenReturn(assetProfile); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); node.init(ctxMock, nodeConfiguration); @@ -214,6 +221,7 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { when(ctxMock.getTenantId()).thenReturn(TENANT_ID); when(ctxMock.getDeviceProfileCache()).thenReturn(deviceProfileCacheMock); when(deviceProfileCacheMock.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); node.init(ctxMock, nodeConfiguration); @@ -242,6 +250,7 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { when(ctxMock.getTenantId()).thenReturn(TENANT_ID); when(ctxMock.getAssetProfileCache()).thenReturn(assetProfileCacheMock); when(assetProfileCacheMock.get(any(TenantId.class), any(AssetId.class))).thenReturn(assetProfile); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); node.init(ctxMock, nodeConfiguration); @@ -266,6 +275,8 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { config.setForwardMsgToDefaultRuleChain(false); nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); + node.init(ctxMock, nodeConfiguration); //WHEN @@ -277,6 +288,56 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { assertThat(ruleChainArgumentCaptor.getValue()).isEqualTo(ruleChainIdFromConfig); } + @Test + public void givenForwardMsgToDefaultIsTrueAndDeviceDefaultRuleChainIsSameAsCurrent_whenOnMsg_thenTellFailureToPreventDirectLoop() throws TbNodeException { + // GIVEN - device's default rule chain == the rule chain that contains this node (self-loop) + // This is caught immediately, before any Kafka round-trip + DeviceProfile deviceProfile = new DeviceProfile(); + deviceProfile.setDefaultRuleChainId(CURRENT_RULE_CHAIN_ID); + + TbMsg msg = getMsg(DEVICE_ID); + + String fallbackRuleChainIdStr = "acbc924f-7f95-4a9b-a854-e4822deb74c7"; + config.setRuleChainId(fallbackRuleChainIdStr); + config.setForwardMsgToDefaultRuleChain(true); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + when(ctxMock.getTenantId()).thenReturn(TENANT_ID); + when(ctxMock.getDeviceProfileCache()).thenReturn(deviceProfileCacheMock); + when(deviceProfileCacheMock.get(any(TenantId.class), any(DeviceId.class))).thenReturn(deviceProfile); + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); + + node.init(ctxMock, nodeConfiguration); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN - tellFailure immediately; ctx.input() must never be called + verify(ctxMock).tellFailure(eq(msg), any(RuntimeException.class)); + verify(ctxMock, never()).input(any(), any()); + } + + @Test + public void givenConfiguredRuleChainIsSameAsCurrent_whenOnMsg_thenTellFailureToPreventDirectLoop() throws TbNodeException { + // GIVEN - static config points to the same rule chain that contains this node + config.setRuleChainId(CURRENT_RULE_CHAIN_ID.getId().toString()); + config.setForwardMsgToDefaultRuleChain(false); + nodeConfiguration = new TbNodeConfiguration(JacksonUtil.valueToTree(config)); + + TbMsg msg = getMsg(DEVICE_ID); + + when(ctxMock.getSelf()).thenReturn(ruleNodeInCurrentChain()); + + node.init(ctxMock, nodeConfiguration); + + // WHEN + node.onMsg(ctxMock, msg); + + // THEN - tellFailure immediately; ctx.input() must never be called + verify(ctxMock).tellFailure(eq(msg), any(RuntimeException.class)); + verify(ctxMock, never()).input(any(), any()); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( //config for version 0 @@ -299,6 +360,13 @@ public class TbRuleChainInputNodeTest extends AbstractRuleNodeUpgradeTest { return node; } + /** Creates a RuleNode that lives in {@link #CURRENT_RULE_CHAIN_ID}. */ + private RuleNode ruleNodeInCurrentChain() { + RuleNode ruleNode = new RuleNode(new RuleNodeId(UUID.fromString("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa"))); + ruleNode.setRuleChainId(CURRENT_RULE_CHAIN_ID); + return ruleNode; + } + private TbMsg getMsg(EntityId entityId) { return TbMsg.newMsg() .type(TbMsgType.POST_TELEMETRY_REQUEST) diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNodeTest.java index 70203437a8..7ce1c9977c 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/gcp/pubsub/TbPubSubNodeTest.java @@ -32,7 +32,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -65,7 +65,7 @@ import static org.mockito.BDDMockito.willThrow; class TbPubSubNodeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("d29849c2-3f21-48e2-8557-74cdd6403290")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private TbPubSubNode node; private TbPubSubNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java index 63a7d8297a..5df7e1be7b 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/kafka/TbKafkaNodeTest.java @@ -40,7 +40,7 @@ import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -79,7 +79,7 @@ public class TbKafkaNodeTest extends AbstractRuleNodeUpgradeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("5f2eac08-bd1f-4635-a6c2-437369f996cf")); private final RuleNodeId RULE_NODE_ID = new RuleNodeId(UUID.fromString("d46bb666-ecab-4d89-a28f-5abdca23ac29")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private final long OFFSET = 1; private final int PARTITION = 0; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java index 928af5570f..f32662dfbc 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/CalculateDeltaNodeTest.java @@ -30,10 +30,10 @@ import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.AbstractListeningExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -91,7 +91,7 @@ public class CalculateDeltaNodeTest extends AbstractRuleNodeUpgradeTest { private final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.fromString("2ba3ded4-882b-40cf-999a-89da9ccd58f9")); private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("3842e740-0d89-43a9-8d52-ae44023847ba")); - private final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; private static final int RULE_DISPATCHER_POOL_SIZE = 2; private static final int DB_CALLBACK_POOL_SIZE = 3; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java index 18a0a009d2..a23bfd360f 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerAttributeNodeTest.java @@ -28,7 +28,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -85,7 +85,7 @@ public class TbGetCustomerAttributeNodeTest { private final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private final TenantId TENANT_ID = TenantId.fromUUID(UUID.randomUUID()); private final CustomerId CUSTOMER_ID = new CustomerId(UUID.randomUUID()); - private final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNodeTest.java index dad2a2ef47..5e9726ec97 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetCustomerDetailsNodeTest.java @@ -26,7 +26,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -77,7 +77,7 @@ public class TbGetCustomerDetailsNodeTest { private static final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; @Mock diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNodeTest.java index 6fd15d45e7..4f267c0dfb 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetDeviceAttrNodeTest.java @@ -26,7 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -62,7 +62,7 @@ public class TbGetDeviceAttrNodeTest extends AbstractRuleNodeUpgradeTest { private final TenantId TENANT_ID = new TenantId(UUID.fromString("5aea576c-66c4-4732-86b8-dc6bfcde7443")); private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("40b6b393-6ddf-47f9-973a-18550ca70384")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private TbGetDeviceAttrNode node; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNodeTest.java index 6c2d37395c..fff7437fdf 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetOriginatorFieldsNodeTest.java @@ -25,7 +25,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -60,7 +60,7 @@ public class TbGetOriginatorFieldsNodeTest { private static final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId DUMMY_TENANT_ID = new TenantId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; @Mock diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNodeTest.java index 14ced06c1b..a31d23db55 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetRelatedAttributeNodeTest.java @@ -28,7 +28,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -92,7 +92,7 @@ public class TbGetRelatedAttributeNodeTest { private static final EntityId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; @Mock diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java index a92978e929..ce3a1b5b22 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTelemetryNodeTest.java @@ -29,7 +29,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -72,7 +72,7 @@ public class TbGetTelemetryNodeTest extends AbstractRuleNodeUpgradeTest { private final TenantId TENANT_ID = TenantId.fromUUID(UUID.fromString("5738401b-9dba-422b-b656-a62fe7431917")); private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("8a8fd749-b2ec-488b-a6c6-fc66614d8686")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private TbGetTelemetryNode node; private TbGetTelemetryNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNodeTest.java index 320339ead4..88db3dc0cd 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/metadata/TbGetTenantAttributeNodeTest.java @@ -27,7 +27,7 @@ import org.mockito.ArgumentMatcher; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -71,7 +71,7 @@ public class TbGetTenantAttributeNodeTest { private static final DeviceId DUMMY_DEVICE_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final TestDbCallbackExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final DirectListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; @Mock diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNodeTest.java index db5e430a73..b8436f3304 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rabbitmq/TbRabbitMqNodeTest.java @@ -36,7 +36,7 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.test.util.ReflectionTestUtils; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; import org.thingsboard.rule.engine.api.TbNodeException; @@ -78,7 +78,7 @@ public class TbRabbitMqNodeTest { ); private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("b3d6f9dd-15cc-4e61-acc0-13197a090406")); - private final ListeningExecutor executor = new TestDbCallbackExecutor(); + private final ListeningExecutor executor = DirectListeningExecutor.INSTANCE; private TbRabbitMqNode node; private TbRabbitMqNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java new file mode 100644 index 0000000000..fc3b825d36 --- /dev/null +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/SsrfSafeAddressResolverGroupTest.java @@ -0,0 +1,161 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.thingsboard.rule.engine.rest; + +import io.netty.channel.nio.NioEventLoopGroup; +import io.netty.resolver.AddressResolver; +import io.netty.util.concurrent.EventExecutor; +import io.netty.util.concurrent.Promise; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.thingsboard.common.util.SsrfProtectionValidator; + +import java.net.InetAddress; +import java.net.InetSocketAddress; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration +class SsrfSafeAddressResolverGroupTest { + + private static NioEventLoopGroup eventLoopGroup; + + @BeforeAll + static void setUp() { + eventLoopGroup = new NioEventLoopGroup(1); + } + + @AfterAll + static void tearDown() { + eventLoopGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); + SsrfProtectionValidator.setEnabled(false); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + + @BeforeEach + void enableSsrf() { + SsrfProtectionValidator.setEnabled(true); + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + } + + @AfterEach + void resetState() { + SsrfProtectionValidator.setAllowedHosts(Collections.emptyList()); + SsrfProtectionValidator.setEnabled(false); + } + + @Test + void isBlockedAddressWorksForLoopback() throws Exception { + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("127.0.0.1"))).isTrue(); + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("192.168.1.1"))).isTrue(); + assertThat(SsrfProtectionValidator.isBlockedAddress(InetAddress.getByName("8.8.8.8"))).isFalse(); + } + + @Test + void resolvePublicIpSucceeds() throws Exception { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise)); + InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); + + assertThat(result.getAddress()).isNotNull(); + assertThat(result.getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void resolveLoopbackFailsWhenSsrfEnabled() throws Exception { + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); + + assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + + @Test + void resolvePrivateIpFailsWhenSsrfEnabled() throws Exception { + assertThat(SsrfProtectionValidator.isEnabled()).isTrue(); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); + + assertThatThrownBy(() -> promise.get(10, TimeUnit.SECONDS)) + .isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + + @Test + void resolveAllowedPrivateIpSucceeds() throws Exception { + SsrfProtectionValidator.setAllowedHosts(List.of("192.168.1.0/24")); + + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise promise = executor.newPromise(); + + executor.submit(() -> resolver.resolve(InetSocketAddress.createUnresolved("192.168.1.1", 80), promise)); + InetSocketAddress result = promise.get(10, TimeUnit.SECONDS); + + assertThat(result.getAddress().getHostAddress()).isEqualTo("192.168.1.1"); + } + + @Test + void resolveAllPublicIpSucceeds() throws Exception { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise> promise = executor.newPromise(); + + executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("8.8.8.8", 80), promise)); + List results = promise.get(10, TimeUnit.SECONDS); + + assertThat(results).isNotEmpty(); + assertThat(results.get(0).getAddress().getHostAddress()).isEqualTo("8.8.8.8"); + } + + @Test + void resolveAllPrivateIpFailsWhenSsrfEnabled() { + assertThatThrownBy(() -> { + EventExecutor executor = eventLoopGroup.next(); + AddressResolver resolver = SsrfSafeAddressResolverGroup.INSTANCE.getResolver(executor); + Promise> promise = executor.newPromise(); + executor.submit(() -> resolver.resolveAll(InetSocketAddress.createUnresolved("127.0.0.1", 80), promise)); + promise.get(10, TimeUnit.SECONDS); + }).isInstanceOf(ExecutionException.class) + .hasRootCauseInstanceOf(RuntimeException.class) + .rootCause().hasMessageContaining("is not allowed"); + } + +} diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java index 6363e1032a..e8cbdb52b6 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbHttpClientTest.java @@ -15,45 +15,56 @@ */ package org.thingsboard.rule.engine.rest; - import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Named; import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.mockito.Mockito; -import org.mockserver.integration.ClientAndServer; import org.springframework.util.LinkedMultiValueMap; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.server.common.data.id.DeviceId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; import java.net.URI; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.hamcrest.MatcherAssert.assertThat; import static org.hamcrest.Matchers.instanceOf; import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.willCallRealMethod; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.mockserver.integration.ClientAndServer.startClientAndServer; import static org.mockserver.model.HttpRequest.request; import static org.mockserver.model.HttpResponse.response; +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration public class TbHttpClientTest { EventLoopGroup eventLoop; @@ -86,123 +97,462 @@ public class TbHttpClientTest { @Test public void testBuildSimpleUri() { - Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); + Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); String url = "http://localhost:8080/"; - URI uri = client.buildEncodedUri(url); + URI uri = client.buildEncodedUri(url, null); Assertions.assertEquals(url, uri.toString()); } @Test public void testBuildUriWithoutProtocol() { - Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); + Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); String url = "localhost:8080/"; - assertThatThrownBy(() -> client.buildEncodedUri(url)); + assertThatThrownBy(() -> client.buildEncodedUri(url, null)); } @Test public void testBuildInvalidUri() { - Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); + Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); String url = "aaa"; - assertThatThrownBy(() -> client.buildEncodedUri(url)); + assertThatThrownBy(() -> client.buildEncodedUri(url, null)); } @Test public void testBuildUriWithSpecialSymbols() { - Mockito.when(client.buildEncodedUri(any())).thenCallRealMethod(); + Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); String url = "http://192.168.1.1/data?d={\"a\": 12}"; String expected = "http://192.168.1.1/data?d=%7B%22a%22:%2012%7D"; - URI uri = client.buildEncodedUri(url); + URI uri = client.buildEncodedUri(url, null); Assertions.assertEquals(expected, uri.toString()); } + @Test + public void testProcessMessageWithQueryParamsPatternProcessing() throws Exception { + // GIVEN + String path = "/api/notify"; + try (var server = startClientAndServer("localhost", 0)) { + server.when( + request() + .withMethod("GET") + .withPath(path) + .withQueryStringParameter("email", "user+tag@test.com") + .withQueryStringParameter("device", "sensor-1") + .withQueryStringParameter("custom-header", "header-value") + .withQueryStringParameter("temp", "25.5") + .withQueryStringParameter("location", "room-1/zone-A") + ).respond( + response().withStatusCode(200) + ); + + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path); + config.setRequestMethod("GET"); + config.setQueryParams(List.of( + new KeyValueEntry<>("email", "${userEmail}"), // ${} from metadata + new KeyValueEntry<>("device", "${deviceName}"), // ${} from metadata + new KeyValueEntry<>("${dynamicParam}", "${dynamicValue}"), // ${} in both key and value + new KeyValueEntry<>("temp", "$[temperature]"), // $[] from data + new KeyValueEntry<>("location", "$[sensor.location]") // $[] from nested data + )); + + var metaData = new TbMsgMetaData(); + metaData.putValue("userEmail", "user+tag@test.com"); + metaData.putValue("deviceName", "sensor-1"); + metaData.putValue("dynamicParam", "custom-header"); + metaData.putValue("dynamicValue", "header-value"); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(new DeviceId(EntityId.NULL_UUID)) + .metaData(metaData) + .data(""" + {"temperature": 25.5, "sensor": {"location": "room-1/zone-A"}} + """) + .build(); + + // WHEN-THEN + processMessageAndWait(config, msg); + } + } + @Test public void testProcessMessageWithJsonInUrlVariable() throws Exception { - String host = "localhost"; + // GIVEN String path = "/api"; - String paramKey = "data"; - String paramVal = "[{\"test\":\"test\"}]"; - String successResponseBody = "SUCCESS"; - - var server = setUpDummyServer(host, path, paramKey, paramVal, successResponseBody); - - String endpointUrl = String.format( - "http://%s:%d%s?%s=%s", - host, server.getPort(), path, paramKey, paramVal - ); - String method = "GET"; - - - var config = new TbRestApiCallNodeConfiguration() - .defaultConfiguration(); - config.setRequestMethod(method); - config.setRestEndpointUrlPattern(endpointUrl); - config.setUseSimpleClientHttpFactory(true); + String paramValue = "[{\"test\":\"test\"}]"; + + try (var server = startClientAndServer("localhost", 0)) { + server.when( + request() + .withMethod("GET") + .withPath(path) + .withQueryStringParameter("data", paramValue) + ).respond( + response().withStatusCode(200) + ); + + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRestEndpointUrlPattern("http://localhost:" + server.getPort() + path + "?data=" + paramValue); + config.setRequestMethod("GET"); + config.setQueryParams(null); + + var msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(new DeviceId(UUID.randomUUID())) + .metaData(new TbMsgMetaData()) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + + // WHEN-THEN + processMessageAndWait(config, msg); + } + } + private void processMessageAndWait(TbRestApiCallNodeConfiguration config, TbMsg msg) throws Exception { var httpClient = new TbHttpClient(config, eventLoop); - - var msg = TbMsg.newMsg() - .type(TbMsgType.POST_TELEMETRY_REQUEST) - .originator(new DeviceId(EntityId.NULL_UUID)) - .copyMetaData(TbMsgMetaData.EMPTY) - .data(TbMsg.EMPTY_JSON_OBJECT) - .build(); - var successMsg = TbMsg.newMsg() - .type(TbMsgType.POST_TELEMETRY_REQUEST) - .originator(msg.getOriginator()) - .copyMetaData(msg.getMetaData()) - .data(msg.getData()) - .build(); - var ctx = mock(TbContext.class); - when(ctx.transformMsg( - eq(msg), - eq(msg.getMetaData()), - eq(msg.getData()) - )).thenReturn(successMsg); - - var capturedData = ArgumentCaptor.forClass(String.class); + when(ctx.getExternalCallExecutor()).thenReturn(DirectListeningExecutor.INSTANCE); + when(ctx.transformMsg(eq(msg), any(), any())).thenReturn(msg); - when(ctx.transformMsg( - eq(msg), - any(), - capturedData.capture() - )).thenReturn(successMsg); - - CountDownLatch latch = new CountDownLatch(1); + var latch = new CountDownLatch(1); + var error = new AtomicReference(); httpClient.processMessage(ctx, msg, m -> { - ctx.tellSuccess(msg); + ctx.tellSuccess(m); latch.countDown(); }, (m, t) -> { ctx.tellFailure(m, t); + error.set(t); latch.countDown(); }); - latch.await(5, TimeUnit.SECONDS); + assertTrue(latch.await(5, TimeUnit.SECONDS), "Request should complete within timeout"); + assertNull(error.get(), "Request should succeed, but got: " + error.get()); - verify(ctx, times(1)).tellSuccess(any()); - verify(ctx, times(0)).tellFailure(any(), any()); - Assertions.assertEquals(successResponseBody, capturedData.getValue()); + verify(ctx).tellSuccess(any()); + verify(ctx, never()).tellFailure(any(), any()); } - private ClientAndServer setUpDummyServer(String host, String path, String paramKey, String paramVal, String successResponseBody) { - var server = startClientAndServer(host, 1080); - createGetMethodExpectations(server, path, paramKey, paramVal, successResponseBody); - return server; + + @ParameterizedTest(name = "{0}") + @MethodSource + public void testQueryParamsEncoding(String endpointUrl, List> queryParams, String expectedEncodedUrl) { + // GIVEN + Mockito.when(client.buildEncodedUri(any(), any())).thenCallRealMethod(); + + // WHEN + URI uri = client.buildEncodedUri(endpointUrl, queryParams); + + // THEN + Assertions.assertEquals(expectedEncodedUrl, uri.toASCIIString()); } - private void createGetMethodExpectations(ClientAndServer server, String path, String paramKey, String paramVal, String successResponseBody) { - server.when( - request() - .withMethod("GET") - .withPath(path) - .withQueryStringParameter(paramKey, paramVal) - ).respond( - response() - .withStatusCode(200) - .withBody(successResponseBody) + private static Stream testQueryParamsEncoding() { + return Stream.of( + Arguments.of( + Named.named("ISO 8601 date-time in value", "http://somecompany/api/data/fetch"), + List.of(new KeyValueEntry<>("ts", "2016-08-01T09:06:06.0+02:00")), + "http://somecompany/api/data/fetch?ts=2016-08-01T09%3A06%3A06.0%2B02%3A00" + ), + Arguments.of( + Named.named("email with plus sign in value", "http://localhost:8080/api/user/sendActivationMail"), + List.of(new KeyValueEntry<>("email", "someperson+test1289@thingsboard.io")), + "http://localhost:8080/api/user/sendActivationMail?email=someperson%2Btest1289%40thingsboard.io" + ), + Arguments.of( + Named.named("plus mixed with spaces in value", "http://url/api"), + List.of(new KeyValueEntry<>("q", "a + b")), + "http://url/api?q=a%20%2B%20b" + ), + Arguments.of( + Named.named("colon in value", "http://url/api"), + List.of(new KeyValueEntry<>("time", "12:00")), + "http://url/api?time=12%3A00" + ), + Arguments.of( + Named.named("slash in value", "http://url"), + List.of(new KeyValueEntry<>("ref", "/home/user")), + "http://url?ref=%2Fhome%2Fuser" + ), + Arguments.of( + Named.named("comma and semicolon in value", "http://url"), + List.of(new KeyValueEntry<>("l", "a,b;c")), + "http://url?l=a%2Cb%3Bc" + ), + Arguments.of( + Named.named("ampersand and equals in value", "http://url"), + List.of(new KeyValueEntry<>("q", "key1=value1&key2=value2")), + "http://url?q=key1%3Dvalue1%26key2%3Dvalue2" + ), + Arguments.of( + Named.named("JSON in value", "http://url"), + List.of(new KeyValueEntry<>("json", """ + { + "string": "hello", + "integer": 42, + "float": 3.14, + "boolTrue": true, + "boolFalse": false, + "null": null, + "array": [ + 1, + "two", + true, + null + ], + "object": { + "nested": "value" + } + }""")), + "http://url?json=%7B%0A%20%20%20%20%22string%22%3A%20%22hello%22%2C%0A%20%20%20%20%22integer%22%3A%2042%2C%0A%20%20%20%20%22float%22%3A%203.14%2C%0A%20%20%20%20%22boolTrue%22%3A%20true%2C%0A%20%20%20%20%22boolFalse%22%3A%20false%2C%0A%20%20%20%20%22null%22%3A%20null%2C%0A%20%20%20%20%22array%22%3A%20%5B%0A%20%20%20%20%20%20%20%201%2C%0A%20%20%20%20%20%20%20%20%22two%22%2C%0A%20%20%20%20%20%20%20%20true%2C%0A%20%20%20%20%20%20%20%20null%0A%20%20%20%20%5D%2C%0A%20%20%20%20%22object%22%3A%20%7B%0A%20%20%20%20%20%20%20%20%22nested%22%3A%20%22value%22%0A%20%20%20%20%7D%0A%7D" + ), + Arguments.of( + Named.named("UTF-8 in query", "http://url/cafes"), + List.of( + new KeyValueEntry<>("nom", "Le Goût Moderne"), + new KeyValueEntry<>("назва", "У Миколи \uD83D\uDE0B") + ), + "http://url/cafes?nom=Le%20Go%C3%BBt%20Moderne&%D0%BD%D0%B0%D0%B7%D0%B2%D0%B0=%D0%A3%20%D0%9C%D0%B8%D0%BA%D0%BE%D0%BB%D0%B8%20%F0%9F%98%8B" + ), + Arguments.of( + Named.named("empty value", "http://url/empty"), + List.of(new KeyValueEntry<>("name", "")), + "http://url/empty?name=" + ), + Arguments.of( + Named.named("blank value (spaces)", "http://url/empty"), + List.of(new KeyValueEntry<>("name", " ")), + "http://url/empty?name=%20%20" + ), + Arguments.of( + Named.named("empty key", "http://url/empty"), + List.of(new KeyValueEntry<>("", "value")), + "http://url/empty?=value" + ), + Arguments.of( + Named.named("blank key (spaces)", "http://url/empty"), + List.of(new KeyValueEntry<>(" ", "value")), + "http://url/empty?%20%20=value" + ), + Arguments.of( + Named.named("blank key with value that needs to be encoded", "http://url/empty"), + List.of(new KeyValueEntry<>(" ", "value1+value2")), + "http://url/empty?%20%20=value1%2Bvalue2" + ), + Arguments.of( + Named.named("blank key and value", "http://url/empty"), + List.of(new KeyValueEntry<>(" ", " ")), + "http://url/empty?%20%20=%20%20" + ), + Arguments.of( + Named.named("fragment with query params", "http://url#frag"), + List.of(new KeyValueEntry<>("docs", "مستندات عقدة القاعدة\n")), + "http://url?docs=%D9%85%D8%B3%D8%AA%D9%86%D8%AF%D8%A7%D8%AA%20%D8%B9%D9%82%D8%AF%D8%A9%20%D8%A7%D9%84%D9%82%D8%A7%D8%B9%D8%AF%D8%A9%0A#frag" + ), + Arguments.of( + Named.named("fragment only", "http://url#frag"), + Collections.emptyList(), + "http://url#frag" + ), + Arguments.of( + Named.named("multiple query params (ordered)", "http://url/api"), + List.of( + new KeyValueEntry<>("param1", "value1"), + new KeyValueEntry<>("param2", "value2"), + new KeyValueEntry<>("param3", "value3") + ), + "http://url/api?param1=value1¶m2=value2¶m3=value3" + ), + Arguments.of( + Named.named("hash (#) in value", "http://url/api"), + List.of(new KeyValueEntry<>("color", "#ff0000")), + "http://url/api?color=%23ff0000" + ), + Arguments.of( + Named.named("question mark (?) in value", "http://url/api"), + List.of(new KeyValueEntry<>("query", "what?")), + "http://url/api?query=what%3F" + ), + Arguments.of( + Named.named("percent sign (%) in value", "http://url/api"), + List.of(new KeyValueEntry<>("discount", "50%")), + "http://url/api?discount=50%25" + ), + Arguments.of( + Named.named("already encoded string - double encoding", "http://url/api"), + List.of(new KeyValueEntry<>("encoded", "%20")), + "http://url/api?encoded=%2520" + ), + Arguments.of( + Named.named("URL with port number", "http://localhost:8080/api/v1/data"), + List.of(new KeyValueEntry<>("key", "value")), + "http://localhost:8080/api/v1/data?key=value" + ), + Arguments.of( + Named.named("URL with userinfo (auth)", "http://user:password@hostname/path"), + List.of(new KeyValueEntry<>("secure", "true")), + "http://user:password@hostname/path?secure=true" + ), + Arguments.of( + Named.named("IPv4 address in URL", "http://192.168.1.100:9090/endpoint"), + List.of(new KeyValueEntry<>("ip", "test")), + "http://192.168.1.100:9090/endpoint?ip=test" + ), + Arguments.of( + Named.named("IPv6 address in URL", "http://[::1]:8080/api"), + List.of(new KeyValueEntry<>("ipv6", "true")), + "http://[::1]:8080/api?ipv6=true" + ), + Arguments.of( + Named.named("HTTPS protocol", "https://secure.example.com/api"), + List.of(new KeyValueEntry<>("token", "abc123")), + "https://secure.example.com/api?token=abc123" + ), + Arguments.of( + Named.named("pipe (|) in value", "http://url/api"), + List.of(new KeyValueEntry<>("filter", "a|b|c")), + "http://url/api?filter=a%7Cb%7Cc" + ), + Arguments.of( + Named.named("caret (^) and backtick (`) in value", "http://url/api"), + List.of(new KeyValueEntry<>("special", "a^b`c")), + "http://url/api?special=a%5Eb%60c" + ), + Arguments.of( + Named.named("tab character in value", "http://url/api"), + List.of(new KeyValueEntry<>("data", "col1\tcol2\tcol3")), + "http://url/api?data=col1%09col2%09col3" + ), + Arguments.of( + Named.named("CRLF in value", "http://url/api"), + List.of(new KeyValueEntry<>("text", "line1\r\nline2")), + "http://url/api?text=line1%0D%0Aline2" + ), + Arguments.of( + Named.named("square brackets in value", "http://url/api"), + List.of(new KeyValueEntry<>("array", "[1,2,3]")), + "http://url/api?array=%5B1%2C2%2C3%5D" + ), + Arguments.of( + Named.named("single and double quotes in value", "http://url/api"), + List.of(new KeyValueEntry<>("quoted", "He said \"Hello\" and 'Hi'")), + "http://url/api?quoted=He%20said%20%22Hello%22%20and%20%27Hi%27" + ), + Arguments.of( + Named.named("angle brackets in value (XSS test)", "http://url/api"), + List.of(new KeyValueEntry<>("html", "")), + "http://url/api?html=%3Cscript%3Ealert%28%27xss%27%29%3C%2Fscript%3E" + ), + Arguments.of( + Named.named("backslash in value", "http://url/api"), + List.of(new KeyValueEntry<>("path", "C:\\Users\\test")), + "http://url/api?path=C%3A%5CUsers%5Ctest" + ), + Arguments.of( + Named.named("at sign (@) in value", "http://url/api"), + List.of(new KeyValueEntry<>("contact", "user@domain.com")), + "http://url/api?contact=user%40domain.com" + ), + Arguments.of( + Named.named("URL as value", "http://url/api"), + List.of(new KeyValueEntry<>("redirect", "https://example.com/path?foo=bar")), + "http://url/api?redirect=https%3A%2F%2Fexample.com%2Fpath%3Ffoo%3Dbar" + ), + Arguments.of( + Named.named("empty list (not null)", "http://url/api"), + Collections.emptyList(), + "http://url/api" + ), + Arguments.of( + Named.named("underscore and hyphen in key/value", "http://url/api"), + List.of(new KeyValueEntry<>("my-key_name", "my-value_data")), + "http://url/api?my-key_name=my-value_data" + ), + Arguments.of( + Named.named("dots in key and value", "http://url/api"), + List.of(new KeyValueEntry<>("version.major", "1.2.3")), + "http://url/api?version.major=1.2.3" + ), + Arguments.of( + Named.named("multiple reserved chars combined", "http://url/api"), + List.of(new KeyValueEntry<>("complex", "a=1&b=2#section?query")), + "http://url/api?complex=a%3D1%26b%3D2%23section%3Fquery" + ), + Arguments.of( + Named.named("trailing slash in URL", "http://url/api/"), + List.of(new KeyValueEntry<>("param", "value")), + "http://url/api/?param=value" + ), + Arguments.of( + Named.named("long value (1000 chars)", "http://url/api"), + List.of(new KeyValueEntry<>("long", "a".repeat(1000))), + "http://url/api?long=" + "a".repeat(1000) + ), + Arguments.of( + Named.named("Chinese characters in value", "http://url/api"), + List.of(new KeyValueEntry<>("greeting", "你好世界")), + "http://url/api?greeting=%E4%BD%A0%E5%A5%BD%E4%B8%96%E7%95%8C" + ), + Arguments.of( + Named.named("Japanese characters in value", "http://url/api"), + List.of(new KeyValueEntry<>("text", "こんにちは")), + "http://url/api?text=%E3%81%93%E3%82%93%E3%81%AB%E3%81%A1%E3%81%AF" + ), + Arguments.of( + Named.named("existing query params in URL", "http://url/api?existing=param"), + List.of(new KeyValueEntry<>("new", "value")), + "http://url/api?existing=param&new=value" + ), + Arguments.of( + Named.named("existing query and fragment in URL", "http://url/api?existing=param#section"), + List.of(new KeyValueEntry<>("additional", "data")), + "http://url/api?existing=param&additional=data#section" + ), + Arguments.of( + Named.named("null byte in value", "http://url/api"), + List.of(new KeyValueEntry<>("data", "before\u0000after")), + "http://url/api?data=before%00after" + ), + Arguments.of( + Named.named("form feed and vertical tab in value", "http://url/api"), + List.of(new KeyValueEntry<>("whitespace", "a\fb\u000Bc")), + "http://url/api?whitespace=a%0Cb%0Bc" + ), + Arguments.of( + Named.named("tilde (unreserved, not encoded)", "http://url/api"), + List.of(new KeyValueEntry<>("pattern", "~user")), + "http://url/api?pattern=~user" + ), + Arguments.of( + Named.named("asterisk in value", "http://url/api"), + List.of(new KeyValueEntry<>("wildcard", "*.txt")), + "http://url/api?wildcard=%2A.txt" + ), + Arguments.of( + Named.named("curly braces in key", "http://url/api"), + List.of(new KeyValueEntry<>("{key}", "value")), + "http://url/api?%7Bkey%7D=value" + ), + Arguments.of( + Named.named("curly braces in value", "http://url/api"), + List.of(new KeyValueEntry<>("data", "{value}")), + "http://url/api?data=%7Bvalue%7D" + ), + Arguments.of( + Named.named("curly braces in both key and value", "http://url/api"), + List.of(new KeyValueEntry<>("{param}", "{data}")), + "http://url/api?%7Bparam%7D=%7Bdata%7D" + ), + Arguments.of( + Named.named("duplicate param names with different values", "http://url/api"), + List.of( + new KeyValueEntry<>("test", "a+b"), + new KeyValueEntry<>("test", "b c") + ), + "http://url/api?test=a%2Bb&test=b%20c" + ) ); } @@ -221,8 +571,8 @@ public class TbHttpClientTest { Map data = metaData.getData(); Assertions.assertEquals(2, data.size()); - Assertions.assertEquals(data.get("Content-Type"), "binary"); - Assertions.assertEquals(data.get("Set-Cookie"), "[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]"); + Assertions.assertEquals("binary", data.get("Content-Type")); + Assertions.assertEquals("[\"sap-context=sap-client=075; path=/\",\"sap-token=sap-client=075; path=/\"]", data.get("Set-Cookie")); } } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java index 5ad0bccf03..7d0e85b849 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbRestApiCallNodeTest.java @@ -25,15 +25,21 @@ import org.apache.http.impl.bootstrap.ServerBootstrap; import org.apache.http.protocol.HttpContext; import org.apache.http.protocol.HttpRequestHandler; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.parallel.ResourceLock; +import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Spy; import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.rule.engine.AbstractRuleNodeUpgradeTest; +import org.thingsboard.rule.engine.api.TbHttpClientSettings; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNode; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -43,35 +49,56 @@ import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.RuleChainId; import org.thingsboard.server.common.data.id.RuleNodeId; import org.thingsboard.server.common.data.msg.TbMsgType; +import org.thingsboard.server.common.data.msg.TbNodeConnectionType; +import org.thingsboard.server.common.data.rule.RuleNode; +import org.thingsboard.server.common.data.util.KeyValueEntry; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgDataType; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.exception.DataValidationException; import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Stream; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotSame; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) +@ResourceLock("SsrfProtectionValidator") // to avoid race conditions when modifying SsrfProtectionValidator's static configuration public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { + static final long TIMEOUT = TimeUnit.SECONDS.toMillis(30); + @Spy private TbRestApiCallNode restNode; @Mock private TbContext ctx; - private EntityId originator = new DeviceId(Uuids.timeBased()); - private TbMsgMetaData metaData = new TbMsgMetaData(); + private final EntityId originator = new DeviceId(Uuids.timeBased()); + private final TbMsgMetaData metaData = new TbMsgMetaData(); - private RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); - private RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); + private final RuleChainId ruleChainId = new RuleChainId(Uuids.timeBased()); + private final RuleNodeId ruleNodeId = new RuleNodeId(Uuids.timeBased()); private HttpServer server; @@ -94,6 +121,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @BeforeEach + public void setup() { + RuleNode ruleNode = new RuleNode(); + ruleNode.setId(ruleNodeId); + ruleNode.setName("Test REST API call node"); + lenient().when(ctx.getSelf()).thenReturn(ruleNode); + } + @AfterEach public void teardown() { if (server != null) { @@ -101,40 +136,118 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @Test + public void shouldNotAllowNullQueryParamNames() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setQueryParams(List.of(new KeyValueEntry<>(null, "value"))); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("query parameter names and values must be non-null"); + } + + @Test + public void shouldNotAllowNullQueryParamValues() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setQueryParams(List.of(new KeyValueEntry<>("key", null))); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("query parameter names and values must be non-null"); + } + + @Test + public void shouldNotAllowNullQueryParamEntries() { + // GIVEN + var config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + + var queryParams = new ArrayList>(); + queryParams.add(new KeyValueEntry<>("key", "value")); + queryParams.add(null); + config.setQueryParams(queryParams); + + // WHEN-THEN + assertThatThrownBy(() -> new TbRestApiCallNode().init(ctx, new TbNodeConfiguration(JacksonUtil.valueToTree(config)))) + .isInstanceOf(TbNodeException.class) + .matches(e -> ((TbNodeException) e).isUnrecoverable()) + .rootCause() + .isInstanceOf(DataValidationException.class) + .hasMessageContaining("must not be null"); + } + + @Test + public void shouldUseNewEncodingForNewNodesByDefault() { + // GIVEN-WHEN + var defaultConfig = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + + // THEN + assertEquals(Collections.emptyList(), defaultConfig.getQueryParams()); + } + + @Test + public void shouldDefaultToOldEncodingForLegacyConfigs() { + // GIVEN + String configJson = """ + { + "restEndpointUrlPattern": "http://url?param=value", + "requestMethod": "GET", + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json", + "X-Authorization": "Bearer eyJhbGciOi...." + }, + "credentials": { + "type": "anonymous" + }, + "maxInMemoryBufferSizeInKb": 256 + }"""; + + // WHEN + var config = JacksonUtil.fromString(configJson, TbRestApiCallNodeConfiguration.class); + + // THEN + assertNull(config.getQueryParams()); + } + @Test public void deleteRequestWithoutBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(request.getRequestLine().getUri(), path, "Request path matches"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - new Thread(new Runnable() { - @Override - public void run() { - try { - Thread.sleep(1000L); - } catch (InterruptedException e) { - // ignore - } finally { - latch.countDown(); - } - } - }).start(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -158,7 +271,7 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); @@ -168,42 +281,28 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { public void deleteRequestWithBody() throws IOException, InterruptedException { final CountDownLatch latch = new CountDownLatch(1); final String path = "/path/to/delete"; - setupServer("*", new HttpRequestHandler() { - - @Override - public void handle(HttpRequest request, HttpResponse response, HttpContext context) - throws HttpException, IOException { - try { - assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); - assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); - assertEquals("application/json", - request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); - assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); - assertEquals("2", - request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); - assertTrue(request.containsHeader("Foo"), "Custom header included"); - assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); - response.setStatusCode(200); - new Thread(new Runnable() { - @Override - public void run() { - try { - Thread.sleep(1000L); - } catch (InterruptedException e) { - // ignore - } finally { - latch.countDown(); - } - } - }).start(); - } catch (Exception e) { - System.out.println("Exception handling request: " + e.toString()); - e.printStackTrace(); - latch.countDown(); - } + setupServer("*", (request, response, _) -> { + try { + assertEquals(path, request.getRequestLine().getUri(), "Request path matches"); + assertTrue(request.containsHeader("Content-Type"), "Content-Type included"); + assertEquals("application/json", + request.getFirstHeader("Content-Type").getValue(), "Content-Type value"); + assertTrue(request.containsHeader("Content-Length"), "Content-Length included"); + assertEquals("2", + request.getFirstHeader("Content-Length").getValue(), "Content-Length value"); + assertTrue(request.containsHeader("Foo"), "Custom header included"); + assertEquals("Bar", request.getFirstHeader("Foo").getValue(), "Custom header value"); + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + System.out.println("Exception handling request: " + e); + e.printStackTrace(); + latch.countDown(); } }); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); config.setRequestMethod("DELETE"); config.setHeaders(Collections.singletonMap("Foo", "Bar")); @@ -227,15 +326,284 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); - verify(ctx).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + verify(ctx, timeout(TIMEOUT)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); assertNotSame(metaData, metadataCaptor.getValue()); assertEquals(TbMsg.EMPTY_JSON_OBJECT, dataCaptor.getValue()); } + @Test + public void givenForceAckTrue_whenOnMsgAndServerReturns200_thenAckedImmediatelyAndEnqueuedForTellNext() throws IOException { + final String path = "/path/to/get"; + setupServer("*", new HttpRequestHandler() { + @Override + public void handle(HttpRequest request, HttpResponse response, HttpContext context) + throws HttpException, IOException { + response.setStatusCode(200); + } + }); + + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + + given(ctx.isExternalNodeForceAck()).willReturn(true); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + verify(ctx).ack(msg); + verify(ctx, timeout(TIMEOUT)).enqueueForTellNext(any(), eq(TbNodeConnectionType.SUCCESS)); + verify(ctx, never()).tellSuccess(any()); + } + + @ParameterizedTest + @ValueSource(booleans = {true, false}) + public void givenMaxParallelRequestsCountAndBadUrl_whenOnMsg_thenSemaphoreIsReleasedAndFailureReported(boolean forceAck) throws IOException { + given(ctx.isExternalNodeForceAck()).willReturn(forceAck); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRestEndpointUrlPattern(""); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertThat(restNode.httpClient.getSemaphore().availablePermits()).isEqualTo(1); + if (forceAck) { + verify(ctx).enqueueForTellFailure(any(), any(Throwable.class)); + } else { + verify(ctx).tellFailure(any(), any()); + } + } + + @Test + public void givenMaxPendingRequestsExceeded_whenOnMsg_thenFailsImmediatelyAndQueuedRequestFiresAfterSlotOpens() throws IOException, InterruptedException { + CountDownLatch releaseResponse = new CountDownLatch(1); + setupServer("*", (request, response, context) -> { + try { + releaseResponse.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + response.setStatusCode(200); + }); + + given(ctx.isExternalNodeForceAck()).willReturn(false); + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + // Simulate server-level cap: maxPendingRequests=1 via TbHttpClientSettings + given(ctx.getTbHttpClientSettings()).willReturn(new TbHttpClientSettings() { + @Override public int getMaxParallelRequests() { return 0; } + @Override public int getMaxPendingRequests() { return 1; } + @Override public int getPoolMaxConnections() { return 0; } + }); + TbMsg transformedMsg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .data(TbMsg.EMPTY_JSON_OBJECT) + .build(); + given(ctx.transformMsg(any(), any(), any())).willReturn(transformedMsg); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setMaxParallelRequestsCount(1); + config.setRequestMethod("GET"); + config.setIgnoreRequestBody(true); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d/path", server.getLocalPort())); + initWithConfig(config); + + TbMsg msg1 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg2 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + TbMsg msg3 = TbMsg.newMsg().type(TbMsgType.POST_TELEMETRY_REQUEST).originator(originator) + .copyMetaData(metaData).dataType(TbMsgDataType.JSON).data(TbMsg.EMPTY_JSON_OBJECT) + .ruleChainId(ruleChainId).ruleNodeId(ruleNodeId).build(); + + restNode.onMsg(ctx, msg1); // fires immediately (semaphore acquired) + restNode.onMsg(ctx, msg2); // queues (semaphore exhausted, queue has room) + restNode.onMsg(ctx, msg3); // fails immediately (queue full — server-level maxPendingRequests=1) + + verify(ctx, timeout(TIMEOUT)).tellFailure(any(), any()); + + releaseResponse.countDown(); + verify(ctx, timeout(TIMEOUT).times(2)).tellSuccess(any()); + } + + @Test + public void postRequestWithBodyTemplate() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"grant_type\":\"client_credentials\",\"client_id\":\"${clientId}\",\"value\":\"$[token]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("clientId", "my-client-123"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"grant_type\":\"client_credentials\",\"client_id\":\"my-client-123\",\"value\":\"abc-xyz\"}", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateAndParseToPlainText() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/text"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setParseToPlainText(true); + config.setRequestBodyTemplate("Hello ${name}, your token is $[token]!"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("name", "World"); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"token\":\"abc-xyz\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("Hello World, your token is abc-xyz!", capturedBody.get()); + } + + @Test + public void postRequestWithBodyTemplateEscapesJsonSpecialChars() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/token"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + config.setRequestBodyTemplate("{\"name\":\"${userName}\",\"desc\":\"$[description]\"}"); + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + metaData.putValue("userName", "John \"Doe\""); + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"description\":\"line1\\nline2\"}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + assertEquals("{\"name\":\"John \\\"Doe\\\"\",\"desc\":\"line1\\nline2\"}", capturedBody.get()); + } + + @Test + public void postRequestWithEmptyBodyTemplateUsesMessageData() throws IOException, InterruptedException { + final CountDownLatch latch = new CountDownLatch(1); + final String path = "/api/data"; + final AtomicReference capturedBody = new AtomicReference<>(); + setupServerWithBodyCapture(capturedBody, latch); + + given(ctx.getExternalCallExecutor()).willReturn(DirectListeningExecutor.INSTANCE); + + TbRestApiCallNodeConfiguration config = new TbRestApiCallNodeConfiguration().defaultConfiguration(); + config.setRequestMethod("POST"); + // requestBodyTemplate is null by default — should use msg.getData() + config.setRestEndpointUrlPattern(String.format("http://localhost:%d%s", server.getLocalPort(), path)); + initWithConfig(config); + + TbMsg msg = TbMsg.newMsg() + .type(TbMsgType.POST_TELEMETRY_REQUEST) + .originator(originator) + .copyMetaData(metaData) + .dataType(TbMsgDataType.JSON) + .data("{\"temperature\":25}") + .ruleChainId(ruleChainId) + .ruleNodeId(ruleNodeId) + .build(); + restNode.onMsg(ctx, msg); + + assertTrue(latch.await(10, TimeUnit.SECONDS), "Server handled request"); + + ArgumentCaptor msgCaptor = ArgumentCaptor.forClass(TbMsg.class); + ArgumentCaptor metadataCaptor = ArgumentCaptor.forClass(TbMsgMetaData.class); + ArgumentCaptor dataCaptor = ArgumentCaptor.forClass(String.class); + verify(ctx, timeout(10_000)).transformMsg(msgCaptor.capture(), metadataCaptor.capture(), dataCaptor.capture()); + assertEquals("{\"temperature\":25}", capturedBody.get()); + } + + private void setupServerWithBodyCapture(AtomicReference capturedBody, CountDownLatch latch) throws IOException { + setupServer("*", (request, response, _) -> { + try { + if (request instanceof org.apache.http.HttpEntityEnclosingRequest entityRequest) { + InputStream is = entityRequest.getEntity().getContent(); + capturedBody.set(new String(is.readAllBytes(), StandardCharsets.UTF_8)); + } + response.setStatusCode(200); + latch.countDown(); + } catch (Exception e) { + e.printStackTrace(); + latch.countDown(); + } + }); + } + private static Stream givenFromVersionAndConfig_whenUpgrade_thenVerifyHasChangesAndConfig() { return Stream.of( - // config for version 2 with upgrade from version 0 Arguments.of(0, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\":\"POST\"," + "\"useSimpleClientHttpFactory\":false,\"ignoreRequestBody\":false,\"enableProxy\":false," + @@ -245,13 +613,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"trimQueue\":null,\"maxQueueSize\":null,\"credentials\":{\"type\":\"anonymous\"},\"trimDoubleQuotes\":false}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 2 with upgrade from version 1 + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(1, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -262,13 +629,12 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}"), - // config for version 3 with upgrade from version 2 + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), Arguments.of(2, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -278,12 +644,63 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { "\"credentials\": {\"type\": \"anonymous\"}}", true, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + - "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + - "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyScheme\": null,\"proxyHost\": null," + + "\"parseToPlainText\": false,\"ignoreRequestBody\": false," + + "\"enableProxy\": false,\"useSystemProxyProperties\": false,\"proxyHost\": null," + "\"proxyPort\": 0,\"proxyUser\": null,\"proxyPassword\": null,\"readTimeoutMs\": 0," + "\"maxParallelRequestsCount\": 0,\"headers\": {\"Content-Type\": \"application/json\"}," + "\"credentials\": {\"type\": \"anonymous\"}," + - "\"maxInMemoryBufferSizeInKb\": 256}") + "\"maxInMemoryBufferSizeInKb\": 256,\"requestBodyTemplate\": null}"), + Arguments.of(3, """ + { + "restEndpointUrlPattern": "http://localhost/api", + "requestMethod": "POST", + "useSimpleClientHttpFactory": true, + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyScheme": null, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json" + }, + "credentials": { + "type": "anonymous" + }, + "maxInMemoryBufferSizeInKb": 256, + "trimQueue": true, + "maxQueueSize": 100, + "trimDoubleQuotes": false, + "useRedisQueueForMsgPersistence": false + }""", + true, """ + { + "restEndpointUrlPattern": "http://localhost/api", + "requestMethod": "POST", + "parseToPlainText": false, + "ignoreRequestBody": false, + "enableProxy": false, + "useSystemProxyProperties": false, + "proxyHost": null, + "proxyPort": 0, + "proxyUser": null, + "proxyPassword": null, + "readTimeoutMs": 0, + "maxParallelRequestsCount": 0, + "headers": { + "Content-Type": "application/json" + }, + "credentials": { + "type": "anonymous" + }, + "requestBodyTemplate": null, + "maxInMemoryBufferSizeInKb": 256 + }""") ); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java index 7b00d249ff..062e2d0a2e 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/rest/TbSendRestApiCallReplyNodeTest.java @@ -51,7 +51,7 @@ public class TbSendRestApiCallReplyNodeTest { private TbSendRestApiCallReplyNode node; private TbSendRestApiCallReplyNodeConfiguration config; - + @Mock private TbContext ctxMock; @Mock @@ -140,4 +140,5 @@ public class TbSendRestApiCallReplyNodeTest { TbMsg.EMPTY_STRING, "Request body is empty!") ); } + } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java index e31e5428bd..a41bad76cc 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbChangeOriginatorNodeTest.java @@ -30,7 +30,7 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.JacksonUtil; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeConfiguration; @@ -89,7 +89,7 @@ public class TbChangeOriginatorNodeTest { private final DeviceId DEVICE_ID = new DeviceId(UUID.fromString("990605a4-db46-4ed4-942f-e18200453571")); private final AssetId ASSET_ID = new AssetId(UUID.fromString("55de3f10-1b55-4950-b711-ed132896b260")); - private final ListeningExecutor dbExecutor = new TestDbCallbackExecutor(); + private final ListeningExecutor dbExecutor = DirectListeningExecutor.INSTANCE; private TbChangeOriginatorNode node; private TbChangeOriginatorNodeConfiguration config; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java index 1baae125ef..ec71819878 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/transform/TbMsgDeduplicationNodeTest.java @@ -47,6 +47,8 @@ import org.thingsboard.server.common.data.msg.TbMsgType; import org.thingsboard.server.common.data.msg.TbNodeConnectionType; import org.thingsboard.server.common.msg.TbMsg; import org.thingsboard.server.common.msg.TbMsgMetaData; +import org.thingsboard.server.common.msg.TbMsgProcessingCtx; +import org.thingsboard.server.common.msg.queue.TbMsgCallback; import java.util.ArrayList; import java.util.List; @@ -79,6 +81,8 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { private TbContext ctx; + private static final int RULE_NODE_EXEC_COUNTER = 5; + private final ScheduledExecutorService executorService = ThingsBoardExecutors.newSingleThreadScheduledExecutor("de-duplication-node-test"); private final int deduplicationInterval = 1; @@ -204,6 +208,8 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { Assertions.assertEquals(firstMsg.getData(), actualMsg.getData()); Assertions.assertEquals(firstMsg.getMetaData(), actualMsg.getMetaData()); Assertions.assertEquals(firstMsg.getType(), actualMsg.getType()); + Assertions.assertEquals(RULE_NODE_EXEC_COUNTER, actualMsg.getAndIncrementRuleNodeCounter()); + Assertions.assertSame(TbMsgCallback.EMPTY, actualMsg.getCallback()); if (queueName == null) { Assertions.assertEquals(firstMsg.getQueueName(), actualMsg.getQueueName()); @@ -257,6 +263,8 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { Assertions.assertEquals(msgWithLatestTs.getData(), actualMsg.getData()); Assertions.assertEquals(msgWithLatestTs.getMetaData(), actualMsg.getMetaData()); Assertions.assertEquals(msgWithLatestTs.getType(), actualMsg.getType()); + Assertions.assertEquals(RULE_NODE_EXEC_COUNTER, actualMsg.getAndIncrementRuleNodeCounter()); + Assertions.assertSame(TbMsgCallback.EMPTY, actualMsg.getCallback()); } @Test @@ -402,6 +410,8 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { Assertions.assertEquals(msgWithLatestTsInFirstPack.getData(), actualMsg.getData()); Assertions.assertEquals(msgWithLatestTsInFirstPack.getMetaData(), actualMsg.getMetaData()); Assertions.assertEquals(msgWithLatestTsInFirstPack.getType(), actualMsg.getType()); + Assertions.assertEquals(RULE_NODE_EXEC_COUNTER, actualMsg.getAndIncrementRuleNodeCounter()); + Assertions.assertSame(TbMsgCallback.EMPTY, actualMsg.getCallback()); // verify that newMsg is called but content of messages is the same as in the last msg for the second pack. actualMsg = resultMsgs.get(1); @@ -411,6 +421,8 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { Assertions.assertEquals(msgWithLatestTsInSecondPack.getData(), actualMsg.getData()); Assertions.assertEquals(msgWithLatestTsInSecondPack.getMetaData(), actualMsg.getMetaData()); Assertions.assertEquals(msgWithLatestTsInSecondPack.getType(), actualMsg.getType()); + Assertions.assertEquals(RULE_NODE_EXEC_COUNTER, actualMsg.getAndIncrementRuleNodeCounter()); + Assertions.assertSame(TbMsgCallback.EMPTY, actualMsg.getCallback()); } @Test @@ -539,6 +551,7 @@ public class TbMsgDeduplicationNodeTest extends AbstractRuleNodeUpgradeTest { .originator(deviceId) .copyMetaData(metaData) .data(JacksonUtil.toString(dataNode)) + .ctx(new TbMsgProcessingCtx(RULE_NODE_EXEC_COUNTER)) .build(); } diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java index 7d13b0aa46..d1bcc4559e 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesFieldsAsyncLoaderTest.java @@ -22,8 +22,8 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; import org.thingsboard.rule.engine.api.RuleEngineAlarmService; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; @@ -76,7 +76,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) public class EntitiesFieldsAsyncLoaderTest { - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; private static EnumSet SUPPORTED_ENTITY_TYPES; private static UUID RANDOM_UUID; private static TenantId TENANT_ID; diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedDeviceIdAsyncLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedDeviceIdAsyncLoaderTest.java index 200b355fcb..23e98dfc58 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedDeviceIdAsyncLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedDeviceIdAsyncLoaderTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.data.DeviceRelationsQuery; import org.thingsboard.server.common.data.Device; @@ -49,7 +49,7 @@ public class EntitiesRelatedDeviceIdAsyncLoaderTest { private static final EntityId DUMMY_ORIGINATOR = new DeviceId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; @Mock private TbContext ctxMock; @Mock diff --git a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedEntityIdAsyncLoaderTest.java b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedEntityIdAsyncLoaderTest.java index e61f51ea96..1131586a07 100644 --- a/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedEntityIdAsyncLoaderTest.java +++ b/rule-engine/rule-engine-components/src/test/java/org/thingsboard/rule/engine/util/EntitiesRelatedEntityIdAsyncLoaderTest.java @@ -21,7 +21,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentMatchers; import org.thingsboard.common.util.ListeningExecutor; -import org.thingsboard.rule.engine.TestDbCallbackExecutor; +import org.thingsboard.common.util.DirectListeningExecutor; import org.thingsboard.rule.engine.api.TbContext; import org.thingsboard.rule.engine.data.RelationsQuery; import org.thingsboard.server.common.data.Device; @@ -56,7 +56,7 @@ public class EntitiesRelatedEntityIdAsyncLoaderTest { private static final EntityId ASSET_ORIGINATOR_ID = new AssetId(UUID.randomUUID()); private static final TenantId TENANT_ID = new TenantId(UUID.randomUUID()); - private static final ListeningExecutor DB_EXECUTOR = new TestDbCallbackExecutor(); + private static final ListeningExecutor DB_EXECUTOR = DirectListeningExecutor.INSTANCE; private TbContext ctxMock; private RelationService relationServiceMock; diff --git a/tools/pom.xml b/tools/pom.xml index 20c8fc95fc..30b563a498 100644 --- a/tools/pom.xml +++ b/tools/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard tools @@ -54,10 +54,24 @@ org.apache.cassandra cassandra-all - - - at.yawk.lz4 - lz4-java + + + + org.perfkit.sjk.parsers + sjk-jfr5 + + + org.perfkit.sjk.parsers + sjk-jfr6 + + + org.perfkit.sjk.parsers + sjk-nps + + commons-io diff --git a/transport/coap/pom.xml b/transport/coap/pom.xml index ded25e8260..b17cc4e888 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.transport @@ -34,7 +34,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-coap-transport @@ -109,8 +108,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/transport/coap/src/main/resources/tb-coap-transport.yml b/transport/coap/src/main/resources/tb-coap-transport.yml index dde415bd91..3bf2295ec9 100644 --- a/transport/coap/src/main/resources/tb-coap-transport.yml +++ b/transport/coap/src/main/resources/tb-coap-transport.yml @@ -15,30 +15,33 @@ # # Spring common parameters +# Controls core Spring Boot application settings such as web environment and application type. spring.main.web-environment: "${WEB_APPLICATION_ENABLE:false}" # If you enabled process metrics you should also enable 'web-environment'. spring.main.web-application-type: "${WEB_APPLICATION_TYPE:none}" # If you enabled process metrics you should set 'web-application-type' to 'servlet' value. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. # Server common parameters +# Configures the HTTP server bind address and port for the actuator/metrics web endpoint. server: # Server bind address (has no effect if web-environment is disabled). address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port (has no effect if web-environment is disabled). port: "${HTTP_BIND_PORT:8083}" -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters. Used for service discovery. +# Defines how this service registers itself and discovers other nodes via Apache ZooKeeper. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -46,6 +49,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cache parameters +# Configures the caching backend and per-entity cache settings used by the transport service. cache: # caffeine or redis type: "${CACHE_TYPE:redis}" @@ -55,6 +59,7 @@ cache: maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled # Redis/Valkey configuration parameters +# Defines connection mode, authentication, SSL, and connection pool settings for Redis or Valkey. redis: connection: # standalone or cluster or sentinel @@ -90,9 +95,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -170,8 +175,18 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # CoAP server parameters +# Configures the CoAP and CoAP/DTLS server endpoints, including bind addresses, timeouts, and TLS credentials. coap: # CoAP bind-address bind_address: "${COAP_BIND_ADDRESS:0.0.0.0}" @@ -253,6 +268,7 @@ coap: dtls_session_report_timeout: "${TB_COAP_X509_DTLS_SESSION_REPORT_TIMEOUT:1800000}" # Queue configuration parameters +# Defines the message queue backend (Kafka) and all topic, partition, and consumer settings for inter-service communication. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -276,8 +292,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -406,12 +422,14 @@ queue: poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" # Service common properties +# Identifies this service instance within the ThingsBoard cluster. service: type: "${TB_SERVICE_TYPE:tb-transport}" # service type # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Usage statistics parameters +# Controls reporting of API usage metrics at system, tenant, and customer levels. usage: stats: report: @@ -421,23 +439,28 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables Micrometer/Actuator metrics collection and configures the exposed management endpoints. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure for health checks and metrics scraping. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters +# Configures deduplication rules for platform notifications to prevent repeated alerts within a time window. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/transport/http/pom.xml b/transport/http/pom.xml index 2c83bebd0a..ae2af1e29b 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.transport @@ -34,7 +34,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-http-transport @@ -109,8 +108,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/transport/http/src/main/resources/tb-http-transport.yml b/transport/http/src/main/resources/tb-http-transport.yml index 39f7d15fb9..945a63240a 100644 --- a/transport/http/src/main/resources/tb-http-transport.yml +++ b/transport/http/src/main/resources/tb-http-transport.yml @@ -15,6 +15,7 @@ # # Server common properties +# Configures the HTTP server bind address, port, SSL/TLS settings, and HTTP/2 support. server: # Server bind address address: "${HTTP_BIND_ADDRESS:0.0.0.0}" @@ -54,23 +55,25 @@ server: enabled: "${HTTP2_ENABLED:true}" # Spring common parameters +# Controls Spring Boot framework-level settings such as circular references and multipart upload limits. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. spring.servlet.multipart.max-file-size: "${SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE:50MB}" # Total file size cannot exceed 50MB when configuring file uploads spring.servlet.multipart.max-request-size: "${SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE:50MB}" # Total request size for a multipart/form-data cannot exceed 50MB -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters +# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -78,6 +81,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cache parameters +# Defines the cache backend type and per-entity cache configuration such as TTL and maximum size. cache: # caffeine or redis type: "${CACHE_TYPE:redis}" @@ -87,6 +91,7 @@ cache: maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled # Redis/Valkey configuration parameters +# Configures connection mode (standalone, cluster, sentinel), credentials, SSL, and connection pool settings. redis: # standalone or cluster or sentinel connection: @@ -123,9 +128,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -166,6 +171,7 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" # HTTP server parameters +# Configures HTTP transport request timeouts, payload limits, session management, JSON processing, logging, and statistics. transport: http: # HTTP request processing timeout in milliseconds @@ -201,8 +207,18 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters +# Configures the messaging queue backend (Kafka), topic definitions, partitioning, and per-service consumer settings. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka) . @@ -226,7 +242,7 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -355,12 +371,14 @@ queue: poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" # General service parameters +# Defines the service type and unique identifier used for service discovery and cluster coordination. service: type: "${TB_SERVICE_TYPE:tb-transport}" # type of service # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Usage statistics parameters +# Controls reporting of API usage statistics at the system, tenant, and optional customer level. usage: stats: report: @@ -370,23 +388,28 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables or disables actuator metrics collection and exposure for monitoring integrations such as Prometheus. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" # General management parameters +# Configures Spring Boot Actuator management endpoints, including which endpoints are exposed over HTTP. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters +# Configures notification rules, including deduplication windows to prevent repeated alerts for the same trigger. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/transport/lwm2m/pom.xml b/transport/lwm2m/pom.xml index e28fd452cf..045c7181f2 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.transport @@ -34,7 +34,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-lwm2m-transport @@ -163,8 +162,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml index 21687bb697..85068152a6 100644 --- a/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml +++ b/transport/lwm2m/src/main/resources/tb-lwm2m-transport.yml @@ -15,30 +15,33 @@ # # Spring common parameters +# Controls Spring Boot web environment and application type settings used for optional metrics exposure. spring.main.web-environment: "${WEB_APPLICATION_ENABLE:false}" # If you enabled process metrics you should also enable 'web-environment'. spring.main.web-application-type: "${WEB_APPLICATION_TYPE:none}" # If you enabled process metrics you should set 'web-application-type' to 'servlet' value. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. # Server common parameters +# HTTP server bind address and port, effective only when the web environment is enabled. server: # Server bind address (has no effect if web-environment is disabled). address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port (has no effect if web-environment is disabled). port: "${HTTP_BIND_PORT:8083}" -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters +# Controls ZooKeeper-based service discovery and cluster coordination for microservice deployments. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -46,6 +49,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cache parameters +# Configures the cache backend type and per-entity cache specifications such as TTL and maximum size. cache: # caffeine or redis type: "${CACHE_TYPE:redis}" @@ -55,6 +59,7 @@ cache: maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled # Redis/Valkey configuration parameters +# Connection, authentication, SSL, and connection pool settings for the Redis or Valkey cache backend. redis: connection: # standalone or cluster or sentinel @@ -90,9 +95,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -133,6 +138,7 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" # LWM2M server parameters +# Session, JSON processing, RPC, logging, and LwM2M-specific transport settings including DTLS, bootstrap, and OTA. transport: sessions: # Session inactivity timeout is a global configuration parameter that defines how long the device transport session will be opened after the last message arrives from the device. @@ -301,8 +307,18 @@ transport: enabled: "${TB_TRANSPORT_STATS_ENABLED:true}" # Interval of transport statistics logging print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration properties +# Defines the message queue backend (Kafka) and per-topic settings for transport, core, rule-engine, and JS evaluation. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -326,8 +342,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -456,12 +472,14 @@ queue: poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" # Service common parameters +# Identifies the service type and its unique instance ID within a ThingsBoard cluster. service: type: "${TB_SERVICE_TYPE:tb-transport}" # service type # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Usage statistics parameters +# Controls reporting of API usage statistics at system, tenant, and optionally customer level. usage: stats: report: @@ -471,23 +489,28 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables or disables Actuator-based metrics collection (e.g., Prometheus endpoint). metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure, e.g. for Prometheus metrics scraping. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters +# Configures deduplication rules for notification triggers to prevent repeated alerts within a time window. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/transport/mqtt/pom.xml b/transport/mqtt/pom.xml index 2bee6f94e4..4e68cbb49e 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT transport org.thingsboard.transport @@ -34,7 +34,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-mqtt-transport @@ -109,8 +108,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml index 4a7571a734..52d469e249 100644 --- a/transport/mqtt/src/main/resources/tb-mqtt-transport.yml +++ b/transport/mqtt/src/main/resources/tb-mqtt-transport.yml @@ -15,30 +15,33 @@ # # Spring common parameters +# Controls Spring Boot application type, web environment, and bean dependency settings. spring.main.web-environment: "${WEB_APPLICATION_ENABLE:false}" # If you enabled process metrics you should also enable 'web-environment'. spring.main.web-application-type: "${WEB_APPLICATION_TYPE:none}" # If you enabled process metrics you should set 'web-application-type' to 'servlet' value. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. # Server common parameters +# Configures the embedded HTTP server bind address and port for actuator/metrics endpoints. server: # Server bind address (has no effect if web-environment is disabled). address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port (has no effect if web-environment is disabled). port: "${HTTP_BIND_PORT:8083}" -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters. Used for service discovery. +# Defines connection, session, retry settings and the node directory path for ZooKeeper-based cluster coordination. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -46,6 +49,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cache parameters +# Configures the cache backend type (Caffeine or Redis) and per-cache TTL and size limits. cache: # caffeine or redis type: "${CACHE_TYPE:redis}" @@ -55,6 +59,7 @@ cache: maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled # Redis/Valkey configuration parameters +# Covers connection mode (standalone, cluster, sentinel), SSL, credentials, and connection pool tuning. redis: # standalone or cluster or sentinel connection: @@ -91,9 +96,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -134,6 +139,7 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" # MQTT server parameters +# Configures the MQTT transport layer including bind address/port, SSL, Netty tuning, session management, rate limits, and message processing options. transport: mqtt: # MQTT bind-address @@ -234,8 +240,18 @@ transport: max_wrong_credentials_per_ip: "${TB_TRANSPORT_MAX_WRONG_CREDENTIALS_PER_IP:10}" # Timeout to expire block IP addresses ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" + ssl: + # SSL/TLS settings for the transport layer + certificate: + # X.509 certificate configuration to auto-detect and reload certificate used by transport protocols in real-time (MQTT, CoAP, LwM2M, etc.) + reload: + # Enable/disable automatic SSL certificates reload + enabled: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_ENABLED:true}" + # Check interval in seconds for certificates reload + check_interval_seconds: "${TB_TRANSPORT_SSL_CERTIFICATE_RELOAD_CHECK_INTERVAL_SECONDS:60}" # Queue configuration parameters +# Defines the messaging queue backend (Kafka), topic names, partitioning, and poll/processing settings for all platform microservices. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -259,8 +275,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -389,12 +405,14 @@ queue: poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" # Service common properties +# Identifies this service instance within the cluster by type and optional unique ID. service: type: "${TB_SERVICE_TYPE:tb-transport}" # service type # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Usage statistics parameters +# Controls reporting of API usage statistics at system, tenant, and customer levels with configurable intervals. usage: stats: report: @@ -404,23 +422,28 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables or disables Micrometer actuator metrics collection for monitoring this service. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure, including which endpoints are accessible over HTTP. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters +# Configures deduplication rules for notification triggers to prevent repeated alerts within a defined time window. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/transport/pom.xml b/transport/pom.xml index 89c0355407..3b6195f9d8 100644 --- a/transport/pom.xml +++ b/transport/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard transport diff --git a/transport/snmp/pom.xml b/transport/snmp/pom.xml index 38682ade69..1575d25bdb 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -21,7 +21,7 @@ org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT transport @@ -36,7 +36,6 @@ UTF-8 ${basedir}/../.. java - false process-resources package tb-snmp-transport @@ -94,8 +93,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/transport/snmp/src/main/resources/tb-snmp-transport.yml b/transport/snmp/src/main/resources/tb-snmp-transport.yml index b98ad2a9e9..46b1d8e48b 100644 --- a/transport/snmp/src/main/resources/tb-snmp-transport.yml +++ b/transport/snmp/src/main/resources/tb-snmp-transport.yml @@ -15,30 +15,33 @@ # # Spring common parameters +# Controls Spring Boot application type and web environment settings for the SNMP transport service. spring.main.web-environment: "${WEB_APPLICATION_ENABLE:false}" # If you enabled process metrics you should also enable 'web-environment'. spring.main.web-application-type: "${WEB_APPLICATION_TYPE:none}" # If you enabled process metrics you should set 'web-application-type' to 'servlet' value. spring.main.allow-circular-references: "true" # Spring Boot configuration property that controls whether circular dependencies between beans are allowed. # Server common parameters +# Configures the embedded HTTP server bind address and port (used when web environment is enabled). server: # Server bind address (has no effect if web-environment is disabled). address: "${HTTP_BIND_ADDRESS:0.0.0.0}" # Server bind port (has no effect if web-environment is disabled). port: "${HTTP_BIND_PORT:8083}" -# Zookeeper connection parameters. Used for service discovery. +# ZooKeeper connection parameters. Used for service discovery. +# Defines ZooKeeper connectivity, timeouts, and cluster node registration settings for service coordination. zk: - # Enable/disable zookeeper discovery service. + # Enable/disable ZooKeeper discovery service. enabled: "${ZOOKEEPER_ENABLED:false}" - # Zookeeper connect string + # ZooKeeper connect string url: "${ZOOKEEPER_URL:localhost:2181}" - # Zookeeper retry interval in milliseconds + # ZooKeeper retry interval in milliseconds retry_interval_ms: "${ZOOKEEPER_RETRY_INTERVAL_MS:3000}" - # Zookeeper connection timeout in milliseconds + # ZooKeeper connection timeout in milliseconds connection_timeout_ms: "${ZOOKEEPER_CONNECTION_TIMEOUT_MS:3000}" - # Zookeeper session timeout in milliseconds + # ZooKeeper session timeout in milliseconds session_timeout_ms: "${ZOOKEEPER_SESSION_TIMEOUT_MS:3000}" - # Name of the directory in zookeeper 'filesystem' + # Name of the directory in ZooKeeper 'filesystem' zk_dir: "${ZOOKEEPER_NODES_DIR:/thingsboard}" # The recalculate_delay property is recommended in a microservices architecture setup for rule-engine services. # This property provides a pause to ensure that when a rule-engine service is restarted, other nodes don't immediately attempt to recalculate their partitions. @@ -46,6 +49,7 @@ zk: recalculate_delay: "${ZOOKEEPER_RECALCULATE_DELAY_MS:0}" # Cache parameters +# Specifies the caching backend (Caffeine or Redis) and per-entity cache TTL and size limits. cache: # caffeine or redis type: "${CACHE_TYPE:redis}" @@ -55,6 +59,7 @@ cache: maxSize: "${CACHE_SPECS_ENTITY_LIMITS_MAX_SIZE:100000}" # 0 means the cache is disabled # Redis/Valkey configuration parameters +# Covers standalone, cluster, and sentinel Redis connection modes, SSL, authentication, and connection pool tuning. redis: connection: # standalone or cluster or sentinel @@ -90,9 +95,9 @@ redis: password: "${REDIS_SENTINEL_PASSWORD:}" # if set false will be used pool config build from values of the pool config section useDefaultPoolConfig: "${REDIS_USE_DEFAULT_POOL_CONFIG:true}" - # db index + # Redis logical database index to select after connecting. db: "${REDIS_DB:0}" - # db password + # Password for Redis authentication (leave empty if not required). password: "${REDIS_PASSWORD:}" # Redis username for ACL authentication (Redis 6.0+). Leave empty for legacy password-only auth username: "${REDIS_USERNAME:}" @@ -133,6 +138,7 @@ redis: blockWhenExhausted: "${REDIS_POOL_CONFIG_BLOCK_WHEN_EXHAUSTED:true}" # Snmp server parameters +# Configures the SNMP transport protocol, bind port, PDU limits, session management, JSON handling, logging, and statistics. transport: snmp: # Enable/disable SNMP transport protocol @@ -184,6 +190,7 @@ transport: print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" # Queue configuration parameters +# Defines the message queue backend (Kafka) and topic/consumer settings for transport, core, rule-engine, and JS evaluation. queue: type: "${TB_QUEUE_TYPE:kafka}" # kafka (Apache Kafka) prefix: "${TB_QUEUE_PREFIX:}" # Global queue prefix. If specified, prefix is added before default topic name: 'prefix.default_topic_name'. Prefix is applied to all topics (and consumer groups for kafka). @@ -207,8 +214,8 @@ queue: acks: "${TB_KAFKA_ACKS:all}" # Number of retries. Resend any record whose send fails with a potentially transient error retries: "${TB_KAFKA_RETRIES:1}" - # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values none or gzip - compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none or gzip + # The compression type for all data generated by the producer. The default is none (i.e. no compression). Valid values: none, gzip or lz4 + compression.type: "${TB_KAFKA_COMPRESSION_TYPE:none}" # none, gzip or lz4 # Default batch size. This setting gives the upper bound of the batch size to be sent batch.size: "${TB_KAFKA_BATCH_SIZE:16384}" # This variable creates a small amount of artificial delay—that is, rather than immediately sending out a record @@ -344,12 +351,14 @@ queue: poll_interval: "${TB_QUEUE_TRANSPORT_NOTIFICATIONS_POLL_INTERVAL_MS:25}" # Service common parameters +# Identifies this service instance by type and unique ID within a ThingsBoard cluster. service: type: "${TB_SERVICE_TYPE:tb-transport}" # service type # Unique id for this service (autogenerated if empty) id: "${TB_SERVICE_ID:}" # Usage statistics parameters +# Controls collection and reporting intervals for API usage statistics at system, tenant, and customer levels. usage: stats: report: @@ -359,23 +368,28 @@ usage: enabled_per_customer: "${USAGE_STATS_REPORT_PER_CUSTOMER_ENABLED:false}" # Interval of reporting the statistics. By default, the summarized statistics are sent every 10 seconds interval: "${USAGE_STATS_REPORT_INTERVAL:60}" + # Reporting interval for urgent keys (e.g. SMS, Email) that require quicker usage state updates + urgent_interval: "${USAGE_STATS_REPORT_URGENT_INTERVAL:10}" # Amount of statistic messages in pack pack_size: "${USAGE_STATS_REPORT_PACK_SIZE:1024}" # Metrics parameters +# Enables or disables actuator-based metrics collection (e.g., Prometheus) for the SNMP transport service. metrics: # Enable/disable actuator metrics. enabled: "${METRICS_ENABLED:false}" # General management parameters +# Configures Spring Boot Actuator endpoint exposure, including which management endpoints are accessible over HTTP. management: endpoints: web: exposure: # Expose metrics endpoint (use value 'prometheus' to enable prometheus metrics). - include: '${METRICS_ENDPOINTS_EXPOSE:info}' + include: "${METRICS_ENDPOINTS_EXPOSE:info}" # Notification system parameters +# Defines deduplication rules for notification triggers to prevent duplicate alerts within configured time windows. notification_system: rules: # Semicolon-separated deduplication durations (in millis) for trigger types. Format: 'NotificationRuleTriggerType1:123;NotificationRuleTriggerType2:456' diff --git a/ui-ngx/.browserslistrc b/ui-ngx/.browserslistrc index e368a649b3..af5c0c7df4 100644 --- a/ui-ngx/.browserslistrc +++ b/ui-ngx/.browserslistrc @@ -2,18 +2,17 @@ # For additional information regarding the format and rule options, please see: # https://github.com/browserslist/browserslist#queries -# For the full list of supported browsers by the Angular framework, please see: +# For Angular's browser support policy, please see: # https://angular.dev/reference/versions#browser-support # You can see what browsers were selected by your queries by running: # npx browserslist ->0.5% -not dead -last 2 Chrome versions -last 1 Firefox version -last 2 Edge major versions -last 2 Safari major versions -last 2 iOS major versions -last 2 Android major versions +Chrome >= 107 +ChromeAndroid >= 107 +Edge >= 107 +Firefox >= 104 +FirefoxAndroid >= 104 +Safari >= 16 +iOS >= 16 Firefox ESR diff --git a/ui-ngx/angular.json b/ui-ngx/angular.json index dbac347096..6d90f9ec20 100644 --- a/ui-ngx/angular.json +++ b/ui-ngx/angular.json @@ -114,7 +114,10 @@ "stylePreprocessorOptions": { "includePaths": [ "src/scss" - ] + ], + "sass": { + "silenceDeprecations": ["mixed-decls", "color-functions", "global-builtin", "import"] + } }, "scripts": [ "node_modules/tinycolor2/dist/tinycolor-min.js", @@ -217,5 +220,31 @@ "schematicCollections": [ "@angular-eslint/schematics" ] + }, + "schematics": { + "@schematics/angular:component": { + "type": "component" + }, + "@schematics/angular:directive": { + "type": "directive" + }, + "@schematics/angular:service": { + "type": "service" + }, + "@schematics/angular:guard": { + "typeSeparator": "." + }, + "@schematics/angular:interceptor": { + "typeSeparator": "." + }, + "@schematics/angular:module": { + "typeSeparator": "." + }, + "@schematics/angular:pipe": { + "typeSeparator": "." + }, + "@schematics/angular:resolver": { + "typeSeparator": "." + } } } diff --git a/ui-ngx/eslint.config.mjs b/ui-ngx/eslint.config.mjs index c446375607..f003d8cc6d 100644 --- a/ui-ngx/eslint.config.mjs +++ b/ui-ngx/eslint.config.mjs @@ -64,7 +64,9 @@ export default tsEslint.config( "@typescript-eslint/ban-ts-comment": "off", "no-case-declarations": "off", "no-prototype-builtins": "off", - "@typescript-eslint/consistent-type-definitions": "off" + "@typescript-eslint/consistent-type-definitions": "off", + "@angular-eslint/prefer-standalone": "off", + "@angular-eslint/prefer-inject": "off" }, }, { diff --git a/ui-ngx/package.json b/ui-ngx/package.json index 3403e7a3aa..3dea406f1a 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -1,6 +1,6 @@ { "name": "thingsboard", - "version": "4.3.0", + "version": "4.4.0", "scripts": { "ng": "ng", "start": "node --max_old_space_size=8048 ./node_modules/@angular/cli/bin/ng serve --configuration development --host 0.0.0.0 --open", @@ -13,47 +13,47 @@ }, "private": true, "dependencies": { - "@angular/animations": "18.2.13", - "@angular/cdk": "18.2.14", - "@angular/common": "18.2.13", - "@angular/compiler": "18.2.13", - "@angular/core": "18.2.13", - "@angular/forms": "18.2.13", - "@angular/material": "18.2.14", - "@angular/platform-browser": "18.2.13", - "@angular/platform-browser-dynamic": "18.2.13", - "@angular/router": "18.2.13", + "@angular/animations": "20.3.19", + "@angular/cdk": "20.2.14", + "@angular/common": "20.3.19", + "@angular/compiler": "20.3.19", + "@angular/core": "20.3.19", + "@angular/forms": "20.3.19", + "@angular/material": "20.2.14", + "@angular/platform-browser": "20.3.19", + "@angular/platform-browser-dynamic": "20.3.19", + "@angular/router": "20.3.19", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", - "@flowjs/ngx-flow": "18.0.1", - "@geoman-io/leaflet-geoman-free": "2.18.3", - "@iplab/ngx-color-picker": "^18.0.1", - "@mat-datetimepicker/core": "~14.0.0", + "@flowjs/ngx-flow": "20.0.2", + "@geoman-io/leaflet-geoman-free": "2.19.3", + "@iplab/ngx-color-picker": "^20.0.0", + "@mat-datetimepicker/core": "~16.0.1", "@mdi/svg": "^7.4.47", "@messageformat/core": "^3.4.0", - "@ngrx/effects": "^18.1.1", - "@ngrx/store": "^18.1.1", - "@ngrx/store-devtools": "^18.1.1", - "@ngx-translate/core": "^15.0.0", - "@svgdotjs/svg.filter.js": "^3.0.8", - "@svgdotjs/svg.js": "^3.2.4", + "@ngrx/effects": "^20.1.0", + "@ngrx/store": "^20.1.0", + "@ngrx/store-devtools": "^20.1.0", + "@ngx-translate/core": "^17.0.0", + "@svgdotjs/svg.filter.js": "^3.0.9", + "@svgdotjs/svg.js": "3.2.4", "@svgdotjs/svg.panzoom.js": "^2.1.2", "@tinymce/tinymce-angular": "^8.0.1", - "ace-builds": "1.36.5", - "ace-diff": "^3.0.3", - "angular-gridster2": "~18.0.1", + "ace-builds": "1.43.6", + "ace-diff": "^3.3.0", + "angular-gridster2": "~20.2.4", "angular2-hotkeys": "^16.0.1", "canvas-gauges": "^2.1.7", - "core-js": "^3.39.0", - "dayjs": "1.11.13", - "echarts": "https://github.com/thingsboard/echarts/archive/5.5.1-TB.tar.gz", + "core-js": "^3.48.0", + "dayjs": "1.11.20", + "echarts": "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz", "flot": "https://github.com/thingsboard/flot.git#0.9-work", "flot.curvedlines": "https://github.com/MichaelZinsmaier/CurvedLines.git#master", "font-awesome": "^4.7.0", "html2canvas": "^1.4.1", "jquery": "^3.7.1", - "jquery.terminal": "^2.44.1", - "js-beautify": "1.15.1", + "jquery.terminal": "^2.45.2", + "js-beautify": "1.15.4", "json-schema-defaults": "^0.4.0", "jstree": "^3.3.17", "jstree-bootstrap-theme": "^1.0.1", @@ -63,87 +63,85 @@ "leaflet-providers": "2.0.0", "leaflet.gridlayer.googlemutant": "0.15.0", "leaflet.markercluster": "1.5.3", - "libphonenumber-js": "^1.11.15", - "maplibre-gl": "^5.2.0", - "marked": "~12.0.2", + "libphonenumber-js": "^1.12.35", + "maplibre-gl": "5.2.0", + "marked": "~16.4.2", "moment": "^2.30.1", - "moment-timezone": "^0.5.45", + "moment-timezone": "^0.6.0", "ngx-clipboard": "^16.0.0", "ngx-daterangepicker-material": "^6.0.4", - "ngx-drag-drop": "^18.0.2", - "ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/3.0.0", - "ngx-hm-carousel": "^18.0.0", - "ngx-markdown": "^18.1.0", - "ngx-sharebuttons": "^15.0.6", - "ngx-translate-messageformat-compiler": "^7.0.0", + "ngx-drag-drop": "^20.0.1", + "ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/4.1.0", + "ngx-hm-carousel": "^19.0.0", + "ngx-markdown": "^20.1.0", + "ngx-sharebuttons": "^17.0.0", + "ngx-translate-messageformat-compiler": "^7.2.0", "objectpath": "^2.0.0", "qrcode": "^1.5.4", "raphael": "^2.3.0", - "rxjs": "~7.8.1", + "rxjs": "~7.8.2", "schema-inspector": "^2.1.0", "screenfull": "^6.0.2", "sorted-btree": "^1.8.1", "split.js": "^1.6.5", "systemjs": "6.15.1", "tinycolor2": "^1.6.0", - "tinymce": "~6.8.5", + "tinymce": "~6.8.6", "tooltipster": "^4.2.8", - "tslib": "^2.7.0", + "tslib": "^2.8.1", "typeface-roboto": "^1.1.13", - "zone.js": "~0.14.10" + "zone.js": "~0.15.1" }, "devDependencies": { - "@angular-builders/custom-esbuild": "18.0.0", - "@angular-devkit/build-angular": "18.2.12", - "@angular-devkit/core": "18.2.12", - "@angular-devkit/schematics": "18.2.12", - "@angular/build": "18.2.12", - "@angular/cli": "18.2.12", - "@angular/compiler-cli": "18.2.13", - "@angular/language-service": "18.2.13", + "@angular-builders/custom-esbuild": "20.0.0", + "@angular-devkit/build-angular": "20.3.24", + "@angular-devkit/core": "20.3.24", + "@angular-devkit/schematics": "20.3.24", + "@angular/build": "20.3.24", + "@angular/cli": "20.3.24", + "@angular/compiler-cli": "20.3.19", + "@angular/language-service": "20.3.19", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", "@types/flowjs": "^2.13.14", - "@types/jquery": "^3.5.32", + "@types/jquery": "^3.5.33", "@types/js-beautify": "^1.14.3", "@types/leaflet": "1.9.14", "@types/leaflet-polylinedecorator": "1.6.4", "@types/leaflet-providers": "1.2.4", "@types/leaflet.gridlayer.googlemutant": "0.4.9", "@types/leaflet.markercluster": "1.5.5", - "@types/lodash": "^4.17.13", - "@types/node": "~20.17.8", + "@types/lodash": "^4.17.22", + "@types/node": "~22.18.13", "@types/raphael": "^2.3.9", - "@types/systemjs": "6.15.1", + "@types/systemjs": "6.15.3", "@types/tinycolor2": "^1.4.6", "@types/tooltipster": "^0.0.35", - "angular-eslint": "~18.4.3", - "autoprefixer": "^10.4.20", + "angular-eslint": "~20.7.0", + "autoprefixer": "^10.4.23", "directory-tree": "^3.5.2", - "eslint": "~9.17.0", - "eslint-plugin-import": "latest", - "eslint-plugin-jsdoc": "^50.6.0", - "eslint-plugin-prefer-arrow": "latest", - "eslint-plugin-tailwindcss": "^3.17.5", + "eslint": "9.39.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-jsdoc": "^62.4.1", + "eslint-plugin-prefer-arrow": "^1.2.3", + "eslint-plugin-tailwindcss": "^3.18.2", "ngrx-store-freeze": "^0.2.4", - "patch-package": "^8.0.0", - "postcss": "^8.4.49", + "patch-package": "^8.0.1", + "postcss": "^8.5.6", "postinstall-prepare": "^2.0.0", - "tailwindcss": "^3.4.15", + "tailwindcss": "^3.4.19", "ts-node": "^10.9.2", - "typescript": "~5.5.4", - "typescript-eslint": "^8.18.1" + "typescript": "~5.9.3", + "typescript-eslint": "^8.54.0" }, "resolutions": { - "ace-builds": "1.36.5", - "tinymce": "6.8.5", - "rollup": "4.22.4", - "@babel/core": "7.25.2", - "esbuild": "0.23.0", - "jquery.terminal/coveralls-next/form-data": "4.0.4", - "node-forge": "1.3.3", - "glob": "10.5.0", - "path-to-regexp": "0.1.12" + "ace-builds": "1.43.6", + "tinymce": "6.8.6", + "@babel/core": "7.28.3", + "esbuild": "0.28.0", + "rollup": "4.59.0", + "jquery.terminal/**/form-data": ">=4.0.4", + "js-beautify/**/minimatch": "^9.0.7" } } diff --git a/ui-ngx/patches/@angular+build+18.2.12.patch b/ui-ngx/patches/@angular+build+20.3.24.patch similarity index 61% rename from ui-ngx/patches/@angular+build+18.2.12.patch rename to ui-ngx/patches/@angular+build+20.3.24.patch index 8861374691..6c88eb6de2 100644 --- a/ui-ngx/patches/@angular+build+18.2.12.patch +++ b/ui-ngx/patches/@angular+build+20.3.24.patch @@ -1,30 +1,30 @@ diff --git a/node_modules/@angular/build/src/tools/angular/compilation/angular-compilation.js b/node_modules/@angular/build/src/tools/angular/compilation/angular-compilation.js -index 625c621..4fc8bd8 100755 +index 53168ea..f07c80a 100755 --- a/node_modules/@angular/build/src/tools/angular/compilation/angular-compilation.js +++ b/node_modules/@angular/build/src/tools/angular/compilation/angular-compilation.js -@@ -68,8 +68,6 @@ class AngularCompilation { +@@ -78,8 +78,6 @@ class AngularCompilation { allowEmptyCodegenFiles: false, annotationsAs: 'decorators', enableResourceInlining: false, - supportTestBed: false, - supportJitMode: false, - })); - } - async diagnoseFiles(modes = DiagnosticModes.All) { + // Disable removing of comments as TS is quite aggressive with these and can + // remove important annotations, such as /* @__PURE__ */ and comments like /* vite-ignore */. + removeComments: false, diff --git a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js -index b03875c..8c48101 100755 +index ee68408..2667b84 100755 --- a/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js +++ b/node_modules/@angular/build/src/tools/esbuild/angular/compiler-plugin.js -@@ -79,7 +79,7 @@ function createCompilerPlugin(pluginOptions, styleOptions) { +@@ -90,7 +90,7 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun sourcemap: !!pluginOptions.sourcemap, thirdPartySourcemaps: pluginOptions.thirdPartySourcemaps, advancedOptimizations: pluginOptions.advancedOptimizations, -- jit: pluginOptions.jit, -+ jit: true, // pluginOptions.jit, +- jit: pluginOptions.jit || pluginOptions.includeTestMetadata, ++ jit: true, //pluginOptions.jit || pluginOptions.includeTestMetadata, }, environment_options_1.maxWorkers, cacheStore?.createCache('jstransformer')); // Setup defines based on the values used by the Angular compiler-cli build.initialOptions.define ??= {}; -@@ -385,12 +385,14 @@ function createCompilerPlugin(pluginOptions, styleOptions) { +@@ -448,12 +448,14 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun async function hasSideEffects(path) { if (!pluginOptions.advancedOptimizations) { return undefined; @@ -41,3 +41,14 @@ index b03875c..8c48101 100755 } }, }; +@@ -554,9 +556,7 @@ function createCompilerOptionsTransformer(setupWarnings, pluginOptions, preserve + sourceRoot: undefined, + preserveSymlinks, + externalRuntimeStyles: pluginOptions.externalRuntimeStyles, +- _enableHmr: !!pluginOptions.templateUpdates, +- supportTestBed: !!pluginOptions.includeTestMetadata, +- supportJitMode: !!pluginOptions.includeTestMetadata, ++ _enableHmr: !!pluginOptions.templateUpdates + }; + }; + } diff --git a/ui-ngx/patches/@angular+common+18.2.13.patch b/ui-ngx/patches/@angular+common+18.2.13.patch deleted file mode 100644 index 984562138a..0000000000 --- a/ui-ngx/patches/@angular+common+18.2.13.patch +++ /dev/null @@ -1,66 +0,0 @@ -diff --git a/node_modules/@angular/common/esm2022/http/src/xsrf.mjs b/node_modules/@angular/common/esm2022/http/src/xsrf.mjs -index da69c17..d17f6ad 100755 ---- a/node_modules/@angular/common/esm2022/http/src/xsrf.mjs -+++ b/node_modules/@angular/common/esm2022/http/src/xsrf.mjs -@@ -19,6 +19,10 @@ export const XSRF_HEADER_NAME = new InjectionToken(ngDevMode ? 'XSRF_HEADER_NAME - providedIn: 'root', - factory: () => XSRF_DEFAULT_HEADER_NAME, - }); -+/** -+ * Regex to match absolute URLs, including protocol-relative URLs. -+ */ -+const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i; - /** - * Retrieves the current XSRF token to use with the next outgoing request. - * -@@ -69,7 +73,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImpo - args: [XSRF_COOKIE_NAME] - }] }] }); - export function xsrfInterceptorFn(req, next) { -- const lcUrl = req.url.toLowerCase(); - // Skip both non-mutating requests and absolute URLs. - // Non-mutating requests don't require a token, and absolute URLs require special handling - // anyway as the cookie set -@@ -77,8 +80,7 @@ export function xsrfInterceptorFn(req, next) { - if (!inject(XSRF_ENABLED) || - req.method === 'GET' || - req.method === 'HEAD' || -- lcUrl.startsWith('http://') || -- lcUrl.startsWith('https://')) { -+ ABSOLUTE_URL_REGEX.test(req.url)) { - return next(req); - } - const token = inject(HttpXsrfTokenExtractor).getToken(); -diff --git a/node_modules/@angular/common/fesm2022/http.mjs b/node_modules/@angular/common/fesm2022/http.mjs -index 1655480..d1dbb38 100755 ---- a/node_modules/@angular/common/fesm2022/http.mjs -+++ b/node_modules/@angular/common/fesm2022/http.mjs -@@ -2352,6 +2352,10 @@ const XSRF_HEADER_NAME = new InjectionToken(ngDevMode ? 'XSRF_HEADER_NAME' : '', - providedIn: 'root', - factory: () => XSRF_DEFAULT_HEADER_NAME, - }); -+/** -+ * Regex to match absolute URLs, including protocol-relative URLs. -+ */ -+const ABSOLUTE_URL_REGEX = /^(?:https?:)?\/\//i; - /** - * Retrieves the current XSRF token to use with the next outgoing request. - * -@@ -2402,7 +2406,6 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.2.13", ngImpo - args: [XSRF_COOKIE_NAME] - }] }] }); - function xsrfInterceptorFn(req, next) { -- const lcUrl = req.url.toLowerCase(); - // Skip both non-mutating requests and absolute URLs. - // Non-mutating requests don't require a token, and absolute URLs require special handling - // anyway as the cookie set -@@ -2410,8 +2413,7 @@ function xsrfInterceptorFn(req, next) { - if (!inject(XSRF_ENABLED) || - req.method === 'GET' || - req.method === 'HEAD' || -- lcUrl.startsWith('http://') || -- lcUrl.startsWith('https://')) { -+ ABSOLUTE_URL_REGEX.test(req.url)) { - return next(req); - } - const token = inject(HttpXsrfTokenExtractor).getToken(); diff --git a/ui-ngx/patches/@angular+compiler+18.2.13.patch b/ui-ngx/patches/@angular+compiler+18.2.13.patch deleted file mode 100644 index af8e6995cd..0000000000 --- a/ui-ngx/patches/@angular+compiler+18.2.13.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/node_modules/@angular/compiler/fesm2022/compiler.mjs b/node_modules/@angular/compiler/fesm2022/compiler.mjs -index a00b189..58896de 100755 ---- a/node_modules/@angular/compiler/fesm2022/compiler.mjs -+++ b/node_modules/@angular/compiler/fesm2022/compiler.mjs -@@ -18631,6 +18631,7 @@ function SECURITY_SCHEMA() { - 'area|ping', - 'audio|src', - 'a|href', -+ 'a|xlink:href', - 'a|ping', - 'blockquote|cite', - 'body|background', -@@ -18644,6 +18645,75 @@ function SECURITY_SCHEMA() { - 'track|src', - 'video|poster', - 'video|src', -+ -+ // MathML namespace -+ // https://crsrc.org/c/third_party/blink/renderer/core/sanitizer/sanitizer.cc;l=753-768;drc=b3eb16372dcd3317d65e9e0265015e322494edcd;bpv=1;bpt=1 -+ 'annotation|href', -+ 'annotation|xlink:href', -+ 'annotation-xml|href', -+ 'annotation-xml|xlink:href', -+ 'maction|href', -+ 'maction|xlink:href', -+ 'malignmark|href', -+ 'malignmark|xlink:href', -+ 'math|href', -+ 'math|xlink:href', -+ 'mroot|href', -+ 'mroot|xlink:href', -+ 'msqrt|href', -+ 'msqrt|xlink:href', -+ 'merror|href', -+ 'merror|xlink:href', -+ 'mfrac|href', -+ 'mfrac|xlink:href', -+ 'mglyph|href', -+ 'mglyph|xlink:href', -+ 'msub|href', -+ 'msub|xlink:href', -+ 'msup|href', -+ 'msup|xlink:href', -+ 'msubsup|href', -+ 'msubsup|xlink:href', -+ 'mmultiscripts|href', -+ 'mmultiscripts|xlink:href', -+ 'mprescripts|href', -+ 'mprescripts|xlink:href', -+ 'mi|href', -+ 'mi|xlink:href', -+ 'mn|href', -+ 'mn|xlink:href', -+ 'mo|href', -+ 'mo|xlink:href', -+ 'mpadded|href', -+ 'mpadded|xlink:href', -+ 'mphantom|href', -+ 'mphantom|xlink:href', -+ 'mrow|href', -+ 'mrow|xlink:href', -+ 'ms|href', -+ 'ms|xlink:href', -+ 'mspace|href', -+ 'mspace|xlink:href', -+ 'mstyle|href', -+ 'mstyle|xlink:href', -+ 'mtable|href', -+ 'mtable|xlink:href', -+ 'mtd|href', -+ 'mtd|xlink:href', -+ 'mtr|href', -+ 'mtr|xlink:href', -+ 'mtext|href', -+ 'mtext|xlink:href', -+ 'mover|href', -+ 'mover|xlink:href', -+ 'munder|href', -+ 'munder|xlink:href', -+ 'munderover|href', -+ 'munderover|xlink:href', -+ 'semantics|href', -+ 'semantics|xlink:href', -+ 'none|href', -+ 'none|xlink:href', - ]); - registerContext(SecurityContext.RESOURCE_URL, [ - 'applet|code', -@@ -18659,6 +18729,8 @@ function SECURITY_SCHEMA() { - 'object|codebase', - 'object|data', - 'script|src', -+ 'script|href', -+ 'script|xlink:href', - ]); - } - return _SECURITY_SCHEMA; diff --git a/ui-ngx/patches/@angular+core+18.2.13.patch b/ui-ngx/patches/@angular+core+18.2.13.patch deleted file mode 100644 index c6d635e621..0000000000 --- a/ui-ngx/patches/@angular+core+18.2.13.patch +++ /dev/null @@ -1,20 +0,0 @@ -diff --git a/node_modules/@angular/core/fesm2022/core.mjs b/node_modules/@angular/core/fesm2022/core.mjs -index 7d9d345..b4d21c6 100755 ---- a/node_modules/@angular/core/fesm2022/core.mjs -+++ b/node_modules/@angular/core/fesm2022/core.mjs -@@ -12859,13 +12859,13 @@ function findDirectiveDefMatches(tView, tNode) { - if (isNodeMatchingSelectorList(tNode, def.selectors, /* isProjectionMode */ false)) { - matches || (matches = []); - if (isComponentDef(def)) { -- if (ngDevMode) { -+ // if (ngDevMode) { - assertTNodeType(tNode, 2 /* TNodeType.Element */, `"${tNode.value}" tags cannot be used as component hosts. ` + - `Please use a different tag to activate the ${stringify(def.type)} component.`); - if (isComponentHost(tNode)) { - throwMultipleComponentError(tNode, matches.find(isComponentDef).type, def.type); - } -- } -+ // } - // Components are inserted at the front of the matches array so that their lifecycle - // hooks run before any directive lifecycle hooks. This appears to be for ViewEngine - // compatibility. This logic doesn't make sense with host directives, because it diff --git a/ui-ngx/patches/@angular+core+20.3.19.patch b/ui-ngx/patches/@angular+core+20.3.19.patch new file mode 100644 index 0000000000..5295946f7b --- /dev/null +++ b/ui-ngx/patches/@angular+core+20.3.19.patch @@ -0,0 +1,20 @@ +diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs +index 4f7d936..4a98b2c 100755 +--- a/node_modules/@angular/core/fesm2022/debug_node.mjs ++++ b/node_modules/@angular/core/fesm2022/debug_node.mjs +@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { + if (isNodeMatchingSelectorList(tNode, def.selectors, /* isProjectionMode */ false)) { + matches ??= []; + if (isComponentDef(def)) { +- if (ngDevMode) { ++ //if (ngDevMode) { + assertTNodeType(tNode, 2 /* TNodeType.Element */, `"${tNode.value}" tags cannot be used as component hosts. ` + + `Please use a different tag to activate the ${stringify(def.type)} component.`); + if (matches.length && isComponentDef(matches[0])) { + throwMultipleComponentError(tNode, matches.find(isComponentDef).type, def.type); + } +- } ++ //} + matches.unshift(def); + } + else { diff --git a/ui-ngx/patches/@angular+material+18.2.14.patch b/ui-ngx/patches/@angular+material+18.2.14.patch deleted file mode 100644 index db380983a2..0000000000 --- a/ui-ngx/patches/@angular+material+18.2.14.patch +++ /dev/null @@ -1,61 +0,0 @@ -diff --git a/node_modules/@angular/material/autocomplete/index.d.ts b/node_modules/@angular/material/autocomplete/index.d.ts -index 1ebc198..f572ea2 100755 ---- a/node_modules/@angular/material/autocomplete/index.d.ts -+++ b/node_modules/@angular/material/autocomplete/index.d.ts -@@ -220,6 +220,10 @@ export declare interface MatAutocompleteDefaultOptions { - requireSelection?: boolean; - /** Class or list of classes to be applied to the autocomplete's overlay panel. */ - overlayPanelClass?: string | string[]; -+ -+ backdropClass?: string; -+ -+ hasBackdrop?: boolean; - /** Wheter icon indicators should be hidden for single-selection. */ - hideSingleSelectionIndicator?: boolean; - } -diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs -index 193f5e4..833446a 100755 ---- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs -+++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete-trigger.mjs -@@ -670,6 +670,8 @@ export class MatAutocompleteTrigger { - scrollStrategy: this._scrollStrategy(), - width: this._getPanelWidth(), - direction: this._dir ?? undefined, -+ hasBackdrop: this._defaults?.hasBackdrop, -+ backdropClass: this._defaults?.backdropClass, - panelClass: this._defaults?.overlayPanelClass, - }); - } -diff --git a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs -index 3d919a4..5fd6b4d 100755 ---- a/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs -+++ b/node_modules/@angular/material/esm2022/autocomplete/autocomplete.mjs -@@ -41,6 +41,7 @@ export function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { - autoSelectActiveOption: false, - hideSingleSelectionIndicator: false, - requireSelection: false, -+ hasBackdrop: false, - }; - } - /** Autocomplete component. */ -diff --git a/node_modules/@angular/material/fesm2022/autocomplete.mjs b/node_modules/@angular/material/fesm2022/autocomplete.mjs -index e3d0253..36cd6d7 100755 ---- a/node_modules/@angular/material/fesm2022/autocomplete.mjs -+++ b/node_modules/@angular/material/fesm2022/autocomplete.mjs -@@ -65,6 +65,7 @@ function MAT_AUTOCOMPLETE_DEFAULT_OPTIONS_FACTORY() { - autoSelectActiveOption: false, - hideSingleSelectionIndicator: false, - requireSelection: false, -+ hasBackdrop: false, - }; - } - /** Autocomplete component. */ -@@ -926,6 +927,8 @@ class MatAutocompleteTrigger { - scrollStrategy: this._scrollStrategy(), - width: this._getPanelWidth(), - direction: this._dir ?? undefined, -+ hasBackdrop: this._defaults?.hasBackdrop, -+ backdropClass: this._defaults?.backdropClass, - panelClass: this._defaults?.overlayPanelClass, - }); - } diff --git a/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch b/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch deleted file mode 100644 index c8090076cc..0000000000 --- a/ui-ngx/patches/@iplab+ngx-color-picker+18.0.1.patch +++ /dev/null @@ -1,97 +0,0 @@ -diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs -index bf5480b..02c8246 100644 ---- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs -+++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hex-input/hex-input.component.mjs -@@ -29,10 +29,10 @@ export class HexComponent { - } - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "

\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, decorators: [{ - type: Component, -- args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaGV4LWlucHV0LmNvbXBvbmVudC5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oZXgtaW5wdXQvaGV4LWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oZXgtaW5wdXQvaGV4LWlucHV0LmNvbXBvbmVudC5odG1sIl0sIm5hbWVzIjpbXSwibWFwcGluZ3MiOiJBQUFBLE9BQU8sRUFBRSxTQUFTLEVBQUUsdUJBQXVCLEVBQUUsZ0JBQWdCLEVBQWUsS0FBSyxFQUFFLEtBQUssRUFBZSxNQUFNLGVBQWUsQ0FBQztBQUM3SCxPQUFPLEVBQUUsS0FBSyxFQUFFLE1BQU0saUNBQWlDLENBQUM7O0FBY3hELE1BQU0sT0FBTyxZQUFZO0lBWHpCO1FBYVcsVUFBSyxHQUF1QixLQUFLLENBQUMsUUFBUSxFQUFTLENBQUM7UUFFcEQsaUJBQVksR0FBeUIsS0FBSyxDQUFtQixLQUFLLEVBQUUsRUFBRSxLQUFLLEVBQUUsT0FBTyxFQUFFLFNBQVMsRUFBRSxnQkFBZ0IsRUFBRSxDQUFDLENBQUM7UUFFckgsZ0JBQVcsR0FBd0IsS0FBSyxDQUFTLEVBQUUsRUFBRSxFQUFFLEtBQUssRUFBRSxRQUFRLEVBQUUsQ0FBQyxDQUFDO0tBMkJwRjtJQXpCRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxXQUFXLEVBQUUsR0FBRyxDQUFDLElBQUksQ0FBQyxLQUFLLEVBQUUsQ0FBQyxDQUFDLENBQUMsSUFBSSxDQUFDLEtBQUssRUFBRSxDQUFDLFdBQVcsQ0FBQyxJQUFJLENBQUMsS0FBSyxFQUFFLENBQUMsT0FBTyxFQUFFLENBQUMsS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLE9BQU8sQ0FBQyxHQUFHLEVBQUUsRUFBRSxDQUFDLENBQUMsQ0FBQyxDQUFDLEVBQUUsQ0FBQyxDQUFDO0lBQ2xJLENBQUM7SUFFTSxhQUFhLENBQUMsS0FBb0IsRUFBRSxVQUFrQjtRQUN6RCxNQUFNLEtBQUssR0FBRyxVQUFVLENBQUMsV0FBVyxFQUFFLENBQUMsT0FBTyxDQUFDLEdBQUcsRUFBRSxFQUFFLENBQUMsQ0FBQztRQUV4RCxJQUNBLENBQUMsQ0FBQyxLQUFLLENBQUMsT0FBTyxLQUFLLEVBQUUsSUFBSSxLQUFLLENBQUMsR0FBRyxDQUFDLFdBQVcsRUFBRSxLQUFLLE9BQU8sQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxDQUFDO2VBQ2xGLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxJQUFJLEtBQUssQ0FBQyxNQUFNLEtBQUssQ0FBQyxFQUFFLENBQUM7WUFDMUMsTUFBTSxHQUFHLEdBQUcsUUFBUSxDQUFDLEtBQUssRUFBRSxFQUFFLENBQUMsQ0FBQztZQUNoQyxNQUFNLE1BQU0sR0FBRyxHQUFHLENBQUMsUUFBUSxDQUFDLEVBQUUsQ0FBQyxDQUFDO1lBRWhDOzs7OztlQUtHO1lBQ0gsSUFBSSxNQUFNLENBQUMsUUFBUSxDQUFDLEtBQUssQ0FBQyxNQUFNLEVBQUUsR0FBRyxDQUFDLEtBQUssS0FBSyxJQUFJLElBQUksQ0FBQyxLQUFLLEtBQUssS0FBSyxFQUFFLENBQUM7Z0JBQ3ZFLE1BQU0sUUFBUSxHQUFHLElBQUksS0FBSyxDQUFDLElBQUksS0FBSyxFQUFFLENBQUMsQ0FBQztnQkFDeEMsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7WUFDN0IsQ0FBQztRQUNMLENBQUM7SUFDTCxDQUFDOzhHQWhDUSxZQUFZO2tHQUFaLFlBQVksZ2dCQ2Z6Qiw0TUFLTTs7MkZEVU8sWUFBWTtrQkFYeEIsU0FBUzsrQkFDSSxxQkFBcUIsbUJBT2QsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJIiwic291cmNlc0NvbnRlbnQiOlsiaW1wb3J0IHsgQ29tcG9uZW50LCBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneSwgYm9vbGVhbkF0dHJpYnV0ZSwgSW5wdXRTaWduYWwsIGlucHV0LCBtb2RlbCwgTW9kZWxTaWduYWwgfSBmcm9tICdAYW5ndWxhci9jb3JlJztcclxuaW1wb3J0IHsgQ29sb3IgfSBmcm9tICcuLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuXHJcblxyXG5AQ29tcG9uZW50KHtcclxuICAgIHNlbGVjdG9yOiBgaGV4LWlucHV0LWNvbXBvbmVudGAsXHJcbiAgICB0ZW1wbGF0ZVVybDogYC4vaGV4LWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL2hleC1pbnB1dC5jb21wb25lbnQuc2Nzc2BcclxuICAgIF0sXHJcbiAgICBjaGFuZ2VEZXRlY3Rpb246IENoYW5nZURldGVjdGlvblN0cmF0ZWd5Lk9uUHVzaCxcclxuICAgIHN0YW5kYWxvbmU6IHRydWVcclxufSlcclxuZXhwb3J0IGNsYXNzIEhleENvbXBvbmVudCB7XHJcblxyXG4gICAgcHVibGljIGNvbG9yOiBNb2RlbFNpZ25hbDxDb2xvcj4gPSBtb2RlbC5yZXF1aXJlZDxDb2xvcj4oKTtcclxuXHJcbiAgICBwdWJsaWMgbGFiZWxWaXNpYmxlOiBJbnB1dFNpZ25hbDxib29sZWFuPiA9IGlucHV0PGJvb2xlYW4sIGJvb2xlYW4+KGZhbHNlLCB7IGFsaWFzOiAnbGFiZWwnLCB0cmFuc2Zvcm06IGJvb2xlYW5BdHRyaWJ1dGUgfSk7XHJcblxyXG4gICAgcHVibGljIHByZWZpeFZhbHVlOiBJbnB1dFNpZ25hbDxzdHJpbmc+ID0gaW5wdXQ8c3RyaW5nPignJywgeyBhbGlhczogJ3ByZWZpeCcgfSk7XHJcblxyXG4gICAgcHVibGljIGdldCB2YWx1ZSgpIHtcclxuICAgICAgICByZXR1cm4gdGhpcy5wcmVmaXhWYWx1ZSgpICsgKHRoaXMuY29sb3IoKSA/IHRoaXMuY29sb3IoKS50b0hleFN0cmluZyh0aGlzLmNvbG9yKCkuZ2V0UmdiYSgpLmFscGhhIDwgMSkucmVwbGFjZSgnIycsICcnKSA6ICcnKTtcclxuICAgIH1cclxuXHJcbiAgICBwdWJsaWMgb25JbnB1dENoYW5nZShldmVudDogS2V5Ym9hcmRFdmVudCwgaW5wdXRWYWx1ZTogc3RyaW5nKTogdm9pZCB7XHJcbiAgICAgICAgY29uc3QgdmFsdWUgPSBpbnB1dFZhbHVlLnRvTG93ZXJDYXNlKCkucmVwbGFjZSgnIycsICcnKTtcclxuXHJcbiAgICAgICAgaWYgKFxyXG4gICAgICAgICgoZXZlbnQua2V5Q29kZSA9PT0gMTMgfHwgZXZlbnQua2V5LnRvTG93ZXJDYXNlKCkgPT09ICdlbnRlcicpICYmIHZhbHVlLmxlbmd0aCA9PT0gMylcclxuICAgICAgICB8fCB2YWx1ZS5sZW5ndGggPT09IDYgfHwgdmFsdWUubGVuZ3RoID09PSA4KSB7XHJcbiAgICAgICAgICAgIGNvbnN0IGhleCA9IHBhcnNlSW50KHZhbHVlLCAxNik7XHJcbiAgICAgICAgICAgIGNvbnN0IGhleFN0ciA9IGhleC50b1N0cmluZygxNik7XHJcblxyXG4gICAgICAgICAgICAvKipcclxuICAgICAgICAgICAgICogaWYgdmFsdWUgaXMgdmFsaWRcclxuICAgICAgICAgICAgICogY2hhbmdlIGNvbG9yIGVsc2UgZG8gbm90aGluZ1xyXG4gICAgICAgICAgICAgKiBhZnRlciBwYXJzaW5nIG51bWJlciBsZWFkaW5nIDAgaXMgcmVtb3ZlZCxcclxuICAgICAgICAgICAgICogY29tcGFyZSBsZW5ndGggYW5kIGFkZCBsZWFkaW5nIDAgYmVmb3JlIGNvbXBhcmluZyB0d28gdmFsdWVzXHJcbiAgICAgICAgICAgICAqL1xyXG4gICAgICAgICAgICBpZiAoaGV4U3RyLnBhZFN0YXJ0KHZhbHVlLmxlbmd0aCwgJzAnKSA9PT0gdmFsdWUgJiYgdGhpcy52YWx1ZSAhPT0gdmFsdWUpIHtcclxuICAgICAgICAgICAgICAgIGNvbnN0IG5ld0NvbG9yID0gbmV3IENvbG9yKGAjJHt2YWx1ZX1gKTtcclxuICAgICAgICAgICAgICAgIHRoaXMuY29sb3Iuc2V0KG5ld0NvbG9yKTtcclxuICAgICAgICAgICAgfVxyXG4gICAgICAgIH1cclxuICAgIH1cclxufVxyXG4iLCI8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgI2VsUmVmIHR5cGU9XCJ0ZXh0XCIgW3ZhbHVlXT1cInZhbHVlXCIgKGtleXVwKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCBlbFJlZi52YWx1ZSlcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPkhFWDwvc3Bhbj5cclxuICAgIH1cclxuPC9kaXY+Il19 -diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs -index 2454b27..dd007ce 100644 ---- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs -+++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/hsla-input/hsla-input.component.mjs -@@ -21,10 +21,10 @@ export class HslaComponent { - this.color.set(newColor); - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, decorators: [{ - type: Component, -- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaHNsYS1pbnB1dC5jb21wb25lbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi8uLi8uLi8uLi9wcm9qZWN0cy9pcGxhYi9uZ3gtY29sb3ItcGlja2VyL3NyYy9saWIvY29tcG9uZW50cy9wYXJ0cy9pbnB1dHMvaHNsYS1pbnB1dC9oc2xhLWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9oc2xhLWlucHV0L2hzbGEtaW5wdXQuY29tcG9uZW50Lmh0bWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSx1QkFBdUIsRUFBRSxnQkFBZ0IsRUFBZSxLQUFLLEVBQUUsS0FBSyxFQUFlLE1BQU0sZUFBZSxDQUFDO0FBQzdILE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxtQ0FBbUMsQ0FBQztBQUMxRCxPQUFPLEVBQUUseUJBQXlCLEVBQUUsTUFBTSxxREFBcUQsQ0FBQzs7QUFlaEcsTUFBTSxPQUFPLGFBQWE7SUFaMUI7UUFjVyxVQUFLLEdBQXVCLEtBQUssQ0FBQyxRQUFRLEVBQVMsQ0FBQztRQUVwRCxpQkFBWSxHQUF5QixLQUFLLENBQW1CLEtBQUssRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztRQUVySCxtQkFBYyxHQUF5QixLQUFLLENBQW1CLElBQUksRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztLQWdCaEk7SUFkRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxLQUFLLEVBQUUsRUFBRSxPQUFPLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBRU0sYUFBYSxDQUFDLFFBQWdCLEVBQUUsS0FBNEI7UUFDL0QsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQztRQUN6QixNQUFNLEdBQUcsR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUM7UUFDakQsTUFBTSxVQUFVLEdBQUcsS0FBSyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsVUFBVSxDQUFDO1FBQy9ELE1BQU0sU0FBUyxHQUFHLEtBQUssS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLFNBQVMsQ0FBQztRQUM3RCxNQUFNLEtBQUssR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUM7UUFFckQsTUFBTSxRQUFRLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQyxPQUFPLENBQUMsR0FBRyxFQUFFLFVBQVUsRUFBRSxTQUFTLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDeEUsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7SUFDN0IsQ0FBQzs4R0FyQlEsYUFBYTtrR0FBYixhQUFhLHNnQkNqQjFCLGlsQ0F5QkMseWVEVmEseUJBQXlCOzsyRkFFMUIsYUFBYTtrQkFaekIsU0FBUzsrQkFDSSxzQkFBc0IsbUJBT2YsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJLFdBQ1AsQ0FBQyx5QkFBeUIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IENvbXBvbmVudCwgQ2hhbmdlRGV0ZWN0aW9uU3RyYXRlZ3ksIGJvb2xlYW5BdHRyaWJ1dGUsIElucHV0U2lnbmFsLCBpbnB1dCwgbW9kZWwsIE1vZGVsU2lnbmFsIH0gZnJvbSAnQGFuZ3VsYXIvY29yZSc7XHJcbmltcG9ydCB7IENvbG9yIH0gZnJvbSAnLi8uLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuaW1wb3J0IHsgQ29sb3JQaWNrZXJJbnB1dERpcmVjdGl2ZSB9IGZyb20gJy4uLy4uLy4uLy4uL2RpcmVjdGl2ZXMvY29sb3ItcGlja2VyLWlucHV0LmRpcmVjdGl2ZSc7XHJcblxyXG5cclxuQENvbXBvbmVudCh7XHJcbiAgICBzZWxlY3RvcjogYGhzbGEtaW5wdXQtY29tcG9uZW50YCxcclxuICAgIHRlbXBsYXRlVXJsOiBgLi9oc2xhLWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL2hzbGEtaW5wdXQuY29tcG9uZW50LnNjc3NgXHJcbiAgICBdLFxyXG4gICAgY2hhbmdlRGV0ZWN0aW9uOiBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneS5PblB1c2gsXHJcbiAgICBzdGFuZGFsb25lOiB0cnVlLFxyXG4gICAgaW1wb3J0czogW0NvbG9yUGlja2VySW5wdXREaXJlY3RpdmVdXHJcbn0pXHJcbmV4cG9ydCBjbGFzcyBIc2xhQ29tcG9uZW50IHtcclxuXHJcbiAgICBwdWJsaWMgY29sb3I6IE1vZGVsU2lnbmFsPENvbG9yPiA9IG1vZGVsLnJlcXVpcmVkPENvbG9yPigpO1xyXG5cclxuICAgIHB1YmxpYyBsYWJlbFZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4oZmFsc2UsIHsgYWxpYXM6ICdsYWJlbCcsIHRyYW5zZm9ybTogYm9vbGVhbkF0dHJpYnV0ZSB9KTtcclxuXHJcbiAgICBwdWJsaWMgaXNBbHBoYVZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4odHJ1ZSwgeyBhbGlhczogJ2FscGhhJywgdHJhbnNmb3JtOiBib29sZWFuQXR0cmlidXRlIH0pO1xyXG5cclxuICAgIHB1YmxpYyBnZXQgdmFsdWUoKSB7XHJcbiAgICAgICAgcmV0dXJuIHRoaXMuY29sb3IoKT8uZ2V0SHNsYSgpO1xyXG4gICAgfVxyXG5cclxuICAgIHB1YmxpYyBvbklucHV0Q2hhbmdlKG5ld1ZhbHVlOiBudW1iZXIsIGNvbG9yOiAnSCcgfCAnUycgfCAnTCcgfCAnQScpIHtcclxuICAgICAgICBjb25zdCB2YWx1ZSA9IHRoaXMudmFsdWU7XHJcbiAgICAgICAgY29uc3QgaHVlID0gY29sb3IgPT09ICdIJyA/IG5ld1ZhbHVlIDogdmFsdWUuaHVlO1xyXG4gICAgICAgIGNvbnN0IHNhdHVyYXRpb24gPSBjb2xvciA9PT0gJ1MnID8gbmV3VmFsdWUgOiB2YWx1ZS5zYXR1cmF0aW9uO1xyXG4gICAgICAgIGNvbnN0IGxpZ2h0bmVzcyA9IGNvbG9yID09PSAnTCcgPyBuZXdWYWx1ZSA6IHZhbHVlLmxpZ2h0bmVzcztcclxuICAgICAgICBjb25zdCBhbHBoYSA9IGNvbG9yID09PSAnQScgPyBuZXdWYWx1ZSA6IHZhbHVlLmFscGhhO1xyXG5cclxuICAgICAgICBjb25zdCBuZXdDb2xvciA9IG5ldyBDb2xvcigpLnNldEhzbGEoaHVlLCBzYXR1cmF0aW9uLCBsaWdodG5lc3MsIGFscGhhKTtcclxuICAgICAgICB0aGlzLmNvbG9yLnNldChuZXdDb2xvcik7XHJcbiAgICB9XHJcbn1cclxuIiwiPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIzNjBcIiBbdmFsdWVdPVwidmFsdWU/LmdldEh1ZSgpLnRvU3RyaW5nKClcIiAoaW5wdXRDaGFuZ2UpPVwib25JbnB1dENoYW5nZSgkZXZlbnQsICdIJylcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPkg8L3NwYW4+XHJcbiAgICB9XHJcbjwvZGl2PlxyXG48ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgdHlwZT1cInRleHRcIiBwYXR0ZXJuPVwiWzAtOV0qXCIgbWluPVwiMFwiIG1heD1cIjEwMFwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0U2F0dXJhdGlvbigpICsgJyUnXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnUycpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5TPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIxMDBcIiBbdmFsdWVdPVwidmFsdWU/LmdldExpZ2h0bmVzcygpICsgJyUnXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnTCcpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5MPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuQGlmIChpc0FscGhhVmlzaWJsZSgpKSB7XHJcbiAgICA8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKyhbXFwuLF1bMC05XXsxLDJ9KT9cIiBtaW49XCIwXCIgbWF4PVwiMVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0QWxwaGEoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQScpXCIgLz5cclxuICAgICAgICBAaWYgKGxhYmVsVmlzaWJsZSgpKSB7XHJcbiAgICAgICAgICAgIDxzcGFuPkE8L3NwYW4+XHJcbiAgICAgICAgfVxyXG4gICAgPC9kaXY+XHJcbn0iXX0= -diff --git a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs -index 6d976e8..8989aad 100644 ---- a/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs -+++ b/node_modules/@iplab/ngx-color-picker/esm2022/lib/components/parts/inputs/rgba-input/rgba-input.component.mjs -@@ -21,10 +21,10 @@ export class RgbaComponent { - this.color.set(newColor); - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, decorators: [{ - type: Component, -- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoicmdiYS1pbnB1dC5jb21wb25lbnQuanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi8uLi8uLi8uLi8uLi8uLi9wcm9qZWN0cy9pcGxhYi9uZ3gtY29sb3ItcGlja2VyL3NyYy9saWIvY29tcG9uZW50cy9wYXJ0cy9pbnB1dHMvcmdiYS1pbnB1dC9yZ2JhLWlucHV0LmNvbXBvbmVudC50cyIsIi4uLy4uLy4uLy4uLy4uLy4uLy4uLy4uL3Byb2plY3RzL2lwbGFiL25neC1jb2xvci1waWNrZXIvc3JjL2xpYi9jb21wb25lbnRzL3BhcnRzL2lucHV0cy9yZ2JhLWlucHV0L3JnYmEtaW5wdXQuY29tcG9uZW50Lmh0bWwiXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IkFBQUEsT0FBTyxFQUFFLFNBQVMsRUFBRSx1QkFBdUIsRUFBRSxnQkFBZ0IsRUFBZSxLQUFLLEVBQUUsS0FBSyxFQUFlLE1BQU0sZUFBZSxDQUFDO0FBQzdILE9BQU8sRUFBRSxLQUFLLEVBQUUsTUFBTSxtQ0FBbUMsQ0FBQztBQUMxRCxPQUFPLEVBQUUseUJBQXlCLEVBQUUsTUFBTSxxREFBcUQsQ0FBQzs7QUFlaEcsTUFBTSxPQUFPLGFBQWE7SUFaMUI7UUFjVyxVQUFLLEdBQXVCLEtBQUssQ0FBQyxRQUFRLEVBQVMsQ0FBQztRQUVwRCxpQkFBWSxHQUF5QixLQUFLLENBQW1CLEtBQUssRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztRQUVySCxtQkFBYyxHQUF5QixLQUFLLENBQW1CLElBQUksRUFBRSxFQUFFLEtBQUssRUFBRSxPQUFPLEVBQUUsU0FBUyxFQUFFLGdCQUFnQixFQUFFLENBQUMsQ0FBQztLQWdCaEk7SUFkRyxJQUFXLEtBQUs7UUFDWixPQUFPLElBQUksQ0FBQyxLQUFLLEVBQUUsRUFBRSxPQUFPLEVBQUUsQ0FBQztJQUNuQyxDQUFDO0lBRU0sYUFBYSxDQUFDLFFBQWdCLEVBQUUsS0FBNEI7UUFDL0QsTUFBTSxLQUFLLEdBQUcsSUFBSSxDQUFDLEtBQUssQ0FBQztRQUN6QixNQUFNLEdBQUcsR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUM7UUFDakQsTUFBTSxLQUFLLEdBQUcsS0FBSyxLQUFLLEdBQUcsQ0FBQyxDQUFDLENBQUMsUUFBUSxDQUFDLENBQUMsQ0FBQyxLQUFLLENBQUMsS0FBSyxDQUFDO1FBQ3JELE1BQU0sSUFBSSxHQUFHLEtBQUssS0FBSyxHQUFHLENBQUMsQ0FBQyxDQUFDLFFBQVEsQ0FBQyxDQUFDLENBQUMsS0FBSyxDQUFDLElBQUksQ0FBQztRQUNuRCxNQUFNLEtBQUssR0FBRyxLQUFLLEtBQUssR0FBRyxDQUFDLENBQUMsQ0FBQyxRQUFRLENBQUMsQ0FBQyxDQUFDLEtBQUssQ0FBQyxLQUFLLENBQUM7UUFFckQsTUFBTSxRQUFRLEdBQUcsSUFBSSxLQUFLLEVBQUUsQ0FBQyxPQUFPLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxJQUFJLEVBQUUsS0FBSyxDQUFDLENBQUM7UUFDOUQsSUFBSSxDQUFDLEtBQUssQ0FBQyxHQUFHLENBQUMsUUFBUSxDQUFDLENBQUM7SUFDN0IsQ0FBQzs4R0FyQlEsYUFBYTtrR0FBYixhQUFhLHNnQkNqQjFCLGlsQ0F5QkMseWVEVmEseUJBQXlCOzsyRkFFMUIsYUFBYTtrQkFaekIsU0FBUzsrQkFDSSxzQkFBc0IsbUJBT2YsdUJBQXVCLENBQUMsTUFBTSxjQUNuQyxJQUFJLFdBQ1AsQ0FBQyx5QkFBeUIsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IENvbXBvbmVudCwgQ2hhbmdlRGV0ZWN0aW9uU3RyYXRlZ3ksIGJvb2xlYW5BdHRyaWJ1dGUsIElucHV0U2lnbmFsLCBpbnB1dCwgbW9kZWwsIE1vZGVsU2lnbmFsIH0gZnJvbSAnQGFuZ3VsYXIvY29yZSc7XHJcbmltcG9ydCB7IENvbG9yIH0gZnJvbSAnLi8uLi8uLi8uLi8uLi9oZWxwZXJzL2NvbG9yLmNsYXNzJztcclxuaW1wb3J0IHsgQ29sb3JQaWNrZXJJbnB1dERpcmVjdGl2ZSB9IGZyb20gJy4uLy4uLy4uLy4uL2RpcmVjdGl2ZXMvY29sb3ItcGlja2VyLWlucHV0LmRpcmVjdGl2ZSc7XHJcblxyXG5cclxuQENvbXBvbmVudCh7XHJcbiAgICBzZWxlY3RvcjogYHJnYmEtaW5wdXQtY29tcG9uZW50YCxcclxuICAgIHRlbXBsYXRlVXJsOiBgLi9yZ2JhLWlucHV0LmNvbXBvbmVudC5odG1sYCxcclxuICAgIHN0eWxlVXJsczogW1xyXG4gICAgICAgIGAuLy4uLy4uL2Jhc2Uuc3R5bGUuc2Nzc2AsXHJcbiAgICAgICAgYC4vLi4vaW5wdXQuY29tcG9uZW50LnNjc3NgLFxyXG4gICAgICAgIGAuL3JnYmEtaW5wdXQuY29tcG9uZW50LnNjc3NgXHJcbiAgICBdLFxyXG4gICAgY2hhbmdlRGV0ZWN0aW9uOiBDaGFuZ2VEZXRlY3Rpb25TdHJhdGVneS5PblB1c2gsXHJcbiAgICBzdGFuZGFsb25lOiB0cnVlLFxyXG4gICAgaW1wb3J0czogW0NvbG9yUGlja2VySW5wdXREaXJlY3RpdmVdXHJcbn0pXHJcbmV4cG9ydCBjbGFzcyBSZ2JhQ29tcG9uZW50IHtcclxuXHJcbiAgICBwdWJsaWMgY29sb3I6IE1vZGVsU2lnbmFsPENvbG9yPiA9IG1vZGVsLnJlcXVpcmVkPENvbG9yPigpO1xyXG5cclxuICAgIHB1YmxpYyBsYWJlbFZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4oZmFsc2UsIHsgYWxpYXM6ICdsYWJlbCcsIHRyYW5zZm9ybTogYm9vbGVhbkF0dHJpYnV0ZSB9KTtcclxuXHJcbiAgICBwdWJsaWMgaXNBbHBoYVZpc2libGU6IElucHV0U2lnbmFsPGJvb2xlYW4+ID0gaW5wdXQ8Ym9vbGVhbiwgYm9vbGVhbj4odHJ1ZSwgeyBhbGlhczogJ2FscGhhJywgdHJhbnNmb3JtOiBib29sZWFuQXR0cmlidXRlIH0pO1xyXG5cclxuICAgIHB1YmxpYyBnZXQgdmFsdWUoKSB7XHJcbiAgICAgICAgcmV0dXJuIHRoaXMuY29sb3IoKT8uZ2V0UmdiYSgpO1xyXG4gICAgfVxyXG5cclxuICAgIHB1YmxpYyBvbklucHV0Q2hhbmdlKG5ld1ZhbHVlOiBudW1iZXIsIGNvbG9yOiAnUicgfCAnRycgfCAnQicgfCAnQScpIHtcclxuICAgICAgICBjb25zdCB2YWx1ZSA9IHRoaXMudmFsdWU7XHJcbiAgICAgICAgY29uc3QgcmVkID0gY29sb3IgPT09ICdSJyA/IG5ld1ZhbHVlIDogdmFsdWUucmVkO1xyXG4gICAgICAgIGNvbnN0IGdyZWVuID0gY29sb3IgPT09ICdHJyA/IG5ld1ZhbHVlIDogdmFsdWUuZ3JlZW47XHJcbiAgICAgICAgY29uc3QgYmx1ZSA9IGNvbG9yID09PSAnQicgPyBuZXdWYWx1ZSA6IHZhbHVlLmJsdWU7XHJcbiAgICAgICAgY29uc3QgYWxwaGEgPSBjb2xvciA9PT0gJ0EnID8gbmV3VmFsdWUgOiB2YWx1ZS5hbHBoYTtcclxuXHJcbiAgICAgICAgY29uc3QgbmV3Q29sb3IgPSBuZXcgQ29sb3IoKS5zZXRSZ2JhKHJlZCwgZ3JlZW4sIGJsdWUsIGFscGhhKTtcclxuICAgICAgICB0aGlzLmNvbG9yLnNldChuZXdDb2xvcik7XHJcbiAgICB9XHJcbn1cclxuIiwiPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIyNTVcIiBbdmFsdWVdPVwidmFsdWU/LmdldFJlZCgpLnRvU3RyaW5nKClcIiAoaW5wdXRDaGFuZ2UpPVwib25JbnB1dENoYW5nZSgkZXZlbnQsICdSJylcIiAvPlxyXG4gICAgQGlmIChsYWJlbFZpc2libGUoKSkge1xyXG4gICAgICAgIDxzcGFuPlI8L3NwYW4+XHJcbiAgICB9XHJcbjwvZGl2PlxyXG48ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICA8aW5wdXQgdHlwZT1cInRleHRcIiBwYXR0ZXJuPVwiWzAtOV0qXCIgbWluPVwiMFwiIG1heD1cIjI1NVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0R3JlZW4oKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnRycpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5HPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuPGRpdiBjbGFzcz1cImNvbHVtblwiPlxyXG4gICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKlwiIG1pbj1cIjBcIiBtYXg9XCIyNTVcIiBbdmFsdWVdPVwidmFsdWU/LmdldEJsdWUoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQicpXCIgLz5cclxuICAgIEBpZiAobGFiZWxWaXNpYmxlKCkpIHtcclxuICAgICAgICA8c3Bhbj5CPC9zcGFuPlxyXG4gICAgfVxyXG48L2Rpdj5cclxuQGlmIChpc0FscGhhVmlzaWJsZSgpKSB7XHJcbiAgICA8ZGl2IGNsYXNzPVwiY29sdW1uXCI+XHJcbiAgICAgICAgPGlucHV0IHR5cGU9XCJ0ZXh0XCIgcGF0dGVybj1cIlswLTldKyhbXFwuLF1bMC05XXsxLDJ9KT9cIiBtaW49XCIwXCIgbWF4PVwiMVwiIFt2YWx1ZV09XCJ2YWx1ZT8uZ2V0QWxwaGEoKS50b1N0cmluZygpXCIgKGlucHV0Q2hhbmdlKT1cIm9uSW5wdXRDaGFuZ2UoJGV2ZW50LCAnQScpXCIgLz5cclxuICAgICAgICBAaWYgKGxhYmVsVmlzaWJsZSgpKSB7XHJcbiAgICAgICAgICAgIDxzcGFuPkE8L3NwYW4+XHJcbiAgICAgICAgfVxyXG4gICAgPC9kaXY+XHJcbn0iXX0= -diff --git a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs -index a3b270c..40bea7f 100644 ---- a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs -+++ b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs -@@ -1123,11 +1123,11 @@ class RgbaComponent { - this.color.set(newColor); - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: RgbaComponent, isStandalone: true, selector: "rgba-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: RgbaComponent, decorators: [{ - type: Component, -- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n R\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n G\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n B\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - - class HslaComponent { -@@ -1149,11 +1149,11 @@ class HslaComponent { - this.color.set(newColor); - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HslaComponent, isStandalone: true, selector: "hsla-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, isAlphaVisible: { classPropertyName: "isAlphaVisible", publicName: "alpha", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], dependencies: [{ kind: "directive", type: ColorPickerInputDirective, selector: "[inputChange]", inputs: ["min", "max"], outputs: ["inputChange"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HslaComponent, decorators: [{ - type: Component, -- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, imports: [ColorPickerInputDirective], template: "
\r\n \r\n @if (labelVisible()) {\r\n H\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n S\r\n }\r\n
\r\n
\r\n \r\n @if (labelVisible()) {\r\n L\r\n }\r\n
\r\n@if (isAlphaVisible()) {\r\n
\r\n \r\n @if (labelVisible()) {\r\n A\r\n }\r\n
\r\n}", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - - class HexComponent { -@@ -1184,11 +1184,11 @@ class HexComponent { - } - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } -+ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "18.0.0", type: HexComponent, isStandalone: true, selector: "hex-input-component", inputs: { color: { classPropertyName: "color", publicName: "color", isSignal: true, isRequired: true, transformFunction: null }, labelVisible: { classPropertyName: "labelVisible", publicName: "label", isSignal: true, isRequired: false, transformFunction: null }, prefixValue: { classPropertyName: "prefixValue", publicName: "prefix", isSignal: true, isRequired: false, transformFunction: null } }, outputs: { color: "colorChange" }, ngImport: i0, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n", ""], changeDetection: i0.ChangeDetectionStrategy.OnPush }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.0", ngImport: i0, type: HexComponent, decorators: [{ - type: Component, -- args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] -+ args: [{ selector: `hex-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, standalone: true, template: "
\r\n \r\n @if (labelVisible()) {\r\n HEX\r\n }\r\n
", styles: [":host,:host ::ng-deep *{padding:0;margin:0;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}\n", ":host{display:table;width:100%;text-align:center;color:#b4b4b4;font-size:11px}.column{display:table-cell;padding:0 2px}input{width:100%;border:1px solid rgb(218,218,218);color:#272727;text-align:center;font-size:12px;-webkit-appearance:none;border-radius:0;margin:0 0 6px;height:26px;outline:none}\n"] }] - }] }); - - const OpacityAnimation = trigger('opacityAnimation', [ diff --git a/ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch b/ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch new file mode 100644 index 0000000000..b0087a67e9 --- /dev/null +++ b/ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch @@ -0,0 +1,13 @@ +diff --git a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs +index a372799..f64a6f8 100644 +--- a/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs ++++ b/node_modules/@iplab/ngx-color-picker/fesm2022/iplab-ngx-color-picker.mjs +@@ -516,7 +516,7 @@ class Color { + const s = (color.saturation / 100) * (l <= 1 ? l : 2 - l); + const value = (l + s) / 2; + const saturation = (2 * s) / (l + s) || 0; +- return new Hsva(hue, saturation, value, color.alpha); ++ return new Hsva(hue, saturation * 100, value * 100, color.alpha); + } + rgbaToHsva(color) { + const red = color.red / 255; diff --git a/ui-ngx/patches/@mat-datetimepicker+core+14.0.0.patch b/ui-ngx/patches/@mat-datetimepicker+core+14.0.0.patch deleted file mode 100644 index ac4f7434e9..0000000000 --- a/ui-ngx/patches/@mat-datetimepicker+core+14.0.0.patch +++ /dev/null @@ -1,92 +0,0 @@ -diff --git a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/clock.mjs b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/clock.mjs -index 7ecfae7..08363d3 100644 ---- a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/clock.mjs -+++ b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/clock.mjs -@@ -259,9 +259,9 @@ export class MatDatetimepickerClockComponent { - value = 0; - } - // Don't close the minutes view if an invalid minute is clicked. -- if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { -- return; -- } -+ // if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { -+ // return; -+ // } - date = this._adapter.createDatetime(this._adapter.getYear(this.activeDate), this._adapter.getMonth(this.activeDate), this._adapter.getDate(this.activeDate), this._adapter.getHour(this.activeDate), value); - } - this._timeChanged = true; -diff --git a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs -index 5b82377..30e35ce 100644 ---- a/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs -+++ b/node_modules/@mat-datetimepicker/core/esm2022/datetimepicker/datetimepicker.mjs -@@ -48,7 +48,7 @@ export class MatDatetimepickerContentComponent { - } - } - /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: i2.MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } -+ /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: i2.MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, decorators: [{ - type: Component, -@@ -56,7 +56,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImpor - class: 'mat-datetimepicker-content', - '[class.mat-datetimepicker-content-touch]': 'datetimepicker?.touchUi', - '(keydown)': '_handleKeydown($event)', -- }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] -+ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] - }], propDecorators: { _calendar: [{ - type: ViewChild, - args: [MatDatetimepickerCalendarComponent, { static: true }] -@@ -301,7 +301,7 @@ export class MatDatetimepickerComponent { - const overlayConfig = new OverlayConfig({ - positionStrategy: this._createPopupPositionStrategy(), - hasBackdrop: true, -- backdropClass: 'mat-overlay-transparent-backdrop', -+ backdropClass: 'cdk-overlay-transparent-backdrop', - direction: this._dir ? this._dir.value : 'ltr', - scrollStrategy: this._scrollStrategy(), - panelClass: 'mat-datetimepicker-popup', -diff --git a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs -index 00f4a52..99c0ac1 100644 ---- a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs -+++ b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs -@@ -946,9 +946,9 @@ class MatDatetimepickerClockComponent { - value = 0; - } - // Don't close the minutes view if an invalid minute is clicked. -- if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { -- return; -- } -+ // if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { -+ // return; -+ // } - date = this._adapter.createDatetime(this._adapter.getYear(this.activeDate), this._adapter.getMonth(this.activeDate), this._adapter.getDate(this.activeDate), this._adapter.getHour(this.activeDate), value); - } - this._timeChanged = true; -@@ -1911,7 +1911,7 @@ class MatDatetimepickerContentComponent { - } - } - /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$2.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } -+ /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "18.0.4", type: MatDatetimepickerContentComponent, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$2.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } - } - i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImport: i0, type: MatDatetimepickerContentComponent, decorators: [{ - type: Component, -@@ -1919,7 +1919,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "18.0.4", ngImpor - class: 'mat-datetimepicker-content', - '[class.mat-datetimepicker-content-touch]': 'datetimepicker?.touchUi', - '(keydown)': '_handleKeydown($event)', -- }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] -+ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] - }], propDecorators: { _calendar: [{ - type: ViewChild, - args: [MatDatetimepickerCalendarComponent, { static: true }] -@@ -2164,7 +2164,7 @@ class MatDatetimepickerComponent { - const overlayConfig = new OverlayConfig({ - positionStrategy: this._createPopupPositionStrategy(), - hasBackdrop: true, -- backdropClass: 'mat-overlay-transparent-backdrop', -+ backdropClass: 'cdk-overlay-transparent-backdrop', - direction: this._dir ? this._dir.value : 'ltr', - scrollStrategy: this._scrollStrategy(), - panelClass: 'mat-datetimepicker-popup', diff --git a/ui-ngx/patches/@mat-datetimepicker+core+16.0.1.patch b/ui-ngx/patches/@mat-datetimepicker+core+16.0.1.patch new file mode 100644 index 0000000000..72e42dc318 --- /dev/null +++ b/ui-ngx/patches/@mat-datetimepicker+core+16.0.1.patch @@ -0,0 +1,44 @@ +diff --git a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs +index 83a6413..6ebae81 100644 +--- a/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs ++++ b/node_modules/@mat-datetimepicker/core/fesm2022/mat-datetimepicker-core.mjs +@@ -935,9 +935,9 @@ class MatDatetimepickerClockComponent { + value = 0; + } + // Don't close the minutes view if an invalid minute is clicked. +- if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { +- return; +- } ++ // if (!this._minutes.find((m) => m?.['value'] === value)?.['enabled']) { ++ // return; ++ // } + date = this._adapter.createDatetime(this._adapter.getYear(this.activeDate), this._adapter.getMonth(this.activeDate), this._adapter.getDate(this.activeDate), this._adapter.getHour(this.activeDate), value); + } + this._timeChanged = true; +@@ -1889,7 +1889,7 @@ class MatDatetimepickerContentComponent { + } + } + /** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: MatDatetimepickerContentComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } +- /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: MatDatetimepickerContentComponent, isStandalone: false, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } ++ /** @nocollapse */ static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "14.0.0", version: "20.2.3", type: MatDatetimepickerContentComponent, isStandalone: false, selector: "mat-datetimepicker-content", host: { listeners: { "keydown": "_handleKeydown($event)" }, properties: { "class.mat-datetimepicker-content-touch": "datetimepicker?.touchUi" }, classAttribute: "mat-datetimepicker-content" }, viewQueries: [{ propertyName: "_calendar", first: true, predicate: MatDatetimepickerCalendarComponent, descendants: true, static: true }], ngImport: i0, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"], dependencies: [{ kind: "directive", type: i1$1.CdkTrapFocus, selector: "[cdkTrapFocus]", inputs: ["cdkTrapFocus", "cdkTrapFocusAutoCapture"], exportAs: ["cdkTrapFocus"] }, { kind: "component", type: MatDatetimepickerCalendarComponent, selector: "mat-datetimepicker-calendar", inputs: ["multiYearSelector", "startView", "twelvehour", "timeInterval", "dateFilter", "ariaLabel", "ariaNextMonthLabel", "ariaPrevMonthLabel", "ariaNextYearLabel", "ariaPrevYearLabel", "ariaNextMultiYearLabel", "ariaPrevMultiYearLabel", "preventSameDateTimeSelection", "type", "startAt", "selected", "minDate", "maxDate"], outputs: ["_userSelection", "selectedChange", "viewChanged"] }], changeDetection: i0.ChangeDetectionStrategy.OnPush, encapsulation: i0.ViewEncapsulation.None }); } + } + i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImport: i0, type: MatDatetimepickerContentComponent, decorators: [{ + type: Component, +@@ -1897,7 +1897,7 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.2.3", ngImpor + class: 'mat-datetimepicker-content', + '[class.mat-datetimepicker-content-touch]': 'datetimepicker?.touchUi', + '(keydown)': '_handleKeydown($event)', +- }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.cdk-overlay-container,.cdk-global-overlay-wrapper{pointer-events:none;top:0;left:0;height:100%;width:100%}.cdk-overlay-container{position:fixed;z-index:1000}.cdk-global-overlay-wrapper{display:flex;position:absolute;z-index:1000}.cdk-overlay-pane{position:absolute;pointer-events:auto;box-sizing:border-box;z-index:1000}.cdk-overlay-backdrop{position:absolute;inset:0;z-index:1000;pointer-events:auto;transition:opacity .4s cubic-bezier(.25,.8,.25,1);opacity:0}.cdk-overlay-backdrop.cdk-overlay-backdrop-showing{opacity:.48}.cdk-overlay-dark-backdrop{background:#0009}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] ++ }, encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, standalone: false, template: "\n\n", styles: [".mat-datetimepicker-content{box-shadow:0 5px 5px -3px #0003,0 8px 10px 1px #00000024,0 3px 14px 2px #0000001f;display:block;background-color:#fff;border-radius:2px;overflow:hidden}.mat-datetimepicker-calendar{width:296px;height:405px}.mat-datetimepicker-calendar[mode=landscape]{width:446px;height:328px}@media (min-width: 480px){.mat-datetimepicker-calendar[mode=auto]{width:446px;height:328px}}.mat-datetimepicker-content-touch{box-shadow:0 0 #0003,0 0 #00000024,0 0 #0000001f;display:block;box-shadow:0 11px 15px -7px #0003,0 24px 38px 3px #00000024,0 9px 46px 8px #0000001f}.mat-datetimepicker-dialog .mat-dialog-container{padding:0}\n"] }] + }], propDecorators: { _calendar: [{ + type: ViewChild, + args: [MatDatetimepickerCalendarComponent, { static: true }] +@@ -2150,7 +2150,7 @@ class MatDatetimepickerComponent { + const overlayConfig = new OverlayConfig({ + positionStrategy: this._createPopupPositionStrategy(), + hasBackdrop: true, +- backdropClass: 'mat-overlay-transparent-backdrop', ++ backdropClass: 'cdk-overlay-transparent-backdrop', + direction: this._dir ? this._dir.value : 'ltr', + scrollStrategy: this._scrollStrategy(), + panelClass: 'mat-datetimepicker-popup', diff --git a/ui-ngx/patches/angular-gridster2+18.0.1.patch b/ui-ngx/patches/angular-gridster2+20.2.4.patch similarity index 92% rename from ui-ngx/patches/angular-gridster2+18.0.1.patch rename to ui-ngx/patches/angular-gridster2+20.2.4.patch index b8f51f39f8..39cc2d2b7d 100644 --- a/ui-ngx/patches/angular-gridster2+18.0.1.patch +++ b/ui-ngx/patches/angular-gridster2+20.2.4.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/angular-gridster2/fesm2022/angular-gridster2.mjs b/node_modules/angular-gridster2/fesm2022/angular-gridster2.mjs -index 0dcd873..e99b602 100644 +index 9fbf502..ee1b125 100644 --- a/node_modules/angular-gridster2/fesm2022/angular-gridster2.mjs +++ b/node_modules/angular-gridster2/fesm2022/angular-gridster2.mjs -@@ -666,8 +666,8 @@ class GridsterRenderer { +@@ -777,8 +777,8 @@ class GridsterRenderer { renderer.setStyle(el, DirTypes.LTR ? 'margin-right' : 'margin-left', ''); } else { diff --git a/ui-ngx/patches/jquery.terminal+2.44.1.patch b/ui-ngx/patches/jquery.terminal+2.44.1.patch deleted file mode 100644 index 640316d87c..0000000000 --- a/ui-ngx/patches/jquery.terminal+2.44.1.patch +++ /dev/null @@ -1,27 +0,0 @@ -diff --git a/node_modules/jquery.terminal/css/jquery.terminal.css b/node_modules/jquery.terminal/css/jquery.terminal.css -index 996fe49..2e71336 100644 ---- a/node_modules/jquery.terminal/css/jquery.terminal.css -+++ b/node_modules/jquery.terminal/css/jquery.terminal.css -@@ -23,7 +23,7 @@ - } - .cmd .cmd-clipboard { - position: absolute !important; -- let: -16px; -+ letf: -16px; - left: calc(-16px / var(--pixel-density, 1)) !important; - top: 0 !important; - width: 16px; -diff --git a/node_modules/jquery.terminal/css/jquery.terminal.min.css b/node_modules/jquery.terminal/css/jquery.terminal.min.css -index c24c7ea..bf55845 100644 ---- a/node_modules/jquery.terminal/css/jquery.terminal.min.css -+++ b/node_modules/jquery.terminal/css/jquery.terminal.min.css -@@ -13,7 +13,7 @@ - * Released under the MIT license - * - * Date: Tue, 08 Oct 2024 20:29:54 +0000 -- */.cmd .format,.cmd-prompt,.cmd-prompt div,.terminal .terminal-output .format{display:inline-block}.cmd,.terminal h1,.terminal h2,.terminal h3,.terminal h4,.terminal h5,.terminal h6,.terminal pre{margin:0}.cmd .cmd-clipboard{let:-16px;background:transparent!important;border:none!important;box-shadow:none!important;color:transparent!important;height:16px;height:calc(16px/var(--pixel-density, 1))!important;left:calc(-16px/var(--pixel-density, 1))!important;outline:none!important;overflow:hidden!important;padding:0!important;position:absolute!important;resize:none!important;text-indent:-9999em!important;top:0!important;top:calc(var(--cursor-line, 0)*var(--size, 1)*(14px/var(--pixel-density, 1)))!important;white-space:pre!important;width:16px;width:calc(16px/var(--pixel-density, 1))!important;z-index:1000!important}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;left:0;overflow:hidden;position:absolute;top:0;user-select:none;white-space:nowrap;width:1px}.cmd .cursor+span:empty,.cmd div.cmd-end-line span[data-text]:last-child,.cmd div.cmd-end-line span[data-text]:last-child span,.cmd span.cmd-end-line,.cmd span.cmd-end-line span,.cmd textarea{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.terminal audio,.terminal canvas,.terminal img,.terminal object{cursor:default}.terminal{--base-background:var(--background,#000);--base-color:var(--color);contain:content;line-height:normal;position:relative}.terminal-scroller{scrollbar-gutter:stable;box-sizing:border-box;height:100%;overflow-x:hidden;overflow-y:auto;position:relative}terminal.terminal-temp{visibility:hidden}body.terminal{height:100%;min-height:100vh}html{height:100%}body.full-screen-terminal,body.terminal{height:100%;margin:0}body.full-screen-terminal .terminal{height:100%!important}body.full-screen-terminal{height:100%;height:var(--terminal-force-height,100dvh)}.terminal>div.terminal-fill{height:100%;min-height:100%}.terminal>.terminal-pixel{display:inline;height:1px;left:-2px;position:absolute;top:-2px;width:1px}.terminal>.terminal-font .terminal-resizer,.terminal>.terminal-resizer{border:none!important;bottom:0!important;height:100%!important;left:0!important;overflow:hidden!important;padding:0!important;pointer-events:none!important;position:absolute!important;right:0!important;top:0!important;width:100%!important;z-index:-1!important}.cmd{padding:0;position:relative;width:100%;z-index:300}.terminal .cmd,.terminal .cmd div{background:transparent!important}.terminal a[tabindex="1000"],.terminal a[tabindex="1000"]:active,.terminal a[tabindex="1000"]:focus{outline:none}.cmd.cmd.cmd .cmd-inverted,.cmd.cmd.cmd .inverted,.terminal .inverted,.terminal .terminal-inverted{background-color:#aaa!important;color:#000!important}.cmd a[href],.terminal .terminal-output>:not(.raw) a[href],.terminal.external a[href]{color:#37f;color:var(--link-color,#37f);cursor:pointer}.cmd a[href]:not(.terminal-inverted),.terminal .terminal-output>:not(.raw) a[href]:not(.terminal-inverted),.terminal.external a[href]:not(.terminal-inverted){--color:var(--link-color,#37f);text-shadow:0 0 calc(var(--glow)*5px) var(--color,#ccc)}.terminal .terminal-output>:not(.raw) a[href].terminal-inverted,.terminal.external a[href].terminal-inverted{background:var(--color,#ccc);text-shadow:0 0 calc(var(--glow)*5px) var(--background,#000)}.cmd a[href]:hover,.terminal .terminal-output>:not(.raw) a[href]:hover,.terminal.external a[href]:hover{background-color:#37f;background-color:var(--link-color,#37f)!important;color:#000;color:var(--base-background,#000)!important;text-decoration:none}.cmd a[href] span,.terminal .terminal-output>:not(.raw) a[href] span,.terminal.external a[href] span{--color:var(--link-color,#37f);color:#37f!important;color:var(--link-color,#37f)!important;text-decoration:underline}.cmd a[href]:hover span,.terminal .terminal-output>:not(.raw) a[href]:hover span,.terminal.external a[href]:hover span{background-color:#37f!important;background-color:var(--link-color,#37f)!important;color:#000!important;color:var(--background,#000)!important;text-decoration:none}.terminal a[href]{transition-duration:.2s;transition-property:color,background-color;transition-timing-function:ease-out}.cmd .cmd-cursor,.cmd .cmd-cursor-line img,.cmd .cmd-cursor-line>span{display:inline-block}.cmd .cmd-cursor .emoji,.cmd .cmd-cursor.cmd-blink .fa,.cmd .cmd-cursor.cmd-blink .far,.cmd .cmd-cursor.cmd-blink .fas,.cmd .cmd-cursor.cmd-blink a,.cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{-webkit-animation:terminal-blink 1s linear infinite;-moz-animation:terminal-blink 1s linear infinite;-ms-animation:terminal-blink 1s linear infinite;animation:terminal-blink 1s linear infinite}.bar.cmd .cmd-inverted,.bar.terminal .inverted{box-shadow:-2px 0 0 -1px #aaa;box-shadow:-2px 0 0 -1px var(--original-color,#aaa)}.cmd .cmd-prompt,.terminal,.terminal .terminal-output>div>div{display:block;height:auto}.terminal .terminal-output>div:not(.raw) div{clear:both;white-space:nowrap}.cmd .cmd-prompt:empty,.cmd .cmd-prompt>a,.cmd .cmd-prompt>span{float:left}.cmd-prompt [data-text]>span,.cmd-wrapper>div{white-space:pre}.cmd [data-text] span,.terminal [data-text] span{display:inline-block}.terminal-ouput span[style*=width]{min-height:14px;min-height:calc(var(--size, 1)*(14px/var(--pixel-density, 1)))}.cmd div,.terminal .terminal-output>:not(.raw)>div,.terminal.external div{line-height:1em}.cmd .cmd-prompt span.fa:before,.cmd .cmd-prompt span.fab:before,.cmd .cmd-prompt span.fad:before,.cmd .cmd-prompt span.fal:before,.cmd .cmd-prompt span.far:before,.cmd .cmd-prompt span.fas{position:relative;top:2px}.cmd,.cmd span:not(.fas):not(.far):not(.fa),.terminal,.terminal-font-forcer,.terminal-output>:not(.raw),.terminal-output>:not(.raw) a,.terminal-output>:not(.raw) span:not(.fas):not(.far):not(.fa){font-family:monospace;font-family:var(--font,monospace)}.cmd,.terminal{font-size:12px}.cmd .cmd-line:before,.terminal-output>div:not(.raw) div:before{content:"\0200B";display:inline-block;width:0}.cmd .cmd-line{white-space:nowrap}.cmd span[data-text],.terminal span[data-text]{display:inline-block}.cmd span[data-text]:not(.cmd-inverted):not(.token):not(.emoji),.terminal,terminal-output>div:not(.raw) div>span:not(.token):not(.inverted):not(.terminal-inverted):not(.cmd-inverted):not(.terminal-error):not(.emoji){background-color:#000;color:#aaa}.cmd span[data-text] span,.terminal span[data-text] span{text-decoration:inherit}.terminal .ansi>div{line-height:13px!important;line-height:calc(var(--size, 1)*(13px/var(--pixel-density, 1)))!important}.cmd .cmd-prompt span,.cmd span.cmd-prompt{--background:transparent}.cmd .emoji,.terminal-output .emoji{background-repeat:no-repeat;background-size:contain;color:transparent;height:12px;height:calc(12px/var(--pixel-density, 1));position:relative}.cmd .fa span,.cmd .far span,.cmd .fas span,.terminal .terminal-output .fa span,.terminal .terminal-output .far span,.terminal .terminal-output .fas span{clip:rect(1px,1px,1px,1px);background:transparent!important;color:transparent!important;position:absolute}.cmd .emoji,.cmd .emoji span,.terminal-output .emoji,.terminal-output .emoji span{display:inline-block;width:2ch}.cmd,.terminal{box-sizing:border-box}.cmd .cmd-cursor span:not(.token):not(.inverted){background-color:inherit;color:inherit}.cmd .emoji.emoji.emoji.emoji,.cmd .emoji.emoji.emoji.emoji span{background-color:transparent;color:transparent}.cmd .cmd-cursor *{background-color:transparent}.cmd span[style*=width] span,.terminal span[style*=width] span{width:inherit}.cmd div{clear:both}.cmd .cmd-prompt+div{clear:right}terminal .terminal-output>div{margin-top:-1px}.terminal-output>div.raw>div *{word-wrap:break-word;overflow-wrap:break-word}.terminal .terminal-font{float:left;font-size:inherit;left:0;line-height:inherit;margin-bottom:1px;position:absolute;top:-100%}.cmd>span:not(.cmd-prompt){float:left}.cmd .cmd-prompt span.cmd-line{display:block;float:none}.terminal table{border-collapse:collapse}.terminal td{border:1px solid #aaa}.cmd span[data-text]:not(.emoji):not(.fa):not(.fas):not(.far) span{background-color:inherit;color:inherit}.cmd [role=presentation].cmd-cursor-line{cursor:text;position:relative;z-index:100}.cmd .cmd-prompt{position:relative;z-index:200}.cmd [role=presentation]:not(.cmd-cursor-line){overflow:hidden}.cmd{--original-color:var( -+ */.cmd .format,.cmd-prompt,.cmd-prompt div,.terminal .terminal-output .format{display:inline-block}.cmd,.terminal h1,.terminal h2,.terminal h3,.terminal h4,.terminal h5,.terminal h6,.terminal pre{margin:0}.cmd .cmd-clipboard{left:-16px;background:transparent!important;border:none!important;box-shadow:none!important;color:transparent!important;height:16px;height:calc(16px/var(--pixel-density, 1))!important;left:calc(-16px/var(--pixel-density, 1))!important;outline:none!important;overflow:hidden!important;padding:0!important;position:absolute!important;resize:none!important;text-indent:-9999em!important;top:0!important;top:calc(var(--cursor-line, 0)*var(--size, 1)*(14px/var(--pixel-density, 1)))!important;white-space:pre!important;width:16px;width:calc(16px/var(--pixel-density, 1))!important;z-index:1000!important}.visually-hidden{clip:rect(0 0 0 0);clip-path:inset(50%);height:1px;left:0;overflow:hidden;position:absolute;top:0;user-select:none;white-space:nowrap;width:1px}.cmd .cursor+span:empty,.cmd div.cmd-end-line span[data-text]:last-child,.cmd div.cmd-end-line span[data-text]:last-child span,.cmd span.cmd-end-line,.cmd span.cmd-end-line span,.cmd textarea{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.terminal audio,.terminal canvas,.terminal img,.terminal object{cursor:default}.terminal{--base-background:var(--background,#000);--base-color:var(--color);contain:content;line-height:normal;position:relative}.terminal-scroller{scrollbar-gutter:stable;box-sizing:border-box;height:100%;overflow-x:hidden;overflow-y:auto;position:relative}terminal.terminal-temp{visibility:hidden}body.terminal{height:100%;min-height:100vh}html{height:100%}body.full-screen-terminal,body.terminal{height:100%;margin:0}body.full-screen-terminal .terminal{height:100%!important}body.full-screen-terminal{height:100%;height:var(--terminal-force-height,100dvh)}.terminal>div.terminal-fill{height:100%;min-height:100%}.terminal>.terminal-pixel{display:inline;height:1px;left:-2px;position:absolute;top:-2px;width:1px}.terminal>.terminal-font .terminal-resizer,.terminal>.terminal-resizer{border:none!important;bottom:0!important;height:100%!important;left:0!important;overflow:hidden!important;padding:0!important;pointer-events:none!important;position:absolute!important;right:0!important;top:0!important;width:100%!important;z-index:-1!important}.cmd{padding:0;position:relative;width:100%;z-index:300}.terminal .cmd,.terminal .cmd div{background:transparent!important}.terminal a[tabindex="1000"],.terminal a[tabindex="1000"]:active,.terminal a[tabindex="1000"]:focus{outline:none}.cmd.cmd.cmd .cmd-inverted,.cmd.cmd.cmd .inverted,.terminal .inverted,.terminal .terminal-inverted{background-color:#aaa!important;color:#000!important}.cmd a[href],.terminal .terminal-output>:not(.raw) a[href],.terminal.external a[href]{color:#37f;color:var(--link-color,#37f);cursor:pointer}.cmd a[href]:not(.terminal-inverted),.terminal .terminal-output>:not(.raw) a[href]:not(.terminal-inverted),.terminal.external a[href]:not(.terminal-inverted){--color:var(--link-color,#37f);text-shadow:0 0 calc(var(--glow)*5px) var(--color,#ccc)}.terminal .terminal-output>:not(.raw) a[href].terminal-inverted,.terminal.external a[href].terminal-inverted{background:var(--color,#ccc);text-shadow:0 0 calc(var(--glow)*5px) var(--background,#000)}.cmd a[href]:hover,.terminal .terminal-output>:not(.raw) a[href]:hover,.terminal.external a[href]:hover{background-color:#37f;background-color:var(--link-color,#37f)!important;color:#000;color:var(--base-background,#000)!important;text-decoration:none}.cmd a[href] span,.terminal .terminal-output>:not(.raw) a[href] span,.terminal.external a[href] span{--color:var(--link-color,#37f);color:#37f!important;color:var(--link-color,#37f)!important;text-decoration:underline}.cmd a[href]:hover span,.terminal .terminal-output>:not(.raw) a[href]:hover span,.terminal.external a[href]:hover span{background-color:#37f!important;background-color:var(--link-color,#37f)!important;color:#000!important;color:var(--background,#000)!important;text-decoration:none}.terminal a[href]{transition-duration:.2s;transition-property:color,background-color;transition-timing-function:ease-out}.cmd .cmd-cursor,.cmd .cmd-cursor-line img,.cmd .cmd-cursor-line>span{display:inline-block}.cmd .cmd-cursor .emoji,.cmd .cmd-cursor.cmd-blink .fa,.cmd .cmd-cursor.cmd-blink .far,.cmd .cmd-cursor.cmd-blink .fas,.cmd .cmd-cursor.cmd-blink a,.cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{-webkit-animation:terminal-blink 1s linear infinite;-moz-animation:terminal-blink 1s linear infinite;-ms-animation:terminal-blink 1s linear infinite;animation:terminal-blink 1s linear infinite}.bar.cmd .cmd-inverted,.bar.terminal .inverted{box-shadow:-2px 0 0 -1px #aaa;box-shadow:-2px 0 0 -1px var(--original-color,#aaa)}.cmd .cmd-prompt,.terminal,.terminal .terminal-output>div>div{display:block;height:auto}.terminal .terminal-output>div:not(.raw) div{clear:both;white-space:nowrap}.cmd .cmd-prompt:empty,.cmd .cmd-prompt>a,.cmd .cmd-prompt>span{float:left}.cmd-prompt [data-text]>span,.cmd-wrapper>div{white-space:pre}.cmd [data-text] span,.terminal [data-text] span{display:inline-block}.terminal-ouput span[style*=width]{min-height:14px;min-height:calc(var(--size, 1)*(14px/var(--pixel-density, 1)))}.cmd div,.terminal .terminal-output>:not(.raw)>div,.terminal.external div{line-height:1em}.cmd .cmd-prompt span.fa:before,.cmd .cmd-prompt span.fab:before,.cmd .cmd-prompt span.fad:before,.cmd .cmd-prompt span.fal:before,.cmd .cmd-prompt span.far:before,.cmd .cmd-prompt span.fas{position:relative;top:2px}.cmd,.cmd span:not(.fas):not(.far):not(.fa),.terminal,.terminal-font-forcer,.terminal-output>:not(.raw),.terminal-output>:not(.raw) a,.terminal-output>:not(.raw) span:not(.fas):not(.far):not(.fa){font-family:monospace;font-family:var(--font,monospace)}.cmd,.terminal{font-size:12px}.cmd .cmd-line:before,.terminal-output>div:not(.raw) div:before{content:"\0200B";display:inline-block;width:0}.cmd .cmd-line{white-space:nowrap}.cmd span[data-text],.terminal span[data-text]{display:inline-block}.cmd span[data-text]:not(.cmd-inverted):not(.token):not(.emoji),.terminal,terminal-output>div:not(.raw) div>span:not(.token):not(.inverted):not(.terminal-inverted):not(.cmd-inverted):not(.terminal-error):not(.emoji){background-color:#000;color:#aaa}.cmd span[data-text] span,.terminal span[data-text] span{text-decoration:inherit}.terminal .ansi>div{line-height:13px!important;line-height:calc(var(--size, 1)*(13px/var(--pixel-density, 1)))!important}.cmd .cmd-prompt span,.cmd span.cmd-prompt{--background:transparent}.cmd .emoji,.terminal-output .emoji{background-repeat:no-repeat;background-size:contain;color:transparent;height:12px;height:calc(12px/var(--pixel-density, 1));position:relative}.cmd .fa span,.cmd .far span,.cmd .fas span,.terminal .terminal-output .fa span,.terminal .terminal-output .far span,.terminal .terminal-output .fas span{clip:rect(1px,1px,1px,1px);background:transparent!important;color:transparent!important;position:absolute}.cmd .emoji,.cmd .emoji span,.terminal-output .emoji,.terminal-output .emoji span{display:inline-block;width:2ch}.cmd,.terminal{box-sizing:border-box}.cmd .cmd-cursor span:not(.token):not(.inverted){background-color:inherit;color:inherit}.cmd .emoji.emoji.emoji.emoji,.cmd .emoji.emoji.emoji.emoji span{background-color:transparent;color:transparent}.cmd .cmd-cursor *{background-color:transparent}.cmd span[style*=width] span,.terminal span[style*=width] span{width:inherit}.cmd div{clear:both}.cmd .cmd-prompt+div{clear:right}terminal .terminal-output>div{margin-top:-1px}.terminal-output>div.raw>div *{word-wrap:break-word;overflow-wrap:break-word}.terminal .terminal-font{float:left;font-size:inherit;left:0;line-height:inherit;margin-bottom:1px;position:absolute;top:-100%}.cmd>span:not(.cmd-prompt){float:left}.cmd .cmd-prompt span.cmd-line{display:block;float:none}.terminal table{border-collapse:collapse}.terminal td{border:1px solid #aaa}.cmd span[data-text]:not(.emoji):not(.fa):not(.fas):not(.far) span{background-color:inherit;color:inherit}.cmd [role=presentation].cmd-cursor-line{cursor:text;position:relative;z-index:100}.cmd .cmd-prompt{position:relative;z-index:200}.cmd [role=presentation]:not(.cmd-cursor-line){overflow:hidden}.cmd{--original-color:var( - --color,#aaa);--original-background:var( - --background)}.cmd a[href]{--original-color:var(--link-color,#37f)}@-webkit-keyframes terminal-blink{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;color:inherit;color:var(--original-color,#aaa)}}@-moz-keyframes terminal-blink{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;color:inherit;color:var(--original-color,#aaa)}}@keyframes terminal-blink{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;-webkit-box-shadow:none;box-shadow:none;color:inherit;color:var(--original-color,#aaa)}}@-webkit-keyframes terminal-glow{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);box-shadow:0 0 3px #aaa;-webkit-box-shadow:0 0 3px var(--color,#aaa);box-shadow:0 0 3px var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;-webkit-box-shadow:none;box-shadow:none;color:inherit}}@-moz-keyframes terminal-glow{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);box-shadow:0 0 3px #aaa;-moz-box-shadow:0 0 3px var(--color,#aaa);box-shadow:0 0 3px var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;-moz-box-shadow:none;box-shadow:none;color:inherit}}@keyframes terminal-glow{0%,50%{background-color:#aaa;background-color:var(--original-color,#aaa);box-shadow:0 0 3px #aaa;box-shadow:0 0 3px var(--color,#aaa);color:#000;color:var(--original-background,#000)}50.1%,to{background-color:inherit;box-shadow:none;color:inherit}}@-webkit-keyframes terminal-bar{0%,50%{box-shadow:-2px 0 0 -1px #aaa;box-shadow:calc(var(--line-thickness, 2)*-1px) 0 0 -1px var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@-moz-keyframes terminal-bar{0%,50%{box-shadow:-2px 0 0 -1px #aaa;box-shadow:calc(var(--line-thickness, 2)*-1px) 0 0 -1px var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@keyframes terminal-bar{0%,50%{box-shadow:-2px 0 0 -1px #aaa;box-shadow:calc(var(--line-thickness, 2)*-1px) 0 0 -1px var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@-webkit-keyframes terminal-underline{0%,50%{box-shadow:0 2px 0 #aaa;box-shadow:0 calc(var(--line-thickness, 2)*1px) 0 var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@-moz-keyframes terminal-underline{0%,50%{box-shadow:0 2px 0 #aaa;box-shadow:0 calc(var(--line-thickness, 2)*1px) 0 var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@keyframes terminal-underline{0%,50%{box-shadow:0 2px 0 #aaa;box-shadow:0 calc(var(--line-thickness, 2)*1px) 0 var(--original-color,#aaa)}50.1%,to{box-shadow:none}}@-webkit-keyframes terminal-none{0%,to{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--original-background,#000)}}@-moz-keyframes terminal-none{0%,to{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--background,#000)}}@keyframes terminal-none{0%,to{background-color:#aaa;background-color:var(--original-color,#aaa);-webkit-box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);box-shadow:0 0 calc(var(--glow)*3px) var(--color,#aaa);color:#000;color:var(--original-background,#000)}}.underline-animation .cmd .cmd-cursor .emoji,.underline-animation .cmd .cmd-cursor.cmd-blink .fa,.underline-animation .cmd .cmd-cursor.cmd-blink .far,.underline-animation .cmd .cmd-cursor.cmd-blink .fas,.underline-animation .cmd .cmd-cursor.cmd-blink a,.underline-animation .cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{-webkit-animation-name:terminal-underline;-moz-animation-name:terminal-underline;-ms-animation-name:terminal-underline;animation-name:terminal-underline}.glow-animation .cmd .cmd-cursor .emoji,.glow-animation .cmd .cmd-cursor.cmd-blink .fa,.glow-animation .cmd .cmd-cursor.cmd-blink .far,.glow-animation .cmd .cmd-cursor.cmd-blink .fas,.glow-animation .cmd .cmd-cursor.cmd-blink a,.glow-animation .cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{-webkit-animation-name:terminal-glow;-moz-animation-name:terminal-glow;-ms-animation-name:terminal-glow;animation-name:terminal-glow}.bar-animation .cmd .cmd-cursor .emoji,.bar-animation .cmd .cmd-cursor.cmd-blink .fa,.bar-animation .cmd .cmd-cursor.cmd-blink .far,.bar-animation .cmd .cmd-cursor.cmd-blink .fas,.bar-animation .cmd .cmd-cursor.cmd-blink a,.bar-animation .cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{-webkit-animation-name:terminal-bar;-moz-animation-name:terminal-bar;-ms-animation-name:terminal-bar;animation-name:terminal-bar}@supports (-ms-ime-align:auto){.cmd .cmd-clipboard{margin-left:-9999px}@keyframes terminal-blink{0%,50%{background-color:var(--original-color,#aaa);color:var(--background,#000)}50.1%,to{background-color:var(--background,#000);color:var(--original-color,#aaa)}}@keyframes terminal-bar{0%,50%{border-left-color:var(--color,#aaa)}50.1%,to{border-left-color:var(--background,#000)}}@keyframes terminal-underline{0%,50%{border-bottom-color:var(--color,#aaa);line-height:12px;line-height:calc(var(--size, 1)*(12px/var(--pixel-density, 1)))}50.1%,to{border-bottom-color:var(--background,#000);line-height:12px;line-height:calc(var(--size, 1)*(12px/var(--pixel-density, 1)))}}}@media (-ms-high-contrast:active),(-ms-high-contrast:none){.cmd .cmd-clipboard{margin-left:-9999px}.underline-animation .cursor.blink span span{margin-top:1px}@-ms-keyframes terminal-blink{0%,50%{background-color:#aaa;color:#000}50.1%,to{background-color:#000;color:#aaa}}}.cmd span[data-text]::-moz-selection,.cmd span[data-text]:not(.far):not(.fa):not(.fas) span::-moz-selection,.terminal .terminal-output .raw ::-moz-selection,.terminal .terminal-output .raw div::-moz-selection,.terminal .terminal-output div div a::-moz-selection,.terminal .terminal-output div div::-moz-selection,.terminal .terminal-output span[data-text]::-moz-selection,.terminal .terminal-output span[data-text]:not(.far):not(.fa):not(.fas) span::-moz-selection{background-color:#aaa;color:#000}.terminal .cmd{pointer-events:none}.terminal .cmd-prompt *{pointer-events:visible}.cmd-prompt>span:first-child{margin-left:var(--prompt-offset)}.terminal .cmd textarea,.terminal .cmd-cursor-line>span,.terminal .cmd-wrapper>div:not(.cmd-cursor-line){pointer-events:visible}.cmd span[data-text]:not(.far):not(.fa):not(.fas) span::selection,.terminal .terminal-output .raw ::selection,.terminal .terminal-output .raw div::selection,.terminal .terminal-output div div a::selection,.terminal .terminal-output div div::selection,.terminal .terminal-output span[data-text]::selection,.terminal .terminal-output span[data-text]:not(.far):not(.fa):not(.fas) span::selection,.terminal-revese{background-color:hsla(0,0%,67%,.99);color:#000}.cmd .emoji span::-moz-selection,.cmd .emoji::-moz-selection,.cmd textarea::-moz-selection,.terminal-output .emoji span::-moz-selection,.terminal-output .emoji::-moz-selection{background-color:transparent!important;color:transparent!important}.cmd .emoji span::selection,.cmd .emoji::selection,.cmd textarea::selection,.terminal-output .emoji span::selection,.terminal-output .emoji::selection{background-color:transparent!important;color:transparent!important}.terminal .terminal-output>:not(.raw) .terminal-error,.terminal .terminal-output>:not(.raw) .terminal-error *{color:red;color:var(--error-color,red)}.tilda{left:0;position:fixed;top:0;width:100%;z-index:1100}.ui-dialog-content .terminal{box-sizing:border-box;height:100%;width:100%}.ui-dialog .ui-dialog-content.dterm{padding:0}.clear{clear:both}.terminal .terminal-fill{border:none;box-sizing:border-box;height:100%;left:0;margin:1px 0 0;opacity:.01;pointer-events:none;position:absolute;top:-100%;width:100%}.cmd-editable,.terminal .terminal-fill,.terminal-scroller{padding:calc(1px*var(--padding, 10)/var(--pixel-density, 1))}.cmd-editable{padding-top:0}.terminal-scroller{padding-bottom:0}.terminal .terminal-output>:not(.raw) .terminal-error,.terminal .terminal-output>:not(.raw) .terminal-error *{--color:var(--error-color,red);text-shadow:0 0 calc(var(--glow)*5px) var(--color)}.terminal .terminal-output>:not(.raw) a[href]{text-shadow:0 0 calc(var(--glow)*5px) var(--color)}.terminal .cmd{margin-bottom:calc(1px*var(--padding, 10)/var(--pixel-density, 1));position:relative}.terminal .partial,.terminal .partial>div{display:inline-block}@supports (--css:variables){.cmd,.cmd div,.cmd span[data-text]:not(.cmd-inverted):not(.token):not(.emoji),.terminal,.terminal-output>:not(.raw) a,.terminal-output>:not(.raw) div,.terminal-output>:not(.raw) span[data-text]:not(.token):not(.inverted):not(.terminal-inverted):not(.cmd-inverted):not(.terminal-error):not(.emoji),.terminal.external [data-text]{background-color:var(--background,#000);color:var(--color,#aaa)}.terminal{--terminal-line:calc(var(--size, 1)*(16px/var(--pixel-density, 1)) + 1px/var(--pixel-density, 1));height:auto;height:calc(var(--terminal-line)*var(--rows) + var(--padding, 10)*2px)}.cmd div,.terminal,.terminal .terminal-output>:not(.raw)>div,.terminal.external div{line-height:round(var(--terminal-line,1em))}.cmd [data-text],.terminal [data-text]{text-shadow:var(--text-shadow,0 0 calc(var(--glow)*5px) var(--color,#ccc))}.terminal span[style*="--length"]{display:inline-block;width:calc(var(--length, 1)*var(--char-width, 7.23438)*1px)}.cmd,.cmd div,.cmd span,.terminal,.terminal-output>:not(.raw) a,.terminal-output>:not(.raw) div,.terminal-output>:not(.raw) span{font-size:calc(var(--size, 1)*(12px/var(--pixel-density, 1)))}.cmd .emoji,.terminal-output .emoji{height:calc(var(--size, 1)*(12px/var(--pixel-density, 1)))}.cmd .clipboard{top:calc(var(--size, 1)*(14/var(--pixel-density, 1))*var(--cursor-line, 0)*1px)}.cmd span[style*="--background"],.terminal span[style*="--background"]{--base-background:var(--background)}.cmd.cmd.cmd .cmd-inverted,.cmd.cmd.cmd .inverted,.terminal .inverted>span,.terminal .terminal-inverted>span{background-color:var(--color,#aaa)!important;color:var(--base-background,#000)!important}.cmd .cmd-cursor.cmd-blink{background-color:var(--background,#000);color:var(--color,#aaa)}.cmd .cmd-cursor.cmd-blink .emoji,.cmd .cmd-cursor.cmd-blink .fa,.cmd .cmd-cursor.cmd-blink .far,.cmd .cmd-cursor.cmd-blink .fas,.cmd .cmd-cursor.cmd-blink a,.cmd .cmd-cursor.cmd-blink>span[data-text]:not(.emoji):not(.fa):not(.far):not(.fas) span{--original-background:inherit;-webkit-animation:var(--animation,terminal-blink) 1s infinite linear;-moz-animation:var(--animation,terminal-blink) 1s infinite linear;-ms-animation:var(--animation,terminal-blink) 1s infinite linear;animation:var(--animation,terminal-blink) 1s infinite linear}.cmd .cmd-cursor.cmd-blink .emoji span{background:transparent;color:transparent}.cmd span[data-text]:not(.far):not(.fa):not(.fas):not(.emoji) span::-moz-selection,.terminal .terminal-output .raw ::-moz-selection,.terminal .terminal-output .raw div::-moz-selection,.terminal .terminal-output div div a::-moz-selection,.terminal .terminal-output div div::-moz-selection,.terminal .terminal-output span[data-text]::-moz-selection,.terminal .terminal-output span[data-text]:not(.far):not(.fa):not(.fas):not(.emoji) span::-moz-selection{background-color:var(--color,#aaa);color:var(--background,#000)}.terminal .terminal-output div div a::-moz-selection{background-color:var(--link-color,rgba(15,96,255,.99))!important;color:var(--background,#000)!important}.terminal .terminal-output div div a:hover::-moz-selection{background-color:var(--link-color,rgba(2,50,144,.99))!important}.cmd span[data-text]:not(.far):not(.fa):not(.fas):not(.emoji) span::selection,.terminal .terminal-output .raw ::selection,.terminal .terminal-output .raw div::selection,.terminal .terminal-output div div a::selection,.terminal .terminal-output div div::selection,.terminal .terminal-output span[data-text]:not(.emoji)::selection,.terminal .terminal-output span[data-text]:not(.far):not(.fa):not(.fas):not(.emoji) span::selection{background-color:var(--color,hsla(0,0%,67%,.99))!important;color:var(--background,#000)!important}.terminal .terminal-output div div a::selection{background-color:var(--link-color,rgba(15,96,255,.99))!important;color:var(--background,#000)!important}.terminal .terminal-output div div a:hover::selection{background-color:var(--link-color,rgba(2,50,144,.99))!important}}@property --color{syntax:"";inherits:true;initial-value:hsla(0,0%,67%,.99)}@property --background{syntax:"";inherits:true;initial-value:#000}@property --link-color{syntax:"";inherits:true;initial-value:#37f}@property --size{syntax:"";inherits:true;initial-value:1}@property --padding{syntax:"";inherits:true;initial-value:10}@property --rows{syntax:" | auto";inherits:true;initial-value:auto}@supports (-ms-ime-align:auto){.cmd span[data-text]::selection,.terminal .terminal-output div div a::selection,.terminal .terminal-output div div::selection,.terminal h1::selection,.terminal h2::selection,.terminal h3::selection,.terminal h4::selection,.terminal h5::selection,.terminal h6::selection,.terminal pre::selection,.terminal td::selection{background-color:hsla(0,0%,67%,.99);color:#000}}.cmd .style .token.string,.cmd .token.entity,.cmd .token.operator,.cmd .token.string,.cmd .token.token,.cmd .token.url,.cmd .token.variable,.terminal .style .token.string,.terminal .token.entity,.terminal .token.operator,.terminal .token.string,.terminal .token.token,.terminal .token.url,.terminal .token.variable{background-color:inherit}.cmd .cursor-wrapper ul{float:left;left:0;list-style:none;margin:0;padding:0;position:absolute;top:14px}.cmd .cursor-wrapper li{cursor:pointer;white-space:nowrap}.cmd .cursor-wrapper li:hover{background:#aaa;color:#000}.cursor-wrapper{position:relative}.terminal-output img{vertical-align:top}.cmd img{border:1px solid transparent;height:14px;height:calc(var(--size, 1)*(14px/var(--pixel-density, 1)))}.cmd-cursor img{border-color:#ccc;border-color:var(--color,#ccc)}.terminal-output svg.terminal-broken-image{height:calc(var(--size, 1)*(14px/var(--pixel-density, 1)))}.terminal-output svg.terminal-broken-image use{fill:var(--color,#ccc)}.terminal-error{--color:var(--error-color)}.terminal-glow{--animation:terminal-glow}.terminal-glow .cmd-prompt>span,.terminal-glow .terminal-output>div a[href],.terminal-glow .terminal-output>div span,.terminal-glow [data-text] span,.terminal-glow a[data-text],.terminal-glow span[data-text]{text-shadow:1px 1px 5px #ccc;text-shadow:1px 1px 5px var(--color,#ccc)}.terminal-scroll-marker{height:1px;height:calc(1px/var(--pixel-density, 1));margin-top:-1px;margin-top:calc(-1px/var(--pixel-density, 1));position:relative;z-index:100}.terminal-scroll-marker div{bottom:0;left:0;position:absolute;right:0;z-index:200}.terminal-less{overscroll-behavior-y:contain;touch-action:none}.terminal-mobile.terminal-less .terminal-wrapper{pointer-events:none}.cmd-editable,.terminal-mobile.terminal-less .terminal-output a{pointer-events:visible}.cmd-editable:before{content:attr(data-cmd-prompt);display:inline-block}.cmd-editable{background:transparent;bottom:0;color:transparent;left:0;opacity:.01;outline:0 solid transparent;position:absolute;right:0;top:calc(var(--cmd-top)*1px);z-index:500}.terminal-scroller::-webkit-scrollbar{background:var(--background,#000);height:calc(6px/var(--pixel-density, 1));width:calc(6px/var(--pixel-density, 1))}.terminal-scroller::-webkit-scrollbar-thumb,.terminal-scroller::-webkit-scrollbar-thumb:hover{background:var(--color,#aaa)}.terminal-scroller{scrollbar-color:#aaa #000;scrollbar-color:var(--color,#aaa) var(--background,#000);scrollbar-width:thin}.terminal-hidden{clip:rect(0,0,0,0)!important;border:0!important;height:1px!important;margin:-1px!important;overflow:hidden!important;padding:0!important;position:absolute!important;white-space:nowrap!important;width:1px!important}.terminal-wrapper{--color:var(--base-color);--background:var(--base-background)}.cmd .token{--original-color:var(--color)}.cmd .terminal-blink,.terminal .terminal-blink{animation:terminal-ansi-blink 1s steps(2,start) infinite;-webkit-animation:terminal-ansi-blink 1s steps(2,start) infinite}.terminal .cmd *,.terminal .terminal-output,.terminal .terminal-output *{--background:transparent}.terminal .cmd ::selection,.terminal .cmd::selection,.terminal .terminal-output ::selection,.terminal .terminal-output::selection{--background:var(--base-background)}@keyframes terminal-ansi-blink{to{color:var(--background)}}@-webkit-keyframes terminal-ansi-blink{to{color:var(--background)}}@media (prefers-reduced-motion){:root{--animation:terminal-none}} - /*# sourceMappingURL=jquery.terminal.min.css.map */ -\ No newline at end of file diff --git a/ui-ngx/patches/ngx-hm-carousel+19.0.0.patch b/ui-ngx/patches/ngx-hm-carousel+19.0.0.patch new file mode 100644 index 0000000000..0dfee707e1 --- /dev/null +++ b/ui-ngx/patches/ngx-hm-carousel+19.0.0.patch @@ -0,0 +1,31 @@ +diff --git a/node_modules/ngx-hm-carousel/fesm2022/ngx-hm-carousel.mjs b/node_modules/ngx-hm-carousel/fesm2022/ngx-hm-carousel.mjs +index 117f782..70e49a7 100644 +--- a/node_modules/ngx-hm-carousel/fesm2022/ngx-hm-carousel.mjs ++++ b/node_modules/ngx-hm-carousel/fesm2022/ngx-hm-carousel.mjs +@@ -1,5 +1,5 @@ + import * as i0 from '@angular/core'; +-import { Directive, inject, ViewContainerRef, TemplateRef, input, PLATFORM_ID, DestroyRef, Renderer2, NgZone, ChangeDetectorRef, viewChild, ElementRef, contentChildren, contentChild, computed, signal, effect, forwardRef, Component, ChangeDetectionStrategy } from '@angular/core'; ++import { Directive, inject, ViewContainerRef, TemplateRef, input, PLATFORM_ID, DestroyRef, Renderer2, NgZone, ChangeDetectorRef, viewChild, ElementRef, contentChildren, contentChild, computed, signal, effect, forwardRef, Component, ChangeDetectionStrategy, afterNextRender } from '@angular/core'; + import { DOCUMENT, isPlatformBrowser, NgTemplateOutlet, AsyncPipe } from '@angular/common'; + import { toObservable, takeUntilDestroyed } from '@angular/core/rxjs-interop'; + import { NG_VALUE_ACCESSOR } from '@angular/forms'; +@@ -340,7 +340,7 @@ class NgxHmCarouselComponent { + }); + }); + }); +- const effectRef = effect(() => { ++ afterNextRender(() => { + this.rootElm = this.container().nativeElement; + this.containerElm = this.rootElm.children[0]; + this.init(); +@@ -365,10 +365,6 @@ class NgxHmCarouselComponent { + ]) + .pipe(takeUntilDestroyed(this._destroyRef)) + .subscribe(); +- // only exec once +- effectRef.destroy(); +- }, { +- allowSignalWrites: true, + }); + } + ngOnDestroy() { diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index bc0f5a4ed8..8e16f17653 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -20,7 +20,7 @@ 4.0.0 org.thingsboard - 4.3.0-RC + 4.4.0-SNAPSHOT thingsboard org.thingsboard @@ -56,7 +56,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 @@ -66,11 +66,22 @@ yarn - install --non-interactive --network-concurrency 4 --network-timeout 100000 --mutex network + install --non-interactive --check-files --network-concurrency 4 --network-timeout 100000 --mutex network
+ + org.apache.maven.plugins + maven-clean-plugin + + + + ${basedir}/node_modules + + + +
@@ -95,7 +106,7 @@ yarn - run build:prod + --mutex network run build:prod diff --git a/ui-ngx/src/app/app.component.ts b/ui-ngx/src/app/app.component.ts index 5bc70240a5..dcb5ec1e1f 100644 --- a/ui-ngx/src/app/app.component.ts +++ b/ui-ngx/src/app/app.component.ts @@ -35,9 +35,10 @@ import { SETTINGS_KEY } from '@core/settings/settings.effects'; import { initCustomJQueryEvents } from '@shared/models/jquery-event.models'; @Component({ - selector: 'tb-root', - templateUrl: './app.component.html', - styleUrls: ['./app.component.scss'] + selector: 'tb-root', + templateUrl: './app.component.html', + styleUrls: ['./app.component.scss'], + standalone: false }) export class AppComponent { @@ -87,7 +88,7 @@ export class AppComponent { if (!env.production) { console.log(`Default Lang: ${env.defaultLang}`); } - this.translate.setDefaultLang(env.defaultLang); + this.translate.setFallbackLang(env.defaultLang); } setupAuth() { diff --git a/ui-ngx/src/app/core/api/alarm-data-subscription.ts b/ui-ngx/src/app/core/api/alarm-data-subscription.ts index 16dafd53eb..ad6abb6573 100644 --- a/ui-ngx/src/app/core/api/alarm-data-subscription.ts +++ b/ui-ngx/src/app/core/api/alarm-data-subscription.ts @@ -23,6 +23,7 @@ import { DatasourceType } from '@shared/models/widget.models'; import { AlarmData, AlarmDataPageLink, + ComplexOperation, EntityFilter, EntityKey, EntityKeyType, @@ -47,6 +48,7 @@ export interface AlarmDataSubscriptionOptions { pageLink?: AlarmDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; } @@ -120,6 +122,7 @@ export class AlarmDataSubscription { entityFilter: this.alarmDataSubscriptionOptions.entityFilter, pageLink: deepClone(this.alarmDataSubscriptionOptions.pageLink), keyFilters, + keyFiltersOperation: this.alarmDataSubscriptionOptions.keyFiltersOperation, alarmFields, entityFields, latestValues diff --git a/ui-ngx/src/app/core/api/alarm-data.service.ts b/ui-ngx/src/app/core/api/alarm-data.service.ts index 901155dec5..c0c5a928bf 100644 --- a/ui-ngx/src/app/core/api/alarm-data.service.ts +++ b/ui-ngx/src/app/core/api/alarm-data.service.ts @@ -85,6 +85,7 @@ export class AlarmDataService { alarmDataSubscriptionOptions.entityFilter = alarmSource.entityFilter; alarmDataSubscriptionOptions.pageLink = pageLink; alarmDataSubscriptionOptions.keyFilters = alarmSource.keyFilters; + alarmDataSubscriptionOptions.keyFiltersOperation = alarmSource.keyFiltersOperation; alarmDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; } return alarmDataSubscriptionOptions; diff --git a/ui-ngx/src/app/core/api/alias-controller.ts b/ui-ngx/src/app/core/api/alias-controller.ts index ffd06a5289..de6863ee56 100644 --- a/ui-ngx/src/app/core/api/alias-controller.ts +++ b/ui-ngx/src/app/core/api/alias-controller.ts @@ -314,7 +314,15 @@ export class AliasController implements IAliasController { || newDatasource.type === DatasourceType.entityCount || newDatasource.type === DatasourceType.alarmCount) { if (newDatasource.filterId) { - newDatasource.keyFilters = this.getKeyFilters(newDatasource.filterId); + const filterInfo = this.getFilterInfo(newDatasource.filterId); + if (filterInfo) { + newDatasource.keyFilters = filterInfoToKeyFilters(filterInfo); + if (filterInfo.keyFiltersOperation) { + newDatasource.keyFiltersOperation = filterInfo.keyFiltersOperation; + } + } else { + newDatasource.keyFilters = []; + } } if (newDatasource.type === DatasourceType.alarmCount) { newDatasource.alarmFilter = this.entityService.resolveAlarmFilter(newDatasource.alarmFilterConfig, false); diff --git a/ui-ngx/src/app/core/api/entity-data-subscription.ts b/ui-ngx/src/app/core/api/entity-data-subscription.ts index 021c5e81e9..6f7949d259 100644 --- a/ui-ngx/src/app/core/api/entity-data-subscription.ts +++ b/ui-ngx/src/app/core/api/entity-data-subscription.ts @@ -34,6 +34,7 @@ import { import { AlarmFilter, ComparisonTsValue, + ComplexOperation, EntityData, EntityDataPageLink, EntityFilter, @@ -112,6 +113,7 @@ export interface EntityDataSubscriptionOptions { pageLink?: EntityDataPageLink; keyFilters?: Array; additionalKeyFilters?: Array; + keyFiltersOperation?: ComplexOperation; subscriptionTimewindow?: SubscriptionTimewindow; latestTsOffset?: number; } @@ -356,7 +358,6 @@ export class EntityDataSubscription { this.subscriber = new TelemetrySubscriber(this.telemetryService); this.dataCommand = new EntityDataCmd(); - let keyFilters = this.entityDataSubscriptionOptions.keyFilters; if (this.entityDataSubscriptionOptions.additionalKeyFilters) { if (keyFilters) { @@ -370,6 +371,7 @@ export class EntityDataSubscription { entityFilter: this.entityDataSubscriptionOptions.entityFilter, pageLink: this.entityDataSubscriptionOptions.pageLink, keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation, entityFields, latestValues: this.latestValues }; @@ -494,7 +496,8 @@ export class EntityDataSubscription { } this.countCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; this.subscriber.subscriptionCommands.push(this.countCommand); @@ -569,7 +572,8 @@ export class EntityDataSubscription { } this.alarmCountCommand.query = { entityFilter: this.entityDataSubscriptionOptions.entityFilter, - keyFilters + keyFilters, + keyFiltersOperation: this.entityDataSubscriptionOptions.keyFiltersOperation }; if (this.entityDataSubscriptionOptions.alarmFilter) { this.alarmCountCommand.query = {...this.alarmCountCommand.query, ...this.entityDataSubscriptionOptions.alarmFilter}; diff --git a/ui-ngx/src/app/core/api/entity-data.service.ts b/ui-ngx/src/app/core/api/entity-data.service.ts index 9ed064403c..697187b61e 100644 --- a/ui-ngx/src/app/core/api/entity-data.service.ts +++ b/ui-ngx/src/app/core/api/entity-data.service.ts @@ -183,6 +183,7 @@ export class EntityDataService { entityDataSubscriptionOptions.alarmFilter = datasource.alarmFilter; entityDataSubscriptionOptions.keyFilters = keyFilters; entityDataSubscriptionOptions.additionalKeyFilters = additionalKeyFilters; + entityDataSubscriptionOptions.keyFiltersOperation = datasource.keyFiltersOperation; if (entityDataSubscriptionOptions.datasourceType === DatasourceType.entity) { entityDataSubscriptionOptions.pageLink = pageLink; } diff --git a/ui-ngx/src/app/core/api/widget-api.models.ts b/ui-ngx/src/app/core/api/widget-api.models.ts index f92db80cc4..12f5bc60d8 100644 --- a/ui-ngx/src/app/core/api/widget-api.models.ts +++ b/ui-ngx/src/app/core/api/widget-api.models.ts @@ -67,6 +67,7 @@ import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; import { MatDialogRef } from '@angular/material/dialog'; import { TbUnit } from '@shared/models/unit.models'; import { UnitService } from '@core/services/unit.service'; +import { HttpOptionsResult, HttpUploadOptionsResult, QueryParams, RequestConfig } from '@core/http/http-utils'; export interface TimewindowFunctions { onUpdateTimewindow: (startTimeMs: number, endTimeMs: number, interval?: number) => void; @@ -94,6 +95,14 @@ export interface IWidgetUtils { getEntityDetailsPageURL: (id: string, entityType: EntityType) => string; } +export interface IWidgetHttpUtils { + defaultHttpOptions: (ignoreLoading?: boolean, ignoreErrors?: boolean, resendRequest?: boolean, queryParams?: QueryParams) => HttpOptionsResult; + defaultHttpOptionsFromConfig: (config?: RequestConfig) => HttpOptionsResult; + defaultHttpOptionsFromParams: (queryParams?: QueryParams, config?: RequestConfig) => HttpOptionsResult; + defaultHttpUploadOptions: (ignoreLoading?: boolean, ignoreErrors?: boolean, resendRequest?: boolean, queryParams?: QueryParams) => HttpUploadOptionsResult; + createDefaultHttpOptions: (queryParamsOrConfig?: QueryParams | RequestConfig, config?: RequestConfig) => HttpOptionsResult; +} + export interface PlaceMapItemActionData { action: WidgetAction | WidgetActionDescriptor; additionalParams?: any; diff --git a/ui-ngx/src/app/core/auth/auth.models.ts b/ui-ngx/src/app/core/auth/auth.models.ts index 623ceefadd..d353ae8c8c 100644 --- a/ui-ngx/src/app/core/auth/auth.models.ts +++ b/ui-ngx/src/app/core/auth/auth.models.ts @@ -40,6 +40,7 @@ export interface SysParamsState { calculatedFieldDebugPerTenantLimitsConfiguration?: string; intermediateAggregationIntervalInSecForCF: number; trendzSettings: TrendzSettings; + allowKeyFiltersOrConditions: boolean; } export interface SysParams extends SysParamsState { diff --git a/ui-ngx/src/app/core/auth/auth.reducer.ts b/ui-ngx/src/app/core/auth/auth.reducer.ts index 51e02b1ab8..aaf8844863 100644 --- a/ui-ngx/src/app/core/auth/auth.reducer.ts +++ b/ui-ngx/src/app/core/auth/auth.reducer.ts @@ -42,7 +42,8 @@ const emptyUserAuthState: AuthPayload = { maxDebugModeDurationMinutes: 0, intermediateAggregationIntervalInSecForCF: 0, userSettings: initialUserSettings, - trendzSettings: initialTrendzSettings + trendzSettings: initialTrendzSettings, + allowKeyFiltersOrConditions: true }; export const initialState: AuthState = { diff --git a/ui-ngx/src/app/core/http/alarm-rules.service.ts b/ui-ngx/src/app/core/http/alarm-rules.service.ts new file mode 100644 index 0000000000..18a51115f3 --- /dev/null +++ b/ui-ngx/src/app/core/http/alarm-rules.service.ts @@ -0,0 +1,73 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Injectable } from '@angular/core'; +import { defaultHttpOptionsFromConfig, defaultHttpOptionsFromParams, RequestConfig } from './http-utils'; +import { Observable } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; +import { PageData } from '@shared/models/page/page-data'; +import { + CalculatedFieldAlarmRule, + CalculatedFieldAlarmRuleInfo, + CalculatedFieldsQuery, + CalculatedFieldTestScriptInputParams +} from '@shared/models/calculated-field.models'; +import { PageLink } from '@shared/models/page/page-link'; +import { EntityId } from '@shared/models/id/entity-id'; +import { EntityTestScriptResult } from '@shared/models/entity.models'; +import { CalculatedFieldEventBody } from '@shared/models/event.models'; + +@Injectable({ + providedIn: 'root' +}) +export class AlarmRulesService { + + constructor( + private http: HttpClient + ) { } + + public getAlarmRuleById(alarmRuleId: string, config?: RequestConfig): Observable { + return this.http.get(`/api/alarm/rule/${alarmRuleId}`, defaultHttpOptionsFromConfig(config)); + } + + public saveAlarmRule(alarmRule: CalculatedFieldAlarmRule, config?: RequestConfig): Observable { + return this.http.post('/api/alarm/rule', alarmRule, defaultHttpOptionsFromConfig(config)); + } + + public deleteAlarmRule(alarmRuleId: string, config?: RequestConfig): Observable { + return this.http.delete(`/api/alarm/rule/${alarmRuleId}`, defaultHttpOptionsFromConfig(config)); + } + + public getAlarmRules(pageLink: PageLink, query: CalculatedFieldsQuery, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules${pageLink.toQuery()}`, defaultHttpOptionsFromParams(query, config)); + } + + public getAlarmRulesByEntityId({ entityType, id }: EntityId, pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules/${entityType}/${id}${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } + + public testScript(inputParams: CalculatedFieldTestScriptInputParams, config?: RequestConfig): Observable { + return this.http.post('/api/alarm/rule/testScript', inputParams, defaultHttpOptionsFromConfig(config)); + } + + public getLatestAlarmRuleDebugEvent(id: string, config?: RequestConfig): Observable { + return this.http.get(`/api/alarm/rule/${id}/debug`, defaultHttpOptionsFromConfig(config)); + } + + public getAlarmRuleNames(pageLink: PageLink, config?: RequestConfig): Observable> { + return this.http.get>(`/api/alarm/rules/names${pageLink.toQuery()}`, defaultHttpOptionsFromConfig(config)); + } +} diff --git a/ui-ngx/src/app/core/http/http-utils.ts b/ui-ngx/src/app/core/http/http-utils.ts index 8b90821c09..98f65f924a 100644 --- a/ui-ngx/src/app/core/http/http-utils.ts +++ b/ui-ngx/src/app/core/http/http-utils.ts @@ -15,7 +15,7 @@ /// import { InterceptorHttpParams } from '../interceptors/interceptor-http-params'; -import { HttpHeaders } from '@angular/common/http'; +import { HttpHeaders, HttpParams } from '@angular/common/http'; import { InterceptorConfig } from '../interceptors/interceptor-config'; export type QueryParams = { [param:string]: any }; @@ -27,6 +27,15 @@ export interface RequestConfig { queryParams?: QueryParams; } +export interface HttpOptionsResult { + headers: HttpHeaders; + params: HttpParams; +} + +export interface HttpUploadOptionsResult { + params: HttpParams; +} + export function hasRequestConfig(config?: any): boolean { if (!config) { return false; diff --git a/ui-ngx/src/app/core/http/image.service.ts b/ui-ngx/src/app/core/http/image.service.ts index c714f728c0..896145b351 100644 --- a/ui-ngx/src/app/core/http/image.service.ts +++ b/ui-ngx/src/app/core/http/image.service.ts @@ -31,7 +31,7 @@ import { removeTbImagePrefix, ResourceSubType } from '@shared/models/resource.models'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { catchError, finalize, map, switchMap } from 'rxjs/operators'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { blobToBase64, blobToText } from '@core/utils'; import { ResourcesService } from '@core/services/resources.service'; @@ -116,16 +116,15 @@ export class ImageService { request = new ReplaySubject(1); this.imagesLoading[imageLink] = request; const options = defaultHttpOptionsFromConfig({ignoreLoading: true, ignoreErrors: true}); - this.http.get(imageLink, {...options, ...{ responseType: 'blob' } }).subscribe({ + this.http.get(imageLink, {...options, ...{ responseType: 'blob' } }).pipe( + finalize(()=> delete this.imagesLoading[imageLink]) + ).subscribe({ next: (value) => { request.next(value); request.complete(); }, error: err => { request.error(err); - }, - complete: () => { - delete this.imagesLoading[imageLink]; } }); } diff --git a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts index d008b8e5c7..69791908b3 100644 --- a/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts +++ b/ui-ngx/src/app/core/interceptors/global-http-interceptor.ts @@ -129,10 +129,6 @@ export class GlobalHttpInterceptor implements HttpInterceptor { this.showError(req.method + ': ' + req.url + '
' + errorResponse.status + ': ' + errorResponse.statusText); } - } else if (errorResponse.status === 504) { - if (!ignoreErrors) { - this.showError('Request timeout'); - } } else { unhandled = true; } diff --git a/ui-ngx/src/app/core/services/calculated-field-form.service.ts b/ui-ngx/src/app/core/services/calculated-field-form.service.ts index 1bedbf99a7..404676d383 100644 --- a/ui-ngx/src/app/core/services/calculated-field-form.service.ts +++ b/ui-ngx/src/app/core/services/calculated-field-form.service.ts @@ -38,7 +38,7 @@ export class CalculatedFieldFormService { buildForm(): FormGroup { return this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + name: ['', [Validators.required, Validators.maxLength(255)]], entityId: [null, Validators.required], type: [CalculatedFieldType.SIMPLE], debugSettings: [], @@ -48,7 +48,7 @@ export class CalculatedFieldFormService { buildAlarmRuleForm(): FormGroup { return this.fb.group({ - name: ['', [Validators.required, Validators.pattern(oneSpaceInsideRegex), Validators.maxLength(255)]], + name: ['', [Validators.required, Validators.maxLength(255)]], entityId: [null, Validators.required], type: [CalculatedFieldType.ALARM], debugSettings: [], diff --git a/ui-ngx/src/app/core/services/dashboard-utils.service.ts b/ui-ngx/src/app/core/services/dashboard-utils.service.ts index 6704bebbab..3ea1923aa3 100644 --- a/ui-ngx/src/app/core/services/dashboard-utils.service.ts +++ b/ui-ngx/src/app/core/services/dashboard-utils.service.ts @@ -243,6 +243,7 @@ export class DashboardUtilsService { row: widget.row, col: widget.col, }; + dashboard.configuration.timewindow = initModelFromDefaultTimewindow(null, false, false, this.timeService, true, false); return dashboard; } diff --git a/ui-ngx/src/app/core/services/item-buffer.service.ts b/ui-ngx/src/app/core/services/item-buffer.service.ts index 7b7c828cf3..1dc7146883 100644 --- a/ui-ngx/src/app/core/services/item-buffer.service.ts +++ b/ui-ngx/src/app/core/services/item-buffer.service.ts @@ -16,7 +16,13 @@ import { Injectable } from '@angular/core'; import { BreakpointId, Dashboard, DashboardLayoutId } from '@app/shared/models/dashboard.models'; -import { AliasesInfo, EntityAlias, EntityAliases, EntityAliasInfo, getEntityAliasId } from '@shared/models/alias.models'; +import { + AliasesInfo, + EntityAlias, + EntityAliases, + EntityAliasInfo, + getEntityAliasId +} from '@shared/models/alias.models'; import { Datasource, DatasourceType, @@ -31,7 +37,7 @@ import { deepClone, isDefinedAndNotNull, isEqual } from '@core/utils'; import { UtilsService } from '@core/services/utils.service'; import { Observable, of, throwError } from 'rxjs'; import { map } from 'rxjs/operators'; -import { FcRuleNode, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; +import { FcRuleNode, FcRuleNote, ruleNodeTypeDescriptors } from '@shared/models/rule-node.models'; import { RuleChainService } from '@core/http/rule-chain.service'; import { RuleChainImport } from '@shared/models/rule-chain.models'; import { Filter, FilterInfo, Filters, FiltersInfo, getFilterId } from '@shared/models/query/query.models'; @@ -72,8 +78,11 @@ export interface RuleNodeConnection { export interface RuleNodesReference { nodes: FcRuleNode[]; connections: RuleNodeConnection[]; + notes?: FcRuleNote[]; originX?: number; originY?: number; + minX?: number; + minY?: number; } @Injectable({ @@ -312,10 +321,11 @@ export class ItemBufferService { return of(theDashboard); } - public copyRuleNodes(nodes: FcRuleNode[], connections: RuleNodeConnection[]) { + public copyRuleChainObjects(nodes: FcRuleNode[], connections: RuleNodeConnection[], notes: FcRuleNote[] = []) { const ruleNodes: RuleNodesReference = { nodes: [], - connections: [] + connections: [], + notes: [] }; let top = -1; let left = -1; @@ -364,23 +374,51 @@ export class ItemBufferService { right = Math.max(right, node.x + 170); } } + for (const origNote of notes) { + ruleNodes.notes.push({ + id: '', + x: origNote.x, + y: origNote.y, + width: origNote.width, + height: origNote.height, + content: origNote.content, + backgroundColor: origNote.backgroundColor, + borderWidth: origNote.borderWidth, + borderColor: origNote.borderColor, + applyDefaultMarkdownStyle: origNote.applyDefaultMarkdownStyle, + markdownCss: origNote.markdownCss + }); + if (top === -1) { + top = origNote.y; + left = origNote.x; + bottom = origNote.y + (origNote.height || 120); + right = origNote.x + (origNote.width || 200); + } else { + top = Math.min(top, origNote.y); + left = Math.min(left, origNote.x); + bottom = Math.max(bottom, origNote.y + (origNote.height || 120)); + right = Math.max(right, origNote.x + (origNote.width || 200)); + } + } ruleNodes.originX = left + (right - left) / 2; ruleNodes.originY = top + (bottom - top) / 2; + ruleNodes.minX = left; + ruleNodes.minY = top; connections.forEach(connection => { ruleNodes.connections.push(connection); }); this.storeSet(RULE_NODES, ruleNodes); } - public hasRuleNodes(): boolean { + public hasRuleChainObjects(): boolean { return this.storeHas(RULE_NODES); } - public pasteRuleNodes(x: number, y: number): RuleNodesReference { + public pasteRuleChainObjects(x: number, y: number): RuleNodesReference { const ruleNodes: RuleNodesReference = this.storeGet(RULE_NODES); if (ruleNodes) { - const deltaX = x - ruleNodes.originX; - const deltaY = y - ruleNodes.originY; + const deltaX = isDefinedAndNotNull(ruleNodes.minX) ? Math.max(x - ruleNodes.originX, -ruleNodes.minX) : x - ruleNodes.originX; + const deltaY = isDefinedAndNotNull(ruleNodes.minY) ? Math.max(y - ruleNodes.originY, -ruleNodes.minY) : y - ruleNodes.originY; for (const node of ruleNodes.nodes) { const component = this.ruleChainService.getRuleNodeComponentByClazz(node.ruleChainType, node.componentClazz); if (component) { @@ -404,6 +442,10 @@ export class ItemBufferService { return null; } } + for (const note of (ruleNodes.notes || [])) { + note.x = Math.round(note.x + deltaX); + note.y = Math.round(note.y + deltaY); + } return ruleNodes; } return null; diff --git a/ui-ngx/src/app/core/services/menu.models.ts b/ui-ngx/src/app/core/services/menu.models.ts index c2d4fc9e21..ab2160ec0f 100644 --- a/ui-ngx/src/app/core/services/menu.models.ts +++ b/ui-ngx/src/app/core/services/menu.models.ts @@ -816,7 +816,8 @@ const defaultUserMenuMap = new Map([ {id: MenuId.domains}, {id: MenuId.clients} ] - } + }, + {id: MenuId.audit_log} ] } ] diff --git a/ui-ngx/src/app/core/services/resources.service.ts b/ui-ngx/src/app/core/services/resources.service.ts index 1efcdc38c7..93b1b616b9 100644 --- a/ui-ngx/src/app/core/services/resources.service.ts +++ b/ui-ngx/src/app/core/services/resources.service.ts @@ -24,14 +24,17 @@ import { ɵCssSelectorList, ɵNG_COMP_DEF, ɵNG_MOD_DEF, - ɵNgModuleDef + ɵNG_PIPE_DEF, + ɵNgModuleDef, + ɵPipeDef, + DOCUMENT } from '@angular/core'; -import { DOCUMENT } from '@angular/common'; + import { forkJoin, from, Observable, ReplaySubject, throwError } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { IModulesMap } from '@modules/common/modules-map.models'; import { TbResourceId } from '@shared/models/id/tb-resource-id'; -import { camelCase, isObject } from '@core/utils'; +import { camelCase, isObject, isUndefined } from '@core/utils'; import { AuthService } from '@core/auth/auth.service'; import { select, Store } from '@ngrx/store'; import { selectIsAuthenticated } from '@core/auth/auth.selectors'; @@ -286,6 +289,29 @@ export class ResourcesService { module: moduleDef, components: [] } + const declarationsDecl = moduleDef.declarations; + let declarations: Type[]; + if (Array.isArray(declarationsDecl)) { + declarations = declarationsDecl; + } else { + declarations = declarationsDecl(); + } + if (declarations) { + for (const decl of declarations) { + if (ɵNG_COMP_DEF in decl) { + const component: ɵComponentDef = decl[ɵNG_COMP_DEF]; + if (isUndefined(component.standalone) || component.standalone) { + (component as any).standalone = false; + } + } + if (ɵNG_PIPE_DEF in decl) { + const pipe: ɵPipeDef = decl[ɵNG_PIPE_DEF]; + if (isUndefined(pipe.standalone) || pipe.standalone) { + (pipe as any).standalone = false; + } + } + } + } modulesWithComponents.modules.push(moduleInfo); const exportsDecl = moduleDef.exports; let exports: Type[]; diff --git a/ui-ngx/src/app/core/services/utils.service.ts b/ui-ngx/src/app/core/services/utils.service.ts index f32ad194dc..66baf3f9a9 100644 --- a/ui-ngx/src/app/core/services/utils.service.ts +++ b/ui-ngx/src/app/core/services/utils.service.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Inject, Injectable, NgZone, Renderer2 } from '@angular/core'; +import { Inject, Injectable, NgZone, Renderer2, DOCUMENT } from '@angular/core'; import { WINDOW } from '@core/services/window.service'; import { ExceptionData, parseException } from '@app/shared/models/error.models'; import { @@ -45,7 +45,7 @@ import { publishReplay, refCount } from 'rxjs/operators'; import { WidgetContext } from '@app/modules/home/models/widget-component.models'; import { AttributeData, LatestTelemetry, TelemetryType } from '@shared/models/telemetry/telemetry.models'; import { EntityId } from '@shared/models/id/entity-id'; -import { DatePipe, DOCUMENT } from '@angular/common'; +import { DatePipe } from '@angular/common'; import { entityTypeTranslations } from '@shared/models/entity-type.models'; import cssjs from '@core/css/css'; import { isNotEmptyTbFunction } from '@shared/models/js-function.models'; diff --git a/ui-ngx/src/app/core/settings/settings.effects.ts b/ui-ngx/src/app/core/settings/settings.effects.ts index b953cb2dab..347c19cc2f 100644 --- a/ui-ngx/src/app/core/settings/settings.effects.ts +++ b/ui-ngx/src/app/core/settings/settings.effects.ts @@ -15,7 +15,7 @@ /// import { ActivationEnd, Router } from '@angular/router'; -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, DOCUMENT } from '@angular/core'; import { select, Store } from '@ngrx/store'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { TranslateService } from '@ngx-translate/core'; @@ -31,7 +31,7 @@ import { updateUserLang } from '@app/core/settings/settings.utils'; import { UtilsService } from '@core/services/utils.service'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { ActionAuthUpdateLastPublicDashboardId } from '../auth/auth.actions'; -import { DOCUMENT } from '@angular/common'; + export const SETTINGS_KEY = 'SETTINGS'; diff --git a/ui-ngx/src/app/core/translate/missing-translate-handler.ts b/ui-ngx/src/app/core/translate/missing-translate-handler.ts index 85f0517052..40b2cf75a0 100644 --- a/ui-ngx/src/app/core/translate/missing-translate-handler.ts +++ b/ui-ngx/src/app/core/translate/missing-translate-handler.ts @@ -14,11 +14,12 @@ /// limitations under the License. /// -import { MissingTranslationHandler, MissingTranslationHandlerParams } from '@ngx-translate/core'; +import { MissingTranslationHandler, MissingTranslationHandlerParams, StrictTranslation } from '@ngx-translate/core'; import { customTranslationsPrefix } from '@app/shared/models/constants'; +import { Observable } from 'rxjs'; export class TbMissingTranslationHandler implements MissingTranslationHandler { - handle(params: MissingTranslationHandlerParams) { + handle(params: MissingTranslationHandlerParams): StrictTranslation | Observable { if (params.key && !params.key.startsWith(customTranslationsPrefix)) { console.warn('Translation for \'' + params.key + '\' doesn\'t exist'); let translations: any; @@ -32,7 +33,8 @@ export class TbMissingTranslationHandler implements MissingTranslationHandler { } translations = newTranslations; } - params.translateService.setTranslation(params.translateService.currentLang, translations, true); + params.translateService.setTranslation(params.translateService.getCurrentLang(), translations, true); } + return undefined; } } diff --git a/ui-ngx/src/app/core/translate/translate-default-loader.ts b/ui-ngx/src/app/core/translate/translate-default-loader.ts index 22c28a0016..7b83d79548 100644 --- a/ui-ngx/src/app/core/translate/translate-default-loader.ts +++ b/ui-ngx/src/app/core/translate/translate-default-loader.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { TranslateLoader } from '@ngx-translate/core'; +import { TranslateLoader, TranslationObject } from '@ngx-translate/core'; import { Observable } from 'rxjs'; import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; @@ -26,7 +26,7 @@ export class TranslateDefaultLoader implements TranslateLoader { } - getTranslation(lang: string): Observable { - return this.http.get(`/assets/locale/locale.constant-${lang}.json`); + getTranslation(lang: string): Observable { + return this.http.get(`/assets/locale/locale.constant-${lang}.json`); } } diff --git a/ui-ngx/src/app/core/translate/translate-default-parser.ts b/ui-ngx/src/app/core/translate/translate-default-parser.ts index 971c698665..0a7e775637 100644 --- a/ui-ngx/src/app/core/translate/translate-default-parser.ts +++ b/ui-ngx/src/app/core/translate/translate-default-parser.ts @@ -15,15 +15,14 @@ /// import { Injectable } from '@angular/core'; -import { TranslateParser } from '@ngx-translate/core'; +import { InterpolateFunction, InterpolationParameters, TranslateParser } from '@ngx-translate/core'; import { isDefinedAndNotNull } from '@core/utils'; @Injectable({ providedIn: 'root' }) export class TranslateDefaultParser extends TranslateParser { templateMatcher: RegExp = /{{\s?([^{}\s]*)\s?}}/g; - // eslint-disable-next-line @typescript-eslint/ban-types - public interpolate(expr: string | Function, params?: any): string { + public interpolate(expr: string | InterpolateFunction, params?: InterpolationParameters): string { let result: string; if (typeof expr === 'string') { @@ -58,12 +57,11 @@ export class TranslateDefaultParser extends TranslateParser { return target; } - // eslint-disable-next-line @typescript-eslint/ban-types - private interpolateFunction(fn: Function, params?: any) { + private interpolateFunction(fn: InterpolateFunction, params?: InterpolationParameters) { return fn(params); } - private interpolateString(expr: string, params?: any) { + private interpolateString(expr: string, params?: InterpolationParameters) { if (!params) { return expr; } diff --git a/ui-ngx/src/app/core/utils.ts b/ui-ngx/src/app/core/utils.ts index bf57dd4441..7f13f08ba6 100644 --- a/ui-ngx/src/app/core/utils.ts +++ b/ui-ngx/src/app/core/utils.ts @@ -23,7 +23,7 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { baseDetailsPageByEntityType, EntityType } from '@shared/models/entity-type.models'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; import { TranslateService } from '@ngx-translate/core'; -import { serverErrorCodesTranslations } from '@shared/models/constants'; +import { httpStatusMessageMap, serverErrorCodesTranslations } from '@shared/models/constants'; import { SubscriptionEntityInfo } from '@core/api/widget-api.models'; import { CompiledTbFunction, @@ -910,11 +910,13 @@ export function parseHttpErrorMessage(errorResponse: HttpErrorResponse, } else { error = errorResponse.error; } - if (error && !error.message) { - errorMessage = prepareMessageFromData(error); - } else if (error && error.message) { + if (error && error.message) { errorMessage = error.message; timeout = error.timeout ? error.timeout : 0; + } else if (isProxyError(errorResponse)) { + errorMessage = httpStatusMessageMap.get(errorResponse.status); + } else if (error) { + errorMessage = prepareMessageFromData(error); } else { errorMessage = `Unhandled error code ${error ? error.status : '\'Unknown\''}`; } @@ -951,6 +953,14 @@ function prepareMessageFromData(data): string { } } +function isProxyError(errorResponse: HttpErrorResponse): boolean { + if (!httpStatusMessageMap.has(errorResponse.status)) { + return false; + } + const error = errorResponse.error; + return !error || typeof error === 'string' || (typeof error === 'object' && !error.message); +} + export const genNextLabel = (name: string, datasources: Datasource[]): string => { let label = name; let i = 1; diff --git a/ui-ngx/src/app/core/ws/websocket.service.ts b/ui-ngx/src/app/core/ws/websocket.service.ts index ea0884489f..2d7da5ae67 100644 --- a/ui-ngx/src/app/core/ws/websocket.service.ts +++ b/ui-ngx/src/app/core/ws/websocket.service.ts @@ -29,9 +29,11 @@ import { WebsocketDataMsg } from '@shared/models/telemetry/telemetry.models'; import { ActionNotificationShow } from '@core/notification/notification.actions'; +import { NotificationType } from '@core/notification/notification.models'; import Timeout = NodeJS.Timeout; const RECONNECT_INTERVAL = 2000; +const MAX_RECONNECT_INTERVAL = 60000; const WS_IDLE_TIMEOUT = 90000; const MAX_PUBLISH_COMMANDS = 10; @@ -57,6 +59,16 @@ export abstract class WebsocketService implements WsServ errorName = 'WebSocket Error'; + // Exponential backoff: tracks the number of consecutive failed reconnect attempts. + // Reset only after a productive connection (i.e. at least one message received). + // This prevents the open→immediately-closed cycle from resetting the counter. + private reconnectAttempts = 0; + + // Stores the last close-event error code shown to the user during a reconnect cycle. + // Only suppresses duplicate notifications for the same error code; a new error code is still shown. + // Cleared after receiving a successful message. + private lastShownCloseCode: number | null = null; + protected constructor(protected store: Store, protected authService: AuthService, protected ngZone: NgZone, @@ -126,6 +138,8 @@ export abstract class WebsocketService implements WsServ this.subscribersCount = 0; this.cmdWrapper.clear(); if (close) { + this.reconnectAttempts = 0; + this.lastShownCloseCode = null; this.closeSocket(); } } @@ -221,6 +235,10 @@ export abstract class WebsocketService implements WsServ this.processOnMessage(message as WebsocketDataMsg); } this.checkToClose(); + if (this.reconnectAttempts) { + this.reconnectAttempts = 0; + this.lastShownCloseCode = null; + } } private onError(errorEvent) { @@ -231,8 +249,13 @@ export abstract class WebsocketService implements WsServ } private onClose(closeEvent: CloseEvent) { + // Show error notification only when the error code changes to prevent notification spam, + // while still surfacing new, potentially actionable errors during a reconnect cycle. + // lastShownCloseCode is cleared only after a productive connection (onMessage). if (closeEvent && closeEvent.code > 1001 && closeEvent.code !== 1006 - && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500) { + && closeEvent.code !== 1011 && closeEvent.code !== 1012 && closeEvent.code !== 4500 + && this.lastShownCloseCode !== closeEvent.code) { + this.lastShownCloseCode = closeEvent.code; this.showWsError(closeEvent.code, closeEvent.reason); } this.isOpening = false; @@ -251,18 +274,28 @@ export abstract class WebsocketService implements WsServ if (this.reconnectTimer) { clearTimeout(this.reconnectTimer); } - this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), RECONNECT_INTERVAL); + const delay = Math.min(RECONNECT_INTERVAL * Math.pow(2, this.reconnectAttempts), MAX_RECONNECT_INTERVAL); + this.reconnectAttempts = Math.min(this.reconnectAttempts + 1, 10); + this.reconnectTimer = setTimeout(() => this.tryOpenSocket(), delay); } } private showWsError(errorCode: number, errorMsg: string) { let message = errorMsg; - if (!message) { - message += `${this.errorName}: error code - ${errorCode}.`; + let notificationType: NotificationType = 'error'; + + if (errorCode === 1008 || (errorMsg && errorMsg.includes('limit reached'))) { + message = 'Too many active sessions. Please close unused browser tabs or sign out from other devices'; + notificationType = 'warn'; + } else if (errorCode === 1009) { + message = 'Too much data to display. Please refresh the page or narrow your request.'; + notificationType = 'warn'; + } else if (!message) { + message = `${this.errorName}: error code - ${errorCode}.`; } - this.store.dispatch(new ActionNotificationShow( - { - message, type: 'error' - })); + + this.store.dispatch(new ActionNotificationShow({ + message, type: notificationType + })); } } diff --git a/ui-ngx/src/app/modules/common/modules-map.ts b/ui-ngx/src/app/modules/common/modules-map.ts index 361ee71ec4..8f721b87dd 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -14,10 +14,9 @@ /// limitations under the License. /// -/* eslint-disable max-len */ - import * as AngularAnimations from '@angular/animations'; import * as AngularCore from '@angular/core'; +import * as AngularCoreRxjsInterop from '@angular/core/rxjs-interop'; import * as AngularCommon from '@angular/common'; import * as AngularForms from '@angular/forms'; import * as AngularPlatformBrowser from '@angular/platform-browser'; @@ -75,8 +74,12 @@ import * as RxJsOperators from 'rxjs/operators'; import * as TranslateCore from '@ngx-translate/core'; import * as MatDateTimePicker from '@mat-datetimepicker/core'; import _moment from 'moment'; +import * as momentTz from 'moment-timezone'; import * as tslib from 'tslib'; +import * as CanvasGauges from 'canvas-gauges'; +import * as NgxHmCarousel from 'ngx-hm-carousel'; + import * as TbCore from '@core/public-api'; import * as TbShared from '@shared/public-api'; import * as TbHomeComponents from '@home/components/public-api'; @@ -344,6 +347,7 @@ import * as AIModelDialogComponent from '@home/components/ai-model/ai-model-dial import { IModulesMap } from '@modules/common/modules-map.models'; import { Observable, of } from 'rxjs'; import { isJSResourceUrl } from '@shared/public-api'; +import { ɵɵinterpolate, ɵɵinterpolate2 } from '@angular/core'; class ModulesMap implements IModulesMap { @@ -351,7 +355,8 @@ class ModulesMap implements IModulesMap { private modulesMap: {[key: string]: any} = { '@angular/animations': AngularAnimations, - '@angular/core': AngularCore, + '@angular/core': this.angularCoreModule20to18Patch(AngularCore), + '@angular/core/rxjs-interop': AngularCoreRxjsInterop, '@angular/common': AngularCommon, '@angular/common/http': HttpClientModule, '@angular/forms': AngularForms, @@ -405,11 +410,15 @@ class ModulesMap implements IModulesMap { '@ngrx/store': NgrxStore, rxjs: RxJs, 'rxjs/operators': RxJsOperators, - '@ngx-translate/core': TranslateCore, + '@ngx-translate/core': this.translateModule20to18Patch(TranslateCore), '@mat-datetimepicker/core': MatDateTimePicker, moment: _moment, + 'moment-timezone': momentTz, tslib, + 'canvas-gauges': CanvasGauges, + 'ngx-hm-carousel': NgxHmCarousel, + '@core/public-api': TbCore, '@shared/public-api': TbShared, '@home/components/public-api': TbHomeComponents, @@ -706,6 +715,55 @@ class ModulesMap implements IModulesMap { } return of(null); } + + private angularCoreModule20to18Patch(module: typeof AngularCore): typeof AngularCore { + const result = {...module}; + (result as any).ɵɵStandaloneFeature = () => {}; + (result as any).ɵɵInputTransformsFeature = () => {}; + (result as any).ɵɵpropertyInterpolate = (propName: string, v0: any, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate(v0), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate1 = (propName: string, prefix: string, v0: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate1(prefix, v0, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate2 = (propName: string, prefix: string, v0: any, i0: string, v1: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate2(prefix, v0, i0, v1, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate3 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate3(prefix, v0, i0, v1, i1, v2, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate4 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, i2: string, v3: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate4(prefix, v0, i0, v1, i1, v2, i2, v3, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate5 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, i2: string, v3: any, i3: string, v4: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate5(prefix, v0, i0, v1, i1, v2, i2, v3, i3, v4, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate6 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, i2: string, v3: any, i3: string, v4: any, i4: string, v5: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate6(prefix, v0, i0, v1, i1, v2, i2, v3, i3, v4, i4, v5, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate7 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, i2: string, v3: any, i3: string, v4: any, i4: string, v5: any, i5: string, v6: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate7(prefix, v0, i0, v1, i1, v2, i2, v3, i3, v4, i4, v5, i5, v6, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolate8 = (propName: string, prefix: string, v0: any, i0: string, v1: any, i1: string, v2: any, i2: string, v3: any, i3: string, v4: any, i4: string, v5: any, i5: string, v6: any, i6: string, v7: any, suffix: string, sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolate8(prefix, v0, i0, v1, i1, v2, i2, v3, i3, v4, i4, v5, i5, v6, i6, v7, suffix), sanitizer); + }; + (result as any).ɵɵpropertyInterpolateV = (propName: string, values: any[], sanitizer?: any) => { + return result.ɵɵproperty(propName, result.ɵɵinterpolateV(values), sanitizer); + }; + return result; + } + + private translateModule20to18Patch(module: typeof TranslateCore): typeof TranslateCore { + const translateServiceCls = module.TranslateService; + Object.defineProperty(translateServiceCls.prototype, 'translations', { + get: function() { + return this.store.translations; + }, + enumerable: true, + configurable: true + }); + return module; + } } export const modulesMap = new ModulesMap(); diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html index 4aafb9b441..6802eaaf8b 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+@if (isLoading$ | async) { + + +} @else { +
+}
@@ -35,12 +38,16 @@ ai-models.name - - {{ 'ai-models.name-required' | translate }} - - - {{ 'ai-models.name-max-length' | translate }} - + @if (aiModelForms.get('name').hasError('required') || aiModelForms.get('name').hasError('pattern')) { + + {{ 'ai-models.name-required' | translate }} + + } + @if (aiModelForms.get('name').hasError('maxlength')) { + + {{ 'ai-models.name-max-length' | translate }} + + }
@@ -50,9 +57,11 @@ ai-models.ai-provider - - {{providerTranslationMap.get(provider) | translate}} - + @for (provider of providerMap; track provider) { + + {{providerTranslationMap.get(provider) | translate}} + + }
@@ -61,30 +70,36 @@ ai-models.personal-access-token - - {{ 'ai-models.personal-access-token-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.personalAccessToken').hasError('required') || + aiModelForms.get('configuration.providerConfig.personalAccessToken').hasError('pattern')) { + + {{ 'ai-models.personal-access-token-required' | translate }} + + } } @if (providerFieldsList.includes('projectId')) { ai-models.project-id - - {{ 'ai-models.project-id-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.projectId').hasError('required') || + aiModelForms.get('configuration.providerConfig.projectId').hasError('pattern')) { + + {{ 'ai-models.project-id-required' | translate }} + + } } @if (providerFieldsList.includes('location')) { ai-models.location - - {{ 'ai-models.location-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.location').hasError('required') || + aiModelForms.get('configuration.providerConfig.location').hasError('pattern')) { + + {{ 'ai-models.location-required' | translate }} + + } } @if (providerFieldsList.includes('serviceAccountKey')) { @@ -104,10 +119,12 @@ ai-models.endpoint - - {{ 'ai-models.endpoint-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.endpoint').hasError('required') || + aiModelForms.get('configuration.providerConfig.endpoint').hasError('pattern')) { + + {{ 'ai-models.endpoint-required' | translate }} + + } } @if (providerFieldsList.includes('serviceVersion')) { @@ -120,10 +137,12 @@ ai-models.baseurl - - {{ 'ai-models.baseurl-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.baseUrl').hasError('required') || + aiModelForms.get('configuration.providerConfig.baseUrl').hasError('pattern')) { + + {{ 'ai-models.baseurl-required' | translate }} + + } } @if (providerFieldsList.includes('apiKey')) { @@ -131,30 +150,36 @@ ai-models.api-key - - {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }} - + @if (aiModelForms.get('configuration.providerConfig.apiKey').hasError('required') || + aiModelForms.get('configuration.providerConfig.apiKey').hasError('pattern')) { + + {{ ( provider === aiProvider.OPENAI ? 'ai-models.api-key-open-ai-required' : 'ai-models.api-key-required') | translate }} + + } } @if (providerFieldsList.includes('region')) { ai-models.region - - {{ 'ai-models.region-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.region').hasError('required') || + aiModelForms.get('configuration.providerConfig.region').hasError('pattern')) { + + {{ 'ai-models.region-required' | translate }} + + } } @if (providerFieldsList.includes('accessKeyId')) { ai-models.access-key-id - - {{ 'ai-models.access-key-id-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.accessKeyId').hasError('required') || + aiModelForms.get('configuration.providerConfig.accessKeyId').hasError('pattern')) { + + {{ 'ai-models.access-key-id-required' | translate }} + + } } @if (providerFieldsList.includes('secretAccessKey')) { @@ -162,10 +187,12 @@ ai-models.secret-access-key - - {{ 'ai-models.secret-access-key-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.secretAccessKey').hasError('required') || + aiModelForms.get('configuration.providerConfig.secretAccessKey').hasError('pattern')) { + + {{ 'ai-models.secret-access-key-required' | translate }} + + } } @if (provider === aiProvider.OLLAMA) { @@ -185,19 +212,23 @@ ai-models.username - - {{ 'ai-models.username-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.auth.username').hasError('required') || + aiModelForms.get('configuration.providerConfig.auth.username').hasError('pattern')) { + + {{ 'ai-models.username-required' | translate }} + + } ai-models.password - - {{ 'ai-models.password-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.auth.password').hasError('required') || + aiModelForms.get('configuration.providerConfig.auth.password').hasError('pattern')) { + + {{ 'ai-models.password-required' | translate }} + + } } @if (aiModelForms.get('configuration.providerConfig.auth.type').value === AuthenticationType.TOKEN) { @@ -205,10 +236,12 @@ ai-models.token - - {{ 'ai-models.token-required' | translate }} - + @if (aiModelForms.get('configuration.providerConfig.auth.token').hasError('required') || + aiModelForms.get('configuration.providerConfig.auth.token').hasError('pattern')) { + + {{ 'ai-models.token-required' | translate }} + + } }
@@ -240,14 +273,15 @@ - - warning - + @if (aiModelForms.get('configuration').get('temperature').hasError('min')) { + + warning + + }
} @@ -259,15 +293,16 @@ - - warning - + @if (aiModelForms.get('configuration').get('topP').hasError('min') || + aiModelForms.get('configuration').get('topP').hasError('max')) { + + warning + + }
} @@ -279,14 +314,15 @@ - - warning - + @if (aiModelForms.get('configuration').get('topK').hasError('min')) { + + warning + + } } diff --git a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts index 379b8f7dbc..77948333a1 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/ai-model-dialog.component.ts @@ -47,9 +47,10 @@ export interface AIModelDialogData { } @Component({ - selector: 'tb-ai-model-dialog', - templateUrl: './ai-model-dialog.component.html', - styleUrls: ['./ai-model-dialog.component.scss'] + selector: 'tb-ai-model-dialog', + templateUrl: './ai-model-dialog.component.html', + styleUrls: ['./ai-model-dialog.component.scss'], + standalone: false }) export class AIModelDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html index 8655789594..28d0fa3904 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.html @@ -26,25 +26,31 @@
- - -
-
- check_circle + @if (!showCheckSuccess && !checkErrMsg) { + + + } + @if (showCheckSuccess) { +
+
+ check_circle +
+ {{ "ai-models.check-connectivity-success" | translate }}
- {{ "ai-models.check-connectivity-success" | translate }} -
-
-
- cancel + } + @if (checkErrMsg) { +
+
+ cancel +
+
{{ "ai-models.check-connectivity-failed" | translate }}
+ +
-
{{ "ai-models.check-connectivity-failed" | translate }}
- - -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts index f16e85983f..3c48890c2f 100644 --- a/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/ai-model/check-connectivity-dialog.component.ts @@ -28,9 +28,10 @@ export interface AIModelDialogData { } @Component({ - selector: 'tb-check-connectivity-dialog', - templateUrl: './check-connectivity-dialog.component.html', - styleUrls: ['./check-connectivity-dialog.component.scss'] + selector: 'tb-check-connectivity-dialog', + templateUrl: './check-connectivity-dialog.component.html', + styleUrls: ['./check-connectivity-dialog.component.scss'], + standalone: false }) export class CheckConnectivityDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts index fc55602e35..5e36be0893 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-details-dialog.component.ts @@ -28,10 +28,11 @@ export interface AlarmRuleDetailsDialogData { } @Component({ - selector: 'tb-edit-alarm-details-dialog', - templateUrl: './alarm-rule-details-dialog.component.html', - providers: [], - styleUrls: ['./cf-alarm-rules-dialog.component.scss'], + selector: 'tb-edit-alarm-details-dialog', + templateUrl: './alarm-rule-details-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], + standalone: false }) export class AlarmRuleDetailsDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html index 3b5379a9d9..48f4e8a5af 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-dialog.component.html @@ -40,8 +40,6 @@ @if (fieldFormGroup.get('name').hasError('required')) { {{ 'alarm-rule.alarm-type-required' | translate }} - } @else if (fieldFormGroup.get('name').hasError('pattern')) { - {{ 'alarm-rule.alarm-type-pattern' | translate }} } @else if (fieldFormGroup.get('name').hasError('maxlength')) { {{ 'alarm-rule.alarm-type-max-length' | translate }} } @@ -104,9 +102,11 @@ delete
-
- alarm-rule.no-clear-alarm-rule -
+ @if (!configFormGroup.get('clearRule').value) { +
+ alarm-rule.no-clear-alarm-rule +
+ }
- - + } @else { +
+
+
alarm-rule.alarm-rule-type-list
+ + +
+
+
entity.entity-type
+ + + {{ 'alarm-rule.any-type' | translate }} + @for (type of listEntityTypes; track type) { + + {{ entityTypeTranslations.get(type)?.type | translate }} + + } + + +
+ @if (alarmRuleFilterConfigForm.get('entityType').value) { +
+
entity.entities
+ + +
+ } +
+ } +} @@ -70,9 +108,11 @@ {{ 'alarm-rule.any-type' | translate }} - - {{ entityTypeTranslations.get(type)?.type | translate }} - + @for (type of listEntityTypes; track type) { + + {{ entityTypeTranslations.get(type)?.type | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts index daf0408182..15e92c7ee6 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-filter-config.component.ts @@ -51,16 +51,17 @@ export interface AlarmRuleFilterConfigData { } @Component({ - selector: 'tb-alarm-rule-filter-config', - templateUrl: './alarm-rule-filter-config.component.html', - styleUrls: ['./alarm-rule-filter-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleFilterConfigComponent), - multi: true - } - ] + selector: 'tb-alarm-rule-filter-config', + templateUrl: './alarm-rule-filter-config.component.html', + styleUrls: ['./alarm-rule-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterConfigComponent), + multi: true + } + ], + standalone: false }) export class AlarmRuleFilterConfigComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts index 95781decdb..4dc6c6dbb9 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rule-table-header.component.ts @@ -22,9 +22,10 @@ import { CalculatedFieldAlarmRule, CalculatedFieldsQuery } from "@shared/models/ import { AlarmRulesTableConfig } from "@home/components/alarm-rules/alarm-rules-table-config"; @Component({ - selector: 'tb-alarm-rule-table-header', - templateUrl: './alarm-rule-table-header.component.html', - styleUrls: ['./alarm-rule-table-header.component.scss'] + selector: 'tb-alarm-rule-table-header', + templateUrl: './alarm-rule-table-header.component.html', + styleUrls: ['./alarm-rule-table-header.component.scss'], + standalone: false }) export class AlarmRuleTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index cadac572c6..f5043bdcee 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts @@ -21,7 +21,7 @@ import { EntityTableColumn, EntityTableConfig } from '@home/models/entity/entities-table-config.models'; -import { EntityType, entityTypeResources, entityTypeTranslations } from '@shared/models/entity-type.models'; +import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.models'; import { TranslateService } from '@ngx-translate/core'; import { Direction } from '@shared/models/page/sort-order'; import { MatDialog } from '@angular/material/dialog'; @@ -35,15 +35,14 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { DestroyRef, Renderer2 } from '@angular/core'; import { EntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; import { ArgumentEntityType, ArgumentType, - CalculatedField, CalculatedFieldAlarmRule, + CalculatedFieldAlarmRuleInfo, CalculatedFieldEventArguments, - CalculatedFieldInfo, CalculatedFieldsQuery, CalculatedFieldType, getCalculatedFieldArgumentsEditorCompleter, @@ -72,7 +71,7 @@ import { Router } from '@angular/router'; import { EntityAction } from '@home/models/entity/entity-component.models'; import { AlarmRulesComponent } from '@home/components/alarm-rules/alarm-rules.component'; -type AlarmRuleTableEntity = CalculatedField | CalculatedFieldInfo; +export type AlarmRuleTableEntity = CalculatedFieldAlarmRule | CalculatedFieldAlarmRuleInfo; export class AlarmRulesTableConfig extends EntityTableConfig { @@ -84,7 +83,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.fetchCalculatedFields(pageLink); this.addEntity = this.getCalculatedAlarmDialog.bind(this); - this.loadEntity = id => this.calculatedFieldsService.getCalculatedFieldById(id.id); - this.saveEntity = (alarmRule) => this.calculatedFieldsService.saveCalculatedField(alarmRule); + this.loadEntity = id => this.alarmRulesService.getAlarmRuleById(id.id); + this.saveEntity = (alarmRule) => this.alarmRulesService.saveAlarmRule(alarmRule); this.deleteEntityTitle = (field) => this.translate.instant('alarm-rule.delete-title', {title: field.name}); this.deleteEntityContent = () => this.translate.instant('alarm-rule.delete-text'); this.deleteEntitiesTitle = count => this.translate.instant('alarm-rule.delete-multiple-title', {count}); this.deleteEntitiesContent = () => this.translate.instant('alarm-rule.delete-multiple-text'); - this.deleteEntity = id => this.calculatedFieldsService.deleteCalculatedField(id.id); + this.deleteEntity = id => this.alarmRulesService.deleteAlarmRule(id.id); this.onEntityAction = action => this.onCFAction(action); @@ -157,9 +158,9 @@ export class AlarmRulesTableConfig extends EntityTableConfig('name', 'alarm-rule.alarm-type', '33%', entity => this.utilsService.customTranslation(entity.name, entity.name))); if (this.pageMode) { - this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', + this.columns.push(new EntityTableColumn('entityType', 'entity.entity-type', '10%', entity => this.translate.instant(entityTypeTranslations.get(entity.entityId.entityType).type))); - this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', + this.columns.push(new EntityLinkTableColumn('entityName', 'entity.entity', '33%', entity => this.utilsService.customTranslation(entity.entityName, entity.entityName), entity => getEntityDetailsPageURL(entity.entityId?.id, entity.entityId?.entityType as EntityType), false)); } @@ -214,8 +215,8 @@ export class AlarmRulesTableConfig extends EntityTableConfig> { return this.pageMode ? - this.calculatedFieldsService.getCalculatedFields(pageLink, {types: [CalculatedFieldType.ALARM], ...this.alarmRuleFilterConfig}) : - this.calculatedFieldsService.getCalculatedFieldsByEntityId(this.entityId, pageLink, CalculatedFieldType.ALARM); + this.alarmRulesService.getAlarmRules(pageLink, this.alarmRuleFilterConfig) : + this.alarmRulesService.getAlarmRulesByEntityId(this.entityId, pageLink); } onOpenDebugConfig($event: Event, calculatedField: AlarmRuleTableEntity): void { @@ -258,7 +259,7 @@ export class AlarmRulesTableConfig extends EntityTableConfig { + private getCalculatedAlarmDialog(value?: AlarmRuleTableEntity, buttonTitle = 'action.add', isDirty = false): Observable { const entityId = this.entityId || value?.entityId; - const entityName = this.entityName || (value as CalculatedFieldInfo)?.entityName; - return this.dialog.open(AlarmRuleDialogComponent, { + const entityName = this.entityName || (value as CalculatedFieldAlarmRuleInfo)?.entityName; + return this.dialog.open(AlarmRuleDialogComponent, { disableClose: true, panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], data: { @@ -351,14 +352,14 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.getCalculatedAlarmDialog(this.updateImportedCalculatedField(calculatedField), 'action.add', true)), filter(Boolean), - switchMap(calculatedField => this.calculatedFieldsService.saveCalculatedField(calculatedField)), + switchMap(calculatedField => this.alarmRulesService.saveAlarmRule(calculatedField)), filter(Boolean), takeUntilDestroyed(this.destroyRef) ) .subscribe(() => this.updateData()); } - private updateImportedCalculatedField(calculatedField: CalculatedField): CalculatedField { + private updateImportedCalculatedField(calculatedField: CalculatedFieldAlarmRule): CalculatedFieldAlarmRule { if (calculatedField.type === CalculatedFieldType.ALARM) { calculatedField.configuration.arguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { const arg = calculatedField.configuration.arguments[key]; @@ -373,48 +374,44 @@ export class AlarmRulesTableConfig extends EntityTableConfig this.calculatedFieldsService.saveCalculatedField({ ...field, debugSettings })), + this.alarmRulesService.getAlarmRuleById(id).pipe( + switchMap(field => this.alarmRulesService.saveAlarmRule({ ...field, debugSettings })), catchError(() => of(null)), takeUntilDestroyed(this.destroyRef), ).subscribe(() => this.updateData()); } - private getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { - if (calculatedField.type === CalculatedFieldType.ALARM) { - const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { - const type = calculatedField.configuration.arguments[key].refEntityKey.type; - acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) - ? {...argumentsObj[key], type} - : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; - return acc; - }, {}); - return this.dialog.open(CalculatedFieldScriptTestDialogComponent, - { - disableClose: true, - panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], - data: { - arguments: resultArguments, - expression, - argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), - argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), - openCalculatedFieldEdit + getTestScriptDialog(calculatedField: AlarmRuleTableEntity, argumentsObj?: CalculatedFieldEventArguments, openCalculatedFieldEdit = true, expression?: string): Observable { + const resultArguments = Object.keys(calculatedField.configuration.arguments).reduce((acc, key) => { + const type = calculatedField.configuration.arguments[key].refEntityKey.type; + acc[key] = isObject(argumentsObj) && argumentsObj.hasOwnProperty(key) + ? {...argumentsObj[key], type} + : type === ArgumentType.Rolling ? {values: [], type} : {value: '', type, ts: new Date().getTime()}; + return acc; + }, {}); + return this.dialog.open(CalculatedFieldScriptTestDialogComponent, + { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog', 'tb-fullscreen-dialog-gt-xs'], + data: { + arguments: resultArguments, + expression, + argumentsEditorCompleter: getCalculatedFieldArgumentsEditorCompleter(calculatedField.configuration.arguments), + argumentsHighlightRules: getCalculatedFieldArgumentsHighlights(calculatedField.configuration.arguments), + openCalculatedFieldEdit + } + }).afterClosed() + .pipe( + filter(Boolean), + tap(expression => { + if (openCalculatedFieldEdit) { + this.editCalculatedField(null, { + entityId: this.entityId, ...calculatedField, + configuration: {...calculatedField.configuration, expression} as any + }, true) } - }).afterClosed() - .pipe( - filter(Boolean), - tap(expression => { - if (openCalculatedFieldEdit) { - this.editCalculatedField(null, { - entityId: this.entityId, ...calculatedField, - configuration: {...calculatedField.configuration, expression} as any - }, true) - } - }), - ); - } else { - return of(null); - } + }), + ); } private openCalculatedField($event: Event, entity: AlarmRuleTableEntity) { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts index 8ae5af0b57..ce0aecb598 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table.component.ts @@ -30,7 +30,7 @@ import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { ImportExportService } from '@shared/import-export/import-export.service'; import { EntityDebugSettingsService } from '@home/components/entity/debug/entity-debug-settings.service'; import { DatePipe } from '@angular/common'; @@ -39,11 +39,12 @@ import { UtilsService } from "@core/services/utils.service"; import { ActivatedRoute, Router } from "@angular/router"; @Component({ - selector: 'tb-alarm-rules-table', - templateUrl: './alarm-rules-table.component.html', - styleUrls: ['./alarm-rules-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [EntityDebugSettingsService] + selector: 'tb-alarm-rules-table', + templateUrl: './alarm-rules-table.component.html', + styleUrls: ['./alarm-rules-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [EntityDebugSettingsService], + standalone: false }) export class AlarmRulesTableComponent { @@ -58,7 +59,7 @@ export class AlarmRulesTableComponent { pageMode: boolean = false; - constructor(private calculatedFieldsService: CalculatedFieldsService, + constructor(private alarmRulesService: AlarmRulesService, private translate: TranslateService, private dialog: MatDialog, private store: Store, @@ -76,7 +77,7 @@ export class AlarmRulesTableComponent { effect(() => { if (this.active() || this.pageMode) { this.alarmRulesTableConfig = new AlarmRulesTableConfig( - this.calculatedFieldsService, + this.alarmRulesService, this.translate, this.dialog, this.datePipe, diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html index 7b1f13756c..daee0a6e8c 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules.component.html @@ -15,26 +15,28 @@ limitations under the License. --> -
- - - -
+@if (!standalone) { +
+ + + +
+}
@@ -47,8 +49,6 @@ @if (entityForm.get('name').hasError('required')) { {{ 'common.hint.title-required' | translate }} - } @else if (entityForm.get('name').hasError('pattern')) { - {{ 'common.hint.title-pattern' | translate }} } @else if (entityForm.get('name').hasError('maxlength')) { {{ 'common.hint.title-max-length' | translate }} } @@ -57,11 +57,11 @@ + formControlName="debugSettings" + style="margin-bottom: 22px" + [entityType]="EntityType.CALCULATED_FIELD" + [additionalActionConfig]="additionalDebugActionConfig" + />
}
-
- alarm-rule.no-clear-alarm-rule -
+ @if (!configFormGroup.get('clearRule').value) { +
+ alarm-rule.no-clear-alarm-rule +
+ }
} +
-
{{ 'alarm-rule.type' | translate }}
- - - {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} - + + @for (alarmConditionType of alarmConditionTypes; track alarmConditionType) { + + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + + }
@if (isNoData) { @@ -157,13 +159,17 @@ - - {{ timeUnitTranslations.get(timeUnit) | translate }} - + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + } - - {{ 'alarm-rule.condition-duration-time-unit-required' | translate }} - + @if (conditionFormGroup.get('unit').hasError('required')) { + + {{ 'alarm-rule.condition-duration-time-unit-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts index f39ed364fd..f2f27c3b8b 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-rule-condition-dialog.component.ts @@ -53,10 +53,11 @@ export interface CfAlarmRuleConditionDialogData { } @Component({ - selector: 'tb-cf-alarm-rule-condition-dialog', - templateUrl: './cf-alarm-rule-condition-dialog.component.html', - providers: [], - styleUrls: ['./cf-alarm-rules-dialog.component.scss'], + selector: 'tb-cf-alarm-rule-condition-dialog', + templateUrl: './cf-alarm-rule-condition-dialog.component.html', + providers: [], + styleUrls: ['./cf-alarm-rules-dialog.component.scss'], + standalone: false }) export class CfAlarmRuleConditionDialogComponent extends DialogComponent { @@ -228,6 +229,11 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent) { @@ -326,7 +332,10 @@ export class CfAlarmRuleConditionDialogComponent extends DialogComponent{{ 'alarm-rule.condition' | translate }} + @if (!disabled) { + + } } @if (!createAlarmRulesFormArray().controls.length) { - alarm-rule.add-create-alarm-rule-prompt + alarm-rule.add-create-alarm-rule-prompt }
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts index 1c56cdbbad..4d9884f851 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.ts @@ -35,21 +35,22 @@ import { coerceBoolean } from "@shared/decorators/coercion"; import { Observable } from "rxjs"; @Component({ - selector: 'tb-create-cf-alarm-rules', - templateUrl: './create-cf-alarm-rules.component.html', - styleUrls: ['./create-cf-alarm-rules.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CreateCfAlarmRulesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CreateCfAlarmRulesComponent), - multi: true, - } - ] + selector: 'tb-create-cf-alarm-rules', + templateUrl: './create-cf-alarm-rules.component.html', + styleUrls: ['./create-cf-alarm-rules.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CreateCfAlarmRulesComponent), + multi: true, + } + ], + standalone: false }) export class CreateCfAlarmRulesComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts index 017cefb890..951aa1a083 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-complex-filter-predicate-dialog.component.ts @@ -40,10 +40,11 @@ export interface AlarmRuleComplexFilterPredicateDialogData { } @Component({ - selector: 'tb-alarm-rule-complex-filter-predicate-dialog', - templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html', - providers: [], - styleUrls: [] + selector: 'tb-alarm-rule-complex-filter-predicate-dialog', + templateUrl: './alarm-rule-complex-filter-predicate-dialog.component.html', + providers: [], + styleUrls: [], + standalone: false }) export class AlarmRuleComplexFilterPredicateDialogComponent extends diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html index 8f6cd68c6a..2d971f98ab 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.html @@ -53,14 +53,18 @@ {{ entityKeyValueTypes.get(filterFormGroup.get('valueType').value)?.name | translate }} - - - {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} - + @for (valueType of entityKeyValueTypesKeys; track valueType) { + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + } - - {{ 'filter.value-type-required' | translate }} - + @if (filterFormGroup.get('valueType').hasError('required')) { + + {{ 'filter.value-type-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts index ba3adc1695..8dcb00daae 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-dialog.component.ts @@ -43,10 +43,11 @@ } @Component({ - selector: 'tb-alarm-rule-filter-dialog', - templateUrl: './alarm-rule-filter-dialog.component.html', - providers: [], - styleUrls: ['./alarm-rule-filter-dialog.component.scss'] + selector: 'tb-alarm-rule-filter-dialog', + templateUrl: './alarm-rule-filter-dialog.component.html', + providers: [], + styleUrls: ['./alarm-rule-filter-dialog.component.scss'], + standalone: false }) export class AlarmRuleFilterDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts index 5bb05ab1eb..706e2c4a86 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-list.component.ts @@ -44,21 +44,22 @@ import { CalculatedFieldArgument } from "@shared/models/calculated-field.models" import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Component({ - selector: 'tb-alarm-rule-filter-list', - templateUrl: './alarm-rule-filter-list.component.html', - styleUrls: ['./alarm-rule-filter-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleFilterListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmRuleFilterListComponent), - multi: true - } - ] + selector: 'tb-alarm-rule-filter-list', + templateUrl: './alarm-rule-filter-list.component.html', + styleUrls: ['./alarm-rule-filter-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterListComponent), + multi: true + } + ], + standalone: false }) export class AlarmRuleFilterListComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts index 7bf08c9ac9..12fce94fb3 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-list.component.ts @@ -52,21 +52,22 @@ import { CalculatedFieldArgument } from "@shared/models/calculated-field.models" import { takeUntilDestroyed } from "@angular/core/rxjs-interop"; @Component({ - selector: 'tb-alarm-rule-filter-predicate-list', - templateUrl: './alarm-rule-filter-predicate-list.component.html', - styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), - multi: true - } - ] + selector: 'tb-alarm-rule-filter-predicate-list', + templateUrl: './alarm-rule-filter-predicate-list.component.html', + styleUrls: ['./alarm-rule-filter-predicate-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateListComponent), + multi: true + } + ], + standalone: false }) export class AlarmRuleFilterPredicateListComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html index 09b468aeb9..b5206437c9 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.html @@ -28,15 +28,15 @@ - @if (filterPredicateValueNoDataFormGroup.get('duration.staticValue').touched && filterPredicateValueNoDataFormGroup.get('duration.staticValue').errors) { - - warning - - } + @if (filterPredicateValueNoDataFormGroup.get('duration.staticValue').touched && filterPredicateValueNoDataFormGroup.get('duration.staticValue').errors) { + + warning + + } } @else { @@ -68,9 +68,11 @@ - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts index 7f9608bd5c..977bb5749f 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-no-data-value.component.ts @@ -31,21 +31,22 @@ import { AlarmRuleFilterPredicateType, NoDataAlarmRuleFilterPredicate } from "@s import { isDefinedAndNotNull } from "@core/utils"; @Component({ - selector: 'tb-alarm-rule-filter-predicate-no-data-value', - templateUrl: './alarm-rule-filter-predicate-no-data-value.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), - multi: true - } - ] + selector: 'tb-alarm-rule-filter-predicate-no-data-value', + templateUrl: './alarm-rule-filter-predicate-no-data-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateNoDataValueComponent), + multi: true + } + ], + standalone: false }) export class AlarmRuleFilterPredicateNoDataValueComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts index bd6bacb762..8db5bc7325 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate-value.component.ts @@ -34,21 +34,22 @@ import { FormControlsFrom } from "@shared/models/tenant.model"; import { isDefinedAndNotNull } from "@core/utils"; @Component({ - selector: 'tb-alarm-rule-filter-predicate-value', - templateUrl: './alarm-rule-filter-predicate-value.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), - multi: true - } - ] + selector: 'tb-alarm-rule-filter-predicate-value', + templateUrl: './alarm-rule-filter-predicate-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleFilterPredicateValueComponent), + multi: true + } + ], + standalone: false }) export class AlarmRuleFilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html index 9d3ad7286a..904e115225 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html @@ -22,9 +22,11 @@
- - {{stringOperationTranslationMap.get(stringOperation[operation]) | translate}} - + @for (operation of stringOperations; track operation) { + + {{stringOperationTranslationMap.get(stringOperation[operation]) | translate}} + + } @if (filterPredicateFormGroup.get('operation').value !== stringOperation.NO_DATA) { @@ -38,9 +40,11 @@
- - {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} - + @for (operation of numericOperations; track operation) { + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + }
@@ -49,16 +53,18 @@
- - {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} - + @for (operation of booleanOperations; track operation) { + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + }
} @case (filterPredicateType.COMPLEX) {
- - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts index 0584c4d4e4..8c9b751348 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.ts @@ -28,9 +28,10 @@ export interface AlarmCommentDialogData { } @Component({ - selector: 'tb-alarm-comment-dialog', - templateUrl: './alarm-comment-dialog.component.html', - styleUrls: [] + selector: 'tb-alarm-comment-dialog', + templateUrl: './alarm-comment-dialog.component.html', + styleUrls: [], + standalone: false }) export class AlarmCommentDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html index 25db54641d..31bf4dda43 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.html @@ -49,104 +49,110 @@
- + @if (!isDirectionAscending()) { - + }
-
-
- - {{ displayDataElement.commentText }} - - - {{ displayDataElement.createdDateAgo }} - -
- -
-
- {{ getUserInitials(displayDataElement.displayName) }} -
- - no_accounts - -
-
- {{ displayDataElement.displayName }} - - {{ displayDataElement.createdDateAgo }} - - - Edited - -
- {{ displayDataElement.commentText }} -
-
- - + @for (displayDataElement of displayData; track displayDataElement; let i = $index) { +
+ @if (displayDataElement.isSystemComment) { +
+ + {{ displayDataElement.commentText }} + + + {{ displayDataElement.createdDateAgo }} +
-
- -
-
- {{ getUserInitials(displayDataElement.displayName) }} -
- - -
+ } @else { + @if (!displayDataElement.edit) { +
+ @if (displayDataElement.userExists) { +
+ {{ getUserInitials(displayDataElement.displayName) }} +
+ } @else { + no_accounts + } +
+
+ {{ displayDataElement.displayName }} + + {{ displayDataElement.createdDateAgo }} + + @if (displayDataElement.isEdited) { + + Edited + + } +
+ {{ displayDataElement.commentText }} +
+
- -
- - -
+
+ } @else { +
+
+ {{ getUserInitials(displayDataElement.displayName) }} +
+ + +
+ + +
+
+
+ } + } +
+ }
- + @if (isDirectionAscending()) { - + }
-
- {{ getUserInitials(userDisplayName) }} -
+ @if (userDisplayName$ | async; as userDisplayName) { +
+ {{ getUserInitials(userDisplayName) }} +
+ } - + @if (getAlarmCommentValue()) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts index d35e87fdbe..cce31840ab 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment.component.ts @@ -59,9 +59,10 @@ interface AlarmCommentsDisplayData { } @Component({ - selector: 'tb-alarm-comment', - templateUrl: './alarm-comment.component.html', - styleUrls: ['./alarm-comment.component.scss'] + selector: 'tb-alarm-comment', + templateUrl: './alarm-comment.component.html', + styleUrls: ['./alarm-comment.component.scss'], + standalone: false }) export class AlarmCommentComponent implements OnInit { @Input() diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html index dbf649ec37..a47de5b3f7 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.html @@ -25,9 +25,13 @@ close - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
@@ -43,14 +47,18 @@
- - alarm.start-time - - - - alarm.duration - - + @if (alarmFormGroup.get('startTime').value) { + + alarm.start-time + + + } + @if (alarmFormGroup.get('duration').value) { + + alarm.duration + + + }
@@ -71,12 +79,13 @@ - - + @if (displayDetails) { + + + }
@@ -88,25 +97,31 @@
-
- - -
+ @if (alarm$ | async; as alarm;) { +
+ @if (allowAcknowledgment && (alarm.status === alarmStatuses.ACTIVE_UNACK || + alarm.status === alarmStatuses.CLEARED_UNACK)) { + + } + @if (allowClear && (alarm.status === alarmStatuses.ACTIVE_ACK || + alarm.status === alarmStatuses.ACTIVE_UNACK)) { + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.ts index b68d1d983a..67d2b71245 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-details-dialog.component.ts @@ -47,9 +47,10 @@ export interface AlarmDetailsDialogData { } @Component({ - selector: 'tb-alarm-details-dialog', - templateUrl: './alarm-details-dialog.component.html', - styleUrls: ['./alarm-details-dialog.component.scss'] + selector: 'tb-alarm-details-dialog', + templateUrl: './alarm-details-dialog.component.html', + styleUrls: ['./alarm-details-dialog.component.scss'], + standalone: false }) export class AlarmDetailsDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html index 966b3374b8..ca1578c054 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.html @@ -15,11 +15,10 @@ limitations under the License. --> - +@if (panelMode) { - - - +} @else { + @if (buttonMode) { - - + } @else { +
+
+
alarm.alarm-status-list
+ + @for (searchStatus of alarmSearchStatuses; track searchStatus) { + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + } + +
+
+
alarm.alarm-severity-list
+ + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} + + } + +
+
+
alarm.alarm-type-list
+ + +
+
+
alarm.assignee
+ + +
+ @if (propagatedFilter) { + + {{ 'alarm.search-propagated-alarms' | translate }} + + } +
+ } +}
@@ -59,17 +102,21 @@
alarm.alarm-status-list
- - {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} - + @for (searchStatus of alarmSearchStatuses; track searchStatus) { + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + }
alarm.alarm-severity-list
- - {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} - + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} + + }
@@ -85,11 +132,13 @@
alarm.assignee
+ formControlName="assigneeId">
- - {{ 'alarm.search-propagated-alarms' | translate }} - + @if (propagatedFilter) { + + {{ 'alarm.search-propagated-alarms' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.ts index c36d528b4e..f236873806 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-filter-config.component.ts @@ -60,16 +60,17 @@ export interface AlarmFilterConfigData { // @dynamic @Component({ - selector: 'tb-alarm-filter-config', - templateUrl: './alarm-filter-config.component.html', - styleUrls: ['./alarm-filter-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmFilterConfigComponent), - multi: true - } - ] + selector: 'tb-alarm-filter-config', + templateUrl: './alarm-filter-config.component.html', + styleUrls: ['./alarm-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmFilterConfigComponent), + multi: true + } + ], + standalone: false }) export class AlarmFilterConfigComponent implements OnInit, OnDestroy, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.ts index c069169e4a..7805498f1c 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table-header.component.ts @@ -23,9 +23,10 @@ import { AlarmTableConfig } from './alarm-table-config'; import { AlarmFilterConfig } from '@shared/models/query/query.models'; @Component({ - selector: 'tb-alarm-table-header', - templateUrl: './alarm-table-header.component.html', - styleUrls: ['./alarm-table-header.component.scss'] + selector: 'tb-alarm-table-header', + templateUrl: './alarm-table-header.component.html', + styleUrls: ['./alarm-table-header.component.scss'], + standalone: false }) export class AlarmTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts index daa28d0fd9..c0754b3a94 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-table.component.ts @@ -42,9 +42,10 @@ interface AlarmPageQueryParams extends PageQueryParam { } @Component({ - selector: 'tb-alarm-table', - templateUrl: './alarm-table.component.html', - styleUrls: ['./alarm-table.component.scss'] + selector: 'tb-alarm-table', + templateUrl: './alarm-table.component.html', + styleUrls: ['./alarm-table.component.scss'], + standalone: false }) export class AlarmTableComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html index 7dd028db41..adf52b3d39 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.html @@ -22,23 +22,29 @@ formControlName="entityInfo" [required]="required" [matAutocomplete]="entityInfoAutocomplete"> - + @if (selectEntityInfoFormGroup.get('entityInfo').value && !disabled) { + + } - - - - - + @for (entityInfo of filteredEntityInfos | async; track entityInfo) { + + + + } + @if (!(filteredEntityInfos | async)?.length) { + + {{ translate.get('entity.no-entities-matching', {entity: searchText}) | async }} - - + + + } diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts index c1f3938853..89bdc9c8cc 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-autocomplete.component.ts @@ -29,14 +29,15 @@ import { EntityService } from '@core/http/entity.service'; import { isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-aliases-entity-autocomplete', - templateUrl: './aliases-entity-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AliasesEntityAutocompleteComponent), - multi: true - }] + selector: 'tb-aliases-entity-autocomplete', + templateUrl: './aliases-entity-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AliasesEntityAutocompleteComponent), + multi: true + }], + standalone: false }) export class AliasesEntityAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html index d65b790222..4dafef12e4 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.html @@ -16,13 +16,15 @@ -->
-
- - -
+ @for (alias of entityAliasesInfo | keyvalue; track alias) { +
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.ts index 9d6c84b3ff..89df2ee017 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select-panel.component.ts @@ -26,9 +26,10 @@ export interface AliasesEntitySelectPanelData { } @Component({ - selector: 'tb-aliases-entity-select-panel', - templateUrl: './aliases-entity-select-panel.component.html', - styleUrls: ['./aliases-entity-select-panel.component.scss'] + selector: 'tb-aliases-entity-select-panel', + templateUrl: './aliases-entity-select-panel.component.html', + styleUrls: ['./aliases-entity-select-panel.component.scss'], + standalone: false }) export class AliasesEntitySelectPanelComponent { diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.html b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.html index add27f87d3..fc1610dc8a 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.html @@ -15,18 +15,20 @@ limitations under the License. --> -
- + - devices_other - - - {{displayValue}} - -
+ {{displayValue}} + + +} diff --git a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts index 74f4a8c866..a8e1ddf762 100644 --- a/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/aliases-entity-select.component.ts @@ -41,9 +41,10 @@ import { deepClone } from '@core/utils'; import { AliasFilterType } from '@shared/models/alias.models'; @Component({ - selector: 'tb-aliases-entity-select', - templateUrl: './aliases-entity-select.component.html', - styleUrls: ['./aliases-entity-select.component.scss'] + selector: 'tb-aliases-entity-select', + templateUrl: './aliases-entity-select.component.html', + styleUrls: ['./aliases-entity-select.component.scss'], + standalone: false }) export class AliasesEntitySelectComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html index 4689bec48f..3394114d88 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.html @@ -25,20 +25,26 @@ close - - + @if (isLoading$ | async) { + + + }
alias.name - - {{ 'alias.name-required' | translate }} - - - {{ 'alias.duplicate-alias' | translate }} - + @if (entityAliasFormGroup.get('alias').hasError('required')) { + + {{ 'alias.name-required' | translate }} + + } + @if (entityAliasFormGroup.get('alias').hasError('duplicateAliasName')) { + + {{ 'alias.duplicate-alias' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts index 5fc1e4baa1..208aabab3c 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-alias-dialog.component.ts @@ -46,10 +46,11 @@ export interface EntityAliasDialogData { } @Component({ - selector: 'tb-entity-alias-dialog', - templateUrl: './entity-alias-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: EntityAliasDialogComponent}], - styleUrls: ['./entity-alias-dialog.component.scss'] + selector: 'tb-entity-alias-dialog', + templateUrl: './entity-alias-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: EntityAliasDialogComponent }], + styleUrls: ['./entity-alias-dialog.component.scss'], + standalone: false }) export class EntityAliasDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html index 3ed3b218bf..05aba8344a 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.html @@ -25,9 +25,13 @@ close - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
@@ -40,45 +44,49 @@
-
- {{$index + 1}}. -
- - - - {{ 'entity.alias-required' | translate }} - - - - -
- - -
- - + @for (entityAliasControl of entityAliasesFormArray().controls; track entityAliasControl) { +
+ {{$index + 1}}. +
+ + + @if (entityAliasControl.get('alias').hasError('required')) { + + {{ 'entity.alias-required' | translate }} + + } + + + +
+ + +
+ + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts index a1a430670e..62f325d8ae 100644 --- a/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/alias/entity-aliases-dialog.component.ts @@ -54,10 +54,11 @@ export interface EntityAliasesDialogData { } @Component({ - selector: 'tb-entity-aliases-dialog', - templateUrl: './entity-aliases-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: EntityAliasesDialogComponent}], - styleUrls: ['./entity-aliases-dialog.component.scss'] + selector: 'tb-entity-aliases-dialog', + templateUrl: './entity-aliases-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: EntityAliasesDialogComponent }], + styleUrls: ['./entity-aliases-dialog.component.scss'], + standalone: false }) export class EntityAliasesDialogComponent extends DialogComponent implements ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html index 738372a74e..05549f5260 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.html @@ -36,9 +36,11 @@ api-key.description - - {{ 'asset.description-required' | translate }} - + @if (apiKeyForm.get('description').hasError('required')) { + + {{ 'asset.description-required' | translate }} + + } {{ 'api-key.enable' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts index b51d7607b9..1ac2fa10ac 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/add-api-key-dialog.component.ts @@ -29,9 +29,10 @@ import { ApiKeysTableDialogData } from '@home/components/api-key/api-keys-table- import { DAY } from '@shared/models/time/time.models'; @Component({ - selector: 'tb-add-api-key-dialog', - templateUrl: './add-api-key-dialog.component.html', - styleUrls: ['./add-api-key-dialog.component.scss'] + selector: 'tb-add-api-key-dialog', + templateUrl: './add-api-key-dialog.component.html', + styleUrls: ['./add-api-key-dialog.component.scss'], + standalone: false }) export class AddApiKeyDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts index a6fed8376e..d080d16451 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-key-generated-dialog.component.ts @@ -28,9 +28,10 @@ export interface ApiKeyGeneratedDialogData { } @Component({ - selector: 'tb-api-key-generated-dialog', - templateUrl: './api-key-generated-dialog.component.html', - styleUrls: ['api-key-generated-dialog.component.scss'] + selector: 'tb-api-key-generated-dialog', + templateUrl: './api-key-generated-dialog.component.html', + styleUrls: ['api-key-generated-dialog.component.scss'], + standalone: false }) export class ApiKeyGeneratedDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts index b2b26ee51e..1522990d6b 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-config.ts @@ -59,7 +59,6 @@ export class ApiKeysTableConfig extends EntityTableConfig { this.entityType = EntityType.API_KEY; this.detailsPanelEnabled = false; - this.addAsTextButton = true; this.pageMode = false; this.entityTranslations = entityTypeTranslations.get(EntityType.API_KEY); diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts index aaba7b9f1b..208e1ac646 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table-dialog.component.ts @@ -27,9 +27,10 @@ export interface ApiKeysTableDialogData { } @Component({ - selector: 'tb-api-keys-table-dialog', - templateUrl: './api-keys-table-dialog.component.html', - styleUrls: ['api-keys-table-dialog.component.scss'] + selector: 'tb-api-keys-table-dialog', + templateUrl: './api-keys-table-dialog.component.html', + styleUrls: ['api-keys-table-dialog.component.scss'], + standalone: false }) export class ApiKeysTableDialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts index 18730803a4..b27da1456e 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/api-keys-table.component.ts @@ -35,10 +35,11 @@ import { TbPopoverService } from '@shared/components/popover.service'; import { UserId } from '@shared/models/id/user-id'; @Component({ - selector: 'tb-api-keys-table', - templateUrl: './api-keys-table.component.html', - styleUrls: ['./api-keys-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-api-keys-table', + templateUrl: './api-keys-table.component.html', + styleUrls: ['./api-keys-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class ApiKeysTableComponent { diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html index 1a3f159af4..ff93fd0ea7 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.html @@ -20,9 +20,11 @@ api-key.description - - {{ 'asset.description-required' | translate }} - + @if (descriptionFormControl.hasError('required')) { + + {{ 'asset.description-required' | translate }} + + } {{ input.value?.length || 0 }}/255
diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss index 5dcf029470..bf6c450d94 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.scss @@ -15,7 +15,7 @@ */ .tb-edit-api-key-description-panel { - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); width: 400px; max-width: 90vw; diff --git a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts index 1614ea1ed3..8f05a755f4 100644 --- a/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/api-key/edit-api-key-description-panel.component.ts @@ -21,10 +21,11 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { ApiKeyService } from '@core/http/api-key.service'; @Component({ - selector: 'tb-edit-api-key-description-panel', - templateUrl: './edit-api-key-description-panel.component.html', - styleUrls: ['./edit-api-key-description-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-edit-api-key-description-panel', + templateUrl: './edit-api-key-description-panel.component.html', + styleUrls: ['./edit-api-key-description-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EditApiKeyDescriptionPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html index fa791d479e..eddcf3f9b2 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-attribute-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html index 18145ac432..41d5d69edb 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.html @@ -25,30 +25,35 @@ close - - + @if (isLoading$ | async) { + + + }
-
+
dashboard.select-existing
-
+
dashboard.create-new - + dashboard.new-dashboard-title - - {{ 'dashboard.title-required' | translate }} - + @if (addWidgetFormGroup.get('newDashboardTitle').hasError('required')) { + + {{ 'dashboard.title-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts index 95e51ac915..c03b7a71f2 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/add-widget-to-dashboard-dialog.component.ts @@ -49,10 +49,11 @@ export interface AddWidgetToDashboardDialogData { } @Component({ - selector: 'tb-add-widget-to-dashboard-dialog', - templateUrl: './add-widget-to-dashboard-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AddWidgetToDashboardDialogComponent}], - styleUrls: ['./add-widget-to-dashboard-dialog.component.scss'] + selector: 'tb-add-widget-to-dashboard-dialog', + templateUrl: './add-widget-to-dashboard-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AddWidgetToDashboardDialogComponent }], + styleUrls: ['./add-widget-to-dashboard-dialog.component.scss'], + standalone: false }) export class AddWidgetToDashboardDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index 6a797a37aa..9ac6ac1846 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html @@ -21,34 +21,40 @@
{{telemetryTypeTranslationsMap.get(attributeScope) | translate}} - - attribute.attributes-scope - - - {{ telemetryTypeTranslationsMap.get(toTelemetryTypeFunc(scope)) | translate }} - - - + @if (!disableAttributeScopeSelection && !attributeScopeSelectionReadonly) { + + attribute.attributes-scope + + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(toTelemetryTypeFunc(scope)) | translate }} + + } + + + }
- - + @if (attributeScope !== attributeScopeTypes.CLIENT_SCOPE && !disableAddTelemetry) { + + } + @if (!isClientSideTelemetryTypeMap.get(attributeScope)) { + + } - - - - - - - -
-
- - - widgets-bundle.empty - widget.select-widgets-bundle + showFirstLastButtons> + @if (mode === 'widget' && widgetsList.length > 0) { + +
+ @for (widgets of widgetsList; track widgets) { +
+ + +
+ } +
+ + @if (widgetsCarouselIndex > 0) { + + } + + + @if (widgetsCarouselIndex < widgetsList.length - 1) { + + } + + +
+
+
+ } + @if (mode === 'widget' && widgetsLoaded && + widgetsList.length === 0 && + widgetBundleSet) { + widgets-bundle.empty + } + @if (mode === 'widget' && !widgetBundleSet) { + widget.select-widgets-bundle + }
diff --git a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts index 94ad550c33..717ff08020 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.ts @@ -40,7 +40,7 @@ import { MatDialog } from '@angular/material/dialog'; import { DialogService } from '@core/services/dialog.service'; import { Direction, SortOrder } from '@shared/models/page/sort-order'; import { forkJoin, merge, Observable, Subject } from 'rxjs'; -import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'; +import { debounceTime, distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; import { EntityId } from '@shared/models/id/entity-id'; import { AttributeData, @@ -56,6 +56,8 @@ import { import { AttributeDatasource } from '@home/models/datasource/attribute-datasource'; import { AttributeService } from '@app/core/http/attribute.service'; import { EntityType } from '@shared/models/entity-type.models'; +import { Authority } from '@shared/models/authority.enum'; +import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { AddAttributeDialogComponent, @@ -88,15 +90,16 @@ import { Filters } from '@shared/models/query/query.models'; import { hidePageSizePixelValue } from '@shared/models/constants'; import { DeleteTimeseriesPanelComponent } from '@home/components/attribute/delete-timeseries-panel.component'; import { FormBuilder } from '@angular/forms'; +import { coerceBoolean } from '@shared/decorators/coercion'; import { AggregationType, defaultTimewindow } from '@shared/models/time/time.models'; import { TimeService } from '@core/services/time.service'; - @Component({ - selector: 'tb-attribute-table', - templateUrl: './attribute-table.component.html', - styleUrls: ['./attribute-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-attribute-table', + templateUrl: './attribute-table.component.html', + styleUrls: ['./attribute-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class AttributeTableComponent extends PageComponent implements AfterViewInit, OnInit, OnDestroy { @@ -143,6 +146,10 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI return this.disableAttributeScopeSelectionValue; } + @Input() + @coerceBoolean() + disableAddTelemetry: boolean; + @Input() set disableAttributeScopeSelection(value: boolean) { this.disableAttributeScopeSelectionValue = coerceBooleanProperty(value); @@ -185,7 +192,10 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI textSearch = this.fb.control('', {nonNullable: true}); + isSysAdmin = false; + private destroy$ = new Subject(); + selectAllModel: boolean = false; constructor(protected store: Store, private attributeService: AttributeService, @@ -205,6 +215,7 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI private fb: FormBuilder, private timeService: TimeService) { super(store); + this.isSysAdmin = getCurrentAuthUser(this.store).authority === Authority.SYS_ADMIN; this.dirtyValue = !this.activeValue; const sortOrder: SortOrder = { property: 'key', direction: Direction.ASC }; this.pageLink = new PageLink(10, 0, null, sortOrder); @@ -222,6 +233,14 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI }); }); this.widgetResize$.observe(this.elementRef.nativeElement); + + this.dataSource.selection.changed.pipe( + takeUntil(this.destroy$) + ).subscribe(() => { + this.dataSource.isAllSelected().pipe(take(1)).subscribe(allSelected => { + this.selectAllModel = allSelected; + }); + }); } ngOnDestroy() { @@ -236,6 +255,7 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI this.attributeScope = attributeScope; this.mode = 'default'; this.paginator.pageIndex = 0; + this.selectAllModel = false; this.updateData(true); } diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html index 600d3f2a93..251496096b 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.html @@ -25,31 +25,37 @@ - + attribute.delete-timeseries.strategy - - {{ strategiesTranslationsMap.get(strategy) | translate }} - + @for (strategy of strategiesTranslationsMap.keys(); track strategy) { + + {{ strategiesTranslationsMap.get(strategy) | translate }} + + } -
- - attribute.delete-timeseries.start-time - - - - - - attribute.delete-timeseries.ends-on - - - - -
- - {{ "attribute.delete-timeseries.rewrite-latest-value" | translate }} - + @if (isPeriodStrategy()) { +
+ + attribute.delete-timeseries.start-time + + + + + + attribute.delete-timeseries.ends-on + + + + +
+ } + @if (isPeriodStrategy() || isDeleteLatestStrategy()) { + + {{ "attribute.delete-timeseries.rewrite-latest-value" | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss index c42ab55735..4a5969afe7 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.scss @@ -26,7 +26,7 @@ display: flex; flex-direction: column; gap: 16px; - padding-top: 0; + padding-top: 4px; } .tb-select-interval { diff --git a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts index 5b24eea5a9..c7d68f04e0 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/delete-timeseries-panel.component.ts @@ -36,9 +36,10 @@ export interface DeleteTimeseriesPanelResult { } @Component({ - selector: 'tb-delete-timeseries-panel', - templateUrl: './delete-timeseries-panel.component.html', - styleUrls: ['./delete-timeseries-panel.component.scss'] + selector: 'tb-delete-timeseries-panel', + templateUrl: './delete-timeseries-panel.component.html', + styleUrls: ['./delete-timeseries-panel.component.scss'], + standalone: false }) export class DeleteTimeseriesPanelComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts index 0eca716bb9..dbe63cb2b3 100644 --- a/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/attribute/edit-attribute-value-panel.component.ts @@ -29,10 +29,11 @@ export interface EditAttributeValuePanelData { } @Component({ - selector: 'tb-edit-attribute-value-panel', - templateUrl: './edit-attribute-value-panel.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: EditAttributeValuePanelComponent}], - styleUrls: ['./edit-attribute-value-panel.component.scss'] + selector: 'tb-edit-attribute-value-panel', + templateUrl: './edit-attribute-value-panel.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: EditAttributeValuePanelComponent }], + styleUrls: ['./edit-attribute-value-panel.component.scss'], + standalone: false }) export class EditAttributeValuePanelComponent extends PageComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts index 17675a913a..eeb976ebf8 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-details-dialog.component.ts @@ -33,9 +33,10 @@ export interface AuditLogDetailsDialogData { } @Component({ - selector: 'tb-audit-log-details-dialog', - templateUrl: './audit-log-details-dialog.component.html', - styleUrls: ['./audit-log-details-dialog.component.scss'] + selector: 'tb-audit-log-details-dialog', + templateUrl: './audit-log-details-dialog.component.html', + styleUrls: ['./audit-log-details-dialog.component.scss'], + standalone: false }) export class AuditLogDetailsDialogComponent extends DialogComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts index 98e76c6fc7..631a795f7d 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-filter.component.ts @@ -43,16 +43,17 @@ import { TranslateService } from '@ngx-translate/core'; // @dynamic @Component({ - selector: 'tb-audit-log-filter', - templateUrl: './audit-log-filter.component.html', - styleUrls: ['./audit-log-filter.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AuditLogFilterComponent), - multi: true - } - ] + selector: 'tb-audit-log-filter', + templateUrl: './audit-log-filter.component.html', + styleUrls: ['./audit-log-filter.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AuditLogFilterComponent), + multi: true + } + ], + standalone: false }) export class AuditLogFilterComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts index 8b8009a838..1fdc68cdfa 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-header.component.ts @@ -21,9 +21,10 @@ import { EntityTableHeaderComponent } from '@home/components/entity/entity-table import { Store } from '@ngrx/store'; @Component({ - selector: 'tb-audit-log-header', - templateUrl: './audit-log-header.component.html', - styles: `` + selector: 'tb-audit-log-header', + templateUrl: './audit-log-header.component.html', + styles: ``, + standalone: false }) export class AuditLogHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.ts b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.ts index 71e845502a..8a912348be 100644 --- a/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/audit-log/audit-log-table.component.ts @@ -32,9 +32,10 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { ActivatedRoute } from '@angular/router'; @Component({ - selector: 'tb-audit-log-table', - templateUrl: './audit-log-table.component.html', - styleUrls: ['./audit-log-table.component.scss'] + selector: 'tb-audit-log-table', + templateUrl: './audit-log-table.component.html', + styleUrls: ['./audit-log-table.component.scss'], + standalone: false }) export class AuditLogTableComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html index 39692168aa..86d183dcfd 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.html @@ -15,26 +15,28 @@ limitations under the License. --> -
- - - -
+@if (!standalone) { +
+ + + +
+}
@@ -47,8 +49,6 @@ @if (entityForm.get('name').hasError('required')) { {{ 'common.hint.title-required' | translate }} - } @else if (entityForm.get('name').hasError('pattern')) { - {{ 'common.hint.title-pattern' | translate }} } @else if (entityForm.get('name').hasError('maxlength')) { {{ 'common.hint.title-max-length' | translate }} } @@ -57,11 +57,11 @@ + formControlName="debugSettings" + style="margin-bottom: 22px" + [entityType]="EntityType.CALCULATED_FIELD" + [additionalActionConfig]="additionalDebugActionConfig" + />
{{ CalculatedFieldTypeTranslations.has(entityForm.get('type').value) - ? (CalculatedFieldTypeTranslations.get(entityForm.get('type').value).name | translate) - : entityForm.get('type').value }} + ? (CalculatedFieldTypeTranslations.get(entityForm.get('type').value).name | translate) + : entityForm.get('type').value }} @for (type of fieldTypes; track type) { @@ -85,67 +85,67 @@ {{ CalculatedFieldTypeTranslations.get(type).name | translate }}
- - {{ CalculatedFieldTypeTranslations.get(type).hint | translate }} - -
- } - - -
- @switch (entityForm.get('type').value) { - @case (CalculatedFieldType.GEOFENCING) { - - } - @case (CalculatedFieldType.PROPAGATION) { - - } - @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { - - } - @case (CalculatedFieldType.ENTITY_AGGREGATION) { - - } - @default { - + + {{ CalculatedFieldTypeTranslations.get(type).hint | translate }} + + + } + + +
+ @switch (entityForm.get('type').value) { + @case (CalculatedFieldType.GEOFENCING) { + + } + @case (CalculatedFieldType.PROPAGATION) { + + } + @case (CalculatedFieldType.RELATED_ENTITIES_AGGREGATION) { + + } + @case (CalculatedFieldType.ENTITY_AGGREGATION) { + + } + @default { + + } } - } +
-
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts index 425069239f..4790789080 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-field.component.ts @@ -45,9 +45,10 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { EntityService } from '@core/http/entity.service'; @Component({ - selector: 'tb-calculated-field', - templateUrl: './calculated-field.component.html', - styleUrls: ['./calculated-field.component.scss'] + selector: 'tb-calculated-field', + templateUrl: './calculated-field.component.html', + styleUrls: ['./calculated-field.component.scss'], + standalone: false }) export class CalculatedFieldComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts index efb767f92e..aac49a3f37 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/calculated-fields-table-config.ts @@ -110,6 +110,8 @@ export class CalculatedFieldsTableConfig extends EntityTableConfig CalculatedFieldArgumentsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), - multi: true - } - ], + selector: 'tb-calculated-field-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldArgumentsTableComponent), + multi: true + } + ], + standalone: false }) export class CalculatedFieldArgumentsTableComponent implements ControlValueAccessor, Validator, OnChanges, AfterViewInit { @@ -169,7 +170,7 @@ export class CalculatedFieldArgumentsTableComponent implements ControlValueAcces this.argumentsFormArray.markAsDirty(); } - manageArgument($event: Event, matButton: MatButton, argument = {} as CalculatedFieldArgumentValue, readonly: boolean = false): void { + manageArgument($event: Event, matButton: MatIconButton, argument = {} as CalculatedFieldArgumentValue, readonly: boolean = false): void { $event?.stopPropagation(); if (this.popoverComponent && !this.popoverComponent.tbHidden) { this.popoverComponent.hide(); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts index b371653b18..62dcff420e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/entity-aggregation-arguments-table.component.ts @@ -26,21 +26,22 @@ import { import { ArgumentEntityType } from '@shared/models/calculated-field.models'; @Component({ - selector: 'tb-entity-aggregation-arguments-table', - templateUrl: './calculated-field-arguments-table.component.html', - styleUrls: [`calculated-field-arguments-table.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), - multi: true - } - ], + selector: 'tb-entity-aggregation-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationArgumentsTableComponent), + multi: true + } + ], + standalone: false }) export class EntityAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts index b84ea2ca9f..b7208d0cbb 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/propagate-arguments-table.component.ts @@ -41,21 +41,22 @@ import { isDefined, isUndefinedOrNull } from '@core/utils'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @Component({ - selector: 'tb-propagate-arguments-table', - templateUrl: './calculated-field-arguments-table.component.html', - styleUrls: [`calculated-field-arguments-table.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PropagateArgumentsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => PropagateArgumentsTableComponent), - multi: true - } - ], + selector: 'tb-propagate-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagateArgumentsTableComponent), + multi: true + } + ], + standalone: false }) export class PropagateArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts index 0a50a82729..52650bbf1c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/calculated-field-arguments/related-aggregation-arguments-table.component.ts @@ -36,21 +36,22 @@ import { AliasFilterType } from '@shared/models/alias.models'; import { EntityType } from '@shared/models/entity-type.models'; @Component({ - selector: 'tb-related-aggregation-arguments-table', - templateUrl: './calculated-field-arguments-table.component.html', - styleUrls: [`calculated-field-arguments-table.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), - multi: true - } - ], + selector: 'tb-related-aggregation-arguments-table', + templateUrl: './calculated-field-arguments-table.component.html', + styleUrls: [`calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RelatedAggregationArgumentsTableComponent), + multi: true + } + ], + standalone: false }) export class RelatedAggregationArgumentsTableComponent extends CalculatedFieldArgumentsTableComponent { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html index b463930c09..503927c71a 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.html @@ -39,8 +39,6 @@ @if (fieldFormGroup.get('name').hasError('required')) { {{ 'common.hint.title-required' | translate }} - } @else if (fieldFormGroup.get('name').hasError('pattern')) { - {{ 'common.hint.title-pattern' | translate }} } @else if (fieldFormGroup.get('name').hasError('maxlength')) { {{ 'common.hint.title-max-length' | translate }} } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss index fb02408d02..b34e717714 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.scss @@ -18,8 +18,8 @@ max-width: 100%; display: grid; grid-template-rows: min-content minmax(auto, 1fr) min-content; - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); - --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-container-shape: 6px; --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); } diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts index 6e8b7fb93f..4e37e67c71 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/dialog/calculated-field-dialog.component.ts @@ -57,10 +57,11 @@ export interface CalculatedFieldDialogData { } @Component({ - selector: 'tb-calculated-field-dialog', - templateUrl: './calculated-field-dialog.component.html', - styleUrls: ['./calculated-field-dialog.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-calculated-field-dialog', + templateUrl: './calculated-field-dialog.component.html', + styleUrls: ['./calculated-field-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CalculatedFieldDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html index ab7987c340..2288d5403d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.html @@ -48,9 +48,11 @@ calculated-fields.aggregate-interval-type - - {{ AggIntervalTypeTranslations.get(type) | translate }} - + @for (type of AggIntervalTypes; track type) { + + {{ AggIntervalTypeTranslations.get(type) | translate }} + + }
{{ 'calculated-fields.entity-aggregation.produce-intermediate-result' | translate }}
@@ -140,5 +142,5 @@ simpleMode hiddenName disableType - [entityId]="entityId"> + [entityId]="entityId">
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts index 11c95352d3..8386555d2b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/entity-aggregation-configuration/entity-aggregation-component.component.ts @@ -59,20 +59,21 @@ enum TimeCategory { } @Component({ - selector: 'tb-entity-aggregation-component', - templateUrl: './entity-aggregation-component.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityAggregationComponentComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => EntityAggregationComponentComponent), - multi: true - } - ], + selector: 'tb-entity-aggregation-component', + templateUrl: './entity-aggregation-component.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityAggregationComponentComponent), + multi: true + } + ], + standalone: false }) export class EntityAggregationComponentComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html index bb191e8f26..2295c17ed1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.html @@ -93,90 +93,92 @@ } -
- - - {{ 'calculated-fields.entity-zone-relationship' | translate }} - -
-
-
-
calculated-fields.level
-
calculated-fields.direction-level
-
calculated-fields.relation-type
-
-
- @if (levelsFormArray()?.controls?.length) { -
- @for (keyControl of levelsFormArray().controls; track trackByKey; ) { -
-
- -
-
-
{{ $index + 1 }}
- - - @for (direction of GeofencingDirectionList; track direction) { - {{ GeofencingDirectionLevelTranslations.get(direction) | translate }} - } - - - - -
-
- -
-
- } -
- } @else { - {{ 'calculated-fields.no-level' | translate }} - } - @if (levelsFormArray().errors) { - - } -
-
- @if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) { -
- warning - {{ 'calculated-fields.max-allowed-levels-error' | translate }} + @if (entityType === ArgumentEntityType.RelationQuery) { +
+ + + {{ 'calculated-fields.entity-zone-relationship' | translate }} + +
+
+
+
calculated-fields.level
+
calculated-fields.direction-level
+
calculated-fields.relation-type
+
- } @else { - - } -
-
-
+ @if (levelsFormArray()?.controls?.length) { +
+ @for (keyControl of levelsFormArray().controls; track trackByKey; ) { +
+
+ +
+
+
{{ $index + 1 }}
+ + + @for (direction of GeofencingDirectionList; track direction) { + {{ GeofencingDirectionLevelTranslations.get(direction) | translate }} + } + + + + +
+
+ +
+
+ } +
+ } @else { + {{ 'calculated-fields.no-level' | translate }} + } + @if (levelsFormArray().errors) { + + } +
+
+ @if (maxRelationLevelPerCfArgument && levelsFormArray().length >= maxRelationLevelPerCfArgument) { +
+ warning + {{ 'calculated-fields.max-allowed-levels-error' | translate }} +
+ } @else { + + } +
+ +
+ } @if (entityFilter.singleEntity?.id || entityType === ArgumentEntityType.RelationQuery || entityType === ArgumentEntityType.Current || entityType === ArgumentEntityType.Owner) { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts index fc70058f8b..c19dcefcd6 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component.ts @@ -58,9 +58,10 @@ import { EntitySearchDirection } from '@shared/models/relation.models'; import { CdkDragDrop } from "@angular/cdk/drag-drop"; @Component({ - selector: 'tb-calculated-field-geofencing-zone-groups-panel', - templateUrl: './calculated-field-geofencing-zone-groups-panel.component.html', - styleUrls: ['../common/calculated-field-panel.scss', './calculated-field-geofencing-zone-groups-panel.component.scss'] + selector: 'tb-calculated-field-geofencing-zone-groups-panel', + templateUrl: './calculated-field-geofencing-zone-groups-panel.component.html', + styleUrls: ['../common/calculated-field-panel.scss', './calculated-field-geofencing-zone-groups-panel.component.scss'], + standalone: false }) export class CalculatedFieldGeofencingZoneGroupsPanelComponent implements OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts index 341b898265..4afbc83dc5 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-table.component.ts @@ -40,7 +40,7 @@ import { CalculatedFieldGeofencingValue, GeofencingReportStrategyTranslations, } from '@shared/models/calculated-field.models'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { EntityId } from '@shared/models/id/entity-id'; @@ -61,21 +61,22 @@ import { } from '@home/components/calculated-fields/components/geofencing-configuration/calculated-field-geofencing-zone-groups-panel.component'; @Component({ - selector: 'tb-calculated-field-geofencing-zone-groups-table', - templateUrl: './calculated-field-geofencing-zone-groups-table.component.html', - styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), - multi: true - } - ], + selector: 'tb-calculated-field-geofencing-zone-groups-table', + templateUrl: './calculated-field-geofencing-zone-groups-table.component.html', + styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldGeofencingZoneGroupsTableComponent), + multi: true + } + ], + standalone: false }) export class CalculatedFieldGeofencingZoneGroupsTableComponent implements ControlValueAccessor, Validator, AfterViewInit { @@ -149,7 +150,7 @@ export class CalculatedFieldGeofencingZoneGroupsTableComponent implements Contro this.zoneGroupsFormArray.markAsDirty(); } - manageZone($event: Event, matButton: MatButton, zone = {} as CalculatedFieldGeofencingValue, readonly: boolean = false): void { + manageZone($event: Event, matButton: MatIconButton, zone = {} as CalculatedFieldGeofencingValue, readonly: boolean = false): void { $event?.stopPropagation(); if (this.popoverComponent && !this.popoverComponent.tbHidden) { this.popoverComponent.hide(); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts index 02d02241b4..2f621e2a38 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/geofencing-configuration/geofencing-configuration.component.ts @@ -43,20 +43,21 @@ import { EntityFilter } from '@shared/models/query/query.models'; import { EntityId } from '@shared/models/id/entity-id'; @Component({ - selector: 'tb-geofencing-configuration', - templateUrl: './geofencing-configuration.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GeofencingConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => GeofencingConfigurationComponent), - multi: true - } - ], + selector: 'tb-geofencing-configuration', + templateUrl: './geofencing-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GeofencingConfigurationComponent), + multi: true + } + ], + standalone: false }) export class GeofencingConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html index 00ae7685e3..96de7bf113 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.html @@ -143,7 +143,7 @@
} - @if (this.metricForm.get('input.type').value === AggInputType.key) { + @if (metricForm.get('input.type').value === AggInputType.key) {
{{ 'calculated-fields.argument-name' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts index 4f98a7225f..7af34df61c 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-panel.component.ts @@ -42,9 +42,10 @@ interface CalculatedFieldAggMetricValuePanel extends CalculatedFieldAggMetricVal } @Component({ - selector: 'tb-calculated-field-metrics-panel', - templateUrl: './calculated-field-metrics-panel.component.html', - styleUrls: ['../common/calculated-field-panel.scss', '../../calculated-field.component.scss'], + selector: 'tb-calculated-field-metrics-panel', + templateUrl: './calculated-field-metrics-panel.component.html', + styleUrls: ['../common/calculated-field-panel.scss', '../../calculated-field.component.scss'], + standalone: false }) export class CalculatedFieldMetricsPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts index 7862de3a33..8301c99a44 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/metrics/calculated-field-metrics-table.component.ts @@ -41,7 +41,7 @@ import { CalculatedFieldAggMetric, CalculatedFieldAggMetricValue, } from '@shared/models/calculated-field.models'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isDefinedAndNotNull, isEqual } from '@core/utils'; @@ -59,21 +59,22 @@ import { AceHighlightRules } from '@shared/models/ace/ace.models'; import { Observable } from "rxjs"; @Component({ - selector: 'tb-calculated-field-metrics-table', - templateUrl: './calculated-field-metrics-table.component.html', - styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), - multi: true - } - ], + selector: 'tb-calculated-field-metrics-table', + templateUrl: './calculated-field-metrics-table.component.html', + styleUrls: [`../calculated-field-arguments/calculated-field-arguments-table.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldMetricsTableComponent), + multi: true + } + ], + standalone: false }) export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValueAccessor, Validator, AfterViewInit { @@ -153,7 +154,7 @@ export class CalculatedFieldMetricsTableComponent implements OnInit, ControlValu this.metricsFormArray.markAsDirty(); } - manageMetrics($event: Event, matButton: MatButton, metric = {} as CalculatedFieldAggMetricValue, readonly: boolean = false): void { + manageMetrics($event: Event, matButton: MatIconButton, metric = {} as CalculatedFieldAggMetricValue, readonly: boolean = false): void { $event?.stopPropagation(); if (this.popoverComponent && !this.popoverComponent.tbHidden) { this.popoverComponent.hide(); diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html index 8a6bc2abe8..aef5126fc3 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.html @@ -80,7 +80,7 @@
-
+
{{ 'calculated-fields.output-strategy.strategy' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts index a9924909b7..0fe3415ac1 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/output/calculated-field-output.component.ts @@ -42,21 +42,22 @@ import { merge } from 'rxjs'; import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-calculate-field-output', - templateUrl: './calculated-field-output.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CalculatedFieldOutputComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CalculatedFieldOutputComponent), - multi: true - } - ], - styleUrls: ['./calculated-field-output.component.scss'], + selector: 'tb-calculate-field-output', + templateUrl: './calculated-field-output.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldOutputComponent), + multi: true + } + ], + styleUrls: ['./calculated-field-output.component.scss'], + standalone: false }) export class CalculatedFieldOutputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html index 475f956b07..bb06683d5d 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/propagation-configuration/propagation-configuration.component.html @@ -57,9 +57,9 @@ [ownerId]="ownerId" [watchKeyChange]="true" [isEditValue]="isEditValue" - [isScript]="this.propagateConfiguration.get('applyExpressionToResolvedArguments').value"/> + [isScript]="propagateConfiguration.get('applyExpressionToResolvedArguments').value"/>
-
+
{{ 'calculated-fields.script' | translate }}
PropagationConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => PropagationConfigurationComponent), - multi: true - } - ], + selector: 'tb-propagation-configuration', + templateUrl: './propagation-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PropagationConfigurationComponent), + multi: true + } + ], + standalone: false }) export class PropagationConfigurationComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts index fcbe46be03..907bb31abb 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/related-entities-aggregation-configuration/related-entities-aggregation-component.component.ts @@ -46,21 +46,22 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-related-entities-aggregation-component', - templateUrl: './related-entities-aggregation-component.component.html', - styleUrl: './related-entities-aggregation-component.component.scss', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), - multi: true - } - ], + selector: 'tb-related-entities-aggregation-component', + templateUrl: './related-entities-aggregation-component.component.html', + styleUrl: './related-entities-aggregation-component.component.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RelatedEntitiesAggregationComponentComponent), + multi: true + } + ], + standalone: false }) export class RelatedEntitiesAggregationComponentComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts index 7292775055..f2f9fbac2f 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/simple-configuration/simple-configuration.component.ts @@ -46,20 +46,21 @@ import { map } from 'rxjs/operators'; type SimpeConfiguration = CalculatedFieldSimpleConfiguration | CalculatedFieldScriptConfiguration; @Component({ - selector: 'tb-simple-configuration', - templateUrl: './simple-configuration.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SimpleConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SimpleConfigurationComponent), - multi: true - } - ], + selector: 'tb-simple-configuration', + templateUrl: './simple-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SimpleConfigurationComponent), + multi: true + } + ], + standalone: false }) export class SimpleConfigurationComponent implements ControlValueAccessor, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts index 09b8b5e2cf..9f67906ce7 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-arguments/calculated-field-test-arguments.component.ts @@ -44,21 +44,22 @@ import { filter } from 'rxjs/operators'; import { MatDialog } from '@angular/material/dialog'; @Component({ - selector: 'tb-calculated-field-test-arguments', - templateUrl: './calculated-field-test-arguments.component.html', - styleUrls: ['./calculated-field-test-arguments.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), - multi: true, - } - ] + selector: 'tb-calculated-field-test-arguments', + templateUrl: './calculated-field-test-arguments.component.html', + styleUrls: ['./calculated-field-test-arguments.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CalculatedFieldTestArgumentsComponent), + multi: true, + } + ], + standalone: false }) export class CalculatedFieldTestArgumentsComponent extends PageComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts index 8cf7515e3c..2593ab503b 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/components/test-dialog/calculated-field-script-test-dialog.component.ts @@ -55,10 +55,11 @@ export interface CalculatedFieldTestScriptDialogData extends CalculatedFieldTest } @Component({ - selector: 'tb-calculated-field-script-test-dialog', - templateUrl: './calculated-field-script-test-dialog.component.html', - styleUrls: ['./calculated-field-script-test-dialog.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-calculated-field-script-test-dialog', + templateUrl: './calculated-field-script-test-dialog.component.html', + styleUrls: ['./calculated-field-script-test-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CalculatedFieldScriptTestDialogComponent extends DialogComponent implements AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html index a1ccb56560..79062b1a0e 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.html @@ -15,11 +15,10 @@ limitations under the License. --> - +@if (panelMode) { - - - +} @else { + @if (buttonMode) { - - + } @else { +
+
+
calculated-fields.calculated-field-types
+ + +
+
+
entity.entity-type
+ + + {{ 'alarm-rule.any-type' | translate }} + @for (type of listEntityTypes; track type) { + + {{ entityTypeTranslations.get(type)?.type | translate }} + + } + + +
+ @if (cfFilterForm.get('entityType').value) { +
+
entity.entities
+ + +
+ } +
+ } +}
@@ -72,9 +112,11 @@ {{ 'alarm-rule.any-type' | translate }} - - {{ entityTypeTranslations.get(type)?.type | translate }} - + @for (type of listEntityTypes; track type) { + + {{ entityTypeTranslations.get(type)?.type | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts index 333e74d195..36095579e8 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-filter-config.component.ts @@ -56,16 +56,17 @@ export interface CalculatedFieldsFilterConfigData { } @Component({ - selector: 'tb-calculated-fields-filter-config', - templateUrl: './calculated-fields-filter-config.component.html', - styleUrls: ['./calculated-fields-filter-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CalculatedFieldsFilterConfigComponent), - multi: true - } - ] + selector: 'tb-calculated-fields-filter-config', + templateUrl: './calculated-fields-filter-config.component.html', + styleUrls: ['./calculated-fields-filter-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CalculatedFieldsFilterConfigComponent), + multi: true + } + ], + standalone: false }) export class CalculatedFieldsFilterConfigComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts index b6bc46371c..07ed7deeda 100644 --- a/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/calculated-fields/table-header/calculated-fields-header.component.ts @@ -22,9 +22,10 @@ import { CalculatedField, CalculatedFieldsQuery } from "@shared/models/calculate import { CalculatedFieldsTableConfig } from '@home/components/calculated-fields/calculated-fields-table-config'; @Component({ - selector: 'tb-calculated-fields-table-header', - templateUrl: './calculated-fields-header.component.html', - styleUrls: ['./calculated-fields-header.component.scss'] + selector: 'tb-calculated-fields-table-header', + templateUrl: './calculated-fields-header.component.html', + styleUrls: ['./calculated-fields-header.component.scss'], + standalone: false }) export class CalculatedFieldsHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html index 0e1b2a7e07..ebd73b5388 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/add-widget-dialog.component.html @@ -21,11 +21,13 @@

{{'widget.add' | translate}}:

{{data.widgetInfo.widgetName}}
- - {{ 'widget.basic-mode' | translate }} - {{ 'widget.advanced-mode' | translate }} - + @if (widgetConfig.hasBasicMode) { + + {{ 'widget.basic-mode' | translate }} + {{ 'widget.advanced-mode' | translate }} + + }
@@ -49,22 +51,24 @@ isAdd formControlName="widgetConfig"> - -
- -
-
+ @if (previewMode) { + +
+ +
+
+ }
+ @if (!previewMode) { + + } - - + @if (isLoading$ | async) { + + + }
@@ -40,8 +42,11 @@
-
{{ 'dashboard.no-image' | translate }}
- + @if (!imageUrl) { +
{{ 'dashboard.no-image' | translate }}
+ } @else { + + }
Right % @@ -63,13 +68,15 @@
+ formControlName="dashboardImage">
-
+ @if (takingScreenshot$ | async) { +
-
+
+ }
- - + } + @if (showDashboardLogo()) { + + }
- - + @if (!isPublicUser() && forceFullscreen) { + + + } - + @if (!readonly && !isEdit) { - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - > - - > - - - - - - - - - - - - - -
-
- - -
- - -
-

{{ translatedDashboardTitle }}

- - dashboard.title - - -
- - - - - - - - -
- {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} -
-
-
-
- - - -
- -
- - -
- - {{ 'widget.basic-mode' | translate }} - {{ 'widget.advanced-mode' | translate }} - -
-
- + edit{{'dashboard.edit-mode' | translate}} + } + @if (isEdit) { + + + + + + } + @if (currentDashboardId && isEdit && isTenantAdmin()) { + + + } + @if (isEdit) { + + + } + @if (isEdit) { + + + } + @if (isEdit) { + + + } + + @if (isEdit) { + + + + + } @else { + @if (displayDashboardTimewindow()) { + > + + > + + } + } + @if (isEdit) { + + + } + + + + + @if (!isEdit && !widgetEditMode && !embedded && displayDashboardsSelect()) { + + + } +
+
+ + +
+ + + @if (!widgetEditMode) { +
+

{{ translatedDashboardTitle }}

+ + dashboard.title + + +
+ } + + @if (layouts.right.show) { + + + + + } + + - - - + + @if (isEdit) { +
+ {{ 'dashboard.breakpoint-value' | translate : {value: currentBreakpointValue } }} +
+ } +
+
+ @if (!readonly && (hideToolbar || widgetEditMode)) { +
+ + + +
+ } + @if (!embedded) { + + } +
+ + @if (!isEditingWidgetClosed) { + +
+ @if (tbEditWidget.widgetConfig?.hasBasicMode) { + + {{ 'widget.basic-mode' | translate }} + {{ 'widget.advanced-mode' | translate }} + + } +
+
+ + +
+ } + @if (!isAddingWidgetClosed && !widgetEditMode) { + -
- -
-
- +
+ +
+ @if (isAddingWidget && dashboardWidgetSelectComponent) { +
+ - -
-
- - - {{ 'widgets-bundle.widgets-bundles' | translate }} - {{ 'widget.all-widgets' | translate }} - - - - {{ 'widget.all' | translate }} - {{ 'widget.actual' | translate }} - {{ 'widget.deprecated' | translate }} - -
- - - - - -
-
+ +
+ } + @if (isAddingWidget) { +
+ @if (dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle) { + + } + @if (dashboardWidgetSelectComponent?.selectWidgetMode === 'bundles' && !dashboardWidgetSelectComponent?.widgetsBundle) { + + {{ 'widgets-bundle.widgets-bundles' | translate }} + {{ 'widget.all-widgets' | translate }} + + } + @if (dashboardWidgetSelectComponent?.widgetTypes.size > 1) { + + } + @if (dashboardWidgetSelectComponent?.hasDeprecated) { + + {{ 'widget.all' | translate }} + {{ 'widget.actual' | translate }} + {{ 'widget.deprecated' | translate }} + + } +
+ } + @if (isAddingWidget) { + + + } + + } + + + +
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts index 3438da8904..3948af744f 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.component.ts @@ -33,7 +33,8 @@ import { StaticProvider, ViewChild, ViewContainerRef, - ViewEncapsulation + ViewEncapsulation, + DOCUMENT } from '@angular/core'; import { PageComponent } from '@shared/components/page.component'; import { Store } from '@ngrx/store'; @@ -143,9 +144,9 @@ import { } from '@home/components/dashboard-page/dashboard-image-dialog.component'; import { SafeUrl } from '@angular/platform-browser'; import cssjs from '@core/css/css'; -import { DOCUMENT } from '@angular/common'; + import { IAliasController } from '@core/api/widget-api.models'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatIconButton } from '@angular/material/button'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; import { TbPopoverService } from '@shared/components/popover.service'; import { catchError, distinctUntilChanged, map, skip, tap } from 'rxjs/operators'; @@ -160,11 +161,12 @@ import { HttpStatusCode } from '@angular/common/http'; // @dynamic @Component({ - selector: 'tb-dashboard-page', - templateUrl: './dashboard-page.component.html', - styleUrls: ['./dashboard-page.component.scss'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-dashboard-page', + templateUrl: './dashboard-page.component.html', + styleUrls: ['./dashboard-page.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class DashboardPageComponent extends PageComponent implements IDashboardController, HasDirtyFlag, OnInit, AfterViewInit, OnDestroy { @@ -653,7 +655,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC private hideToolbarSetting(): boolean { if (isDefined(this.dashboard.configuration?.settings?.hideToolbar)) { - const canApplyHideSetting = !this.forceFullscreen || this.isMobileApp; + const canApplyHideSetting = !this.forceFullscreen || this.isMobileApp || this.isPublicUser(); return this.dashboard.configuration.settings.hideToolbar && canApplyHideSetting; } else { return false; @@ -1760,7 +1762,7 @@ export class DashboardPageComponent extends PageComponent implements IDashboardC }); } - toggleVersionControl($event: Event, versionControlButton: MatButton) { + toggleVersionControl($event: Event, versionControlButton: MatButton | MatIconButton) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts index afb239ac34..5531f4c346 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-page.models.ts @@ -28,7 +28,6 @@ import { IAliasController, IStateController } from '@core/api/widget-api.models' import { ILayoutController } from './layout/layout.models'; import { DashboardContextMenuItem, WidgetContextMenuItem } from '@home/models/dashboard-component.models'; import { BehaviorSubject, Observable } from 'rxjs'; -import { displayGrids } from 'angular-gridster2/lib/gridsterConfig.interface'; import { ElementRef } from '@angular/core'; export declare type DashboardPageScope = 'tenant' | 'customer'; @@ -88,7 +87,7 @@ export interface DashboardPageLayoutContext { ctrl: ILayoutController; dashboardCtrl: IDashboardController; ignoreLoading: boolean; - displayGrid: displayGrids; + displayGrid: 'always' | 'onDrag&Resize' | 'none'; } export interface DashboardPageLayout { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html index 70be4a9e55..7fb0f3f68d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-settings-dialog.component.html @@ -25,224 +25,258 @@ close - - + @if (isLoading$ | async) { + + + }
-
-
-
{{ 'dashboard.state-controller' | translate }}
- - - - {{ getStatesControllerTranslation(stateControllerId) }} - - - -
-
-
dashboard.title-settings
+ @if (settings) { +
- - {{ 'dashboard.title' | translate }} - - - -
-
-
-
dashboard.dashboard-logo-settings
-
- - {{ 'dashboard.display-dashboard-logo' | translate }} - -
- - -
-
-
dashboard.toolbar-settings
- - {{ 'dashboard.hide-toolbar' | translate }} - - - {{ 'dashboard.toolbar-always-open' | translate }} - - - {{ 'dashboard.display-dashboards-selection' | translate }} - - - {{ 'dashboard.display-entities-selection' | translate }} - - - {{ 'dashboard.display-filters' | translate }} - - - {{ 'dashboard.display-dashboard-timewindow' | translate }} - - - {{ 'dashboard.display-dashboard-export' | translate }} - - - {{ 'dashboard.display-update-dashboard-image' | translate }} - -
- - - - dashboard.advanced-settings - - - - - - -
-
-
-
dashboard.layout-settings-type
-
-
dashboard.view-format
+
{{ 'dashboard.state-controller' | translate }}
- - - {{ viewFormatTypeTranslationMap.get(type) | translate }} - + + @for (stateControllerId of stateControllerIds; track stateControllerId) { + + {{ getStatesControllerTranslation(stateControllerId) }} + + }
-
-
dashboard.columns-count
- +
dashboard.title-settings
+
+ + {{ 'dashboard.title' | translate }} + + + +
+
+
+
dashboard.dashboard-logo-settings
+
+ + {{ 'dashboard.display-dashboard-logo' | translate }} + +
+ + +
+
+
dashboard.toolbar-settings
+ + {{ 'dashboard.hide-toolbar' | translate }} + + + {{ 'dashboard.toolbar-always-open' | translate }} + + + {{ 'dashboard.display-dashboards-selection' | translate }} + + + {{ 'dashboard.display-entities-selection' | translate }} + + + {{ 'dashboard.display-filters' | translate }} + + + {{ 'dashboard.display-dashboard-timewindow' | translate }} + + + {{ 'dashboard.display-dashboard-export' | translate }} + + + {{ 'dashboard.display-update-dashboard-image' | translate }} + +
+ + + + dashboard.advanced-settings + + + + + + +
+ } + @if (gridSettings) { +
+
+
dashboard.layout-settings-type
+ @if (isDefaultLayout && !isDefaultBreakpoint) { +
+
dashboard.view-format
+ + + @for (type of viewFormatTypes; track type) { + + {{ viewFormatTypeTranslationMap.get(type) | translate }} + + } + + +
+ } +
+
dashboard.columns-count
+ @if (!isScada()) { + - - - - - - {{ columns }} - - - -
-
-
dashboard.min-layout-width
- + + } + @if (isScada()) { + + + @for (columns of scadaColumns; track columns) { + + {{ columns }} + + } + + + } +
+
+
dashboard.min-layout-width
+ - - dashboard.columns-suffix - -
-
-
dashboard.widgets-margins
- + dashboard.columns-suffix + +
+ @if (!isScada()) { +
+
dashboard.widgets-margins
+ - - -
-
- - {{ 'dashboard.apply-outer-margin' | translate }} - -
-
- - {{ 'dashboard.autofill-height' | translate }} - -
-
-
dashboard.row-height
- + +
+ } + @if (!isScada()) { +
+ + {{ 'dashboard.apply-outer-margin' | translate }} + +
+ } + @if (!isScada()) { +
+ + {{ 'dashboard.autofill-height' | translate }} + +
+ } + @if (isDefaultLayout && !isDefaultBreakpoint) { +
+
dashboard.row-height
+ - - px - -
-
-
dashboard.background-color
- - + + px + +
+ } +
+
dashboard.background-color
+ + +
+ + +
+
{{ 'dashboard.background-size-mode' | translate }}
+ + + Fit width + Fit height + Cover + Contain + Original size + + +
- - -
-
{{ 'dashboard.background-size-mode' | translate }}
- - - Fit width - Fit height - Cover - Contain - Original size - - -
-
-
-
dashboard.mobile-layout
-
- - {{ 'dashboard.display-first-in-mobile-view' | translate }} - -
-
- - {{ 'dashboard.autofill-height' | translate }} - -
-
-
dashboard.mobile-row-height
- +
dashboard.mobile-layout
+ @if (isRightLayout) { +
+ + {{ 'dashboard.display-first-in-mobile-view' | translate }} + +
+ } + @if (!isScada()) { +
+ + {{ 'dashboard.autofill-height' | translate }} + +
+ } + @if (!isScada()) { +
+
dashboard.mobile-row-height
+ - - px - -
+ + px +
+
+ } +
+ }
-
+ }
-
widget.deprecated
+ @if (item.deprecated) { +
widget.deprecated
+ }
@@ -64,51 +107,10 @@
- {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} + {{(selectWidgetMode === 'bundles' ? 'widgets-bundle.empty' : 'widget.no-widgets-text') | translate}} - - - - - -
-
- {{item.title}} -
-
-
sys
-
i
-
-
-
-
- {{ item.title }} -
-
-
- -
- - {{ 'widgets-bundle.loading-widgets-bundles' | translate }} - - -
-
- - widgets-bundle.no-widgets-bundles-text - -
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts index 5ae629a003..bf4a7b78eb 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/dashboard-widget-select.component.ts @@ -48,10 +48,11 @@ interface BundleWidgetsFilter extends WidgetsFilter { } @Component({ - selector: 'tb-dashboard-widget-select', - templateUrl: './dashboard-widget-select.component.html', - styleUrls: ['./dashboard-widget-select.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-dashboard-widget-select', + templateUrl: './dashboard-widget-select.component.html', + styleUrls: ['./dashboard-widget-select.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DashboardWidgetSelectComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html index 9b8e45c6dd..cc53c50fa9 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.html @@ -28,56 +28,63 @@ [isDefaultBreakpoint]="isDefaultBreakpoint" formControlName="widgetConfig">
- - + @if (previewMode) { + + } + @if (!previewMode) { + + }
- + @if (!previewMode) { + + }
-
- - -
+ @if (previewMode) { +
+ + +
+ } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts index 1bb15be0e3..e7d0491efd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/edit-widget.component.ts @@ -31,9 +31,10 @@ import { DataKeySettingsFunction } from '@home/components/widget/lib/settings/co import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-edit-widget', - templateUrl: './edit-widget.component.html', - styleUrls: ['./edit-widget.component.scss'] + selector: 'tb-edit-widget', + templateUrl: './edit-widget.component.html', + styleUrls: ['./edit-widget.component.scss'], + standalone: false }) export class EditWidgetComponent extends PageComponent implements OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html index 26068f7c2d..14656f6b7c 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.html @@ -29,23 +29,27 @@ layout.breakpoint - {{ getName(this.addBreakpointFormGroup.get('newBreakpointId').value) }} - - {{ getIcon(breakpoint) }} -
{{ getName(breakpoint) }}
-
{{ getSizeDescription(breakpoint) }}
-
+ {{ getName(addBreakpointFormGroup.get('newBreakpointId').value) }} + @for (breakpoint of allowBreakpointIds; track breakpoint) { + + {{ getIcon(breakpoint) }} +
{{ getName(breakpoint) }}
+
{{ getSizeDescription(breakpoint) }}
+
+ }
layout.copy-from - {{ getName(this.addBreakpointFormGroup.get('copyFrom').value) }} - - {{ getIcon(breakpoint) }} -
{{ getName(breakpoint) }}
-
{{ getSizeDescription(breakpoint) }}
-
+ {{ getName(addBreakpointFormGroup.get('copyFrom').value) }} + @for (breakpoint of selectedBreakpointIds; track breakpoint) { + + {{ getIcon(breakpoint) }} +
{{ getName(breakpoint) }}
+
{{ getSizeDescription(breakpoint) }}
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts index 08f577a045..94c068da8e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/add-new-breakpoint-dialog.component.ts @@ -35,8 +35,9 @@ export interface AddNewBreakpointDialogResult { } @Component({ - selector: 'add-new-breakpoint-dialog', - templateUrl: './add-new-breakpoint-dialog.component.html', + selector: 'add-new-breakpoint-dialog', + templateUrl: './add-new-breakpoint-dialog.component.html', + standalone: false }) export class AddNewBreakpointDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html index ef892d7ac1..e07be60cb5 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/dashboard-layout.component.html @@ -15,28 +15,36 @@ limitations under the License. --> -
- - -
+@if (layoutCtx.widgets.isLoading()) { +
+ + +
+}
-
- - {{'dashboard.no-widgets' | translate}} - - -
+ style="position: relative; width: 100%; height: 100%;" [tb-hotkeys]="hotKeys" + [cheatSheet]="dashboardCheatSheet" + [style.background-image]="backgroundImage$ | async" + [style]="dashboardStyle"> + @if (layoutCtx.widgets.isEmpty()) { +
+ @if (!isEdit) { + + {{'dashboard.no-widgets' | translate}} + + } + @if (isEdit && !widgetEditMode) { + + } +
+ } layout.manage @@ -31,201 +31,226 @@
dashboard.layout
- - {{ layoutTypeTranslations.get(type) | translate }} - + @for (type of layoutTypes; track type) { + + {{ layoutTypeTranslations.get(type) | translate }} + + }
-
-
-
-
-
layout.breakpoints
-
layout.size
-
-
-
- -
-
- {{ breakpoint.icon }} -
-
- {{ breakpoint.name }} -
-
- {{ breakpoint.descriptionSize }} + @if (!isDividerLayout) { +
+
+
+
+
layout.breakpoints
+
layout.size
+
+
+
+ @for (breakpoint of layoutBreakpoints; track breakpoint; let last = $last) { +
+
+ {{ breakpoint.icon }} +
+
+ {{ breakpoint.name }} +
+
+ {{ breakpoint.descriptionSize }} +
+
+ + +
-
+ @if (!last) { + + } + } +
+
+ +
+ } @else { +
+ + + {{ 'layout.percentage-width' | translate }} + + + {{ 'layout.fixed-width' | translate }} + + +
+
+
+ + + @if (showPreviewInputs('main')) { +
+ @if (layoutsFormGroup.get('type').value !== layoutWidthType.FIXED) { + + } + @if (layoutsFormGroup.get('type').value === layoutWidthType.FIXED && + layoutsFormGroup.get('fixedLayout').value === 'main') { + + } +
+ } +
+ @if (isDividerLayout) { +
- + @if (showPreviewInputs('right')) { +
+ @if (layoutsFormGroup.get('type').value !== layoutWidthType.FIXED) { + + } + @if (layoutsFormGroup.get('type').value === layoutWidthType.FIXED) { + + } +
+ }
-
- - -
-
- -
- - -
- - - {{ 'layout.percentage-width' | translate }} - - - {{ 'layout.fixed-width' | translate }} - - -
-
-
- - -
- - + }
-
- - -
- - + @if (isDividerLayout) { +
+ @if (layoutsFormGroup.get('type').value === layoutWidthType.PERCENTAGE) { + + + } + @if (layoutsFormGroup.get('type').value !== layoutWidthType.PERCENTAGE) { + + + } +
+ + +
-
-
-
-
- - - - -
- - + }
-
-
- + } +
- - + @if (isLoading$ | async) { + + + }
dashboard.move-by
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts index 2b9a430e3b..d377983169 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/move-widgets-dialog.component.ts @@ -28,10 +28,11 @@ export interface MoveWidgetsDialogResult { } @Component({ - selector: 'tb-move-widgets-dialog', - templateUrl: './move-widgets-dialog.component.html', - providers: [], - styleUrls: [] + selector: 'tb-move-widgets-dialog', + templateUrl: './move-widgets-dialog.component.html', + providers: [], + styleUrls: [], + standalone: false }) export class MoveWidgetsDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html index d9e98fc48a..312cf304cd 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.html @@ -21,9 +21,11 @@ [(ngModel)]="selectedBreakpoint" (ngModelChange)="selectLayoutChanged()"> {{ getName(selectedBreakpoint) }} - - {{ getIcon(breakpointId) }} -
{{ getName(breakpointId) }}
-
{{ getSizeDescription(breakpointId) }}
-
+ @for (breakpointId of breakpointIds; track breakpointId) { + + {{ getIcon(breakpointId) }} +
{{ getName(breakpointId) }}
+
{{ getSizeDescription(breakpointId) }}
+
+ } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts index c9ad66d233..25e2a23a5d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/layout/select-dashboard-breakpoint.component.ts @@ -21,9 +21,10 @@ import { BreakpointId } from '@shared/models/dashboard.models'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @Component({ - selector: 'tb-select-dashboard-breakpoint', - templateUrl: './select-dashboard-breakpoint.component.html', - styleUrls: ['./select-dashboard-breakpoint.component.scss'] + selector: 'tb-select-dashboard-breakpoint', + templateUrl: './select-dashboard-breakpoint.component.html', + styleUrls: ['./select-dashboard-breakpoint.component.scss'], + standalone: false }) export class SelectDashboardBreakpointComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html index c06d184162..58cb21253d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html @@ -25,26 +25,34 @@ close - - + @if (isLoading$ | async) { + + + }
- + dashboard.state-name - - {{ 'dashboard.state-name-required' | translate }} - + @if (stateFormGroup.get('name').hasError('required')) { + + {{ 'dashboard.state-name-required' | translate }} + + } - + dashboard.state-id - - {{ 'dashboard.state-id-required' | translate }} - - - {{ 'dashboard.state-id-exists' | translate }} - + @if (stateFormGroup.get('id').hasError('required')) { + + {{ 'dashboard.state-id-required' | translate }} + + } + @if (stateFormGroup.get('id').hasError('stateExists')) { + + {{ 'dashboard.state-id-exists' | translate }} + + } {{ 'dashboard.is-root-state' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.ts index 64f9153546..b90164e6c0 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.ts @@ -43,10 +43,11 @@ export interface DashboardStateDialogData { } @Component({ - selector: 'tb-dashboard-state-dialog', - templateUrl: './dashboard-state-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: DashboardStateDialogComponent}], - styleUrls: [] + selector: 'tb-dashboard-state-dialog', + templateUrl: './dashboard-state-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: DashboardStateDialogComponent }], + styleUrls: [], + standalone: false }) export class DashboardStateDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html index fcf77163c9..a84fb44d26 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.html @@ -16,8 +16,10 @@ --> - - {{getStateName(stateKv.key, stateKv.value)}} - + [(ngModel)]="stateObject[0].id" (ngModelChange)="selectedStateIdChanged()"> + @for (stateKv of states | keyvalue; track stateKv) { + + {{getStateName(stateKv.key, stateKv.value)}} + + } diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts index 92be4229b6..c0b3668e2e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/default-state-controller.component.ts @@ -29,9 +29,10 @@ import { EntityService } from '@core/http/entity.service'; import { MobileService } from '@core/services/mobile.service'; @Component({ - selector: 'tb-default-state-controller', - templateUrl: './default-state-controller.component.html', - styleUrls: ['./default-state-controller.component.scss'] + selector: 'tb-default-state-controller', + templateUrl: './default-state-controller.component.html', + styleUrls: ['./default-state-controller.component.scss'], + standalone: false }) export class DefaultStateControllerComponent extends StateControllerComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html index 394bf14091..a932cd145e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.html @@ -16,20 +16,28 @@ -->
-
- + @for (state of stateObject; track state; let i = $index; let isLast = $last) { + - {{getStateName(i)}} + {{getStateName(i)}} > -
- - - {{getStateName(i)}} - - + } +
+ } + @if (isMobile && stateObject.length > 1) { + + @for (state of stateObject; track state; let i = $index) { + + {{getStateName(i)}} + + } + + }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts index 15e18573f2..f8dfff6c97 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/entity-state-controller.component.ts @@ -31,9 +31,10 @@ import { map, tap } from 'rxjs/operators'; import { MobileService } from '@core/services/mobile.service'; @Component({ - selector: 'tb-entity-state-controller', - templateUrl: './entity-state-controller.component.html', - styleUrls: ['./entity-state-controller.component.scss'] + selector: 'tb-entity-state-controller', + templateUrl: './entity-state-controller.component.html', + styleUrls: ['./entity-state-controller.component.scss'], + standalone: false }) export class EntityStateControllerComponent extends StateControllerComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts index c570783684..7e0971aa4b 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/manage-dashboard-states-dialog.component.ts @@ -53,9 +53,10 @@ export interface ManageDashboardStatesDialogResult { } @Component({ - selector: 'tb-manage-dashboard-states-dialog', - templateUrl: './manage-dashboard-states-dialog.component.html', - styleUrls: ['./manage-dashboard-states-dialog.component.scss'] + selector: 'tb-manage-dashboard-states-dialog', + templateUrl: './manage-dashboard-states-dialog.component.html', + styleUrls: ['./manage-dashboard-states-dialog.component.scss'], + standalone: false }) export class ManageDashboardStatesDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/states/states-component.directive.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/states/states-component.directive.ts index 3588b22158..2229e6c24b 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/states/states-component.directive.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/states/states-component.directive.ts @@ -31,8 +31,9 @@ import { IStateControllerComponent } from '@home/components/dashboard-page/state import { BehaviorSubject, Subject } from 'rxjs'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: 'tb-states-component' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'tb-states-component', + standalone: false }) export class StatesComponentDirective implements OnInit, OnDestroy, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html index 51eebbf7ed..f26be70987 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.html @@ -16,8 +16,10 @@ -->
- - {{ 'widget.' + type.type | translate }} - + @for (type of types; track type) { + + {{ 'widget.' + type.type | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts index 4aec56771c..cb43dce650 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-page/widget-types-panel.component.ts @@ -30,9 +30,10 @@ export interface DisplayWidgetTypesPanelData { } @Component({ - selector: 'tb-widget-types-panel', - templateUrl: './widget-types-panel.component.html', - styleUrls: ['./widget-types-panel.component.scss'] + selector: 'tb-widget-types-panel', + templateUrl: './widget-types-panel.component.html', + styleUrls: ['./widget-types-panel.component.scss'], + standalone: false }) export class DisplayWidgetTypesPanelComponent { diff --git a/ui-ngx/src/app/modules/home/components/dashboard-view/dashboard-view.component.ts b/ui-ngx/src/app/modules/home/components/dashboard-view/dashboard-view.component.ts index 3e22d0719e..14bbd20688 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard-view/dashboard-view.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard-view/dashboard-view.component.ts @@ -22,9 +22,10 @@ import { Dashboard } from '@shared/models/dashboard.models'; import { ActivatedRoute } from '@angular/router'; @Component({ - selector: 'tb-dashboard-view', - templateUrl: './dashboard-view.component.html', - styleUrls: ['./dashboard-view.component.scss'] + selector: 'tb-dashboard-view', + templateUrl: './dashboard-view.component.html', + styleUrls: ['./dashboard-view.component.scss'], + standalone: false }) export class DashboardViewComponent extends PageComponent { diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html index ef9d754ebb..5ba7c784b3 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.html @@ -24,10 +24,10 @@
+ [class.center-vertical]="centerVertical" + [class.center-horizontal]="centerHorizontal" + (mousedown)="onDashboardMouseDown($event)" + (tbcontextmenu)="openDashboardContextMenu($event)">
- + @for (item of items; track item) { + + }
@@ -54,37 +60,45 @@
- + @for (item of items; track item) { + + }
- - - - + @for (widget of dashboardWidgets; track widget) { + + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts index 802b305d8e..5288a45fd0 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/dashboard.component.ts @@ -58,15 +58,15 @@ import { distinct, take } from 'rxjs/operators'; import { UtilsService } from '@core/services/utils.service'; import { WidgetComponentAction, WidgetComponentActionType } from '@home/components/widget/widget-container.component'; import { TbPopoverComponent } from '@shared/components/popover.component'; -import { displayGrids } from 'angular-gridster2/lib/gridsterConfig.interface'; import { coerceBoolean } from '@shared/decorators/coercion'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; @Component({ - selector: 'tb-dashboard', - templateUrl: './dashboard.component.html', - styleUrls: ['./dashboard.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-dashboard', + templateUrl: './dashboard.component.html', + styleUrls: ['./dashboard.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class DashboardComponent extends PageComponent implements IDashboardComponent, DoCheck, OnInit, OnDestroy, AfterViewInit, OnChanges { @@ -101,7 +101,7 @@ export class DashboardComponent extends PageComponent implements IDashboardCompo outerMargin: boolean; @Input() - displayGrid: displayGrids = 'onDrag&Resize'; + displayGrid: 'always' | 'onDrag&Resize' | 'none' = 'onDrag&Resize'; @Input() gridType: GridType; diff --git a/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.html index c7524428e3..bf89db1b8d 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.html @@ -20,13 +20,15 @@

layout.select

- - + @if (isLoading$ | async) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.ts index 0ef839365f..a8e4e1c88e 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/select-target-layout-dialog.component.ts @@ -23,9 +23,10 @@ import { DialogComponent } from '@app/shared/components/dialog.component'; import { DashboardLayoutId } from '@app/shared/models/dashboard.models'; @Component({ - selector: 'tb-select-target-layout-dialog', - templateUrl: './select-target-layout-dialog.component.html', - styleUrls: ['./layout-button.scss'] + selector: 'tb-select-target-layout-dialog', + templateUrl: './select-target-layout-dialog.component.html', + styleUrls: ['./layout-button.scss'], + standalone: false }) export class SelectTargetLayoutDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.html index a1ae9ba969..fdd4b84397 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.html @@ -25,16 +25,20 @@ close - - + @if (isLoading$ | async) { + + + }
dashboard.state - - {{ stateItem.value.name }} - + @for (stateItem of states | keyvalue; track stateItem) { + + {{ stateItem.value.name }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.ts b/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.ts index 7c10619b08..20f81fe724 100644 --- a/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/dashboard/select-target-state-dialog.component.ts @@ -30,10 +30,11 @@ export interface SelectTargetStateDialogData { } @Component({ - selector: 'tb-select-target-state-dialog', - templateUrl: './select-target-state-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: SelectTargetStateDialogComponent}], - styleUrls: [] + selector: 'tb-select-target-state-dialog', + templateUrl: './select-target-state-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: SelectTargetStateDialogComponent }], + styleUrls: [], + standalone: false }) export class SelectTargetStateDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.html b/ui-ngx/src/app/modules/home/components/details-panel.component.html index db9caf2cf3..eebb9a6db7 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.html @@ -28,41 +28,49 @@
-
+ @if (isShowSearch) { +
+ +
+ +
+
+ } + @if (isShowSearch && !showSearchPane) { -
- -
-
- + } - + @if (showCloseDetails) { + + }
-
- - -
+ @if (!isReadOnly) { +
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/details-panel.component.ts b/ui-ngx/src/app/modules/home/components/details-panel.component.ts index dd82bf9879..1ab62c3081 100644 --- a/ui-ngx/src/app/modules/home/components/details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/details-panel.component.ts @@ -31,9 +31,10 @@ import { Subscription } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-details-panel', - templateUrl: './details-panel.component.html', - styleUrls: ['./details-panel.component.scss'] + selector: 'tb-details-panel', + templateUrl: './details-panel.component.html', + styleUrls: ['./details-panel.component.scss'], + standalone: false }) export class DetailsPanelComponent extends PageComponent implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.ts b/ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.ts index 038c83b643..4d668d3255 100644 --- a/ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/copy-device-credentials.component.ts @@ -28,9 +28,10 @@ import { AppState } from '@core/core.state'; import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: 'tb-copy-device-credentials', - templateUrl: './copy-device-credentials.component.html', - styleUrls: [] + selector: 'tb-copy-device-credentials', + templateUrl: './copy-device-credentials.component.html', + styleUrls: [], + standalone: false }) export class CopyDeviceCredentialsComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html index 4aa7c5e2e4..27db006a0f 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.html @@ -16,16 +16,18 @@ -->
- + device.lwm2m-security-config.mode - - {{ lwm2mSecurityTypeTranslationMap.get(securityType) }} - + @for (securityType of securityConfigLwM2MTypes; track securityType) { + + {{ lwm2mSecurityTypeTranslationMap.get(securityType) }} + + }
- + {{ 'device.lwm2m-security-config.client-publicKey-or-id' | translate }} help @@ -36,11 +38,11 @@ formControlName="clientPublicKeyOrId" required> - - {{ 'device.lwm2m-security-config.client-publicKey-or-id-required' | translate }} - + @if (serverFormGroup.get('clientPublicKeyOrId').hasError('required')) { + {{ 'device.lwm2m-security-config.client-publicKey-or-id-required' | translate }} + } - + {{ 'device.lwm2m-security-config.client-secret-key' | translate }} help @@ -51,9 +53,9 @@ formControlName="clientSecretKey" required> - - {{ 'device.lwm2m-security-config.client-secret-key-required' | translate }} - + @if (serverFormGroup.get('clientSecretKey').hasError('required')) { + {{ 'device.lwm2m-security-config.client-secret-key-required' | translate }} + }
diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.ts index 7d7a3c2c6b..810993d6f5 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m-server.component.ts @@ -36,21 +36,22 @@ import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; @Component({ - selector: 'tb-device-credentials-lwm2m-server', - templateUrl: './device-credentials-lwm2m-server.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceCredentialsLwm2mServerComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceCredentialsLwm2mServerComponent), - multi: true - } - ] + selector: 'tb-device-credentials-lwm2m-server', + templateUrl: './device-credentials-lwm2m-server.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceCredentialsLwm2mServerComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceCredentialsLwm2mServerComponent), + multi: true + } + ], + standalone: false }) export class DeviceCredentialsLwm2mServerComponent implements OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html index 243c0b47b3..f45ae6f38c 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.html @@ -18,66 +18,75 @@ - + device.lwm2m-security-config.endpoint - - {{ 'device.lwm2m-security-config.endpoint-required' | translate }} - + @if (lwm2mConfigFormGroup.get('client.endpoint').hasError('required')) { + {{ 'device.lwm2m-security-config.endpoint-required' | translate }} + } - + device.lwm2m-security-config.mode - - {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityConfigClientMode]) }} - + @for (securityConfigClientMode of securityConfigLwM2MTypes; track securityConfigClientMode) { + + {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityConfigClientMode]) }} + + } - - {{ 'device.lwm2m-security-config.identity' | translate }} - help - - - {{ 'device.lwm2m-security-config.identity-required' | translate }} - - - - {{ 'device.lwm2m-security-config.client-key' | translate }} - help - - - {{ 'device.lwm2m-security-config.client-key-required' | translate }} - - - - device.lwm2m-security-config.client-public-key - help - - device.lwm2m-security-config.client-public-key-hint - - + @if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK) { + + {{ 'device.lwm2m-security-config.identity' | translate }} + help + + @if (lwm2mConfigFormGroup.get('client.identity').hasError('required')) { + {{ 'device.lwm2m-security-config.identity-required' | translate }} + } + + } + @if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.RPK || + lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.PSK) { + + {{ 'device.lwm2m-security-config.client-key' | translate }} + help + + @if (lwm2mConfigFormGroup.get('client.key').hasError('required')) { + {{ 'device.lwm2m-security-config.client-key-required' | translate }} + } + + } + @if (lwm2mConfigFormGroup.get('client.securityConfigClientMode').value === securityConfigLwM2MType.X509) { + + device.lwm2m-security-config.client-public-key + @if (lwm2mConfigFormGroup.get('client.cert').value) { + help + } + + device.lwm2m-security-config.client-public-key-hint + + } + @if (deviceId) { + + } @@ -109,11 +118,12 @@
- + @if (deviceId) { + + } diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss index cef379e280..13dd61a4e7 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.scss @@ -27,4 +27,7 @@ .mat-mdc-tab-body { padding: 16px 0; } + .mat-mdc-tab-body-content { + padding-top: 4px; + } } diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts index 5c602f1189..3800f2deb8 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-lwm2m.component.ts @@ -43,21 +43,22 @@ import { Store } from "@ngrx/store"; import { AppState } from "@core/core.state"; @Component({ - selector: 'tb-device-credentials-lwm2m', - templateUrl: './device-credentials-lwm2m.component.html', - styleUrls: ['./device-credentials-lwm2m.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceCredentialsLwm2mComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceCredentialsLwm2mComponent), - multi: true - } - ] + selector: 'tb-device-credentials-lwm2m', + templateUrl: './device-credentials-lwm2m.component.html', + styleUrls: ['./device-credentials-lwm2m.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceCredentialsLwm2mComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceCredentialsLwm2mComponent), + multi: true + } + ], + standalone: false }) export class DeviceCredentialsLwm2mComponent implements ControlValueAccessor, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html b/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html index f36eaa4565..deaaee3063 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials-mqtt-basic.component.html @@ -16,90 +16,87 @@ -->
- + device.client-id - - - - - - - {{ 'device.client-id-pattern' | translate }} - + + @if (!deviceCredentialsMqttFormGroup.get('clientId').value && !disabled) { + + } @else if (deviceCredentialsMqttFormGroup.get('clientId').value) { + + + } + + @if (deviceCredentialsMqttFormGroup.get('clientId').hasError('pattern')) { + {{ 'device.client-id-pattern' | translate }} + } - + device.user-name - - - - - - - {{ 'device.user-name-required' | translate }} - + + @if (!deviceCredentialsMqttFormGroup.get('userName').value && !disabled) { + + } @else if (deviceCredentialsMqttFormGroup.get('userName').value) { + + + } + + @if (deviceCredentialsMqttFormGroup.get('userName').hasError('required')) { + {{ 'device.user-name-required' | translate }} + } - + device.password - - - - - + + @if (!deviceCredentialsMqttFormGroup.get('password').value && !disabled) { + + } @else if (deviceCredentialsMqttFormGroup.get('password').value) { + + + } + 1)" style="margin-bottom: 16px">
device.credentials-type
- - {{ credentialTypeNamesMap.get(credentialsType) }} - + @for (credentialsType of credentialsTypes; track credentialsType) { + + {{ credentialTypeNamesMap.get(credentialsType) }} + + }
-
- - + @switch (deviceCredentialsFormGroup.get('credentialsType').value) { + @case (deviceCredentialsType.ACCESS_TOKEN) { + device.access-token - - + + @if (!deviceCredentialsFormGroup.get('credentialsId').value && !disabled) { + + } @else if (deviceCredentialsFormGroup.get('credentialsId').value) { + + + } + + @if (deviceCredentialsFormGroup.get('credentialsId').hasError('required')) { + {{ 'device.access-token-required' | translate }} + } + @if (deviceCredentialsFormGroup.get('credentialsId').hasError('pattern')) { + {{ 'device.access-token-invalid' | translate }} + } + + } + @case (deviceCredentialsType.X509_CERTIFICATE) { + + device.certificate-pem-format + + @if (deviceCredentialsFormGroup.get('credentialsValue').value) { - - - {{ 'device.access-token-required' | translate }} - - - {{ 'device.access-token-invalid' | translate }} - - - - - - device.certificate-pem-format - - - - - {{ 'device.certificate-pem-format-required' | translate }} - + } + @if (deviceCredentialsFormGroup.get('credentialsValue').hasError('required')) { + {{ 'device.certificate-pem-format-required' | translate }} + } - - + } + @case (deviceCredentialsType.MQTT_BASIC) { - - + } + @case (deviceCredentialsType.LWM2M_CREDENTIALS) { - -
+ } + } diff --git a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts index 11e82fafdf..1d1838a9ea 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-credentials.component.ts @@ -39,20 +39,22 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { DeviceId } from "@shared/models/id/device-id"; @Component({ - selector: 'tb-device-credentials', - templateUrl: './device-credentials.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceCredentialsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceCredentialsComponent), - multi: true, - }], - styleUrls: ['./device-credentials.component.scss'] + selector: 'tb-device-credentials', + templateUrl: './device-credentials.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceCredentialsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceCredentialsComponent), + multi: true, + } + ], + styleUrls: ['./device-credentials.component.scss'], + standalone: false }) export class DeviceCredentialsComponent implements ControlValueAccessor, OnInit, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.html b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.html index f2617c7fc8..0029a3ba3f 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.html @@ -15,11 +15,10 @@ limitations under the License. --> - +@if (panelMode) { - - - +} @else { + @if (buttonMode) { - - + } @else { +
+
+
device-profile.device-profile
+ + +
+
+
device.device-state
+ + + {{ 'device.any' | translate }} + {{ 'device.active' | translate }} + {{ 'device.inactive' | translate }} + + +
+
+ } +}
@@ -59,13 +83,13 @@
device-profile.device-profile
+ class="flex-1" + formControlName="deviceProfileId" + [inlineField]="true" + [displayAllOnEmpty]="true" + (deviceProfileChanged)="deviceProfileChanged($event)" + [editProfileEnabled]="false" + [addNewProfile]="false">
diff --git a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts index 4e35be3060..48f873ef61 100644 --- a/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts +++ b/ui-ngx/src/app/modules/home/components/device/device-info-filter.component.ts @@ -51,16 +51,17 @@ export interface DeviceFilterConfigData { // @dynamic @Component({ - selector: 'tb-device-info-filter', - templateUrl: './device-info-filter.component.html', - styleUrls: ['./device-info-filter.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceInfoFilterComponent), - multi: true - } - ] + selector: 'tb-device-info-filter', + templateUrl: './device-info-filter.component.html', + styleUrls: ['./device-info-filter.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceInfoFilterComponent), + multi: true + } + ], + standalone: false }) export class DeviceInfoFilterComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.ts b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.ts index d6e9c29c37..e1366d474c 100644 --- a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table-header.component.ts @@ -22,9 +22,10 @@ import { EdgeEvent } from '@shared/models/edge.models'; import { EdgeDownlinkTableConfig } from '@home/components/edge/edge-downlink-table-config'; @Component({ - selector: 'tb-edge-downlink-table-header', - templateUrl: './edge-downlink-table-header.component.html', - styleUrls: ['./edge-downlink-table-header.component.scss'] + selector: 'tb-edge-downlink-table-header', + templateUrl: './edge-downlink-table-header.component.html', + styleUrls: ['./edge-downlink-table-header.component.scss'], + standalone: false }) export class EdgeDownlinkTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.ts b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.ts index 279381b110..8bb83eda19 100644 --- a/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/edge/edge-downlink-table.component.ts @@ -29,9 +29,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-edge-downlink-table', - templateUrl: './edge-downlink-table.component.html', - styleUrls: ['./edge-downlink-table.component.scss'] + selector: 'tb-edge-downlink-table', + templateUrl: './edge-downlink-table.component.html', + styleUrls: ['./edge-downlink-table.component.scss'], + standalone: false }) export class EdgeDownlinkTableComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.html index 955f8f58be..795bde21c1 100644 --- a/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.html @@ -26,9 +26,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.ts index f117fd2a44..5894921d1a 100644 --- a/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/add-entity-dialog.component.ts @@ -31,10 +31,11 @@ import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; @Component({ - selector: 'tb-add-entity-dialog', - templateUrl: './add-entity-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AddEntityDialogComponent}], - styleUrls: ['./add-entity-dialog.component.scss'] + selector: 'tb-add-entity-dialog', + templateUrl: './add-entity-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AddEntityDialogComponent }], + styleUrls: ['./add-entity-dialog.component.scss'], + standalone: false }) export class AddEntityDialogComponent extends DialogComponent> implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts index f849f65d04..5d0ac5d5e1 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-button.component.ts @@ -39,23 +39,22 @@ import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entit import { EntityType } from '@shared/models/entity-type.models'; @Component({ - selector: 'tb-entity-debug-settings-button', - templateUrl: './entity-debug-settings-button.component.html', - standalone: true, - imports: [ - CommonModule, - SharedModule, - DurationLeftPipe, - ], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityDebugSettingsButtonComponent), - multi: true - }, - EntityDebugSettingsService - ], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-entity-debug-settings-button', + templateUrl: './entity-debug-settings-button.component.html', + imports: [ + CommonModule, + SharedModule, + DurationLeftPipe, + ], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityDebugSettingsButtonComponent), + multi: true + }, + EntityDebugSettingsService + ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class EntityDebugSettingsButtonComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html index 4d7ab8ee58..f23f7d00fc 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.html @@ -36,14 +36,16 @@ {{ 'debug-settings.all-messages' | translate: { time: (isDebugAllActive$ | async) && !allEnabled && debugAllControl.untouched ? (allEnabledUntil | durationLeft) : (maxDebugModeDuration | milliSecondsToTimeString: true : true) } }}
- + @if ((isDebugAllActive$ | async) && !allEnabled && debugAllControl.untouched) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts index 88dd185141..9b2fd42210 100644 --- a/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/debug/entity-debug-settings-panel.component.ts @@ -39,15 +39,14 @@ import { EntityType, entityTypeTranslations } from '@shared/models/entity-type.m import { getCurrentAuthState } from '@core/auth/auth.selectors'; @Component({ - selector: 'tb-entity-debug-settings-panel', - templateUrl: './entity-debug-settings-panel.component.html', - standalone: true, - imports: [ - SharedModule, - CommonModule, - DurationLeftPipe - ], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-entity-debug-settings-panel', + templateUrl: './entity-debug-settings-panel.component.html', + imports: [ + SharedModule, + CommonModule, + DurationLeftPipe + ], + changeDetection: ChangeDetectionStrategy.OnPush }) export class EntityDebugSettingsPanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 3de62b856d..c59c4cf5a9 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html @@ -16,108 +16,160 @@ --> - - - - + @if (entitiesTableConfig.detailsPanelEnabled) { + + + + + }
- {{ entitiesTableConfig.tableTitle }} + @if (entitiesTableConfig.tableTitle) { + {{ entitiesTableConfig.tableTitle }} + } - + @if (entitiesTableConfig.useTimePageLink) { + + }
-
- - - - - - - - - - + } @else { + @if (entitiesTableConfig.addActionDescriptors.length === 1) { + @if (entitiesTableConfig.addActionDescriptors[0].isEnabled()) { + + } + } @else { - - - - - + } + }
- + @for (actionDescriptor of headerActionDescriptors; track actionDescriptor) { + + } - + @if (entitiesTableConfig.searchEnabled) { + + } +
+ @if (!entitiesTableConfig.addActionDescriptors.length) { + + + } @else { + @if (entitiesTableConfig.addActionDescriptors.length === 1) { + @if (entitiesTableConfig.addActionDescriptors[0].isEnabled()) { + + + } + } @else { + + + } + } +
+ + @for (actionDescriptor of entitiesTableConfig.addActionDescriptors; track actionDescriptor) { + + } +
@@ -140,24 +192,28 @@
- -
- - {{ translations.selectedEntities | translate:{count: dataSource.selection.selected.length} }} - - - -
-
+ @if (entitiesTableConfig.selectionEnabled) { + +
+ + {{ translations.selectedEntities | translate:{count: dataSource.selection.selected.length} }} + + + @for (actionDescriptor of groupActionDescriptors; track actionDescriptor) { + + } +
+
+ }
+ matSort [matSortActive]="pageLink.sortOrder?.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> - - + @for (column of entityColumns; track column.key) { + + {{ column.ignoreTranslate ? column.title : (column.title | translate) }} - - - - - - - - - - - - - - - - - - - - - - - - - - - {{ column.title | translate }} - - - - + + @switch (column.type) { + @case ('link') { + @if (column.entityURL(entity); as detailsPageURL) { + + } @else { + + } + } + @case ('entityChips') { + + } + @default { + + } + } + @if (column.actionCell) { + @switch (column.actionCell.type) { + @case (cellActionType.COPY_BUTTON) { + + + } + @default { + + } + } + } + + + } + @for (column of actionColumns; track trackByColumnKey($index, column)) { + + {{ column.title | translate }} + + + + + } - + @if (cellActionDescriptors.length) {
{{ entitiesTableConfig.actionsColumnTitle ? (entitiesTableConfig.actionsColumnTitle | translate) : '' }}
@@ -256,21 +320,22 @@ [style.min-width]="(cellActionDescriptors.length * 48) + 'px'"> {{ entitiesTableConfig.actionsColumnTitle ? (entitiesTableConfig.actionsColumnTitle | translate) : '' }} -
+ }
- + @if (cellActionDescriptors.length) {
- + @for (actionDescriptor of cellActionDescriptors; track actionDescriptor) { + + }
- + @for (actionDescriptor of cellActionDescriptors; track actionDescriptor) { + + }
-
+ }
@@ -299,21 +366,25 @@ [class.mat-selected]="dataSource.selection.isSelected(entity)" [class.tb-current-entity]="dataSource.isCurrentEntity(entity)" [class.tb-pointer]="entitiesTableConfig.rowPointer" - *matRowDef="let entity; columns: displayedColumns;" (click)="onRowClick($event, entity)"> + *matRowDef="let entity; columns: displayedColumns;" (click)="onRowClick($event, entity)">
{{ translations.noEntities | translate }} + class="no-data-found flex items-center justify-center">{{ translations.noEntities | translate }} {{ 'common.loading' | translate }} + class="no-data-found flex items-center justify-center">{{ 'common.loading' | translate }}
- - + @if (displayPagination) { + + } + @if (displayPagination) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts index ec04a7eed8..45fcf98eb0 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.ts @@ -49,7 +49,7 @@ import { CellActionDescriptorType, EntityActionTableColumn, EntityChipsEntityTableColumn, - EntityColumn, + EntityColumn, EntityColumnsType, EntityColumnType, EntityLinkTableColumn, EntityTableColumn, EntityTableConfig, @@ -71,10 +71,11 @@ import { EntityDetailsPanelComponent } from '@home/components/entity/entity-deta import { FormBuilder } from '@angular/forms'; @Component({ - selector: 'tb-entities-table', - templateUrl: './entities-table.component.html', - styleUrls: ['./entities-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-entities-table', + templateUrl: './entities-table.component.html', + styleUrls: ['./entities-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class EntitiesTableComponent extends PageComponent implements IEntitiesTableComponent, AfterViewInit, OnInit, OnChanges, OnDestroy { @@ -88,8 +89,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa cellActionDescriptors: Array>>; actionColumns: Array>>; - entityColumns: Array>>; - + entityColumns: EntityColumnsType; displayedColumns: string[]; headerCellStyleCache: Array = []; @@ -593,8 +593,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa columnsUpdated(resetData: boolean = false) { this.entityColumns = this.entitiesTableConfig.columns.filter( (column) => column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn || - column instanceof EntityChipsEntityTableColumn) - .map(column => column as EntityTableColumn>); + column instanceof EntityChipsEntityTableColumn); this.actionColumns = this.entitiesTableConfig.columns.filter( (column) => column instanceof EntityActionTableColumn) .map(column => column as EntityActionTableColumn>); @@ -623,8 +622,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa this.cellActionDescriptors = [...this.entitiesTableConfig.cellActionDescriptors]; } - headerCellStyle(column: EntityColumn>) { - const index = this.entitiesTableConfig.columns.indexOf(column); + headerCellStyle(column: EntityColumnType) { + const index = this.entitiesTableConfig.columns.indexOf(column as EntityColumn>); let res = this.headerCellStyleCache[index]; if (!res) { const widthStyle: any = {width: column.width}; @@ -649,7 +648,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa this.cellStyleCache[index] = undefined; } - cellContent(entity: BaseData, column: EntityColumn>, row: number) { + cellContent(entity: BaseData, column: EntityColumnType, row: number) { if (column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn) { const col = this.entitiesTableConfig.columns.indexOf(column); const index = row * this.entitiesTableConfig.columns.length + col; @@ -664,7 +663,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } } - cellTooltip(entity: BaseData, column: EntityColumn>, row: number) { + cellTooltip(entity: BaseData, column: EntityColumnType, row: number) { if (column instanceof EntityTableColumn || column instanceof EntityLinkTableColumn) { const col = this.entitiesTableConfig.columns.indexOf(column); const index = row * this.entitiesTableConfig.columns.length + col; @@ -681,8 +680,8 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa } } - cellStyle(entity: BaseData, column: EntityColumn>, row: number) { - const col = this.entitiesTableConfig.columns.indexOf(column); + cellStyle(entity: BaseData, column: EntityColumnType, row: number) { + const col = this.entitiesTableConfig.columns.indexOf(column as EntityColumn>); const index = row * this.entitiesTableConfig.columns.length + col; let res = this.cellStyleCache[index]; if (!res) { @@ -701,7 +700,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa return res; } - trackByColumnKey(index, column: EntityTableColumn>) { + trackByColumnKey(index, column: EntityTableColumn> | EntityActionTableColumn>) { return column.key; } diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.html index b0c7c71e00..856e56b9ea 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.html @@ -15,7 +15,9 @@ limitations under the License. --> - - {{ subEntity.name | customTranslate }} - +@for (subEntity of subEntities; track subEntity) { + + {{ subEntity.name | customTranslate }} + +} diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts index eed41e2820..7dbe3d2949 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-chips.component.ts @@ -21,9 +21,10 @@ import { baseDetailsPageByEntityType, EntityType } from '@app/shared/public-api' import { isEqual, isNotEmptyStr, isObject } from '@core/utils'; @Component({ - selector: 'tb-entity-chips', - templateUrl: './entity-chips.component.html', - styleUrls: ['./entity-chips.component.scss'] + selector: 'tb-entity-chips', + templateUrl: './entity-chips.component.html', + styleUrls: ['./entity-chips.component.scss'], + standalone: false }) export class EntityChipsComponent implements OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html index 496566ed3a..070d80b4ab 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.html @@ -30,25 +30,27 @@
-
- - -
+ @if (!isReadOnly) { +
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts index a66e04f23f..8988a02380 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-page.component.ts @@ -37,10 +37,11 @@ import { DialogService } from '@core/services/dialog.service'; import { IEntityDetailsPageComponent } from '@home/models/entity/entity-details-page-component.models'; @Component({ - selector: 'tb-entity-details-page', - templateUrl: './entity-details-page.component.html', - styleUrls: ['./entity-details-page.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-entity-details-page', + templateUrl: './entity-details-page.component.html', + styleUrls: ['./entity-details-page.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class EntityDetailsPageComponent extends EntityDetailsPanelComponent implements IEntityDetailsPageComponent, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts index 88b981d9db..8c6fc004d8 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-details-panel.component.ts @@ -47,10 +47,11 @@ import { catchError } from 'rxjs/operators'; import { HttpStatusCode } from '@angular/common/http'; @Component({ - selector: 'tb-entity-details-panel', - templateUrl: './entity-details-panel.component.html', - styleUrls: ['./entity-details-panel.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-entity-details-panel', + templateUrl: './entity-details-panel.component.html', + styleUrls: ['./entity-details-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class EntityDetailsPanelComponent extends PageComponent implements AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html index 333eafd7fa..319bb2fbf0 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.html @@ -16,10 +16,11 @@ -->
-
alias.no-entity-filter-specified
- + @if (!filter || !filter.type) { +
alias.no-entity-filter-specified
+ } @else {
{{ filterDisplayValue }}
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts index 7fb4768be4..ce5d78804d 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter-view.component.ts @@ -21,16 +21,17 @@ import { AliasEntityType, EntityType, entityTypeTranslations } from '@shared/mod import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: 'tb-entity-filter-view', - templateUrl: './entity-filter-view.component.html', - styleUrls: ['./entity-filter-view.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityFilterViewComponent), - multi: true - } - ] + selector: 'tb-entity-filter-view', + templateUrl: './entity-filter-view.component.html', + styleUrls: ['./entity-filter-view.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityFilterViewComponent), + multi: true + } + ], + standalone: false }) export class EntityFilterViewComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html index edbea5d1aa..ab517b2501 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.html @@ -19,315 +19,330 @@ alias.filter-type - - {{aliasFilterTypeTranslations.get(type) | translate}} - + @for (type of aliasFilterTypes; track type) { + + {{aliasFilterTypeTranslations.get(type) | translate}} + + } - - {{ 'alias.filter-type-required' | translate }} - + @if (entityFilterFormGroup.get('type').hasError('required')) { + + {{ 'alias.filter-type-required' | translate }} + + } -
- - - - - - - - - - - - - - - entity.name-starts-with - help - - - {{ 'entity.entity-name-filter-required' | translate }} - - - - - - - - - - alias.state-entity-parameter-name - - -
-
{{ 'alias.default-state-entity' | translate }}
- + @switch (entityFilterFormGroup.get('type').value) { + @case (aliasFilterType.singleEntity) { + + formControlName="singleEntity"> -
-
- - - - - asset.name-starts-with - help - - - - - - - - device.name-starts-with - help - - - - - - - - entity-view.name-starts-with - help - - - - - - - - edge.name-starts-with - - - - -
-
-
alias.root-entity
- - {{ 'alias.root-state-entity' | translate }} - - @if (filterFormGroup.get('rootStateEntity').value) { -
- - alias.state-entity-parameter-name - - -
-
{{ 'alias.default-state-entity' | translate }}
- - -
-
- } @else { - - + } + @case (aliasFilterType.entityList) { + + + + + } + @case (aliasFilterType.entityName) { + + + + entity.name-starts-with + help + + @if (filterFormGroup.get('entityNameFilter').hasError('required')) { + + {{ 'entity.entity-name-filter-required' | translate }} + } + + } + @case (aliasFilterType.entityType) { + + + } + @case (aliasFilterType.stateEntity) { + + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ +
-
-
alias.query-options
-
- - relation.direction - - - {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} - - - - - alias.max-relation-level - - + } + @case (aliasFilterType.assetType) { + + + + asset.name-starts-with + help + + + } + @case (aliasFilterType.deviceType) { + + + + device.name-starts-with + help + + + } + @case (aliasFilterType.entityViewType) { + + + + entity-view.name-starts-with + help + + + } + @case (aliasFilterType.edgeType) { + + + + edge.name-starts-with + + + } + @case (aliasFilterType.relationsQuery) { +
+
+
alias.root-entity
+ + {{ 'alias.root-state-entity' | translate }} + + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+
+ } @else { + + + }
- - {{ 'alias.last-level-relation' | translate }} - -
-
-
relation.relation-filters
- - -
-
-
- -
-
-
alias.root-entity
- - {{ 'alias.root-state-entity' | translate }} - - @if (filterFormGroup.get('rootStateEntity').value) { -
- - alias.state-entity-parameter-name - +
+
alias.query-options
+
+ + relation.direction + + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} + + } + -
-
{{ 'alias.default-state-entity' | translate }}
- - + + alias.max-relation-level + + +
+ @if (filterFormGroup.get('maxLevel').value > 1 || !filterFormGroup.get('maxLevel').value) { + + {{ 'alias.last-level-relation' | translate }} + + } +
+
+
relation.relation-filters
+ + +
+
+ } + @case (entityFilterFormGroup.get('type').value === aliasFilterType.assetSearchQuery || + entityFilterFormGroup.get('type').value === aliasFilterType.deviceSearchQuery || + entityFilterFormGroup.get('type').value === aliasFilterType.edgeSearchQuery || + entityFilterFormGroup.get('type').value === aliasFilterType.entityViewSearchQuery ? + entityFilterFormGroup.get('type').value : '') { +
+
+
alias.root-entity
+ + {{ 'alias.root-state-entity' | translate }} + + @if (filterFormGroup.get('rootStateEntity').value) { +
+ + alias.state-entity-parameter-name + + +
+
{{ 'alias.default-state-entity' | translate }}
+ + +
+ } @else { + + + } +
+
+
alias.query-options
+
+ + relation.direction + + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} + + } + + + + alias.max-relation-level + +
- } @else { - - - } -
-
-
alias.query-options
-
- - relation.direction - - - {{ directionTypeTranslations.get(directionTypeEnum[type]) | translate }} - - - - - alias.max-relation-level - - + @if (filterFormGroup.get('maxLevel').value > 1 || !filterFormGroup.get('maxLevel').value) { + + {{ 'alias.last-level-relation' | translate }} + + }
- - {{ 'alias.last-level-relation' | translate }} - -
-
-
relation.relation-filter
- - - - - - - - - - - - - - - - - - - - -
-
-
- +
+
relation.relation-filter
+ + + @switch (entityFilterFormGroup.get('type').value) { + @case (aliasFilterType.assetSearchQuery) { + + + } + @case (aliasFilterType.deviceSearchQuery) { + + + } + @case (aliasFilterType.edgeSearchQuery) { + + + } + @case (aliasFilterType.entityViewSearchQuery) { + + + } + } +
+
+ } + }
diff --git a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts index 88c0035902..dcd127c4a3 100644 --- a/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts +++ b/ui-ngx/src/app/modules/home/components/entity/entity-filter.component.ts @@ -24,16 +24,17 @@ import { Subject, Subscription } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-entity-filter', - templateUrl: './entity-filter.component.html', - styleUrls: ['./entity-filter.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityFilterComponent), - multi: true - } - ] + selector: 'tb-entity-filter', + templateUrl: './entity-filter.component.html', + styleUrls: ['./entity-filter.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityFilterComponent), + multi: true + } + ], + standalone: false }) export class EntityFilterComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts index 90192e7fa0..7fb51c13f6 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/event/event-content-dialog.component.ts @@ -36,9 +36,10 @@ export interface EventContentDialogData { } @Component({ - selector: 'tb-event-content-dialog', - templateUrl: './event-content-dialog.component.html', - styleUrls: ['./event-content-dialog.component.scss'] + selector: 'tb-event-content-dialog', + templateUrl: './event-content-dialog.component.html', + styleUrls: ['./event-content-dialog.component.scss'], + standalone: false }) export class EventContentDialogComponent extends DialogComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html index df073b8702..fa9044ed9b 100644 --- a/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html @@ -17,48 +17,52 @@ -->
- - - - + @for (column of showColumns; track column) { + @switch (column.key) { + @case (isSelector(column.key)) { + {{ column.title | translate}} {{ 'event.all-events' | translate}} - - {{ value }} - + @for (value of selectorValues(column.key); track value) { + + {{ value }} + + } - - - + } + @case (isNumberFields(column.key)) { + {{ column.title | translate}} - - {{ 'event.min-value' | translate }} - + @if (eventFilterFormGroup.get(column.key).hasError('min')) { + + {{ 'event.min-value' | translate }} + + } - - + } + @case ('isError') { {{ 'event.has-error' | translate }} - - - + } + @case ('errorStr') { + {{ column.title | translate}} - - - + } + @default { + {{ column.title | translate}} - - - + } + } + }
-
- - filter.operation.operation - - - {{complexOperationTranslations.get(complexOperationEnum[operation]) | translate}} - - - +
+
+
{{ 'filter.filters' | translate }} +
+ + {{ complexOperationTranslations.get(complexOperationEnum.AND) | translate }} + {{ complexOperationTranslations.get(complexOperationEnum.OR) | translate }} + +
-
+
- + @if (!data.readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts index f2770affb5..ceb5b63ce7 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate-dialog.component.ts @@ -31,10 +31,11 @@ import { import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models'; @Component({ - selector: 'tb-complex-filter-predicate-dialog', - templateUrl: './complex-filter-predicate-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: ComplexFilterPredicateDialogComponent}], - styleUrls: [] + selector: 'tb-complex-filter-predicate-dialog', + templateUrl: './complex-filter-predicate-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: ComplexFilterPredicateDialogComponent }], + styleUrls: [], + standalone: false }) export class ComplexFilterPredicateDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts index 0df6a9d69b..88a2971b87 100644 --- a/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/complex-filter-predicate.component.ts @@ -24,16 +24,17 @@ import { COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN } from '@home/component import { ComponentType } from '@angular/cdk/portal'; @Component({ - selector: 'tb-complex-filter-predicate', - templateUrl: './complex-filter-predicate.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ComplexFilterPredicateComponent), - multi: true - } - ] + selector: 'tb-complex-filter-predicate', + templateUrl: './complex-filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ComplexFilterPredicateComponent), + multi: true + } + ], + standalone: false }) export class ComplexFilterPredicateComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html index 230c8c8bb7..0cd0ea729e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.html @@ -16,54 +16,61 @@ -->
- +

{{ (isAdd ? 'filter.add' : 'filter.edit') | translate }}

-
- - -
-
-
-
- - filter.name - - - {{ 'filter.name-required' | translate }} - - - {{ 'filter.duplicate-filter' | translate }} - - -
- - - -
-
- - -
-
+
+
+ + filter.name + + @if (filterFormGroup.controls.filter.hasError('required')) { + {{ 'filter.name-required' | translate }} + } + @if (filterFormGroup.controls.filter.hasError('duplicateFilterName')) { + {{ 'filter.duplicate-filter' | translate }} + } + + +
+ + + {{ complexOperationTranslationMap.get(ComplexOperation.AND) | translate | titlecase }} + + {{ complexOperationTranslationMap.get(ComplexOperation.OR) | translate | titlecase }} + + +
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts index 75b56e15a5..30e5a791b8 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.ts @@ -14,25 +14,23 @@ /// limitations under the License. /// -import { Component, Inject, OnInit, SkipSelf } from '@angular/core'; -import { ErrorStateMatcher } from '@angular/material/core'; +import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, - FormGroupDirective, - NgForm, - ValidatorFn, - Validators -} from '@angular/forms'; +import { FormBuilder, FormControl, FormGroup, ValidatorFn, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { UtilsService } from '@core/services/utils.service'; -import { TranslateService } from '@ngx-translate/core'; -import { Filter, Filters } from '@shared/models/query/query.models'; +import { + ComplexOperation, + complexOperationTranslationMap, + Filter, + FilterInfo, + Filters +} from '@shared/models/query/query.models'; +import { getCurrentAuthState } from '@core/auth/auth.selectors'; +import { FormControlsFrom } from '@shared/models/tenant.model'; export interface FilterDialogData { isAdd: boolean; @@ -43,29 +41,27 @@ export interface FilterDialogData { @Component({ selector: 'tb-filter-dialog', templateUrl: './filter-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: FilterDialogComponent}], - styleUrls: ['./filter-dialog.component.scss'] + standalone: false }) -export class FilterDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { +export class FilterDialogComponent extends DialogComponent { isAdd: boolean; - filters: Array; - filter: Filter; + filterFormGroup: FormGroup>; - filterFormGroup: UntypedFormGroup; + ComplexOperation = ComplexOperation; + complexOperationTranslationMap = complexOperationTranslationMap; + allowKeyFiltersOrConditions: boolean; - submitted = false; + private readonly filter: Filter; + private filters: Array; constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: FilterDialogData, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, - private fb: UntypedFormBuilder, - private utils: UtilsService, - public translate: TranslateService) { + protected dialogRef: MatDialogRef, + private fb: FormBuilder, + private utils: UtilsService) { super(store, router, dialogRef); this.isAdd = data.isAdd; if (Array.isArray(data.filters)) { @@ -86,16 +82,25 @@ export class FilterDialogComponent extends DialogComponent { + return (c: FormControl) => { const newFilter = c.value.trim(); const found = this.filters.find((filter) => filter.filter === newFilter); if (found) { @@ -111,13 +116,11 @@ export class FilterDialogComponent extends DialogComponent -
- - - -
filter.filters
-
-
-
- -
-
-
- - +
+ @if (predicatesFormArray.length) { +
+
+
+
+ +
- + @if (displayUserParameters) { +   + }
- -  
-
- -
-
-
- {{ complexOperationTranslations.get(operation) | translate }} -
-
-
- - - + +
+ @for (predicateControl of predicatesFormArray.controls; track predicateControl) { +
+ @if ($index) { +
+ {{ complexOperationTranslations.get(operation) | translate }} +
+ } +
+
+ + + +
+
-
+ }
- filter.no-filters
-
- -
-
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss index 5155866e84..9a895dc7e0 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.scss @@ -13,18 +13,40 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../../../../../scss/constants"; + :host { - .predicate-list { - overflow-y: auto; - overflow-x: hidden; - max-height: 350px; - .no-data-found { - height: 50px; - } + .filter-title { + padding: 12px 8px; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + + .key-filter-list-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); } .filters-operation { - margin-top: -18px; - color: #666; - font-weight: 500; + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + position: absolute; + top: -12px; + left: 10px; + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); + } } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts index 1fb3a00360..8ab2bd6e35 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.ts @@ -43,21 +43,22 @@ import { COMPLEX_FILTER_PREDICATE_DIALOG_COMPONENT_TOKEN } from '@home/component import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models'; @Component({ - selector: 'tb-filter-predicate-list', - templateUrl: './filter-predicate-list.component.html', - styleUrls: ['./filter-predicate-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FilterPredicateListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FilterPredicateListComponent), - multi: true - } - ] + selector: 'tb-filter-predicate-list', + templateUrl: './filter-predicate-list.component.html', + styleUrls: ['./filter-predicate-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterPredicateListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateListComponent), + multi: true + } + ], + standalone: false }) export class FilterPredicateListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html index eb1aa4ce53..63554e8034 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.html @@ -15,71 +15,84 @@ limitations under the License. --> -
-
-
- - - - - - - - - - - - - - - - {{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }} - - -
-
{{ hintText | translate }}
-
-
-
-
- - - - {{'filter.no-dynamic-value' | translate}} - - - {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} - - - -
filter.dynamic-source-type
-
-
- - - -
filter.source-attribute
+
+
+
+
+ @switch (valueType) { + @case (valueTypeEnum.STRING) { + + {{ hintText | translate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ hintText | translate }} + + + } + @case (valueTypeEnum.DATE_TIME) { + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ (filterPredicateValueFormGroup.get('defaultValue').value ? 'value.true' : 'value.false') | translate }} + + } + }
-
- - {{ 'filter.inherit-owner' | translate}} - -
filter.source-attribute-not-set
+
+
+
+
+ + filter.dynamic-source-type + + + {{'filter.no-dynamic-value' | translate}} + + @for (sourceType of dynamicValueSourceTypes; track sourceType) { + + {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} + + } + + +
+
+ + filter.source-attribute + + +
+ @if (!allow && inheritMode) { +
+ + {{ 'filter.inherit-owner' | translate}} + +
filter.source-attribute-not-set
+
+ }
- + + @if (!onlyUserDynamicSource) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts index 01154c8930..14c334b72c 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-value.component.ts @@ -38,21 +38,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-filter-predicate-value', - templateUrl: './filter-predicate-value.component.html', - styleUrls: ['./filter-predicate.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FilterPredicateValueComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FilterPredicateValueComponent), - multi: true - } - ] + selector: 'tb-filter-predicate-value', + templateUrl: './filter-predicate-value.component.html', + styleUrls: ['./filter-predicate.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterPredicateValueComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateValueComponent), + multi: true + } + ], + standalone: false }) export class FilterPredicateValueComponent implements ControlValueAccessor, Validator, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html index 89db536b53..6c99a7bfcd 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.html @@ -15,47 +15,84 @@ limitations under the License. --> -
-
- - +
+ @switch (type) { + @case (filterPredicateType.STRING) { +
+ + + @for (operation of stringOperations; track operation) { + + {{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}} + + } + + + + {{ 'alarm-rule.ignore-case' | translate }} + +
+ } + @case (filterPredicateType.NUMERIC) { +
+ + + @for (operation of numericOperations; track operation) { + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + } + + +
+ } + @case (filterPredicateType.BOOLEAN) { +
+ + + @for (operation of booleanOperations; track operation) { + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + } + + +
+ } + @case (filterPredicateType.COMPLEX) { +
+ filter.complex-filter + +
+ } + } + @if (type !== filterPredicateType.COMPLEX) { + - - - - - - - - - - - - - - + formControlName="value"> + + }
- - + @if (type !== filterPredicateType.COMPLEX && displayUserParameters) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts index 170945e4ce..1311176976 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate.component.ts @@ -14,38 +14,55 @@ /// limitations under the License. /// -import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input } from '@angular/core'; import { ControlValueAccessor, UntypedFormBuilder, - UntypedFormGroup, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, - Validators + Validators, } from '@angular/forms'; -import { EntityKeyValueType, FilterPredicateType, KeyFilterPredicateInfo } from '@shared/models/query/query.models'; +import { + EntityKeyValueType, + FilterPredicateType, + KeyFilterPredicateInfo, + BooleanOperation, booleanOperationTranslationMap, + NumericOperation, numericOperationTranslationMap, + StringOperation, stringOperationTranslationMap, ComplexFilterPredicateInfo, KeyFilterPredicateUserInfo, + KeyFilterPredicate +} from '@shared/models/query/query.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ComplexFilterPredicateDialogData } from '@home/components/filter/filter-component.models'; +import { + ComplexFilterPredicateDialogComponent +} from '@home/components/filter/complex-filter-predicate-dialog.component'; +import { MatDialog } from '@angular/material/dialog'; +import { + FilterUserInfoDialogComponent, + FilterUserInfoDialogData +} from '@home/components/filter/filter-user-info-dialog.component'; @Component({ - selector: 'tb-filter-predicate', - templateUrl: './filter-predicate.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FilterPredicateComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FilterPredicateComponent), - multi: true - } - ] + selector: 'tb-filter-predicate', + templateUrl: './filter-predicate.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FilterPredicateComponent), + multi: true + } + ], + standalone: false }) -export class FilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { +export class FilterPredicateComponent implements ControlValueAccessor, Validator { @Input() disabled: boolean; @@ -59,23 +76,35 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator @Input() onlyUserDynamicSource = false; - filterPredicateFormGroup: UntypedFormGroup; + filterPredicateFormGroup = this.fb.group({ + operation: [], + ignoreCase: false, + predicates: [], + value: [], + userInfo: [] + }); type: FilterPredicateType; filterPredicateType = FilterPredicateType; + stringOperations = Object.keys(StringOperation); + stringOperationEnum = StringOperation; + stringOperationTranslations = stringOperationTranslationMap; + + numericOperations = Object.keys(NumericOperation); + numericOperationEnum = NumericOperation; + numericOperationTranslations = numericOperationTranslationMap; + + booleanOperations = Object.keys(BooleanOperation); + booleanOperationEnum = BooleanOperation; + booleanOperationTranslations = booleanOperationTranslationMap; + private propagateChange = null; constructor(private fb: UntypedFormBuilder, - private destroyRef: DestroyRef) { - } - - ngOnInit(): void { - this.filterPredicateFormGroup = this.fb.group({ - predicate: [null, [Validators.required]], - userInfo: [null, []] - }); + private destroyRef: DestroyRef, + private dialog: MatDialog) { this.filterPredicateFormGroup.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(() => { @@ -92,7 +121,7 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; - if (this.disabled) { + if (isDisabled) { this.filterPredicateFormGroup.disable({emitEvent: false}); } else { this.filterPredicateFormGroup.enable({emitEvent: false}); @@ -107,19 +136,97 @@ export class FilterPredicateComponent implements ControlValueAccessor, Validator writeValue(predicate: KeyFilterPredicateInfo): void { this.type = predicate.keyFilterPredicate.type; - this.filterPredicateFormGroup.get('predicate').patchValue(predicate.keyFilterPredicate, {emitEvent: false}); + this.updateValidators(); + this.filterPredicateFormGroup.patchValue(predicate.keyFilterPredicate, {emitEvent: false}); this.filterPredicateFormGroup.get('userInfo').patchValue(predicate.userInfo, {emitEvent: false}); } + private updateValidators(): void { + const operationCtrl = this.filterPredicateFormGroup.get('operation'); + const predicatesCtrl = this.filterPredicateFormGroup.get('predicates'); + operationCtrl.setValidators([Validators.required]); + if (this.type === FilterPredicateType.COMPLEX) { + predicatesCtrl.setValidators([Validators.required]); + } else { + predicatesCtrl.clearValidators(); + } + operationCtrl.updateValueAndValidity({emitEvent: false}); + predicatesCtrl.updateValueAndValidity({emitEvent: false}); + } + private updateModel() { let predicate: KeyFilterPredicateInfo = null; if (this.filterPredicateFormGroup.valid) { + const v = this.filterPredicateFormGroup.getRawValue(); + let keyFilterPredicate: KeyFilterPredicate; + if (this.type === FilterPredicateType.COMPLEX) { + keyFilterPredicate = { + type: FilterPredicateType.COMPLEX, + operation: v.operation, + predicates: v.predicates + } as KeyFilterPredicate; + } else { + keyFilterPredicate = { + type: this.type, + value: v.value, + operation: v.operation, + ignoreCase: !!v.ignoreCase + } as KeyFilterPredicate; + } predicate = { - keyFilterPredicate: this.filterPredicateFormGroup.getRawValue().predicate, - userInfo: this.filterPredicateFormGroup.getRawValue().userInfo + keyFilterPredicate, + userInfo: v.userInfo }; } this.propagateChange(predicate); } + public openComplexFilterDialog() { + this.dialog.open(ComplexFilterPredicateDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + complexPredicate: { + type: FilterPredicateType.COMPLEX, + operation: this.filterPredicateFormGroup.get('operation').value, + predicates: this.filterPredicateFormGroup.get('predicates').value + }, + readonly: this.disabled, + valueType: this.valueType, + isAdd: false, + key: this.key, + displayUserParameters: this.displayUserParameters, + allowUserDynamicSource: this.allowUserDynamicSource, + onlyUserDynamicSource: this.onlyUserDynamicSource + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.patchValue(result); + } + } + ); + } + + public openFilterUserInfoDialog() { + this.dialog.open(FilterUserInfoDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + keyFilterPredicateUserInfo: this.filterPredicateFormGroup.get('userInfo').value, + valueType: this.valueType, + key: this.key, + operation: this.filterPredicateFormGroup.get('operation').value, + readonly: this.disabled + } + }).afterClosed().subscribe( + (result) => { + if (result) { + this.filterPredicateFormGroup.get('userInfo').patchValue(result); + } + } + ); + } } diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts index 2dce9164db..94e246af52 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-text.component.ts @@ -14,13 +14,12 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnInit } from '@angular/core'; -import { ControlValueAccessor, UntypedFormBuilder, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatDialog } from '@angular/material/dialog'; -import { KeyFilter, keyFiltersToText } from '@shared/models/query/query.models'; +import { Component, forwardRef, Input, OnChanges, SimpleChanges } from '@angular/core'; +import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; +import { ComplexOperation, KeyFilter, keyFiltersToText } from '@shared/models/query/query.models'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; -import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ selector: 'tb-filter-text', @@ -32,18 +31,14 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; useExisting: forwardRef(() => FilterTextComponent), multi: true } - ] + ], + standalone: false }) -export class FilterTextComponent implements ControlValueAccessor, OnInit { +export class FilterTextComponent implements ControlValueAccessor, OnChanges { - private requiredValue: boolean; - get required(): boolean { - return this.requiredValue; - } @Input() - set required(value: boolean) { - this.requiredValue = coerceBooleanProperty(value); - } + @coerceBoolean() + required = false; @Input() disabled: boolean; @@ -57,26 +52,30 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit { @Input() nowrap = false; + @Input() + operation: ComplexOperation = ComplexOperation.AND; + requiredClass = false; public filterText: string; - private propagateChange = (v: any) => { }; + private currentValue: Array; - constructor(private dialog: MatDialog, - private fb: UntypedFormBuilder, - private translate: TranslateService, + constructor(private translate: TranslateService, private datePipe: DatePipe) { } - registerOnChange(fn: any): void { - this.propagateChange = fn; + registerOnChange(_fn: any): void { } - registerOnTouched(fn: any): void { + registerOnTouched(_fn: any): void { } - ngOnInit() { + ngOnChanges(changes: SimpleChanges) { + if (changes.operation && !changes.operation.firstChange + && changes.operation.currentValue !== changes.operation.previousValue) { + this.updateFilterText(this.currentValue); + } } setDisabledState(isDisabled: boolean): void { @@ -84,13 +83,14 @@ export class FilterTextComponent implements ControlValueAccessor, OnInit { } writeValue(value: Array): void { + this.currentValue = value; this.updateFilterText(value); } private updateFilterText(value: Array) { this.requiredClass = false; if (value && value.length) { - this.filterText = keyFiltersToText(this.translate, this.datePipe, value); + this.filterText = keyFiltersToText(this.translate, this.datePipe, value, this.operation); } else { if (this.required && !this.disabled) { this.filterText = this.addFilterPrompt; diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.html index 52df01d33d..1190125501 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.html @@ -50,11 +50,13 @@
-
-
filter.unit
- - -
+ @if (showUniInput) { +
+
filter.unit
+ + +
+ }
@@ -65,11 +67,12 @@ cdkFocusInitial> {{ (data.readonly ? 'action.close' : 'action.cancel') | translate }} - + @if (!data.readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.ts index 0c4509f133..6eb7da12b2 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-user-info-dialog.component.ts @@ -43,10 +43,11 @@ export interface FilterUserInfoDialogData { } @Component({ - selector: 'tb-filter-user-info-dialog', - templateUrl: './filter-user-info-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: FilterUserInfoDialogComponent}], - styleUrls: [] + selector: 'tb-filter-user-info-dialog', + templateUrl: './filter-user-info-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: FilterUserInfoDialogComponent }], + styleUrls: [], + standalone: false }) export class FilterUserInfoDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.ts b/ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.ts index 006bbf81f6..952e0ac9d6 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filter-user-info.component.ts @@ -30,16 +30,17 @@ import { import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-filter-user-info', - templateUrl: './filter-user-info.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FilterUserInfoComponent), - multi: true - } - ] + selector: 'tb-filter-user-info', + templateUrl: './filter-user-info.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterUserInfoComponent), + multi: true + } + ], + standalone: false }) export class FilterUserInfoComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html index 5251d87c4d..452045d164 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html @@ -25,66 +25,61 @@ close - - -
-
- -
-
filter.filter
-
filter.editable
-
+
+
+ + + filter.filter + + {{ control.get('filter').value }} + + + + filter.editable + + + + + + + +
+ + + +
+
+
+ + +
-
-
- -
- {{$index + 1}}. -
- {{filterControl.get('filter').value}} -
- - -
- - - -
+
+ filter.no-filters
-
+
- + @for (filter of filtersInfo | keyvalue; track filter; let last = $last) { +
+
+ {{filter.value.filter}} + +
+ @if (!last) { + + }
- -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.ts index ccb05c7f0c..06a218394c 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.ts @@ -29,9 +29,10 @@ export interface FiltersEditPanelData { } @Component({ - selector: 'tb-filters-edit-panel', - templateUrl: './filters-edit-panel.component.html', - styleUrls: ['./filters-edit-panel.component.scss'] + selector: 'tb-filters-edit-panel', + templateUrl: './filters-edit-panel.component.html', + styleUrls: ['./filters-edit-panel.component.scss'], + standalone: false }) export class FiltersEditPanelComponent { diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.html index f70b00d863..f669c77b28 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.html @@ -15,18 +15,20 @@ limitations under the License. --> -
- + - filter_list - - - {{ 'filter.filters' | translate }} - -
+ {{ 'filter.filters' | translate }} + +
+} diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts b/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts index 48a33a8765..5bbd4ac034 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/filters-edit.component.ts @@ -42,9 +42,10 @@ import { UserFilterDialogComponent, UserFilterDialogData } from '@home/component import { MatDialog } from '@angular/material/dialog'; @Component({ - selector: 'tb-filters-edit', - templateUrl: './filters-edit.component.html', - styleUrls: ['./filters-edit.component.scss'] + selector: 'tb-filters-edit', + templateUrl: './filters-edit.component.html', + styleUrls: ['./filters-edit.component.scss'], + standalone: false }) export class FiltersEditComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html index d8e4264a8e..f7624c873f 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html @@ -26,101 +26,128 @@
-
-
-
+
+
{{ 'filter.key-filter' | translate }}
+
+
- - filter.key-type.key-type - - - {{entityKeyTypeTranslations.get(type) | translate}} - - - - - filter.key-name - - - - - - - - - {{ 'filter.key-name-required' | translate }} - - -
- + + filter.key-type.key-type + + @for (type of entityKeyTypes; track type) { + + {{entityKeyTypeTranslations.get(type) | translate}} + + } + + + + filter.key-name + + @if (keyFilterFormGroup.get('key.key').value && showAutocomplete) { + + } + + @for (keyName of filteredKeysName | async; track keyName) { + + + + } + + @if (keyFilterFormGroup.get('key.key').hasError('required')) { + + {{ 'filter.key-name-required' | translate }} + + } + +
+ - filter.value-type.value-type - - - - {{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }} - - - - {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} - - - - {{ 'filter.value-type-required' | translate }} - - -
-
- - - filter.value - - - - - - filter.value - - - - - - - - - {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }} - - -
+ filter.value-type.value-type + + + + {{ entityKeyValueTypes.get(keyFilterFormGroup.get('valueType').value)?.name | translate }} + + @for (valueType of entityKeyValueTypesKeys; track valueType) { + + + {{ entityKeyValueTypes.get(entityKeyValueTypeEnum[valueType]).name | translate }} + + } + + @if (keyFilterFormGroup.get('valueType').hasError('required')) { + + {{ 'filter.value-type-required' | translate }} + + } + + @if (isConstantKeyType) { +
+
+ @switch (keyFilterFormGroup.get('valueType').value) { + @case (entityKeyValueTypeEnum.STRING) { + + filter.value + + + } + @case (entityKeyValueTypeEnum.NUMERIC) { + + filter.value + + + } + @case (entityKeyValueTypeEnum.DATE_TIME) { + + } + @case (entityKeyValueTypeEnum.BOOLEAN) { + + {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | translate }} + + } + } +
+
+ }
- - - + @if (keyFilterFormGroup.get('valueType').value) { +
+
+
{{ 'filter.filters' | translate }} +
+
+ + +
+ } +
- + @if (!data.readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts index e6e58c33cb..ffaa10570d 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.ts @@ -50,10 +50,11 @@ export interface KeyFilterDialogData { } @Component({ - selector: 'tb-key-filter-dialog', - templateUrl: './key-filter-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: KeyFilterDialogComponent}], - styleUrls: ['./key-filter-dialog.component.scss'] + selector: 'tb-key-filter-dialog', + templateUrl: './key-filter-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: KeyFilterDialogComponent }], + styleUrls: ['./key-filter-dialog.component.scss'], + standalone: false }) export class KeyFilterDialogComponent extends DialogComponent diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html index ba5fbfc250..8bfd7792d5 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.html @@ -15,78 +15,86 @@ limitations under the License. --> -
- - - - -
filter.key-filters
-
-
-
- -
- - -   -   -
-
- -
-
-
- filter.operation.and +
+
+
+
filter.key-filters
+ +
+ @if (keyFiltersFormArray.length) { +
+
+ +
+
filter.key-name
+
filter.key-type.key-type
+   +  
-
-
-
{{ keyFilterControl.value.key.key }}
-
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
- - +
+ +
+ @for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl; let index = $index) { +
+
+ @if (index) { +
+ {{ complexOperationTranslationMap.get(operation) | translate }} +
+ } +
+
+
+
{{ keyFilterControl.value.key.key }}
+
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
+ + +
+
- -
+ }
- filter.no-key-filters -
-
- -
- - +
+ } @else { + filter.no-key-filters + } +
+ +
+
+
+ - -
filter.preview
-
+ {{ 'filter.preview' | translate }}
-
- -
+ + @if (keyFiltersFormArray.length) { + + } @else { + filter.no-filter-text + } +
- +
diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss index 2d1bcc923a..9ef70c8845 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.scss @@ -13,30 +13,45 @@ * See the License for the specific language governing permissions and * limitations under the License. */ +@import "../scss/constants"; + :host { - .key-filter-list { + .filter-title { + padding: 12px 0; + font-size: 14px; + font-weight: 500; + } + .no-data-found { + height: 50px; + font-size: 16px; + } + .filter-list { overflow: auto; max-height: 300px; - .no-data-found { - height: 50px; + + &-divider { + border-top: 1px solid rgba(0, 0, 0, 0.12); + } + + .filters-text { + font-size: 14px; } } .filters-operation { - margin-top: -18px; - color: #666; - font-weight: 500; - } - .tb-filter-preview { - padding: 8px; - border: 1px groove rgba(0, 0, 0, .25); - border-radius: 4px; - } -} - -:host ::ng-deep { - .tb-filter-preview { - .tb-filter-text { - max-height: 200px; + display: flex; + justify-content: center; + margin-top: -14px; + &-container { + background-color: white; + } + &-label { + font-size: 15px; + font-weight: 400; + color: $tb-primary-color; + padding: 0 8px; + border-radius: 4px; + border: 1px solid rgba($tb-primary-color, 0.32); + background-color: rgba($tb-primary-color, 0.04); } } } diff --git a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts index ec3075f9f8..73804faa7a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/key-filter-list.component.ts @@ -14,24 +14,26 @@ /// limitations under the License. /// -import { Component, forwardRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { Component, DestroyRef, forwardRef, Input, OnInit } from '@angular/core'; import { - AbstractControl, ControlValueAccessor, - UntypedFormArray, - UntypedFormBuilder, - UntypedFormControl, - UntypedFormGroup, + FormArray, + FormBuilder, + FormControl, NG_VALIDATORS, NG_VALUE_ACCESSOR, ValidationErrors, Validator, Validators } from '@angular/forms'; -import { Observable, Subject } from 'rxjs'; +import { Observable } from 'rxjs'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { + ComplexOperation, + complexOperationTranslationMap, EntityKeyType, entityKeyTypeTranslationMap, + KeyFilter, KeyFilterInfo, keyFilterInfosToKeyFilters } from '@shared/models/query/query.models'; @@ -39,7 +41,6 @@ import { MatDialog } from '@angular/material/dialog'; import { deepClone } from '@core/utils'; import { KeyFilterDialogComponent, KeyFilterDialogData } from '@home/components/filter/key-filter-dialog.component'; import { EntityId } from '@shared/models/id/entity-id'; -import { takeUntil } from 'rxjs/operators'; @Component({ selector: 'tb-key-filter-list', @@ -56,9 +57,10 @@ import { takeUntil } from 'rxjs/operators'; useExisting: forwardRef(() => KeyFilterListComponent), multi: true } - ] + ], + standalone: false }) -export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { +export class KeyFilterListComponent implements ControlValueAccessor, Validator, OnInit { @Input() disabled: boolean; @@ -70,39 +72,32 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, @Input() entityId: EntityId; - keyFilterListFormGroup: UntypedFormGroup; + @Input() operation: ComplexOperation = ComplexOperation.AND; + + complexOperationTranslationMap = complexOperationTranslationMap; entityKeyTypeTranslations = entityKeyTypeTranslationMap; - keyFiltersControl: UntypedFormControl; + keyFiltersFormArray: FormArray>; + + keyFiltersControl: FormControl>; - private destroy$ = new Subject(); private propagateChange = null; - constructor(private fb: UntypedFormBuilder, - private dialog: MatDialog) { + constructor(private fb: FormBuilder, + private dialog: MatDialog, + private destroyRef: DestroyRef) { } ngOnInit(): void { - this.keyFilterListFormGroup = this.fb.group({ - keyFilters: this.fb.array([]) - }); + this.keyFiltersFormArray = this.fb.array>([]); this.keyFiltersControl = this.fb.control(null); - this.keyFilterListFormGroup.valueChanges.pipe( - takeUntil(this.destroy$) + this.keyFiltersFormArray.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) ).subscribe(() => this.updateModel()); } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - - get keyFiltersFormArray(): UntypedFormArray { - return this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray; - } - registerOnChange(fn: any): void { this.propagateChange = fn; } @@ -113,61 +108,61 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; if (this.disabled) { - this.keyFilterListFormGroup.disable({emitEvent: false}); + this.keyFiltersFormArray.disable({emitEvent: false}); this.keyFiltersControl.disable({emitEvent: false}); } else { - this.keyFilterListFormGroup.enable({emitEvent: false}); + this.keyFiltersFormArray.enable({emitEvent: false}); this.keyFiltersControl.enable({emitEvent: false}); } } validate(): ValidationErrors | null { - return this.keyFilterListFormGroup.valid && this.keyFiltersControl.valid ? null : { + return this.keyFiltersFormArray.valid && this.keyFiltersControl.valid ? null : { keyFilterList: {valid: false} }; } writeValue(keyFilters: Array): void { - if (keyFilters.length === this.keyFiltersFormArray.length) { + if (keyFilters?.length === this.keyFiltersFormArray.length) { this.keyFiltersFormArray.patchValue(keyFilters, {emitEvent: false}); } else { - const keyFilterControls: Array = []; + this.keyFiltersFormArray.clear({emitEvent: false}); if (keyFilters) { for (const keyFilter of keyFilters) { - keyFilterControls.push(this.fb.control(keyFilter, [Validators.required])); + this.keyFiltersFormArray.push( + this.fb.control(keyFilter, [Validators.required]), + {emitEvent: false} + ); } } - this.keyFilterListFormGroup.setControl('keyFilters', this.fb.array(keyFilterControls), {emitEvent: false}); if (this.disabled) { - this.keyFilterListFormGroup.disable({emitEvent: false}); + this.keyFiltersFormArray.disable({emitEvent: false}); } else { - this.keyFilterListFormGroup.enable({emitEvent: false}); + this.keyFiltersFormArray.enable({emitEvent: false}); } } const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); } - public removeKeyFilter(index: number) { - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).removeAt(index); + removeKeyFilter(index: number) { + this.keyFiltersFormArray.removeAt(index); } - public addKeyFilter() { - const keyFiltersFormArray = this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray; + addKeyFilter() { this.openKeyFilterDialog(null).subscribe((result) => { if (result) { - keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); + this.keyFiltersFormArray.push(this.fb.control(result, [Validators.required])); } }); } - public editKeyFilter(index: number) { - const keyFilter: KeyFilterInfo = - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).value; + editKeyFilter(index: number) { + const keyFilter = this.keyFiltersFormArray.at(index).value; this.openKeyFilterDialog(keyFilter).subscribe( (result) => { if (result) { - (this.keyFilterListFormGroup.get('keyFilters') as UntypedFormArray).at(index).patchValue(result); + this.keyFiltersFormArray.at(index).patchValue(result); } } ); @@ -203,12 +198,8 @@ export class KeyFilterListComponent implements ControlValueAccessor, Validator, } private updateModel() { - const keyFilters: Array = this.keyFilterListFormGroup.getRawValue().keyFilters; - if (keyFilters.length) { - this.propagateChange(keyFilters); - } else { - this.propagateChange(null); - } + const keyFilters = this.keyFiltersFormArray.getRawValue(); + this.propagateChange(keyFilters.length ? keyFilters : null); const keyFiltersArray = keyFilterInfosToKeyFilters(keyFilters); this.keyFiltersControl.patchValue(keyFiltersArray, {emitEvent: false}); } diff --git a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html index f113bfcde9..76394def54 100644 --- a/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/numeric-filter-predicate.component.html @@ -18,9 +18,11 @@
- - {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} - + @for (operation of numericOperations; track operation) { + + {{numericOperationTranslations.get(numericOperationEnum[operation]) | translate}} + + } NumericFilterPredicateComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => NumericFilterPredicateComponent), - multi: true - } - ] + selector: 'tb-numeric-filter-predicate', + templateUrl: './numeric-filter-predicate.component.html', + styleUrls: ['./filter-predicate.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NumericFilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NumericFilterPredicateComponent), + multi: true + } + ], + standalone: false }) export class NumericFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html index ab6737ee97..10b3087e71 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.html @@ -19,9 +19,11 @@
- - {{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}} - + @for (operation of stringOperations; track operation) { + + {{stringOperationTranslations.get(stringOperationEnum[operation]) | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts index c2fac83db0..d4e3be6b5f 100644 --- a/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/string-filter-predicate.component.ts @@ -35,21 +35,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-string-filter-predicate', - templateUrl: './string-filter-predicate.component.html', - styleUrls: ['./filter-predicate.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => StringFilterPredicateComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => StringFilterPredicateComponent), - multi: true - } - ] + selector: 'tb-string-filter-predicate', + templateUrl: './string-filter-predicate.component.html', + styleUrls: ['./filter-predicate.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringFilterPredicateComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => StringFilterPredicateComponent), + multi: true + } + ], + standalone: false }) export class StringFilterPredicateComponent implements ControlValueAccessor, Validator, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html index 8ca43427e9..f1836ae17e 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.html @@ -15,64 +15,61 @@ limitations under the License. --> -
- + +

{{ filter.filter | customTranslate }}

-
- -
-
-
-
- - - {{ userInputControl.get('label').value | customTranslate }} - - - - - - {{ userInputControl.get('label').value | customTranslate }} - -
- {{ userInputControl.get('unitSymbol').value }} + @for (userInputControl of userInputsFormArray.controls; track userInputControl) { + @switch (userInputControl.controls.valueType.value) { + @case (valueTypeEnum.STRING) { + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ userInputControl.controls.label.value | customTranslate }} + + @if (userInputControl.controls.unitSymbol.value) { +
+ {{ userInputControl.controls.unitSymbol.value }}
-
- - - - - - - - {{ userInputControl.get('label').value | customTranslate }} - - -
-
-
+ } + + } + @case (valueTypeEnum.DATE_TIME) { + + } + @case (valueTypeEnum.BOOLEAN) { + + + {{ userInputControl.controls.label.value | customTranslate }} + + + } + } + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss index d3b1d316ab..552c49b56a 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.scss @@ -14,14 +14,14 @@ * limitations under the License. */ :host ::ng-deep { - .user-filter-dialog { - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { + .numeric-field { + &.mat-mdc-form-field-has-icon-suffix.mat-form-field-hide-placeholder { .mat-mdc-form-field-icon-suffix { place-self: center; } } - .mat-mdc-form-field.mat-mdc-form-field-has-icon-suffix { + &.mat-mdc-form-field-has-icon-suffix { .mat-mdc-form-field-icon-suffix { place-self: baseline; } diff --git a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts index 279b257178..59231f8b80 100644 --- a/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/filter/user-filter-dialog.component.ts @@ -14,12 +14,11 @@ /// limitations under the License. /// -import { Component, DestroyRef, Inject, SkipSelf } from '@angular/core'; -import { ErrorStateMatcher } from '@angular/material/core'; +import { Component, DestroyRef, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; -import { FormArray, FormBuilder, FormControl, FormGroup, FormGroupDirective, NgForm, Validators } from '@angular/forms'; +import { FormArray, FormBuilder, FormControl, FormGroup, Validators } from '@angular/forms'; import { Router } from '@angular/router'; import { DialogComponent } from '@app/shared/components/dialog.component'; import { TranslateService } from '@ngx-translate/core'; @@ -39,28 +38,31 @@ export interface UserFilterDialogData { filter: Filter; } +interface UserInputForm { + label: FormControl; + valueType: FormControl; + unitSymbol: FormControl; + value: FormControl; +} + @Component({ - selector: 'tb-user-filter-dialog', - templateUrl: './user-filter-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: UserFilterDialogComponent}], - styleUrls: ['./user-filter-dialog.component.scss'], + selector: 'tb-user-filter-dialog', + templateUrl: './user-filter-dialog.component.html', + styleUrls: ['./user-filter-dialog.component.scss'], + standalone: false }) -export class UserFilterDialogComponent extends DialogComponent - implements ErrorStateMatcher { +export class UserFilterDialogComponent extends DialogComponent { filter: Filter; - userFilterFormGroup: FormGroup; + userInputsFormArray: FormArray>; valueTypeEnum = EntityKeyValueType; - submitted = false; - constructor(protected store: Store, protected router: Router, @Inject(MAT_DIALOG_DATA) public data: UserFilterDialogData, - @SkipSelf() private errorStateMatcher: ErrorStateMatcher, - public dialogRef: MatDialogRef, + protected dialogRef: MatDialogRef, private fb: FormBuilder, private translate: TranslateService, private destroyRef: DestroyRef, @@ -69,19 +71,13 @@ export class UserFilterDialogComponent extends DialogComponent = []; - for (const userInput of userInputs) { - userInputControls.push(this.createUserInputFormControl(userInput)); - } - - this.userFilterFormGroup = this.fb.group({ - userInputs: this.fb.array(userInputControls) - }); + const userInputControls = userInputs.map(input => this.createUserInputFormControl(input)); + this.userInputsFormArray = this.fb.array(userInputControls); } - private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { + private createUserInputFormControl(userInput: UserFilterInputInfo): FormGroup { const predicateValue: FilterPredicateValue = (userInput.info.keyFilterPredicate as any).value; - let value = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; + let value: string | number | boolean = isDefinedAndNotNull(predicateValue.userValue) ? predicateValue.userValue : predicateValue.defaultValue; let unitSymbol = ''; let valueConvertor: TbUnitConverter; if (userInput.valueType === EntityKeyValueType.NUMERIC) { @@ -90,42 +86,31 @@ export class UserFilterDialogComponent extends DialogComponent({ + label: this.fb.control(userInput.label), + valueType: this.fb.control(userInput.valueType), + unitSymbol: this.fb.control(unitSymbol), + value: this.fb.control(value, userInput.valueType === EntityKeyValueType.NUMERIC || - userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []] + userInput.valueType === EntityKeyValueType.DATE_TIME ? [Validators.required] : []) }); - userInputControl.get('value').valueChanges.pipe( + userInputControl.controls.value.valueChanges.pipe( takeUntilDestroyed(this.destroyRef) ).subscribe(userValue => { - let value = userValue; + let val = userValue; if (valueConvertor) { - value = valueConvertor(value as number); + val = valueConvertor(val as number); } - (userInput.info.keyFilterPredicate as any).value.userValue = value; + (userInput.info.keyFilterPredicate as any).value.userValue = val; }); return userInputControl; } - userInputsFormArray(): FormArray { - return this.userFilterFormGroup.get('userInputs') as FormArray; - } - - isErrorState(control: FormControl | null, form: FormGroupDirective | NgForm | null): boolean { - const originalErrorState = this.errorStateMatcher.isErrorState(control, form); - const customErrorState = !!(control && control.invalid && this.submitted); - return originalErrorState || customErrorState; - } - cancel(): void { this.dialogRef.close(null); } save(): void { - this.submitted = true; this.dialogRef.close(this.filter); } } diff --git a/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss b/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss index a434e430f2..74f346c2aa 100644 --- a/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss +++ b/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.scss @@ -17,10 +17,10 @@ &:has(section) { height: 100%; } - --mat-outlined-button-horizontal-padding: 8px; - --mdc-outlined-button-container-height: 30px; - --mdc-outlined-button-label-text-size: 12px; - --mat-outlined-button-hover-state-layer-opacity: 0; + --mat-button-outlined-horizontal-padding: 8px; + --mat-button-outlined-container-height: 30px; + --mat-button-outlined-label-text-size: 12px; + --mat-button-outlined-hover-state-layer-opacity: 0; --mat-icon-button-hover-state-layer-opacity: 0; .group { @@ -45,6 +45,6 @@ .button-label { height: 28px; - --mat-divider-color: var(--mdc-outlined-button-label-text-color); + --mat-divider-color: var(--mat-button-outlined-label-text-color); } } diff --git a/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts b/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts index a1ec8318d3..7b12265e66 100644 --- a/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts +++ b/ui-ngx/src/app/modules/home/components/github-badge/github-badge.component.ts @@ -27,9 +27,10 @@ import { Subject } from 'rxjs'; const SETTINGS_KEY = 'HIDE_GITHUB_STAR_BUTTON'; @Component({ - selector: 'tb-github-badge', - templateUrl: './github-badge.component.html', - styleUrl: './github-badge.component.scss' + selector: 'tb-github-badge', + templateUrl: './github-badge.component.html', + styleUrl: './github-badge.component.scss', + standalone: false }) export class GithubBadgeComponent implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts index 4060e4c54f..0ab521230e 100644 --- a/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts +++ b/ui-ngx/src/app/modules/home/components/notification/notification-bell.component.ts @@ -26,7 +26,7 @@ import { import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; import { BehaviorSubject, ReplaySubject, Subscription } from 'rxjs'; import { distinctUntilChanged, map, share, skip, tap } from 'rxjs/operators'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { ShowNotificationPopoverComponent } from '@home/components/notification/show-notification-popover.component'; import { NotificationSubscriber } from '@shared/models/telemetry/telemetry.models'; @@ -35,9 +35,10 @@ import { selectIsAuthenticated } from '@core/auth/auth.selectors'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-notification-bell', - templateUrl: './notification-bell.component.html', - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-notification-bell', + templateUrl: './notification-bell.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class NotificationBellComponent implements OnDestroy { @@ -73,7 +74,7 @@ export class NotificationBellComponent implements OnDestroy { this.unsubscribeSubscription(); } - showNotification($event: Event, createVersionButton: MatButton) { + showNotification($event: Event, createVersionButton: MatIconButton) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html index 0b04505520..ad9f187f76 100644 --- a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html +++ b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.html @@ -15,12 +15,14 @@ limitations under the License. --> -
- - -
+@if (show()) { +
+ + +
+} diff --git a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts index 8bb603d023..d159baecd4 100644 --- a/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts +++ b/ui-ngx/src/app/modules/home/components/notification/send-notification-button.component.ts @@ -30,8 +30,9 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-send-notification-button', - templateUrl: './send-notification-button.component.html', + selector: 'tb-send-notification-button', + templateUrl: './send-notification-button.component.html', + standalone: false }) export class SendNotificationButtonComponent { diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html index d58b525d5f..f05d9e372c 100644 --- a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.html @@ -17,43 +17,52 @@ -->
notification.notification
- + @if ((notifications$ | async)?.length) { + + }
- -
-
-
- - - -
+@if (notifications$ | async) { + @if ((notifications$ | async).length) { +
+
+ @for (notification of (notifications$ | async); track notification.id.id; let last = $last) { +
+ + + @if (!last) { + + } +
+ } +
+
+ } @else { +
+ + + +
+ notification.no-notifications-yet + } + @if ((notifications$ | async).length) { + + } + @if ((notifications$ | async).length) { +
+
-
- -
- -
-
- -
- - - -
- notification.no-notifications-yet -
- + } +} @else {
notification.loading-notifications
-
+} diff --git a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts index 65891d77c3..7bc4934629 100644 --- a/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts +++ b/ui-ngx/src/app/modules/home/components/notification/show-notification-popover.component.ts @@ -28,9 +28,10 @@ import { NotificationSubscriber } from '@shared/models/telemetry/telemetry.model import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-show-notification-popover', - templateUrl: './show-notification-popover.component.html', - styleUrls: ['show-notification-popover.component.scss'] + selector: 'tb-show-notification-popover', + templateUrl: './show-notification-popover.component.html', + styleUrls: ['show-notification-popover.component.scss'], + standalone: false }) export class ShowNotificationPopoverComponent extends PageComponent implements OnDestroy { @@ -45,8 +46,8 @@ export class ShowNotificationPopoverComponent extends PageComponent implements O private notificationSubscriber = NotificationSubscriber.createNotificationsSubscription(this.notificationWsService, this.zone, 6); - notifications$: Observable = this.notificationSubscriber.notifications$.pipe( - filter(value => Array.isArray(value)), + notifications$: Observable = (this.notificationSubscriber.notifications$ as Observable).pipe( + filter((value): value is Notification[] => Array.isArray(value)), shareReplay(1), tap(() => setTimeout(() => this.cd.markForCheck())) ); @@ -90,8 +91,4 @@ export class ShowNotificationPopoverComponent extends PageComponent implements O this.onClose(); this.router.navigateByUrl(this.router.parseUrl('/notification/inbox')).then(() => {}); } - - trackById(index: number, item: NotificationRequest): string { - return item.id.id; - } } diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html index cc48c99c2c..de39c93a2f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.html @@ -25,9 +25,11 @@ close
- - -
+@if (isLoading$ | async) { + +} @else { +
+}
{{ 'device-profile.device-profile-details' | translate }} -
- +
+ device-profile.name - - {{ 'device-profile.name-required' | translate }} - - - {{ 'device-profile.name-max-length' | translate }} - + @if (deviceProfileDetailsFormGroup.get('name').hasError('required')) { + {{ 'device-profile.name-required' | translate }} + } + @if (deviceProfileDetailsFormGroup.get('name').hasError('maxlength')) { + {{ 'device-profile.name-max-length' | translate }} + } {{'device-profile.mobile-dashboard-hint' | translate}} {{'device-profile.default-edge-rule-chain-hint' | translate}} - + device-profile.type - - {{deviceProfileTypeTranslations.get(type) | translate}} - + @for (type of deviceProfileTypes; track type) { + + {{ deviceProfileTypeTranslations.get(type) | translate }} + + } - - {{ 'device-profile.type-required' | translate }} - + @if (deviceProfileDetailsFormGroup.get('type').hasError('required')) { + {{ 'device-profile.type-required' | translate }} + } - + device-profile.description - +
@@ -92,24 +100,30 @@
{{ 'device-profile.transport-configuration' | translate }} - - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - - - +
+ + device-profile.transport-type + + @for (type of deviceTransportTypes; track type) { + + {{ deviceTransportTypeTranslations.get(type) | translate }} + + } + + @if (transportConfigFormGroup.get('transportType').value) { + + {{ deviceTransportTypeHints.get(transportConfigFormGroup.get('transportType').value) | translate }} + + } + @if (transportConfigFormGroup.get('transportType').hasError('required')) { + {{ 'device-profile.transport-type-required' | translate }} + } + + + +
@@ -124,15 +138,18 @@
- + @if (selectedIndex > 0) { + + } - + @if (showNext) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss index 8363e183bb..efc1d43647 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.scss @@ -52,6 +52,7 @@ width: 100%;; overflow-y: auto; scrollbar-gutter: stable; + padding-top: 4px; @media #{$mat-gt-sm} { min-width: 500px; } diff --git a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts index 05b8517e55..9891a4f0b3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/add-device-profile-dialog.component.ts @@ -54,10 +54,11 @@ export interface AddDeviceProfileDialogData { } @Component({ - selector: 'tb-add-device-profile-dialog', - templateUrl: './add-device-profile-dialog.component.html', - providers: [], - styleUrls: ['./add-device-profile-dialog.component.scss'] + selector: 'tb-add-device-profile-dialog', + templateUrl: './add-device-profile-dialog.component.html', + providers: [], + styleUrls: ['./add-device-profile-dialog.component.scss'], + standalone: false }) export class AddDeviceProfileDialogComponent extends DialogComponent { @@ -216,8 +217,6 @@ export class AddDeviceProfileDialogComponent extends case 1: return 'device-profile.transport-configuration'; case 2: - return 'device-profile.alarm-rules'; - case 3: return 'device-profile.device-provisioning'; } } diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html index 0fae730f16..39a5c625e3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-duration-predicate-value.component.html @@ -22,18 +22,26 @@ step="1" min="1" max="2147483647" formControlName="defaultValue" placeholder="{{ defaultValuePlaceholder | translate }}"> - - {{ defaultValueRequiredError | translate }} - - - {{ defaultValueRangeError | translate }} - - - {{ defaultValueRangeError | translate }} - - - {{ defaultValuePatternError | translate }} - + @if (alarmDurationPredicateValueFormGroup.get('defaultValue').hasError('required')) { + + {{ defaultValueRequiredError | translate }} + + } + @if (alarmDurationPredicateValueFormGroup.get('defaultValue').hasError('min')) { + + {{ defaultValueRangeError | translate }} + + } + @if (alarmDurationPredicateValueFormGroup.get('defaultValue').hasError('max')) { + + {{ defaultValueRangeError | translate }} + + } + @if (alarmDurationPredicateValueFormGroup.get('defaultValue').hasError('pattern')) { + + {{ defaultValuePatternError | translate }} + + }
@@ -44,9 +52,11 @@ {{'filter.no-dynamic-value' | translate}} - - {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} - + @for (sourceType of dynamicValueSourceTypes; track sourceType) { + + {{dynamicValueSourceTypeTranslations.get(sourceType) | translate}} + + }
@@ -55,13 +65,15 @@
-
- - {{ 'filter.inherit-owner' | translate}} - -
+ @if (inheritMode) { +
+ + {{ 'filter.inherit-owner' | translate}} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts index af7e4b07be..da5022571a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-dynamic-value.component.ts @@ -29,13 +29,14 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-alarm-dynamic-value', - templateUrl: './alarm-dynamic-value.component.html', - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmDynamicValue), - multi: true - }] + selector: 'tb-alarm-dynamic-value', + templateUrl: './alarm-dynamic-value.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmDynamicValue), + multi: true + }], + standalone: false }) export class AlarmDynamicValue implements ControlValueAccessor, OnInit{ diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.html index 7fb953620b..c039992035 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.html @@ -25,8 +25,10 @@ close - - + @if (isLoading$ | async) { + + + }
@@ -41,35 +43,47 @@ device-profile.condition-type - - {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} - + @for (alarmConditionType of alarmConditionTypes; track alarmConditionType) { + + {{ alarmConditionTypeTranslation.get(alarmConditionType) | translate }} + + } - - {{ 'device-profile.condition-type-required' | translate }} - + @if (conditionFormGroup.get('spec.type').hasError('required')) { + + {{ 'device-profile.condition-type-required' | translate }} + + } -
- - -
- - - - {{ timeUnitTranslations.get(timeUnit) | translate }} - - - - {{ 'device-profile.condition-duration-time-unit-required' | translate }} - - + @if (conditionFormGroup.get('spec.type').value != AlarmConditionType.SIMPLE) { +
+ + + @if (conditionFormGroup.get('spec.type').value == AlarmConditionType.DURATION) { +
+ + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + } + + @if (conditionFormGroup.get('spec.unit').hasError('required')) { + + {{ 'device-profile.condition-duration-time-unit-required' | translate }} + + } + +
+ }
-
+ }
@@ -81,11 +95,12 @@ (click)="cancel()" cdkFocusInitial> {{ (readonly ? 'action.close' : 'action.cancel') | translate }} - + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts index 0f3f183341..ba16a910db 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition-dialog.component.ts @@ -36,10 +36,11 @@ export interface AlarmRuleConditionDialogData { } @Component({ - selector: 'tb-alarm-rule-condition-dialog', - templateUrl: './alarm-rule-condition-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AlarmRuleConditionDialogComponent}], - styleUrls: ['./alarm-rule-condition-dialog.component.scss'] + selector: 'tb-alarm-rule-condition-dialog', + templateUrl: './alarm-rule-condition-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AlarmRuleConditionDialogComponent }], + styleUrls: ['./alarm-rule-condition-dialog.component.scss'], + standalone: false }) export class AlarmRuleConditionDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.html index 38d632b8ad..31cfb9aebb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule-condition.component.html @@ -23,8 +23,10 @@ required addFilterPrompt="{{'device-profile.enter-alarm-rule-condition-prompt' | translate}}"> - - + @if (specText) { + + + }
-
-
- - {{ ('device-profile.alarm-rule-mobile-dashboard' | translate) + ': ' }} - - - {{'device-profile.alarm-rule-mobile-dashboard-hint' | translate}} - -
+ @if (!disabled || alarmRuleFormGroup.get('alarmDetails').value) { +
+ + {{ ('device-profile.alarm-rule-additional-info' | translate) + ': ' }} + + @if (alarmRuleFormGroup.get('alarmDetails').value) { + + } + +
+ } + @if (!disabled || alarmRuleFormGroup.get('dashboardId').value) { +
+ + {{ ('device-profile.alarm-rule-mobile-dashboard' | translate) + ': ' }} + + + {{'device-profile.alarm-rule-mobile-dashboard-hint' | translate}} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.ts index a3238e4385..a6506aa33d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-rule.component.ts @@ -39,21 +39,22 @@ import { UtilsService } from '@core/services/utils.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-alarm-rule', - templateUrl: './alarm-rule.component.html', - styleUrls: ['./alarm-rule.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmRuleComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmRuleComponent), - multi: true, - } - ] + selector: 'tb-alarm-rule', + templateUrl: './alarm-rule.component.html', + styleUrls: ['./alarm-rule.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmRuleComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmRuleComponent), + multi: true, + } + ], + standalone: false }) export class AlarmRuleComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.html index 1e302d9063..141713832f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.html @@ -25,8 +25,10 @@ close - - + @if (isLoading$ | async) { + + + }
@@ -39,11 +41,12 @@ (click)="cancel()" cdkFocusInitial> {{ (readonly ? 'action.close' : 'action.cancel') | translate }} - + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.ts index 24424f5048..f9224cdccc 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-dialog.component.ts @@ -32,10 +32,11 @@ export interface AlarmScheduleDialogData { } @Component({ - selector: 'tb-alarm-schedule-dialog', - templateUrl: './alarm-schedule-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AlarmScheduleDialogComponent}], - styleUrls: [] + selector: 'tb-alarm-schedule-dialog', + templateUrl: './alarm-schedule-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AlarmScheduleDialogComponent }], + styleUrls: [], + standalone: false }) export class AlarmScheduleDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.ts index a0257da8ad..30209a2f7c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule-info.component.ts @@ -31,14 +31,15 @@ import { deepClone, isDefinedAndNotNull } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: 'tb-alarm-schedule-info', - templateUrl: './alarm-schedule-info.component.html', - styleUrls: ['./alarm-schedule-info.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmScheduleInfoComponent), - multi: true - }] + selector: 'tb-alarm-schedule-info', + templateUrl: './alarm-schedule-info.component.html', + styleUrls: ['./alarm-schedule-info.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmScheduleInfoComponent), + multi: true + }], + standalone: false }) export class AlarmScheduleInfoComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html index 1e0675900f..23226cd17a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.html @@ -18,91 +18,106 @@
- - {{ alarmScheduleTypeTranslate.get(alarmScheduleType) | translate }} - + @for (alarmScheduleType of alarmScheduleTypes; track alarmScheduleType) { + + {{ alarmScheduleTypeTranslate.get(alarmScheduleType) | translate }} + + } - - {{ 'device-profile.schedule-type-required' | translate }} - + @if (alarmScheduleForm.get('type').hasError('required')) { + + {{ 'device-profile.schedule-type-required' | translate }} + + } -
- - -
- -
device-profile.schedule-days
-
-
- - {{ dayOfWeekTranslationsArray[day] | translate }} - -
-
- - {{ dayOfWeekTranslationsArray[day] | translate }} - -
-
+ @if (alarmScheduleForm.get('type').value !== alarmScheduleType.ANY_TIME) { +
+ + + @if (alarmScheduleForm.get('type').value === alarmScheduleType.SPECIFIC_TIME) { +
+ +
device-profile.schedule-days
+
+
+ @for (day of firstRowDays; track day) { + + {{ dayOfWeekTranslationsArray[day] | translate }} + + } +
+
+ @for (day of secondRowDays; track day) { + + {{ dayOfWeekTranslationsArray[day] | translate }} + + } +
+
-
device-profile.schedule-time
-
-
- - device-profile.schedule-time-from - - - - - - device-profile.schedule-time-to - - - - -
-
-
-
-
-
-
-
- -
device-profile.schedule-days
-
-
- - {{ dayOfWeekTranslationsArray[day] | translate }} - -
- - device-profile.schedule-time-from - - - - - - device-profile.schedule-time-to - - - - +
device-profile.schedule-time
+
+
+ + device-profile.schedule-time-from + + + + + + device-profile.schedule-time-to + + + + +
+
+
+
+
-
-
-
-
- +
+ } + @if (alarmScheduleForm.get('type').value === alarmScheduleType.CUSTOM) { +
+ +
device-profile.schedule-days
+ @for (day of allDays; track day) { +
+
+ + {{ dayOfWeekTranslationsArray[day] | translate }} + +
+ + device-profile.schedule-time-from + + + + + + device-profile.schedule-time-to + + + + +
+
+
+
+
+ } -
-
+
+ } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts index 384a266a08..e8224cabf3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/alarm-schedule.component.ts @@ -43,18 +43,19 @@ import { getDefaultTimezone } from '@shared/models/time/time.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-alarm-schedule', - templateUrl: './alarm-schedule.component.html', - styleUrls: ['./alarm-schedule.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmScheduleComponent), - multi: true - }, { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AlarmScheduleComponent), - multi: true - }] + selector: 'tb-alarm-schedule', + templateUrl: './alarm-schedule.component.html', + styleUrls: ['./alarm-schedule.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmScheduleComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AlarmScheduleComponent), + multi: true + }], + standalone: false }) export class AlarmScheduleComponent implements ControlValueAccessor, Validator, OnInit { @Input() @@ -62,7 +63,7 @@ export class AlarmScheduleComponent implements ControlValueAccessor, Validator, alarmScheduleForm: UntypedFormGroup; - alarmScheduleTypes = Object.keys(AlarmScheduleType); + alarmScheduleTypes = Object.values(AlarmScheduleType); alarmScheduleType = AlarmScheduleType; alarmScheduleTypeTranslate = AlarmScheduleTypeTranslationMap; dayOfWeekTranslationsArray = dayOfWeekTranslations; diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.html index d0c5d25273..e98fe00d2c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.html @@ -16,49 +16,60 @@ -->
-
-
- - alarm.severity - - - {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} - - - - {{ 'device-profile.alarm-severity-required' | translate }} - - - - - + @for (createAlarmRuleControl of createAlarmRulesFormArray().controls; track createAlarmRuleControl; let isLast = $last) { +
+
+ + alarm.severity + + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} + + } + + @if (createAlarmRuleControl.get('severity').hasError('required')) { + + {{ 'device-profile.alarm-severity-required' | translate }} + + } + + + + +
+ @if (!disabled) { + + }
- -
-
+ } + @if (!createAlarmRulesFormArray().controls.length && !disabled) { +
device-profile.add-create-alarm-rule-prompt -
-
- -
+ class="tb-prompt required">device-profile.add-create-alarm-rule-prompt +
+ } + @if (!disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.ts index b81c65dbcf..4445f45ca8 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/create-alarm-rules.component.ts @@ -35,21 +35,22 @@ import { EntityId } from '@shared/models/id/entity-id'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-create-alarm-rules', - templateUrl: './create-alarm-rules.component.html', - styleUrls: ['./create-alarm-rules.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CreateAlarmRulesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CreateAlarmRulesComponent), - multi: true, - } - ] + selector: 'tb-create-alarm-rules', + templateUrl: './create-alarm-rules.component.html', + styleUrls: ['./create-alarm-rules.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CreateAlarmRulesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CreateAlarmRulesComponent), + multi: true, + } + ], + standalone: false }) export class CreateAlarmRulesComponent implements ControlValueAccessor, OnInit, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.html index 3a9577df18..739f9e0716 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.html @@ -24,13 +24,15 @@
- + @if (!disabled) { + + }
@@ -39,15 +41,21 @@ {{'device-profile.alarm-type' | translate}} - - {{ 'device-profile.alarm-type-required' | translate }} - - - {{ 'device-profile.alarm-type-unique' | translate }} - - - {{ 'device-profile.alarm-type-max-length' | translate }} - + @if (alarmFormGroup.get('alarmType').hasError('required')) { + + {{ 'device-profile.alarm-type-required' | translate }} + + } + @if (alarmFormGroup.get('alarmType').hasError('unique')) { + + {{ 'device-profile.alarm-type-unique' | translate }} + + } + @if (alarmFormGroup.get('alarmType').hasError('maxlength')) { + + {{ 'device-profile.alarm-type-max-length' | translate }} + + } @@ -62,25 +70,28 @@ {{ 'device-profile.propagate-alarm' | translate }} -
- - device-profile.alarm-rule-relation-types-list - - - {{key}} - close - - - - - -
+ @if (alarmFormGroup.get('propagate').value === true) { +
+ + device-profile.alarm-rule-relation-types-list + + @for (key of alarmFormGroup.get('propagateRelationTypes').value; track key) { + + {{key}} + close + + } + + + + +
+ } {{ 'device-profile.propagate-alarm-to-owner' | translate }} @@ -103,29 +114,35 @@ - - -
- device-profile.no-clear-alarm-rule -
-
- + @if (!disabled) { + + }
+ @if (disabled && !alarmFormGroup.get('clearRule').value) { +
+ device-profile.no-clear-alarm-rule +
+ } + @if (!disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts index cf42c444d2..a282f1ee0b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarm.component.ts @@ -34,21 +34,22 @@ import { UtilsService } from '@core/services/utils.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-profile-alarm', - templateUrl: './device-profile-alarm.component.html', - styleUrls: ['./device-profile-alarm.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileAlarmComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceProfileAlarmComponent), - multi: true, - } - ] + selector: 'tb-device-profile-alarm', + templateUrl: './device-profile-alarm.component.html', + styleUrls: ['./device-profile-alarm.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileAlarmComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceProfileAlarmComponent), + multi: true, + } + ], + standalone: false }) export class DeviceProfileAlarmComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html index 648217c6f5..71a8ad389e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.html @@ -17,25 +17,30 @@ -->
-
- - -
+ @for (alarmControl of alarmsFormArray.controls; track trackByAlarm($index, alarmControl); let isLast = $last) { +
+ + +
+ }
-
+ @if (!alarmsFormArray.controls.length) { +
device-profile.no-alarm-rules -
-
- -
+ class="tb-prompt flex items-center justify-center">device-profile.no-alarm-rules +
+ } + @if (!disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.ts index ff0f824cf1..14e8d0d32d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/device-profile-alarms.component.ts @@ -37,21 +37,22 @@ import { EntityId } from '@shared/models/id/entity-id'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-device-profile-alarms', - templateUrl: './device-profile-alarms.component.html', - styleUrls: ['./device-profile-alarms.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileAlarmsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceProfileAlarmsComponent), - multi: true, - } - ] + selector: 'tb-device-profile-alarms', + templateUrl: './device-profile-alarms.component.html', + styleUrls: ['./device-profile-alarms.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileAlarmsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceProfileAlarmsComponent), + multi: true, + } + ], + standalone: false }) export class DeviceProfileAlarmsComponent implements ControlValueAccessor, OnInit, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.html index 7a94f51423..30caafd1aa 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.html @@ -25,14 +25,16 @@ close - - + @if (isLoading$ | async) { + + + }
+ placeholder="{{ 'device-profile.alarm-rule-additional-info-placeholder' | translate }}">
@@ -45,10 +47,12 @@ (click)="cancel()" cdkFocusInitial> {{ 'action.cancel' | translate }} - + @if (!data.readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.ts index 4078930902..45f13a4487 100644 --- a/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/alarm/edit-alarm-details-dialog.component.ts @@ -31,10 +31,11 @@ export interface EditAlarmDetailsDialogData { } @Component({ - selector: 'tb-edit-alarm-details-dialog', - templateUrl: './edit-alarm-details-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: EditAlarmDetailsDialogComponent}], - styleUrls: [] + selector: 'tb-edit-alarm-details-dialog', + templateUrl: './edit-alarm-details-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: EditAlarmDetailsDialogComponent }], + styleUrls: [], + standalone: false }) export class EditAlarmDetailsDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html index 4f8f3e035b..c699aea0a1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ 'asset-profile.asset-profile' | translate }} - - {{ displayAssetProfileFn(selectAssetProfileFormGroup.get('assetProfile').value) }} - - - - + @if (showDetailsPageLink && useAssetProfileLink && selectAssetProfileFormGroup.get('assetProfile').value && disabled) { + + {{ displayAssetProfileFn(selectAssetProfileFormGroup.get('assetProfile').value) }} + + } + @if (selectAssetProfileFormGroup.get('assetProfile').value && !disabled) { + + } + @if (selectAssetProfileFormGroup.get('assetProfile').value?.id && !disabled && editProfileEnabled) { + + } + @if (!selectAssetProfileFormGroup.get('assetProfile').value && !disabled && addNewProfile) { + + } - - - - -
-
- asset-profile.no-asset-profiles-found -
- - + @for (assetProfile of filteredAssetProfiles | async; track assetProfile) { + + + + } + @if (!(filteredAssetProfiles | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText) || !addNewProfile) { +
+ asset-profile.no-asset-profiles-found +
+ } @else { + {{ translate.get('asset-profile.no-asset-profiles-matching', - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} - - - asset-profile.create-new-asset-profile - - -
-
+ {entity: truncate.transform(searchText, true, 6, '...')}) | async }} +
+ + asset-profile.create-new-asset-profile + + } +
+
+ }
- - {{ 'asset-profile.asset-profile-required' | translate }} - - {{ hint | translate }} + @if (selectAssetProfileFormGroup.get('assetProfile').hasError('required')) { + + {{ 'asset-profile.asset-profile-required' | translate }} + + } + @if (hint && !disabled) { + {{ hint | translate }} + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts index 0c2a7bbd9f..66c84aa27a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile-autocomplete.component.ts @@ -44,21 +44,22 @@ import { AssetProfileId } from '@shared/models/id/asset-profile-id'; import { AssetProfile, AssetProfileInfo } from '@shared/models/asset.models'; import { AssetProfileService } from '@core/http/asset-profile.service'; import { AssetProfileDialogComponent, AssetProfileDialogData } from './asset-profile-dialog.component'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { coerceBoolean } from '@shared/decorators/coercion'; import { AuthUser } from '@shared/models/user.model'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-asset-profile-autocomplete', - templateUrl: './asset-profile-autocomplete.component.html', - styleUrls: ['./asset-profile-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AssetProfileAutocompleteComponent), - multi: true - }] + selector: 'tb-asset-profile-autocomplete', + templateUrl: './asset-profile-autocomplete.component.html', + styleUrls: ['./asset-profile-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AssetProfileAutocompleteComponent), + multi: true + }], + standalone: false }) export class AssetProfileAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -97,6 +98,9 @@ export class AssetProfileAutocompleteComponent implements ControlValueAccessor, @Input() hint: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Output() assetProfileUpdated = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.html index 724bc80e61..d47b4fa65c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements ErrorStateMatcher, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html index 4a0d57e428..9782cf9d19 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.html @@ -15,70 +15,76 @@ limitations under the License. --> -
- - - - -
- + + + +
+ +
-
+}
-
- +
+ asset-profile.name - - {{ 'asset-profile.name-required' | translate }} - - - {{ 'asset-profile.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'asset-profile.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'asset-profile.name-max-length' | translate }} + } {{'asset-profile.mobile-dashboard-hint' | translate}} {{'asset-profile.default-edge-rule-chain-hint' | translate}} @@ -87,9 +93,9 @@ label="{{'asset-profile.image' | translate}}" formControlName="image"> - + asset-profile.description - +
diff --git a/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.ts index 7c78ea881f..18374dc7df 100644 --- a/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/asset-profile.component.ts @@ -31,9 +31,10 @@ import { AssetProfile } from '@shared/models/asset.models'; import { RuleChainType } from '@shared/models/rule-chain.models'; @Component({ - selector: 'tb-asset-profile', - templateUrl: './asset-profile.component.html', - styleUrls: [] + selector: 'tb-asset-profile', + templateUrl: './asset-profile.component.html', + styleUrls: [], + standalone: false }) export class AssetProfileComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html index 183f4319d4..1f1d13b0be 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.html @@ -21,7 +21,9 @@ class="mat-block" [appearance]="inlineField ? 'outline' : appearance" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ 'device-profile.device-profile' | translate }} + @if (!inlineField) { + {{ 'device-profile.device-profile' | translate }} + } - - {{ displayDeviceProfileFn(selectDeviceProfileFormGroup.get('deviceProfile').value) }} - - - - + @if (showDetailsPageLink && useDeviceProfileLink && selectDeviceProfileFormGroup.get('deviceProfile').value && disabled) { + + {{ displayDeviceProfileFn(selectDeviceProfileFormGroup.get('deviceProfile').value) }} + + } + @if (selectDeviceProfileFormGroup.get('deviceProfile').value && !disabled) { + + } + @if (selectDeviceProfileFormGroup.get('deviceProfile').value?.id && !disabled && editProfileEnabled) { + + } + @if (!selectDeviceProfileFormGroup.get('deviceProfile').value && !disabled && addNewProfile) { + + } - - - - -
-
- device-profile.no-device-profiles-found -
- - + @for (deviceProfile of filteredDeviceProfiles | async; track deviceProfile) { + + + + } + @if (!(filteredDeviceProfiles | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText) || !addNewProfile) { +
+ device-profile.no-device-profiles-found +
+ } @else { + {{ translate.get('device-profile.no-device-profiles-matching', - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} - - - device-profile.create-new-device-profile - - -
-
+ {entity: truncate.transform(searchText, true, 6, '...')}) | async }} +
+ + device-profile.create-new-device-profile + + } +
+
+ }
- - {{ 'device-profile.device-profile-required' | translate }} - - {{ hint | translate }} + @if (selectDeviceProfileFormGroup.get('deviceProfile').hasError('required')) { + + {{ 'device-profile.device-profile-required' | translate }} + + } + @if (hint && !disabled) { + {{ hint | translate }} + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts index b26bbb7817..c041d0420a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-autocomplete.component.ts @@ -54,14 +54,15 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-device-profile-autocomplete', - templateUrl: './device-profile-autocomplete.component.html', - styleUrls: ['./device-profile-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileAutocompleteComponent), - multi: true - }] + selector: 'tb-device-profile-autocomplete', + templateUrl: './device-profile-autocomplete.component.html', + styleUrls: ['./device-profile-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileAutocompleteComponent), + multi: true + }], + standalone: false }) export class DeviceProfileAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html index 246912b170..dcb15e48b8 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements ErrorStateMatcher, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html index f11bca1242..14c6e53546 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.html @@ -16,25 +16,27 @@ -->
- + device-profile.provision-strategy - - {{deviceProvisionTypeTranslateMap.get(type) | translate}} - + @for (type of deviceProvisionTypes; track type) { + + {{deviceProvisionTypeTranslateMap.get(type) | translate}} + + } - - {{ 'device-profile.provision-strategy-required' | translate }} - + @if (provisionConfigurationFormGroup.get('type').hasError('required')) { + {{ 'device-profile.provision-strategy-required' | translate }} + } -
- - - - - - - + @switch (provisionConfigurationFormGroup.get('type').value) { + @case (deviceProvisionType.ALLOW_CREATE_NEW_DEVICES) { + + } + @case (deviceProvisionType.CHECK_PRE_PROVISIONED_DEVICES) { + + } + @case (deviceProvisionType.X509_CERTIFICATE_CHAIN) {
- + device-profile.provision-strategy-x509.certificate-value - - - {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} - + + @if (provisionConfigurationFormGroup.get('certificateValue').hasError('required')) { + {{ 'device-profile.provision-strategy-x509.certificate-value-required' | translate }} + } - + device-profile.provision-strategy-x509.cn-regex-variable - - {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} - + tb-help-popup="device-profile/x509-chain-regex-examples" + tb-help-popup-placement="top" + trigger-style="letter-spacing:0.25px" + [tb-help-popup-style]="{maxWidth: '820px'}">
+ @if (provisionConfigurationFormGroup.get('certificateRegExPattern').hasError('required')) { + {{ 'device-profile.provision-strategy-x509.cn-regex-variable-required' | translate }} + } device-profile.provision-strategy-x509.cn-regex-variable-hint - - -
- - device-profile.provision-device-key - - - - {{ 'device-profile.provision-device-key-required' | translate }} - - - - device-profile.provision-device-secret - - - - {{ 'device-profile.provision-device-secret-required' | translate }} - - -
-
-
+ } + } + +
+ + device-profile.provision-device-key + + + @if (provisionConfigurationFormGroup.get('provisionDeviceKey').hasError('required')) { + {{ 'device-profile.provision-device-key-required' | translate }} + } + + + device-profile.provision-device-secret + + + @if (provisionConfigurationFormGroup.get('provisionDeviceSecret').hasError('required')) { + {{ 'device-profile.provision-device-secret-required' | translate }} + } + +
+
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts index 6ba2a62f0e..05aef4cfbb 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile-provision-configuration.component.ts @@ -40,28 +40,29 @@ import { TranslateService } from '@ngx-translate/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-profile-provision-configuration', - templateUrl: './device-profile-provision-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileProvisionConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceProfileProvisionConfigurationComponent), - multi: true, - } - ] + selector: 'tb-device-profile-provision-configuration', + templateUrl: './device-profile-provision-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileProvisionConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceProfileProvisionConfigurationComponent), + multi: true, + } + ], + standalone: false }) export class DeviceProfileProvisionConfigurationComponent implements ControlValueAccessor, OnInit, Validator { provisionConfigurationFormGroup: UntypedFormGroup; deviceProvisionType = DeviceProvisionType; - deviceProvisionTypes = Object.keys(DeviceProvisionType); + deviceProvisionTypes = Object.values(DeviceProvisionType); deviceProvisionTypeTranslateMap = deviceProvisionTypeTranslationMap; private requiredValue: boolean; diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html index 00ddc65358..9b54e59253 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.html @@ -15,79 +15,90 @@ limitations under the License. --> -
- - - - -
- + + + +
+ +
-
+}
-
- +
+ device-profile.name - - {{ 'device-profile.name-required' | translate }} - - - {{ 'device-profile.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'device-profile.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'device-profile.name-max-length' | translate }} + } - {{'device-profile.mobile-dashboard-hint' | translate}} + @if (!entityForm.get('defaultDashboardId').disabled) { + {{'device-profile.mobile-dashboard-hint' | translate}} + } - {{'device-profile.default-edge-rule-chain-hint' | translate}} + @if (!entityForm.get('defaultEdgeRuleChainId').disabled) { + {{'device-profile.default-edge-rule-chain-hint' | translate}} + } - + device-profile.type - - {{deviceProfileTypeTranslations.get(type) | translate}} - + @for (type of deviceProfileTypes; track type) { + + {{ deviceProfileTypeTranslations.get(type) | translate }} + + } - - {{ 'device-profile.type-required' | translate }} - + @if (entityForm.get('type').hasError('required')) { + {{ 'device-profile.type-required' | translate }} + } - + device-profile.description - + - - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - - - -
device-profile.profile-configuration
-
-
- - - - -
- - - -
device-profile.transport-configuration
-
-
- - - - -
- @if (entityForm.get('profileData.alarms').value?.length) { - + @if (standalone) { + + device-profile.transport-type + + @for (type of deviceTransportTypes; track type) { + + {{ deviceTransportTypeTranslations.get(type) | translate }} + + } + + @if (entityForm.get('transportType').hasError('required')) { + {{ 'device-profile.transport-type-required' | translate }} + } + +
+ + @if (displayProfileConfiguration) { + + + +
device-profile.profile-configuration
+
+
+ + + + +
+ } + @if (displayTransportConfiguration) { + + + +
device-profile.transport-configuration
+
+
+ + + + +
+ } + @if (entityForm.get('profileData.alarms').value?.length) { + + + +
{{'device-profile.alarm-rules-with-count' | translate: + {count: entityForm.get('profileData.alarms').value ? + entityForm.get('profileData.alarms').value.length : 0} }}
+
+
+ + + + +
+ } + -
{{'device-profile.alarm-rules-with-count' | translate: - {count: entityForm.get('profileData.alarms').value ? - entityForm.get('profileData.alarms').value.length : 0} }}
+
device-profile.device-provisioning
- - + +
- } - - - -
device-profile.device-provisioning
-
-
- - - - -
-
-
+
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts index c7533cd09f..1c7c52ec32 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device-profile.component.ts @@ -46,9 +46,10 @@ import { RuleChainType } from '@shared/models/rule-chain.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-profile', - templateUrl: './device-profile.component.html', - styleUrls: [] + selector: 'tb-device-profile', + templateUrl: './device-profile.component.html', + styleUrls: [], + standalone: false }) export class DeviceProfileComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html index 50f1040052..d345fc789c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.html @@ -20,81 +20,81 @@
device-profile.coap-device-type
- + - - {{ coapTransportDeviceTypeTranslations.get(type) | translate}} - + @for (type of coapTransportDeviceTypes; track type) { + + {{ coapTransportDeviceTypeTranslations.get(type) | translate }} + + } - - {{ 'device-profile.coap-device-type-required' | translate }} - + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.coapDeviceType').hasError('required')) { + {{ 'device-profile.coap-device-type-required' | translate }} + }
-
-
- device-profile.coap-device-payload-type -
- - - - {{ transportPayloadTypeTranslations.get(type) | translate}} - - - - {{ 'device-profile.mqtt-payload-type-required' | translate }} - - -
- - - - - {{ 'device-profile.telemetry-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.attributes-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.rpc-request-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.rpc-response-proto-schema-required' | translate}} - - + @if (coapDeviceTypeDefault) { +
+
+ device-profile.coap-device-payload-type +
+ + + @for (type of transportPayloadTypes; track type) { + + {{ transportPayloadTypeTranslations.get(type) | translate }} + + } + + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.transportPayloadType').hasError('required')) { + {{ 'device-profile.mqtt-payload-type-required' | translate }} + } + + @if (protoPayloadType) { +
+ + + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')) { + {{ 'device-profile.telemetry-proto-schema-required' | translate }} + } + + + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')) { + {{ 'device-profile.attributes-proto-schema-required' | translate }} + } + + + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')) { + {{ 'device-profile.rpc-request-proto-schema-required' | translate }} + } + + + @if (coapTransportConfigurationFormGroup.get('coapDeviceTypeConfiguration.transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')) { + {{ 'device-profile.rpc-response-proto-schema-required' | translate }} + } +
+ }
-
-
-
+
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts index d264626399..72c1552978 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/coap-device-profile-transport-configuration.component.ts @@ -46,21 +46,22 @@ import { takeUntil } from 'rxjs/operators'; import { PowerMode } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; @Component({ - selector: 'tb-coap-device-profile-transport-configuration', - templateUrl: './coap-device-profile-transport-configuration.component.html', - styleUrls: ['./coap-device-profile-transport-configuration.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CoapDeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CoapDeviceProfileTransportConfigurationComponent), - multi: true - } - ] + selector: 'tb-coap-device-profile-transport-configuration', + templateUrl: './coap-device-profile-transport-configuration.component.html', + styleUrls: ['./coap-device-profile-transport-configuration.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CoapDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CoapDeviceProfileTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class CoapDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html b/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html index 85328ee0d1..e96c05fb0c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.html @@ -16,37 +16,48 @@ -->
- - {{ "device-profile.power-saving-mode" | translate }} + + @if (isDeviceSetting) { + {{ "device-profile.power-saving-mode" | translate }} + } - {{ "device-profile.power-saving-mode-type.default" | translate }} - - {{ powerModeTranslationMap.get(powerMod) | translate}} - + @if (isDeviceSetting) { + {{ "device-profile.power-saving-mode-type.default" | translate }} + } + @for (powerMod of powerMods; track powerMod) { + + {{ powerModeTranslationMap.get(powerMod) | translate}} + + } -
- - - + + + + +
+ } @else if (parentForm.get('powerMode').value === 'PSM') { + + [minText]="'device-profile.psm-activity-timer-min'" + appearance="outline" + formControlName="psmActivityTimer"> -
- - + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.ts index 7b653f43e0..ec4576a162 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/common/power-mode-setting.component.ts @@ -26,9 +26,10 @@ import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; @Component({ - selector: 'tb-power-mode-settings', - templateUrl: './power-mode-setting.component.html', - styleUrls: [] + selector: 'tb-power-mode-settings', + templateUrl: './power-mode-setting.component.html', + styleUrls: [], + standalone: false }) export class PowerModeSettingComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html b/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html index 132d46ab69..7b1b74bcb5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.html @@ -16,25 +16,33 @@ -->
- + {{ labelText | translate }} - - {{ requiredText | translate }} - - - {{ patternText | translate }} - - - {{ (minText || patternText) | translate : {min: minTime/1000} }} - + @if (timeUnitSelectFormGroup.get('time').hasError('required')) { + + {{ requiredText | translate }} + + } + @if (timeUnitSelectFormGroup.get('time').hasError('pattern')) { + + {{ patternText | translate }} + + } + @if (timeUnitSelectFormGroup.get('time').hasError('min')) { + + {{ (minText || patternText) | translate : {min: minTime/1000} }} + + } - + device-profile.condition-duration-time-unit - - {{ timeUnitTranslations.get(timeUnit) | translate }} - + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitTranslations.get(timeUnit) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts index 49f84c14fa..29cf614c2e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/common/time-unit-select.component.ts @@ -37,6 +37,7 @@ import { timeUnitTranslationMap } from '@shared/models/time/time.models'; import { isDefinedAndNotNull, isNumber } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; interface FormGroupModel { time: number; @@ -44,21 +45,22 @@ interface FormGroupModel { } @Component({ - selector: 'tb-time-unit-select', - templateUrl: './time-unit-select.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeUnitSelectComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeUnitSelectComponent), - multi: true - } - ] + selector: 'tb-time-unit-select', + templateUrl: './time-unit-select.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeUnitSelectComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeUnitSelectComponent), + multi: true + } + ], + standalone: false }) export class TimeUnitSelectComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { @@ -104,6 +106,9 @@ export class TimeUnitSelectComponent implements OnInit, OnDestroy, ControlValueA @Input() minText: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private propagateChange = (v: any) => { } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts index 0f082fd97a..e9c5ef36dc 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-configuration.component.ts @@ -26,14 +26,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-default-device-profile-configuration', - templateUrl: './default-device-profile-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultDeviceProfileConfigurationComponent), - multi: true - }] + selector: 'tb-default-device-profile-configuration', + templateUrl: './default-device-profile-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceProfileConfigurationComponent), + multi: true + }], + standalone: false }) export class DefaultDeviceProfileConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts index 1b7db15d7c..dcb8abc4a3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/default-device-profile-transport-configuration.component.ts @@ -32,21 +32,22 @@ import { DefaultDeviceProfileTransportConfiguration, DeviceTransportType } from import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-default-device-profile-transport-configuration', - templateUrl: './default-device-profile-transport-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), - multi: true - } - ] + selector: 'tb-default-device-profile-transport-configuration', + templateUrl: './default-device-profile-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DefaultDeviceProfileTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class DefaultDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html index 30cbfd8d36..61c5f4f9d4 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.html @@ -16,11 +16,13 @@ -->
-
- - - - +
+ @switch (type) { + @case (deviceProfileType.DEFAULT) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts index 80d2a8e0d7..5e6cc15409 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-configuration.component.ts @@ -24,14 +24,15 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-device-profile-configuration', - templateUrl: './device-profile-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileConfigurationComponent), - multi: true - }] + selector: 'tb-device-profile-configuration', + templateUrl: './device-profile-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileConfigurationComponent), + multi: true + }], + standalone: false }) export class DeviceProfileConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html index f8fdf1895d..84b7893cee 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.html @@ -16,32 +16,34 @@ -->
-
- - - - - - - - - - - - - - - - - - - - +
+ @switch (transportType) { + @case (deviceTransportType.DEFAULT) { + + + } + @case (deviceTransportType.MQTT) { + + + } + @case (deviceTransportType.COAP) { + + + } + @case (deviceTransportType.LWM2M) { + + + } + @case (deviceTransportType.SNMP) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts index 8ef5804433..574e14ac7e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/device-profile-transport-configuration.component.ts @@ -32,21 +32,22 @@ import { deepClone } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-profile-transport-configuration', - templateUrl: './device-profile-transport-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceProfileTransportConfigurationComponent), - multi: true, - } - ] + selector: 'tb-device-profile-transport-configuration', + templateUrl: './device-profile-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceProfileTransportConfigurationComponent), + multi: true, + } + ], + standalone: false }) export class DeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.html index 7b4913446c..6a631c8df2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.html @@ -27,8 +27,10 @@ close - - + @if (isLoading$ | async) { + + + }
{{ (readonly ? 'action.close' : 'action.cancel') | translate }} - + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.ts index 595b62ffde..c61622963f 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-dialog.component.ts @@ -32,10 +32,11 @@ export interface Lwm2mAttributesDialogData { } @Component({ - selector: 'tb-lwm2m-attributes-dialog', - templateUrl: './lwm2m-attributes-dialog.component.html', - styleUrls: [], - providers: [{provide: ErrorStateMatcher, useExisting: Lwm2mAttributesDialogComponent}], + selector: 'tb-lwm2m-attributes-dialog', + templateUrl: './lwm2m-attributes-dialog.component.html', + styleUrls: [], + providers: [{ provide: ErrorStateMatcher, useExisting: Lwm2mAttributesDialogComponent }], + standalone: false }) export class Lwm2mAttributesDialogComponent extends DialogComponent implements ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html index 7d82ef2581..1ad031c3a9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html @@ -21,51 +21,63 @@
-
-
- - - - {{ attributeNameTranslationMap.get(attributeName) | translate }} - - - - {{ 'device-profile.lwm2m.attribute-name-required' | translate }} - - - - - - {{ 'device-profile.lwm2m.attribute-value-required' | translate }} - - - {{ 'device-profile.lwm2m.attribute-value-pattern' | translate }} - - - + } +
+
+ } +
device-profile.lwm2m.no-attributes-set
+ @if (!disabled && isAddEnabled) { +
+
-
-
device-profile.lwm2m.no-attributes-set
-
- -
+ } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.ts index d17b37b5fa..0d6cf4df1d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.ts @@ -41,21 +41,22 @@ import { PageComponent } from '@shared/components/page.component'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-lwm2m-attributes-key-list', - templateUrl: './lwm2m-attributes-key-list.component.html', - styleUrls: ['./lwm2m-attributes-key-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mAttributesKeyListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mAttributesKeyListComponent), - multi: true, - } - ] + selector: 'tb-lwm2m-attributes-key-list', + templateUrl: './lwm2m-attributes-key-list.component.html', + styleUrls: ['./lwm2m-attributes-key-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mAttributesKeyListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mAttributesKeyListComponent), + multi: true, + } + ], + standalone: false }) export class Lwm2mAttributesKeyListComponent extends PageComponent implements ControlValueAccessor, OnDestroy, OnDestroy, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.ts index af12a85389..a3a29e6961 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes.component.ts @@ -25,14 +25,15 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-profile-lwm2m-attributes', - templateUrl: './lwm2m-attributes.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mAttributesComponent), - multi: true - }] + selector: 'tb-profile-lwm2m-attributes', + templateUrl: './lwm2m-attributes.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mAttributesComponent), + multi: true + }], + standalone: false }) export class Lwm2mAttributesComponent implements ControlValueAccessor, OnDestroy { attributesFormGroup: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.html index 468cb62a60..a966d6197b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.html @@ -23,17 +23,23 @@ close - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
{{ 'device-profile.lwm2m.server-type' | translate }} - - {{ serverConfigTypeNamesMap.get(serverType) | translate }} - + @for (serverType of serverTypes; track serverType) { + + {{ serverConfigTypeNamesMap.get(serverType) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.ts index d9ca2426a6..e50fe79cda 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-add-config-server-dialog.component.ts @@ -27,8 +27,9 @@ import { } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; @Component({ - selector: 'tb-profile-lwm2m-bootstrap-add-config-server-dialog', - templateUrl: './lwm2m-bootstrap-add-config-server-dialog.component.html' + selector: 'tb-profile-lwm2m-bootstrap-add-config-server-dialog', + templateUrl: './lwm2m-bootstrap-add-config-server-dialog.component.html', + standalone: false }) export class Lwm2mBootstrapAddConfigServerDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html index eda38cfa13..1074428d93 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html @@ -17,25 +17,31 @@ -->
-
- - -
+ @for (serverConfig of serverConfigsFromArray.controls; track trackByParams($index)) { +
+ + +
+ }
-
+ @if (!serverConfigsFromArray.controls.length) { +
device-profile.lwm2m.no-config-servers -
-
-
+ } + @if (!disabled) { +
+ -
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts index a64e262213..3b6d676ab3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.ts @@ -18,11 +18,12 @@ import { Component, EventEmitter, forwardRef, Input, OnDestroy, OnInit, Output } import { AbstractControl, ControlValueAccessor, - UntypedFormArray, UntypedFormBuilder, UntypedFormControl, UntypedFormGroup, NG_VALIDATORS, - NG_VALUE_ACCESSOR + NG_VALUE_ACCESSOR, + FormControl, + FormArray } from '@angular/forms'; import { of, Subject } from 'rxjs'; import { ServerSecurityConfig } from '@home/components/profile/device/lwm2m/lwm2m-profile-config.models'; @@ -35,20 +36,21 @@ import { DeviceProfileService } from '@core/http/device-profile.service'; import { Lwm2mSecurityType } from '@shared/models/lwm2m-security-config.models'; @Component({ - selector: 'tb-profile-lwm2m-bootstrap-config-servers', - templateUrl: './lwm2m-bootstrap-config-servers.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mBootstrapConfigServersComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mBootstrapConfigServersComponent), - multi: true, - } - ] + selector: 'tb-profile-lwm2m-bootstrap-config-servers', + templateUrl: './lwm2m-bootstrap-config-servers.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mBootstrapConfigServersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mBootstrapConfigServersComponent), + multi: true, + } + ], + standalone: false }) export class Lwm2mBootstrapConfigServersComponent implements OnInit, ControlValueAccessor, OnDestroy { @@ -103,8 +105,8 @@ export class Lwm2mBootstrapConfigServersComponent implements OnInit, ControlValu this.destroy$.complete(); } - get serverConfigsFromArray(): UntypedFormArray { - return this.bootstrapConfigServersFormGroup.get('serverConfigs') as UntypedFormArray; + get serverConfigsFromArray(): FormArray { + return this.bootstrapConfigServersFormGroup.get('serverConfigs') as FormArray; } setDisabledState(isDisabled: boolean): void { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html index 3f38a5c33a..e69cb9660c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.html @@ -22,160 +22,180 @@
{{ (serverFormGroup.get('bootstrapServerIs').value ? 'device-profile.lwm2m.bootstrap-server' : 'device-profile.lwm2m.lwm2m-server') | translate }}
-
-
{{ ('device-profile.lwm2m.short-id' | translate) + ': ' }} - {{ serverFormGroup.get('shortServerId').value ? serverFormGroup.get('shortServerId').value : '' }} + @if (!serverPanel.expanded) { +
+
{{ ('device-profile.lwm2m.short-id' | translate) + ': ' }} + {{ serverFormGroup.get('shortServerId').value ? serverFormGroup.get('shortServerId').value : '' }} +
+
{{ ('device-profile.lwm2m.mode' | translate) + ': ' }} + {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[serverFormGroup.get('securityMode').value]) }} +
-
{{ ('device-profile.lwm2m.mode' | translate) + ': ' }} - {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[serverFormGroup.get('securityMode').value]) }} -
-
+ }
- + @if (!disabled) { + + }
- + {{ 'device-profile.lwm2m.mode' | translate }} - - {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityMode]) }} - + @for (securityMode of securityConfigLwM2MTypes; track securityMode) { + + {{ credentialTypeLwM2MNamesMap.get(securityConfigLwM2MType[securityMode]) }} + + } - - {{ 'device-profile.lwm2m.short-id' | translate }} - help - - - {{ 'device-profile.lwm2m.short-id-required' | translate }} - - - {{ (isBootstrap ? 'device-profile.lwm2m.short-id-pattern-bs' : 'device-profile.lwm2m.short-id-pattern') | translate }} - - - {{ 'device-profile.lwm2m.short-id-range' | translate : {min: shortServerIdMin, max: shortServerIdMax} }} - - + @if (!isBootstrap) { + + {{ 'device-profile.lwm2m.short-id' | translate }} + @if (!disabled) { + help + } + + @if (serverFormGroup.get('shortServerId').hasError('required')) { + {{ 'device-profile.lwm2m.short-id-required' | translate }} + } + @if (serverFormGroup.get('shortServerId').hasError('pattern')) { + {{ (isBootstrap ? 'device-profile.lwm2m.short-id-pattern-bs' : 'device-profile.lwm2m.short-id-pattern') | translate }} + } + @if (serverFormGroup.get('shortServerId').hasError('min') || + serverFormGroup.get('shortServerId').hasError('max')) { + {{ 'device-profile.lwm2m.short-id-range' | translate : {min: shortServerIdMin, max: shortServerIdMax} }} + } + + }
- + {{ 'device-profile.lwm2m.server-host' | translate }} - - {{ 'device-profile.lwm2m.server-host-required' | translate }} - + @if (serverFormGroup.get('host').hasError('required')) { + {{ 'device-profile.lwm2m.server-host-required' | translate }} + } - + {{ 'device-profile.lwm2m.server-port' | translate }} - - {{ 'device-profile.lwm2m.server-port-required' | translate }} - - - {{ 'device-profile.lwm2m.server-port-pattern' | translate }} - - - {{ 'device-profile.lwm2m.server-port-range' | translate }} - + @if (serverFormGroup.get('port').hasError('required')) { + {{ 'device-profile.lwm2m.server-port-required' | translate }} + } + @if (serverFormGroup.get('port').hasError('pattern')) { + {{ 'device-profile.lwm2m.server-port-pattern' | translate }} + } + @if (serverFormGroup.get('port').hasError('min') || + serverFormGroup.get('port').hasError('max')) { + {{ 'device-profile.lwm2m.server-port-range' | translate }} + }
- + {{ 'device-profile.lwm2m.client-hold-off-time' | translate }} - help + @if (!disabled) { + help + } - - {{ 'device-profile.lwm2m.client-hold-off-time-required' | translate }} - - - {{ 'device-profile.lwm2m.client-hold-off-time-pattern' | translate }} - + @if (serverFormGroup.get('clientHoldOffTime').hasError('required')) { + {{ 'device-profile.lwm2m.client-hold-off-time-required' | translate }} + } + @if (serverFormGroup.get('clientHoldOffTime').hasError('min') || + serverFormGroup.get('clientHoldOffTime').hasError('pattern')) { + {{ 'device-profile.lwm2m.client-hold-off-time-pattern' | translate }} + } - + {{ 'device-profile.lwm2m.account-after-timeout' | translate }} - help + @if (!disabled) { + help + } - - {{ 'device-profile.lwm2m.account-after-timeout-required' | translate }} - - - {{ 'device-profile.lwm2m.account-after-timeout-pattern' | translate }} - + @if (serverFormGroup.get('bootstrapServerAccountTimeout').hasError('required')) { + {{ 'device-profile.lwm2m.account-after-timeout-required' | translate }} + } + @if (serverFormGroup.get('bootstrapServerAccountTimeout').hasError('min') || + serverFormGroup.get('bootstrapServerAccountTimeout').hasError('pattern')) { + {{ 'device-profile.lwm2m.account-after-timeout-pattern' | translate }} + }
-
- + @if (serverFormGroup.get('securityMode').value === securityConfigLwM2MType.RPK || + serverFormGroup.get('securityMode').value === securityConfigLwM2MType.X509) { + {{ 'device-profile.lwm2m.server-public-key' | translate }} - help + @if (!disabled) { + help + } - - {{ 'device-profile.lwm2m.server-public-key-required' | translate }} - + @if (serverFormGroup.get('serverPublicKey').hasError('required')) { + {{ 'device-profile.lwm2m.server-public-key-required' | translate }} + } -
+ }
- + {{ 'device-profile.lwm2m.lifetime' | translate }} - - {{ 'device-profile.lwm2m.lifetime-required' | translate }} - - - {{ 'device-profile.lwm2m.lifetime-pattern' | translate }} - + @if (serverFormGroup.get('lifetime').hasError('required')) { + {{ 'device-profile.lwm2m.lifetime-required' | translate }} + } + @if (serverFormGroup.get('lifetime').hasError('pattern') || + serverFormGroup.get('lifetime').hasError('min')) { + {{ 'device-profile.lwm2m.lifetime-pattern' | translate }} + } - + {{ 'device-profile.lwm2m.default-min-period' | translate }} - help + @if (!disabled) { + help + } - - {{ 'device-profile.lwm2m.default-min-period-required' | translate }} - - - {{ 'device-profile.lwm2m.default-min-period-pattern' | translate }} - + @if (serverFormGroup.get('defaultMinPeriod').hasError('required')) { + {{ 'device-profile.lwm2m.default-min-period-required' | translate }} + } + @if (serverFormGroup.get('defaultMinPeriod').hasError('pattern') || + serverFormGroup.get('defaultMinPeriod').hasError('min')) { + {{ 'device-profile.lwm2m.default-min-period-pattern' | translate }} + }
- + {{ 'device-profile.lwm2m.binding' | translate }} - help + @if (!disabled) { + help + } - - {{ bindingModeTypeNamesMap.get(bindingMode) | translate }} - + @for (bindingMode of bindingModeTypes; track bindingMode) { + + {{ bindingModeTypeNamesMap.get(bindingMode) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.ts index 823c1bb5b7..ba8a1c3fc1 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-config-server.component.ts @@ -41,20 +41,21 @@ import { import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-profile-lwm2m-device-config-server', - templateUrl: './lwm2m-device-config-server.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mDeviceConfigServerComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mDeviceConfigServerComponent), - multi: true - }, - ] + selector: 'tb-profile-lwm2m-device-config-server', + templateUrl: './lwm2m-device-config-server.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mDeviceConfigServerComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mDeviceConfigServerComponent), + multi: true + }, + ], + standalone: false }) export class Lwm2mDeviceConfigServerComponent implements OnInit, ControlValueAccessor, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html index f61ad0bf68..a09f088853 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.html @@ -19,10 +19,11 @@ -
+
@@ -30,18 +31,20 @@ {{ 'device-profile.lwm2m.init-attr-tel-as-obs-strategy' | translate }} - + device-profile.lwm2m.observe-strategy.observe-strategy {{ observeStrategyMap.get(lwm2mDeviceProfileFormGroup.get('observeStrategy').value)?.name | translate }} - - {{ observeStrategyMap.get(strategy).name | translate }} - - {{ observeStrategyMap.get(strategy).description | translate }} - - + @for (strategy of observeStrategyList; track strategy) { + + {{ observeStrategyMap.get(strategy).name | translate }} + + {{ observeStrategyMap.get(strategy).description | translate }} + + + }
-
- warning - LwM2M transport was run without bootstrap server -
+ @if (!isTransportWasRunWithBootstrap) { +
+ warning + LwM2M transport was run without bootstrap server +
+ } {{ 'device-profile.lwm2m.include-bootstrap-server' | translate }}
- +
@@ -79,7 +85,7 @@
device-profile.lwm2m.fw-update - + {{ 'device-profile.lwm2m.fw-update-strategy' | translate }} {{ 'device-profile.lwm2m.fw-update-strategy-package' | translate }} @@ -87,30 +93,34 @@ {{ 'device-profile.lwm2m.fw-update-strategy-data' | translate }} - - {{ 'device-profile.lwm2m.fw-update-resource' | translate }} - - - {{ 'device-profile.lwm2m.fw-update-resource-required' | translate }} - - + @if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateStrategy').value === 2) { + + {{ 'device-profile.lwm2m.fw-update-resource' | translate }} + + @if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.fwUpdateResource').hasError('required')) { + {{ 'device-profile.lwm2m.fw-update-resource-required' | translate }} + } + + }
device-profile.lwm2m.sw-update - + {{ 'device-profile.lwm2m.sw-update-strategy' | translate }} {{ 'device-profile.lwm2m.sw-update-strategy-package' | translate }} {{ 'device-profile.lwm2m.sw-update-strategy-package-uri' | translate }} - - {{ 'device-profile.lwm2m.sw-update-resource' | translate }} - - - {{ 'device-profile.lwm2m.sw-update-resource-required' | translate }} - - + @if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateStrategy').value === 2) { + + {{ 'device-profile.lwm2m.sw-update-resource' | translate }} + + @if (lwm2mDeviceProfileFormGroup.get('clientLwM2mSettings.swUpdateResource').hasError('required')) { + {{ 'device-profile.lwm2m.sw-update-resource-required' | translate }} + } + + }
@@ -120,11 +130,13 @@
device-profile.lwm2m.default-object-id - + - - {{ objectIDVerTranslationMap.get(objectIDVer) | translate }} - + @for (objectIDVer of objectIDVers; track objectIDVer) { + + {{ objectIDVerTranslationMap.get(objectIDVer) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts index 06c19c88c0..cf1083e3cc 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-device-profile-transport-configuration.component.ts @@ -58,20 +58,22 @@ import { DialogService } from '@core/services/dialog.service'; import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: 'tb-profile-lwm2m-device-transport-configuration', - templateUrl: './lwm2m-device-profile-transport-configuration.component.html', - styleUrls: ['./lwm2m-device-profile-transport-configuration.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mDeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mDeviceProfileTransportConfigurationComponent), - multi: true - }] + selector: 'tb-profile-lwm2m-device-transport-configuration', + templateUrl: './lwm2m-device-profile-transport-configuration.component.html', + styleUrls: ['./lwm2m-device-profile-transport-configuration.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mDeviceProfileTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.html index 41ac62dae2..1f36c01fe4 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.html index af8be58cc0..a222c748cd 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.html @@ -16,16 +16,19 @@ -->
- + {{ 'device-profile.lwm2m.instances-list' | translate }} - - {{instanceId}} - close - + @for (instanceId of instancesId; track instanceId) { + + {{instanceId}} + @if (!disabled) { + close + } + + } - - {{ 'device-profile.lwm2m.instances-list-required' | translate }} - - - {{ 'device-profile.lwm2m.instance-id-pattern' | translate }} - - - {{ 'device-profile.lwm2m.instance-id-max' | translate: {max: instanceIdValueMax} }} - + @if (instancesListFormGroup.get('instanceList').hasError('required')) { + + {{ 'device-profile.lwm2m.instances-list-required' | translate }} + + } + @if (instancesListFormGroup.get('instanceId').hasError('pattern')) { + + {{ 'device-profile.lwm2m.instance-id-pattern' | translate }} + + } + @if (instancesListFormGroup.get('instanceId').hasError('max')) { + + {{ 'device-profile.lwm2m.instance-id-max' | translate: {max: instanceIdValueMax} }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.ts index f139d40d2f..9c9d25e2f0 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-add-instances-list.component.ts @@ -29,19 +29,21 @@ import { COMMA, ENTER, SEMICOLON } from '@angular/cdk/keycodes'; import { MatChipInputEvent } from '@angular/material/chips'; @Component({ - selector: 'tb-profile-lwm2m-object-add-instances-list', - templateUrl: './lwm2m-object-add-instances-list.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mObjectAddInstancesListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mObjectAddInstancesListComponent), - multi: true - }] + selector: 'tb-profile-lwm2m-object-add-instances-list', + templateUrl: './lwm2m-object-add-instances-list.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mObjectAddInstancesListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mObjectAddInstancesListComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mObjectAddInstancesListComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html index 5bd040695a..d56183f562 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.html @@ -15,16 +15,19 @@ limitations under the License. --> - + device-profile.lwm2m.object-list - - {{objectLwm2m.name}} #{{objectLwm2m.keyId}} - close - + @for (objectLwm2m of objectsList; track objectLwm2m) { + + {{objectLwm2m.name}} #{{objectLwm2m.keyId}} + @if (!disabled) { + close + } + + } - - - - -
- device-profile.lwm2m.no-objects-found -
- - + @for (objectLwm2m of filteredObjectsList | async; track objectLwm2m) { + + + + } + @if (!(filteredObjectsList | async)?.length) { + + @if (!textIsNotEmpty(searchText)) { +
+ device-profile.lwm2m.no-objects-found +
+ } @else { + {{ 'device-profile.lwm2m.no-objects-matching' | translate:{object: (searchText | truncate: true: 6: '...')} }} - -
-
+ + } + + } - - {{ 'device-profile.lwm2m.object-list-empty' | translate }} - + @if (lwm2mListFormGroup.get('objectsList').hasError('required')) { + + {{ 'device-profile.lwm2m.object-list-empty' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts index 2db720da8c..76a12f0f4c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-object-list.component.ts @@ -35,22 +35,24 @@ import { isDefined, isDefinedAndNotNull, isObject, isString } from '@core/utils' import { PageLink } from '@shared/models/page/page-link'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-profile-lwm2m-object-list', - templateUrl: './lwm2m-object-list.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mObjectListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mObjectListComponent), - multi: true - } - ] + selector: 'tb-profile-lwm2m-object-list', + templateUrl: './lwm2m-object-list.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mObjectListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mObjectListComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mObjectListComponent implements ControlValueAccessor, OnInit, Validator { @@ -73,6 +75,9 @@ export class Lwm2mObjectListComponent implements ControlValueAccessor, OnInit, V this.updateValidators(); } + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Output() addList = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html index b88cd297ce..a0ab434c9a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.html @@ -17,57 +17,58 @@ -->
- - - -
- {{ 'device-profile.lwm2m.instance' | translate }} #{{instances.get('id').value}} -
-
- - -
-
- - -
-
- - -
- - - -
-
- - - - -
+ @for (instances of instancesFormArray.controls; track trackByParams($index, instances.value)) { + + + +
+ {{ 'device-profile.lwm2m.instance' | translate }} #{{ instances.get('id').value }} +
+
+ + +
+
+ + +
+
+ + +
+ + + +
+
+ + + + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.ts index ecfeb9cf55..1bdd5cba82 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-instances.component.ts @@ -34,21 +34,22 @@ import { TranslateService } from '@ngx-translate/core'; import { Subscription } from 'rxjs'; @Component({ - selector: 'tb-profile-lwm2m-observe-attr-telemetry-instances', - templateUrl: './lwm2m-observe-attr-telemetry-instances.component.html', - styleUrls: [ './lwm2m-observe-attr-telemetry-instances.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryInstancesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryInstancesComponent), - multi: true - } - ] + selector: 'tb-profile-lwm2m-observe-attr-telemetry-instances', + templateUrl: './lwm2m-observe-attr-telemetry-instances.component.html', + styleUrls: ['./lwm2m-observe-attr-telemetry-instances.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryInstancesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryInstancesComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mObserveAttrTelemetryInstancesComponent implements ControlValueAccessor, Validator, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html index c125f345a3..38c3f2e087 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.html @@ -34,41 +34,42 @@
-
-
-
- #{{resourceLwM2M.get('id').value}} {{resourceLwM2M.get('name').value}} +
+ @for (resourceLwM2M of resourcesFormArray.controls; track trackByParams($index, resourceLwM2M.value)) { +
+
+ #{{ resourceLwM2M.get('id').value }} {{ resourceLwM2M.get('name').value }} +
+
+ + +
+
+ + +
+
+ + +
+ + + @if (resourceLwM2M.get('keyName').hasError('required') || + resourceLwM2M.get('keyName').hasError('pattern')) { + {{ 'device-profile.lwm2m.key-name-required' | translate }} + } + + + +
-
- - -
-
- - -
-
- - -
- - - - {{ 'device-profile.lwm2m.key-name-required' | translate }} - - - - - -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.ts index d3686b002f..0e107d43bf 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry-resources.component.ts @@ -33,21 +33,22 @@ import { combineLatest, Subject } from 'rxjs'; import { startWith, takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-profile-lwm2m-observe-attr-telemetry-resource', - templateUrl: './lwm2m-observe-attr-telemetry-resources.component.html', - styleUrls: ['./lwm2m-observe-attr-telemetry-resources.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryResourcesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryResourcesComponent), - multi: true - } - ] + selector: 'tb-profile-lwm2m-observe-attr-telemetry-resource', + templateUrl: './lwm2m-observe-attr-telemetry-resources.component.html', + styleUrls: ['./lwm2m-observe-attr-telemetry-resources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryResourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryResourcesComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mObserveAttrTelemetryResourcesComponent implements ControlValueAccessor, OnDestroy, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html index aa3ea82f64..46051f9cc5 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.html @@ -17,31 +17,33 @@ -->
- - - - {{ objectLwM2M.get('name').value}} #{{ objectLwM2M.get('keyId').value}} - - - - - - - - - - - + @for (objectLwM2M of modelsFormArray.controls; track trackByParams($index, objectLwM2M.value)) { + + + + {{ objectLwM2M.get('name').value }} #{{ objectLwM2M.get('keyId').value }} + + + + @if (!disabled && objectLwM2M.get('multiple').value) { + + } + + + + + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.ts index 2de0b0f0a7..c2d5a21537 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-observe-attr-telemetry.component.ts @@ -39,21 +39,22 @@ import _ from 'lodash'; import { Subscription } from 'rxjs'; @Component({ - selector: 'tb-profile-lwm2m-observe-attr-telemetry', - templateUrl: './lwm2m-observe-attr-telemetry.component.html', - styleUrls: [ './lwm2m-observe-attr-telemetry.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryComponent), - multi: true - } - ] + selector: 'tb-profile-lwm2m-observe-attr-telemetry', + templateUrl: './lwm2m-observe-attr-telemetry.component.html', + styleUrls: ['./lwm2m-observe-attr-telemetry.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => Lwm2mObserveAttrTelemetryComponent), + multi: true + } + ], + standalone: false }) export class Lwm2mObserveAttrTelemetryComponent implements ControlValueAccessor, OnDestroy, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html index 71996cc67d..9e2c976c52 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.html @@ -19,156 +19,150 @@ {{ 'device-profile.mqtt-device-topic-filters-spark-plug' | translate }} -
- - + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value) { +
+ + + } -
-
- device-profile.mqtt-device-topic-filters -
-
- - device-profile.telemetry-topic-filter - - - {{ 'device-profile.telemetry-topic-filter-required' | translate}} - - - {{ 'device-profile.not-valid-single-character' | translate}} - - - {{ 'device-profile.not-valid-multi-character' | translate}} - - - - device-profile.attributes-topic-filter - - - {{ 'device-profile.attributes-topic-filter-required' | translate}} - - - {{ 'device-profile.not-valid-single-character' | translate}} - - - {{ 'device-profile.not-valid-multi-character' | translate}} - - - - device-profile.attributes-subscribe-topic-filter - - - {{ 'device-profile.attributes-subscribe-topic-filter-required' | translate}} - - - {{ 'device-profile.not-valid-single-character' | translate}} - - - {{ 'device-profile.not-valid-multi-character' | translate}} - - -
- - {{ 'device-profile.mqtt-device-topic-filters-unique' | translate }} - -
-
-
-
-
-
+@if (!mqttDeviceProfileTransportConfigurationFormGroup.get('sparkplug').value) { +
- device-profile.mqtt-device-payload-type + device-profile.mqtt-device-topic-filters
- - - - {{transportPayloadTypeTranslations.get(type) | translate}} - - - - {{ 'device-profile.mqtt-payload-type-required' | translate }} - - -
- - {{ 'device-profile.mqtt-enable-compatibility-with-json-payload-format' | translate }} - -
-
- - {{ 'device-profile.mqtt-use-json-format-for-default-downlink-topics' | translate }} - -
-
-
-
- - - - - {{ 'device-profile.telemetry-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.attributes-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.rpc-request-proto-schema-required' | translate}} - - - - - - - {{ 'device-profile.rpc-response-proto-schema-required' | translate}} - - +
+ + device-profile.telemetry-topic-filter + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('required')) { + {{ 'device-profile.telemetry-topic-filter-required' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidSingleTopicCharacter')) { + {{ 'device-profile.not-valid-single-character' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceTelemetryTopic').hasError('invalidMultiTopicCharacter')) { + {{ 'device-profile.not-valid-multi-character' | translate }} + } + + + device-profile.attributes-topic-filter + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('required')) { + {{ 'device-profile.attributes-topic-filter-required' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidSingleTopicCharacter')) { + {{ 'device-profile.not-valid-single-character' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesTopic').hasError('invalidMultiTopicCharacter')) { + {{ 'device-profile.not-valid-multi-character' | translate }} + } + + + device-profile.attributes-subscribe-topic-filter + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('required')) { + {{ 'device-profile.attributes-subscribe-topic-filter-required' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidSingleTopicCharacter')) { + {{ 'device-profile.not-valid-single-character' | translate }} + } + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('deviceAttributesSubscribeTopic').hasError('invalidMultiTopicCharacter')) { + {{ 'device-profile.not-valid-multi-character' | translate }} + } +
+ @if (mqttDeviceProfileTransportConfigurationFormGroup.hasError('unique')) { + {{ 'device-profile.mqtt-device-topic-filters-unique' | translate }} + } +
+
+
-
-
- - {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} - -
-
-
+
+
+ device-profile.mqtt-device-payload-type +
+ + + @for (type of transportPayloadTypes; track type) { + + {{ getTransportPayloadType(type) | translate }} + + } + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.transportPayloadType').hasError('required')) { + {{ 'device-profile.mqtt-payload-type-required' | translate }} + } + + @if (protoPayloadType) { +
+ + {{ 'device-profile.mqtt-enable-compatibility-with-json-payload-format' | translate }} + +
+ @if (compatibilityWithJsonPayloadFormatEnabled) { + + {{ 'device-profile.mqtt-use-json-format-for-default-downlink-topics' | translate }} + +
+ } +
+
+ + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceTelemetryProtoSchema').hasError('required')) { + {{ 'device-profile.telemetry-proto-schema-required' | translate }} + } + + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceAttributesProtoSchema').hasError('required')) { + {{ 'device-profile.attributes-proto-schema-required' | translate }} + } + + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcRequestProtoSchema').hasError('required')) { + {{ 'device-profile.rpc-request-proto-schema-required' | translate }} + } + + + @if (mqttDeviceProfileTransportConfigurationFormGroup.get('transportPayloadTypeConfiguration.deviceRpcResponseProtoSchema').hasError('required')) { + {{ 'device-profile.rpc-response-proto-schema-required' | translate }} + } +
+ } +
+
+
+
+ + {{ 'device-profile.mqtt-send-ack-on-validation-exception' | translate }} + +
+
+ +} diff --git a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts index 9ade4171bf..d1a6d8ace2 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/mqtt-device-profile-transport-configuration.component.ts @@ -44,21 +44,22 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-mqtt-device-profile-transport-configuration', - templateUrl: './mqtt-device-profile-transport-configuration.component.html', - styleUrls: ['./mqtt-device-profile-transport-configuration.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MqttDeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MqttDeviceProfileTransportConfigurationComponent), - multi: true - } - ] + selector: 'tb-mqtt-device-profile-transport-configuration', + templateUrl: './mqtt-device-profile-transport-configuration.component.html', + styleUrls: ['./mqtt-device-profile-transport-configuration.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MqttDeviceProfileTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class MqttDeviceProfileTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy, Validator { @@ -251,4 +252,8 @@ export class MqttDeviceProfileTransportConfigurationComponent implements Control } return null; } + + getTransportPayloadType(type: string): string { + return this.transportPayloadTypeTranslations.get(type as TransportPayloadType); + } } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html index 0d5d03067b..790c13c47e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.html @@ -16,57 +16,66 @@ -->
-
-
- - device-profile.snmp.scope - - - {{ snmpSpecTypeTranslationMap.get(snmpSpecType) }} - - - - {{ 'device-profile.snmp.scope-required' | translate }} - - - -
- - device-profile.snmp.querying-frequency - - - {{ 'device-profile.snmp.querying-frequency-required' | translate }} - - - {{ 'device-profile.snmp.querying-frequency-invalid-format' | translate }} - + @for (deviceProfileCommunication of communicationConfigFormArray.controls; track $index) { +
+
+ + device-profile.snmp.scope + + @for (snmpSpecType of snmpSpecTypes; track snmpSpecType) { + + {{ snmpSpecTypeTranslationMap.get(snmpSpecType) }} + + } + + @if (deviceProfileCommunication.get('spec').hasError('required')) { + {{ 'device-profile.snmp.scope-required' | translate }} + } - - -
+ +
+ @if (isShowFrequency(deviceProfileCommunication.get('spec').value)) { + + device-profile.snmp.querying-frequency + + @if (deviceProfileCommunication.get('queryingFrequencyMs').hasError('required')) { + {{ 'device-profile.snmp.querying-frequency-required' | translate }} + } + @if (deviceProfileCommunication.get('queryingFrequencyMs').hasError('pattern') || + deviceProfileCommunication.get('queryingFrequencyMs').hasError('min')) { + {{ 'device-profile.snmp.querying-frequency-invalid-format' | translate }} + } + + } + + +
+
+ @if (!disabled) { + + }
- -
-
+ } + @if (!communicationConfigFormArray.controls.length && !disabled) { +
device-profile.snmp.please-add-communication-config -
-
- -
+
+ } + @if (!disabled && isAddEnabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts index 6811f636dd..e2a60ad899 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-communication-config.component.ts @@ -24,7 +24,7 @@ import { NG_VALIDATORS, NG_VALUE_ACCESSOR, Validator, - Validators + Validators, FormArray, FormGroup } from '@angular/forms'; import { SnmpCommunicationConfig, SnmpSpecType, SnmpSpecTypeTranslationMap } from '@shared/models/device.models'; import { Subject } from 'rxjs'; @@ -32,20 +32,22 @@ import { isUndefinedOrNull } from '@core/utils'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-snmp-device-profile-communication-config', - templateUrl: './snmp-device-profile-communication-config.component.html', - styleUrls: ['./snmp-device-profile-communication-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SnmpDeviceProfileCommunicationConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SnmpDeviceProfileCommunicationConfigComponent), - multi: true - }] + selector: 'tb-snmp-device-profile-communication-config', + templateUrl: './snmp-device-profile-communication-config.component.html', + styleUrls: ['./snmp-device-profile-communication-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SnmpDeviceProfileCommunicationConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SnmpDeviceProfileCommunicationConfigComponent), + multi: true + } + ], + standalone: false }) export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { @@ -77,8 +79,8 @@ export class SnmpDeviceProfileCommunicationConfigComponent implements OnInit, On this.destroy$.complete(); } - get communicationConfigFormArray(): UntypedFormArray { - return this.deviceProfileCommunicationConfig.get('communicationConfig') as UntypedFormArray; + get communicationConfigFormArray(): FormArray { + return this.deviceProfileCommunicationConfig.get('communicationConfig') as FormArray; } registerOnChange(fn: any): void { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html index edc9e44bf8..2f0ef4d0af 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.html @@ -25,54 +25,70 @@
-
-
- - - - {{ dataTypesTranslationMap.get(dataType) | translate }} - - - - {{ 'device-profile.snmp.data-type-required' | translate }} - - - - - - {{ 'device-profile.snmp.data-key-required' | translate }} - - - - - - {{ 'device-profile.snmp.oid-required' | translate }} - - - {{ 'device-profile.snmp.oid-pattern' | translate }} - - - + } +
+
+ } + @if (!mappingsConfigFormArray.controls.length && !disabled) { +
+ device-profile.snmp.please-add-mapping-config +
+ } + @if (!disabled) { +
+
- -
- device-profile.snmp.please-add-mapping-config -
-
- -
+ } diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.ts index b18a1c37f0..66f8c0737e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-mapping.component.ts @@ -34,20 +34,22 @@ import { isUndefinedOrNull } from '@core/utils'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-snmp-device-profile-mapping', - templateUrl: './snmp-device-profile-mapping.component.html', - styleUrls: ['./snmp-device-profile-mapping.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SnmpDeviceProfileMappingComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SnmpDeviceProfileMappingComponent), - multi: true - }] + selector: 'tb-snmp-device-profile-mapping', + templateUrl: './snmp-device-profile-mapping.component.html', + styleUrls: ['./snmp-device-profile-mapping.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SnmpDeviceProfileMappingComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SnmpDeviceProfileMappingComponent), + multi: true + } + ], + standalone: false }) export class SnmpDeviceProfileMappingComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html index 5e558a51dd..1813e6d35e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.html @@ -17,27 +17,27 @@ -->
- + device-profile.snmp.timeout-ms - - {{ 'device-profile.snmp.timeout-ms-required' | translate }} - - - {{ 'device-profile.snmp.timeout-ms-invalid-format' | translate }} - + @if (snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('required')) { + {{ 'device-profile.snmp.timeout-ms-required' | translate }} + } + @if (snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('pattern') || + snmpDeviceProfileTransportConfigurationFormGroup.get('timeoutMs').hasError('min')) { + {{ 'device-profile.snmp.timeout-ms-invalid-format' | translate }} + } - + device-profile.snmp.retries - - {{ 'device-profile.snmp.retries-required' | translate }} - - - {{ 'device-profile.snmp.retries-invalid-format' | translate }} - + @if (snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('required')) { + {{ 'device-profile.snmp.retries-required' | translate }} + } + @if (snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('pattern') || + snmpDeviceProfileTransportConfigurationFormGroup.get('retries').hasError('min')) { + {{ 'device-profile.snmp.retries-invalid-format' | translate }} + }
device-profile.snmp.communication-configs
diff --git a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts index e2681319c2..15338a385d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/device/snmp/snmp-device-profile-transport-configuration.component.ts @@ -42,20 +42,22 @@ export interface OidMappingConfiguration { } @Component({ - selector: 'tb-snmp-device-profile-transport-configuration', - templateUrl: './snmp-device-profile-transport-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SnmpDeviceProfileTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SnmpDeviceProfileTransportConfigurationComponent), - multi: true - }] + selector: 'tb-snmp-device-profile-transport-configuration', + templateUrl: './snmp-device-profile-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SnmpDeviceProfileTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SnmpDeviceProfileTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class SnmpDeviceProfileTransportConfigurationComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.html b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.html index 18019f3532..1c462bb9b7 100644 --- a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.html @@ -16,40 +16,46 @@ -->
-
- - -
- - {{ getTitle(queuesControl.value.name) }} - - - -
-
- - - -
-
-
+ @for (queuesControl of queuesFormArray.controls; track trackByQueue($index, queuesControl); let isLast = $last) { +
+ + +
+ + {{ getTitle(queuesControl.value.name) }} + + + @if (!($index === 0) && !disabled) { + + } +
+
+ + + +
+
+ } + @if (!queuesFormArray.controls.length) { +
tenant-profile.no-queue -
-
- -
+ class="tb-prompt flex items-center justify-center">tenant-profile.no-queue +
+ } + @if (!disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts index c2166ae859..aa7bebaba8 100644 --- a/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/queue/tenant-profile-queues.component.ts @@ -37,21 +37,22 @@ import { guid } from '@core/utils'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-tenant-profile-queues', - templateUrl: './tenant-profile-queues.component.html', - styleUrls: ['./tenant-profile-queues.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TenantProfileQueuesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TenantProfileQueuesComponent), - multi: true, - } - ] + selector: 'tb-tenant-profile-queues', + templateUrl: './tenant-profile-queues.component.html', + styleUrls: ['./tenant-profile-queues.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TenantProfileQueuesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TenantProfileQueuesComponent), + multi: true, + } + ], + standalone: false }) export class TenantProfileQueuesComponent implements ControlValueAccessor, Validator, OnDestroy, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html index 791b8c1096..4d7b127aa3 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + tenant-profile.tenant-profile - - {{ displayTenantProfileFn(selectTenantProfileFormGroup.get('tenantProfile').value) }} - - - - + @if (showDetailsPageLink && selectTenantProfileFormGroup.get('tenantProfile').value && disabled) { + + {{ displayTenantProfileFn(selectTenantProfileFormGroup.get('tenantProfile').value) }} + + } + @if (selectTenantProfileFormGroup.get('tenantProfile').value && !disabled) { + + } + @if (selectTenantProfileFormGroup.get('tenantProfile').value?.id && !disabled) { + + } + @if (!selectTenantProfileFormGroup.get('tenantProfile').value && !disabled) { + + } - - - - -
-
- tenant-profile.no-tenant-profiles-found -
- - + @for (tenantProfile of filteredTenantProfiles | async; track tenantProfile) { + + + + } + @if (!(filteredTenantProfiles | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ tenant-profile.no-tenant-profiles-found +
+ } @else { + {{ translate.get('tenant-profile.no-tenant-profiles-matching', - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + + } + + tenant-profile.create-new-tenant-profile - - - tenant-profile.create-new-tenant-profile - -
-
+
+
+ }
- - {{ 'tenant-profile.tenant-profile-required' | translate }} - + @if (selectTenantProfileFormGroup.get('tenantProfile').hasError('required')) { + + {{ 'tenant-profile.tenant-profile-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts index 207eb19253..f59d79cd4a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.ts @@ -35,16 +35,18 @@ import { MatDialog } from '@angular/material/dialog'; import { TenantProfileDialogComponent, TenantProfileDialogData } from './tenant-profile-dialog.component'; import { emptyPageData } from '@shared/models/page/page-data'; import { getEntityDetailsPageURL } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-tenant-profile-autocomplete', - templateUrl: './tenant-profile-autocomplete.component.html', - styleUrls: ['./tenant-profile-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TenantProfileAutocompleteComponent), - multi: true - }] + selector: 'tb-tenant-profile-autocomplete', + templateUrl: './tenant-profile-autocomplete.component.html', + styleUrls: ['./tenant-profile-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TenantProfileAutocompleteComponent), + multi: true + }], + standalone: false }) export class TenantProfileAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -70,6 +72,9 @@ export class TenantProfileAutocompleteComponent implements ControlValueAccessor, @Input() showDetailsPageLink = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Output() tenantProfileUpdated = new EventEmitter(); diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.ts index 543fdf9e1a..695fa2ceca 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-data.component.ts @@ -23,14 +23,15 @@ import { TenantProfileData } from '@shared/models/tenant.model'; import { Subscription } from 'rxjs'; @Component({ - selector: 'tb-tenant-profile-data', - templateUrl: './tenant-profile-data.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TenantProfileDataComponent), - multi: true - }] + selector: 'tb-tenant-profile-data', + templateUrl: './tenant-profile-data.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TenantProfileDataComponent), + multi: true + }], + standalone: false }) export class TenantProfileDataComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.html index 7f86c1d259..19ef843762 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements ErrorStateMatcher, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html index c230e469d0..3a53b4cbc9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.html @@ -15,48 +15,54 @@ limitations under the License. --> -
- - - -
- + + +
+ +
-
+}
- + tenant-profile.name - - {{ 'tenant-profile.name-required' | translate }} - - - {{ 'tenant-profile.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + + {{ 'tenant-profile.name-required' | translate }} + + } + @if (entityForm.get('name').hasError('maxlength')) { + + {{ 'tenant-profile.name-max-length' | translate }} + + }
@@ -66,20 +72,22 @@
- - - - {{'tenant-profile.queues-with-count' | translate: + @if (entityForm.get('isolatedTbRuleEngine').value) { + + + + {{'tenant-profile.queues-with-count' | translate: {count: entityForm.get('profileData').get('queueConfiguration').value ? - entityForm.get('profileData').get('queueConfiguration').value.length : 0} }} - - - - - - + entityForm.get('profileData').get('queueConfiguration').value.length : 0} }} + + + + + + + } @@ -95,9 +103,9 @@
- + tenant-profile.description - +
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts index 5025ce556a..4ae02c8ac8 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant-profile.component.ts @@ -27,9 +27,10 @@ import { guid } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-tenant-profile', - templateUrl: './tenant-profile.component.html', - styleUrls: ['./tenant-profile.component.scss'] + selector: 'tb-tenant-profile', + templateUrl: './tenant-profile.component.html', + styleUrls: ['./tenant-profile.component.scss'], + standalone: false }) export class TenantProfileComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html index 19bd852f0a..ddd159388a 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.html @@ -21,58 +21,74 @@ {{ 'tenant-profile.entities' | translate }} tenant-profile.unlimited
- + tenant-profile.maximum-devices - - {{ 'tenant-profile.maximum-devices-required' | translate}} - - - {{ 'tenant-profile.maximum-devices-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDevices').hasError('required')) { + + {{ 'tenant-profile.maximum-devices-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDevices').hasError('min')) { + + {{ 'tenant-profile.maximum-devices-range' | translate}} + + } - + tenant-profile.maximum-dashboards - - {{ 'tenant-profile.maximum-dashboards-required' | translate}} - - - {{ 'tenant-profile.maximum-dashboards-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDashboards').hasError('required')) { + + {{ 'tenant-profile.maximum-dashboards-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDashboards').hasError('min')) { + + {{ 'tenant-profile.maximum-dashboards-range' | translate}} + + }
- + tenant-profile.maximum-assets - - {{ 'tenant-profile.maximum-assets-required' | translate}} - - - {{ 'tenant-profile.maximum-assets-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxAssets').hasError('required')) { + + {{ 'tenant-profile.maximum-assets-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxAssets').hasError('min')) { + + {{ 'tenant-profile.maximum-assets-range' | translate}} + + } - + tenant-profile.maximum-users - - {{ 'tenant-profile.maximum-users-required' | translate}} - - - {{ 'tenant-profile.maximum-users-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxUsers').hasError('required')) { + + {{ 'tenant-profile.maximum-users-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxUsers').hasError('min')) { + + {{ 'tenant-profile.maximum-users-range' | translate}} + + }
@@ -84,45 +100,57 @@
- + tenant-profile.maximum-customers - - {{ 'tenant-profile.maximum-customers-required' | translate}} - - - {{ 'tenant-profile.maximum-customers-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCustomers').hasError('required')) { + + {{ 'tenant-profile.maximum-customers-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCustomers').hasError('min')) { + + {{ 'tenant-profile.maximum-customers-range' | translate}} + + } - + tenant-profile.maximum-rule-chains - - {{ 'tenant-profile.maximum-rule-chains-required' | translate}} - - - {{ 'tenant-profile.maximum-rule-chains-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRuleChains').hasError('required')) { + + {{ 'tenant-profile.maximum-rule-chains-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRuleChains').hasError('min')) { + + {{ 'tenant-profile.maximum-rule-chains-range' | translate}} + + }
- + tenant-profile.maximum-edges - - {{ 'tenant-profile.maximum-edges-required' | translate }} - - - {{ 'tenant-profile.maximum-edges-range' | translate }} - + @if (tenantProfileConfigurationForm.get('maxEdges').hasError('required')) { + + {{ 'tenant-profile.maximum-edges-required' | translate }} + + } + @if (tenantProfileConfigurationForm.get('maxEdges').hasError('min')) { + + {{ 'tenant-profile.maximum-edges-range' | translate }} + + }
@@ -136,30 +164,38 @@ {{ 'tenant-profile.rule-engine' | translate }} tenant-profile.unlimited
- + tenant-profile.max-r-e-executions - - {{ 'tenant-profile.max-r-e-executions-required' | translate}} - - - {{ 'tenant-profile.max-r-e-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxREExecutions').hasError('required')) { + + {{ 'tenant-profile.max-r-e-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxREExecutions').hasError('min')) { + + {{ 'tenant-profile.max-r-e-executions-range' | translate}} + + } - + tenant-profile.max-transport-messages - - {{ 'tenant-profile.max-transport-messages-required' | translate}} - - - {{ 'tenant-profile.max-transport-messages-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTransportMessages').hasError('required')) { + + {{ 'tenant-profile.max-transport-messages-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTransportMessages').hasError('min')) { + + {{ 'tenant-profile.max-transport-messages-range' | translate}} + + }
@@ -171,58 +207,74 @@
- + tenant-profile.max-j-s-executions - - {{ 'tenant-profile.max-j-s-executions-required' | translate}} - - - {{ 'tenant-profile.max-j-s-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxJSExecutions').hasError('required')) { + + {{ 'tenant-profile.max-j-s-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxJSExecutions').hasError('min')) { + + {{ 'tenant-profile.max-j-s-executions-range' | translate}} + + } - + tenant-profile.max-tbel-executions - - {{ 'tenant-profile.max-tbel-executions-required' | translate}} - - - {{ 'tenant-profile.max-tbel-executions-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('required')) { + + {{ 'tenant-profile.max-tbel-executions-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTbelExecutions').hasError('min')) { + + {{ 'tenant-profile.max-tbel-executions-range' | translate}} + + }
- + tenant-profile.max-rule-node-executions-per-message - - {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} - - - {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('required')) { + + {{ 'tenant-profile.max-rule-node-executions-per-message-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRuleNodeExecutionsPerMessage').hasError('min')) { + + {{ 'tenant-profile.max-rule-node-executions-per-message-range' | translate}} + + } - + tenant-profile.max-transport-data-points - - {{ 'tenant-profile.max-transport-data-points-required' | translate}} - - - {{ 'tenant-profile.max-transport-data-points-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('required')) { + + {{ 'tenant-profile.max-transport-data-points-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxTransportDataPoints').hasError('min')) { + + {{ 'tenant-profile.max-transport-data-points-range' | translate}} + + }
@@ -234,45 +286,57 @@ {{ 'tenant-profile.calculated-fields' | translate }} tenant-profile.unlimited
- + tenant-profile.max-calculated-fields - - {{ 'tenant-profile.max-calculated-fields-required' | translate}} - - - {{ 'tenant-profile.max-calculated-fields-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('required')) { + + {{ 'tenant-profile.max-calculated-fields-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCalculatedFieldsPerEntity').hasError('min')) { + + {{ 'tenant-profile.max-calculated-fields-range' | translate}} + + } - + tenant-profile.max-data-points-per-rolling-arg - - {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} - - - {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('required')) { + + {{ 'tenant-profile.max-data-points-per-rolling-arg-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDataPointsPerRollingArg').hasError('min')) { + + {{ 'tenant-profile.max-data-points-per-rolling-arg-range' | translate}} + + }
- + tenant-profile.max-arguments-per-cf - - {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} - - - {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('required')) { + + {{ 'tenant-profile.max-arguments-per-cf-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxArgumentsPerCF').hasError('min')) { + + {{ 'tenant-profile.max-arguments-per-cf-range' | translate}} + + }
@@ -285,129 +349,165 @@
- + tenant-profile.max-state-size - - {{ 'tenant-profile.max-state-size-required' | translate}} - - - {{ 'tenant-profile.max-state-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('required')) { + + {{ 'tenant-profile.max-state-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxStateSizeInKBytes').hasError('min')) { + + {{ 'tenant-profile.max-state-size-range' | translate}} + + } - + tenant-profile.max-value-argument-size - - {{ 'tenant-profile.max-value-argument-size-required' | translate}} - - - {{ 'tenant-profile.max-value-argument-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('required')) { + + {{ 'tenant-profile.max-value-argument-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxSingleValueArgumentSizeInKBytes').hasError('min')) { + + {{ 'tenant-profile.max-value-argument-size-range' | translate}} + + }
- + tenant-profile.max-related-level-per-argument - - {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} - - - {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('required')) { + + {{ 'tenant-profile.max-related-level-per-argument-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRelationLevelPerCfArgument').hasError('min')) { + + {{ 'tenant-profile.max-related-level-per-argument-range' | translate}} + + } - + tenant-profile.min-allowed-scheduled-update-interval - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedScheduledUpdateIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-scheduled-update-interval-range' | translate}} + + }
- + tenant-profile.min-allowed-aggregation-interval - - {{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-aggregation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedAggregationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-aggregation-interval-range' | translate}} + + } - + tenant-profile.min-allowed-deduplication-interval - - {{ 'tenant-profile.min-allowed-deduplication-interval-required' | translate}} - - - {{ 'tenant-profile.min-allowed-deduplication-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('minAllowedDeduplicationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.min-allowed-deduplication-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('minAllowedDeduplicationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.min-allowed-deduplication-interval-range' | translate}} + + }
- + tenant-profile.intermediate-aggregation-interval - - {{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}} - - - {{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('required')) { + + {{ 'tenant-profile.intermediate-aggregation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('intermediateAggregationIntervalInSecForCF').hasError('min')) { + + {{ 'tenant-profile.intermediate-aggregation-interval-range' | translate}} + + } - + tenant-profile.reevaluation-check-interval - - {{ 'tenant-profile.reevaluation-check-interval-required' | translate}} - - - {{ 'tenant-profile.reevaluation-check-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('required')) { + + {{ 'tenant-profile.reevaluation-check-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('cfReevaluationCheckInterval').hasError('min')) { + + {{ 'tenant-profile.reevaluation-check-interval-range' | translate}} + + }
- + tenant-profile.relation-search-entity-limit - - {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} - - - {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('required')) { + + {{ 'tenant-profile.relation-search-entity-limit-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxRelatedEntitiesToReturnPerCfArgument').hasError('min')) { + + {{ 'tenant-profile.relation-search-entity-limit-range' | translate}} + + } tenant-profile.relation-search-entity-limit-hint
@@ -421,86 +521,110 @@ {{ 'tenant-profile.time-to-live' | translate }} tenant-profile.unlimited
- + tenant-profile.max-d-p-storage-days - - {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} - - - {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('required')) { + + {{ 'tenant-profile.max-d-p-storage-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxDPStorageDays').hasError('min')) { + + {{ 'tenant-profile.max-d-p-storage-days-range' | translate}} + + } - + tenant-profile.alarms-ttl-days - - {{ 'tenant-profile.alarms-ttl-days-required' | translate}} - - - {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('required')) { + + {{ 'tenant-profile.alarms-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('alarmsTtlDays').hasError('min')) { + + {{ 'tenant-profile.alarms-ttl-days-days-range' | translate}} + + }
- + tenant-profile.default-storage-ttl-days - - {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} - - - {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('required')) { + + {{ 'tenant-profile.default-storage-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('defaultStorageTtlDays').hasError('min')) { + + {{ 'tenant-profile.default-storage-ttl-days-range' | translate}} + + } - + tenant-profile.rpc-ttl-days - - {{ 'tenant-profile.rpc-ttl-days-required' | translate}} - - - {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('rpcTtlDays').hasError('required')) { + + {{ 'tenant-profile.rpc-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('rpcTtlDays').hasError('min')) { + + {{ 'tenant-profile.rpc-ttl-days-days-range' | translate}} + + }
- + tenant-profile.queue-stats-ttl-days - - {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} - - - {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('required')) { + + {{ 'tenant-profile.queue-stats-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('queueStatsTtlDays').hasError('min')) { + + {{ 'tenant-profile.queue-stats-ttl-days-range' | translate}} + + } - + tenant-profile.rule-engine-exceptions-ttl-days - - {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} - - - {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} - + @if (tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('required')) { + + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('ruleEngineExceptionsTtlDays').hasError('min')) { + + {{ 'tenant-profile.rule-engine-exceptions-ttl-days-range' | translate}} + + }
@@ -513,59 +637,77 @@ {{ 'tenant-profile.sms-enabled' | translate }} - - tenant-profile.max-sms - - - {{ 'tenant-profile.max-sms-required' | translate}} - - - {{ 'tenant-profile.max-sms-range' | translate}} - - + @if (tenantProfileConfigurationForm.get('smsEnabled').value) { + + tenant-profile.max-sms + + @if (tenantProfileConfigurationForm.get('maxSms').hasError('required')) { + + {{ 'tenant-profile.max-sms-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxSms').hasError('min')) { + + {{ 'tenant-profile.max-sms-range' | translate}} + + } + + }
- + tenant-profile.max-emails - - {{ 'tenant-profile.max-emails-required' | translate}} - - - {{ 'tenant-profile.max-emails-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxEmails').hasError('required')) { + + {{ 'tenant-profile.max-emails-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxEmails').hasError('min')) { + + {{ 'tenant-profile.max-emails-range' | translate}} + + } - + tenant-profile.max-created-alarms - - {{ 'tenant-profile.max-created-alarms-required' | translate}} - - - {{ 'tenant-profile.max-created-alarms-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('required')) { + + {{ 'tenant-profile.max-created-alarms-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxCreatedAlarms').hasError('min')) { + + {{ 'tenant-profile.max-created-alarms-range' | translate}} + + }
- + tenant-profile.alarms-reevaluation-interval - - {{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}} - - - {{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}} - + @if (tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('required')) { + + {{ 'tenant-profile.alarms-reevaluation-interval-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('alarmsReevaluationInterval').hasError('min')) { + + {{ 'tenant-profile.alarms-reevaluation-interval-range' | translate}} + + }
@@ -577,14 +719,16 @@ {{ 'tenant-profile.debug' | translate }}
- + tenant-profile.maximum-debug-duration-min - - {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} - + @if (tenantProfileConfigurationForm.get('maxDebugModeDurationMinutes').hasError('min')) { + + {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} + + }
@@ -596,45 +740,57 @@ {{ 'tenant-profile.ota-files-in-bytes' | translate }} tenant-profile.unlimited
- + tenant-profile.maximum-resources-sum-data-size - - {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} - - - {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('required')) { + + {{ 'tenant-profile.maximum-resources-sum-data-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxResourcesInBytes').hasError('min')) { + + {{ 'tenant-profile.maximum-resources-sum-data-size-range' | translate}} + + } - + tenant-profile.maximum-ota-packages-sum-data-size - - {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} - - - {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('required')) { + + {{ 'tenant-profile.maximum-ota-package-sum-data-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxOtaPackagesInBytes').hasError('min')) { + + {{ 'tenant-profile.maximum-ota-package-sum-data-size-range' | translate}} + + }
- + tenant-profile.maximum-resource-size - - {{ 'tenant-profile.maximum-resource-size-required' | translate}} - - - {{ 'tenant-profile.maximum-resource-size-range' | translate}} - + @if (tenantProfileConfigurationForm.get('maxResourceSize').hasError('required')) { + + {{ 'tenant-profile.maximum-resource-size-required' | translate}} + + } + @if (tenantProfileConfigurationForm.get('maxResourceSize').hasError('min')) { + + {{ 'tenant-profile.maximum-resource-size-range' | translate}} + + }
@@ -646,35 +802,43 @@ {{ 'tenant-profile.ws-title' | translate }} tenant-profile.unlimited
- + tenant-profile.ws-limit-max-sessions-per-tenant - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerTenant').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-tenant - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerTenant').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-max-sessions-per-customer - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerCustomer').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-customer - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerCustomer').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
@@ -685,44 +849,54 @@
- + tenant-profile.ws-limit-max-sessions-per-public-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerPublicUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-public-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerPublicUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-max-sessions-per-regular-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSessionsPerRegularUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + } - + tenant-profile.ws-limit-max-subscriptions-per-regular-user - - {{ 'tenant-profile.too-small-value-zero' | translate}} - + @if (tenantProfileConfigurationForm.get('maxWsSubscriptionsPerRegularUser').hasError('min')) { + + {{ 'tenant-profile.too-small-value-zero' | translate}} + + }
- + tenant-profile.ws-limit-queue-per-session - - {{ 'tenant-profile.too-small-value-one' | translate}} - + @if (tenantProfileConfigurationForm.get('wsMsgQueueLimitPerSession').hasError('min')) { + + {{ 'tenant-profile.too-small-value-one' | translate}} + + } {{ 'tenant-profile.queue-size-is-limited-by-system-configuration' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss index 38ab6294ea..3a1e3f6a1b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.scss @@ -43,7 +43,7 @@ } } - > .mat-expansion-panel-content { + .mat-expansion-panel-content { > .mat-expansion-panel-body { padding: 0; } diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts index be674b78fc..9fe6232f4c 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/default-tenant-profile-configuration.component.ts @@ -23,14 +23,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-default-tenant-profile-configuration', - templateUrl: './default-tenant-profile-configuration.component.html', - styleUrls: ['./default-tenant-profile-configuration.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultTenantProfileConfigurationComponent), - multi: true - }] + selector: 'tb-default-tenant-profile-configuration', + templateUrl: './default-tenant-profile-configuration.component.html', + styleUrls: ['./default-tenant-profile-configuration.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultTenantProfileConfigurationComponent), + multi: true + }], + standalone: false }) export class DefaultTenantProfileConfigurationComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html index 5d7fc9876f..370849422e 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.html @@ -35,9 +35,11 @@ cdkFocusInitial> {{ 'action.cancel' | translate }} - + @if (!data.readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.ts index abca908623..a7ada0090b 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-details-dialog.component.ts @@ -29,7 +29,8 @@ export interface RateLimitsDetailsDialogData { } @Component({ - templateUrl: './rate-limits-details-dialog.component.html' + templateUrl: './rate-limits-details-dialog.component.html', + standalone: false }) export class RateLimitsDetailsDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html index 617a8beaee..159dcc6540 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html @@ -16,51 +16,56 @@ -->
-
- @if($index > 0) { -
tenant-profile.rate-limits.and-also-less-than
- } -
- - tenant-profile.rate-limits.number-of-messages - - - @if (rateLimit.get('value').hasError('required')) { - tenant-profile.rate-limits.number-of-messages-required - } @else if(rateLimit.get('value').hasError('min')) { - tenant-profile.rate-limits.number-of-messages-min + @for (rateLimit of rateLimitsFormArray.controls; track rateLimit) { +
+ @if($index > 0) { +
tenant-profile.rate-limits.and-also-less-than
+ } +
+ + tenant-profile.rate-limits.number-of-messages + + + @if (rateLimit.get('value').hasError('required')) { + tenant-profile.rate-limits.number-of-messages-required + } @else if(rateLimit.get('value').hasError('min')) { + tenant-profile.rate-limits.number-of-messages-min + } + + + tenant-profile.rate-limits.per-seconds + + + @if (rateLimit.get('time').hasError('required')) { + tenant-profile.rate-limits.per-seconds-required + } @else if (rateLimit.get('time').hasError('min')) { + tenant-profile.rate-limits.per-seconds-min + } @else if (rateLimit.get('time').hasError('duplicateTime')) { + tenant-profile.rate-limits.per-seconds-duplicate + } + + @if (!rateLimitsFormArray.disabled) { + } - - - tenant-profile.rate-limits.per-seconds - - - @if (rateLimit.get('time').hasError('required')) { - tenant-profile.rate-limits.per-seconds-required - } @else if (rateLimit.get('time').hasError('min')) { - tenant-profile.rate-limits.per-seconds-min - } @else if (rateLimit.get('time').hasError('duplicateTime')) { - tenant-profile.rate-limits.per-seconds-duplicate - } - - +
-
- + } + @if (!rateLimitsFormArray.disabled) { + + }
tenant-profile.rate-limits.preview diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.ts index 4434259bb4..7b58242e4d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.ts @@ -33,21 +33,22 @@ import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-rate-limits-list', - templateUrl: './rate-limits-list.component.html', - styleUrls: ['./rate-limits-list.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RateLimitsListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => RateLimitsListComponent), - multi: true - } - ] + selector: 'tb-rate-limits-list', + templateUrl: './rate-limits-list.component.html', + styleUrls: ['./rate-limits-list.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RateLimitsListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RateLimitsListComponent), + multi: true + } + ], + standalone: false }) export class RateLimitsListComponent implements ControlValueAccessor, Validator, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.ts index 16b00f0d6e..d5195cbad9 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-text.component.ts @@ -19,9 +19,10 @@ import { TranslateService } from '@ngx-translate/core'; import { RateLimits, rateLimitsArrayToHtml } from './rate-limits.models'; @Component({ - selector: 'tb-rate-limits-text', - templateUrl: './rate-limits-text.component.html', - styleUrls: ['./rate-limits-text.component.scss'] + selector: 'tb-rate-limits-text', + templateUrl: './rate-limits-text.component.html', + styleUrls: ['./rate-limits-text.component.scss'], + standalone: false }) export class RateLimitsTextComponent implements OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.html index 39d97ae424..f0dd188aa6 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.html @@ -24,10 +24,12 @@
- + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.ts index 6e03646bba..397253b753 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits.component.ts @@ -39,21 +39,22 @@ import { import { isDefined } from '@core/utils'; @Component({ - selector: 'tb-rate-limits', - templateUrl: './rate-limits.component.html', - styleUrls: ['./rate-limits.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RateLimitsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => RateLimitsComponent), - multi: true, - } - ] + selector: 'tb-rate-limits', + templateUrl: './rate-limits.component.html', + styleUrls: ['./rate-limits.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RateLimitsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RateLimitsComponent), + multi: true, + } + ], + standalone: false }) export class RateLimitsComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.html index a99101afa3..a72c51f430 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.html @@ -16,12 +16,14 @@ -->
-
- - - - +
+ @switch (type) { + @case (tenantProfileType.DEFAULT) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.ts b/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.ts index 0117706d12..a140a7053d 100644 --- a/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/profile/tenant/tenant-profile-configuration.component.ts @@ -24,14 +24,15 @@ import { TenantProfileConfiguration, TenantProfileType } from '@shared/models/te import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-tenant-profile-configuration', - templateUrl: './tenant-profile-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TenantProfileConfigurationComponent), - multi: true - }] + selector: 'tb-tenant-profile-configuration', + templateUrl: './tenant-profile-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TenantProfileConfigurationComponent), + multi: true + }], + standalone: false }) export class TenantProfileConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html index 1dd2a79e27..94803a8739 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html @@ -16,18 +16,24 @@ -->
- + admin.queue-name - - {{ 'queue.name-required' | translate }} - - - {{ 'queue.name-unique' | translate }} - - - {{ 'queue.name-pattern' | translate }} - + @if (queueFormGroup.get('name').hasError('required')) { + + {{ 'queue.name-required' | translate }} + + } + @if (queueFormGroup.get('name').hasError('unique')) { + + {{ 'queue.name-unique' | translate }} + + } + @if (queueFormGroup.get('name').hasError('pattern')) { + + {{ 'queue.name-pattern' | translate }} + + } @@ -41,30 +47,38 @@
- - {{ queueSubmitStrategyTypesMap.get(queueSubmitStrategyTypes[strategy]).label | translate }} - - - + @for (strategy of submitStrategies; track strategy) { + + {{ queueSubmitStrategyTypesMap.get(queueSubmitStrategyTypes[strategy]).label | translate }} + + + + }
-
- - - queue.batch-size - - - {{ 'queue.batch-size-required' | translate }} - - - {{ 'queue.batch-size-min-value' | translate }} - - -
+ @if (hideBatchSize) { +
+ + + queue.batch-size + + @if (queueFormGroup.get('submitStrategy.batchSize').hasError('required')) { + + {{ 'queue.batch-size-required' | translate }} + + } + @if (queueFormGroup.get('submitStrategy.batchSize').hasError('min') && + !queueFormGroup.get('submitStrategy.batchSize').hasError('required')) { + + {{ 'queue.batch-size-min-value' | translate }} + + } + +
+ }
@@ -78,8 +92,9 @@
- - + + @for (strategy of processingStrategies; track strategy) { + {{ queueProcessingStrategyTypesMap.get(queueProcessingStrategyTypes[strategy]).label | translate }} - + } +
- + queue.retries - - {{ 'queue.retries-required' | translate }} - - - {{ 'queue.retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.retries').hasError('required')) { + + {{ 'queue.retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.retries').hasError('min') && + !queueFormGroup.get('processingStrategy.retries').hasError('required')) { + + {{ 'queue.retries-min-value' | translate }} + + } - + queue.failure-percentage - - {{ 'queue.failure-percentage-required' | translate }} - - - {{ 'queue.failure-percentage-min-value' | translate }} - - - {{ 'queue.failure-percentage-max-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('required')) { + + {{ 'queue.failure-percentage-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('min') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('required') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('max')) { + + {{ 'queue.failure-percentage-min-value' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.failurePercentage').hasError('max') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('required') && + !queueFormGroup.get('processingStrategy.failurePercentage').hasError('min')) { + + {{ 'queue.failure-percentage-max-value' | translate }} + + } - + queue.pause-between-retries - - {{ 'queue.pause-between-retries-required' | translate }} - - - {{ 'queue.pause-between-retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('required')) { + + {{ 'queue.pause-between-retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('min') && + !queueFormGroup.get('processingStrategy.pauseBetweenRetries').hasError('required')) { + + {{ 'queue.pause-between-retries-min-value' | translate }} + + } - + queue.max-pause-between-retries - - {{ 'queue.max-pause-between-retries-required' | translate }} - - - {{ 'queue.max-pause-between-retries-min-value' | translate }} - + @if (queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('required')) { + + {{ 'queue.max-pause-between-retries-required' | translate }} + + } + @if (queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('min') && + !queueFormGroup.get('processingStrategy.maxPauseBetweenRetries').hasError('required')) { + + {{ 'queue.max-pause-between-retries-min-value' | translate }} + + }
@@ -160,27 +194,35 @@
- + queue.poll-interval - - {{ 'queue.poll-interval-required' | translate }} - - - {{ 'queue.poll-interval-min-value' | translate }} - + @if (queueFormGroup.get('pollInterval').hasError('required')) { + + {{ 'queue.poll-interval-required' | translate }} + + } + @if (queueFormGroup.get('pollInterval').hasError('min') && + !queueFormGroup.get('pollInterval').hasError('required')) { + + {{ 'queue.poll-interval-min-value' | translate }} + + } - + queue.partitions - - {{ 'queue.partitions-required' | translate }} - - - {{ 'queue.partitions-min-value' | translate }} - + @if (queueFormGroup.get('partitions').hasError('required')) { + + {{ 'queue.partitions-required' | translate }} + + } + @if (queueFormGroup.get('partitions').hasError('min') && + !queueFormGroup.get('partitions').hasError('required')) { + + {{ 'queue.partitions-min-value' | translate }} + + }
@@ -190,16 +232,20 @@ {{ 'queue.consumer-per-partition' | translate }} - + queue.processing-timeout - - {{ 'queue.pack-processing-timeout-required' | translate }} - - - {{ 'queue.pack-processing-timeout-min-value' | translate }} - + @if (queueFormGroup.get('packProcessingTimeout').hasError('required')) { + + {{ 'queue.pack-processing-timeout-required' | translate }} + + } + @if (queueFormGroup.get('packProcessingTimeout').hasError('min') && + !queueFormGroup.get('packProcessingTimeout').hasError('required')) { + + {{ 'queue.pack-processing-timeout-min-value' | translate }} + + }
@@ -211,14 +257,14 @@ {{ 'queue.duplicate-msg-to-all-partitions' | translate }} - + queue.custom-properties - + queue.custom-properties-hint - + queue.description - + queue.description-hint
diff --git a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts index 2760226998..e3b22095c4 100644 --- a/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts +++ b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.ts @@ -38,21 +38,22 @@ import { Subscription } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-queue-form', - templateUrl: './queue-form.component.html', - styleUrls: ['./queue-form.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => QueueFormComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => QueueFormComponent), - multi: true, - } - ] + selector: 'tb-queue-form', + templateUrl: './queue-form.component.html', + styleUrls: ['./queue-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => QueueFormComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => QueueFormComponent), + multi: true, + } + ], + standalone: false }) export class QueueFormComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html index 6b63c75a35..ffbfb48cf3 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.html @@ -25,20 +25,25 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
+ [required]="true"> {{(direction === entitySearchDirection.FROM ? - 'relation.to-entity' : 'relation.from-entity') | translate}} + 'relation.to-entity' : 'relation.from-entity') | translate}} - + @if (submitted && + additionalInfo.invalid) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts index c9a4fedb69..362bb73dab 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-dialog.component.ts @@ -44,10 +44,11 @@ export interface RelationDialogData { } @Component({ - selector: 'tb-relation-dialog', - templateUrl: './relation-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: RelationDialogComponent}], - styleUrls: ['./relation-dialog.component.scss'] + selector: 'tb-relation-dialog', + templateUrl: './relation-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: RelationDialogComponent }], + styleUrls: ['./relation-dialog.component.scss'], + standalone: false }) export class RelationDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html index 99444c2d2f..71166057d5 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.html @@ -18,40 +18,46 @@
-
+ @if (enableNotOption) { +
+ }
{{ 'relation.relation-type' | translate }}
{{ 'entity.entity-types' | translate }}
-
- - {{ 'relation.not' | translate }} - - - - - -
- + @for (relationFilterControl of relationFiltersFormArray.controls; track relationFilterControl) { +
+ @if (enableNotOption) { + + {{ 'relation.not' | translate }} + + } + + + + +
+ +
-
+ }
relation.any-relation
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts index 76ca033183..f8e1e796a5 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-filters.component.ts @@ -33,16 +33,17 @@ import { takeUntil } from 'rxjs/operators'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-relation-filters', - templateUrl: './relation-filters.component.html', - styleUrls: ['./relation-filters.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelationFiltersComponent), - multi: true - } - ] + selector: 'tb-relation-filters', + templateUrl: './relation-filters.component.html', + styleUrls: ['./relation-filters.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationFiltersComponent), + multi: true + } + ], + standalone: false }) export class RelationFiltersComponent extends PageComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html index 5335f36ff4..3984d85077 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html @@ -19,17 +19,19 @@
-
+
{{(direction == directions.FROM ? 'relation.from-relations' : 'relation.to-relations') | translate}} - + relation.direction - - {{ directionTypeTranslations.get(directions[type]) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(directions[type]) | translate }} + + }
@@ -87,7 +89,7 @@
+ matSort [matSortActive]="pageLink.sortOrder.property" [matSortDirection]="pageLink.sortDirection()" matSortDisableClear> + > - - {{ 'relation.to-entity-type' | translate }} - - {{ relation.toEntityTypeName }} - - - - {{ 'relation.to-entity-name' | translate }} - + @if (direction === directions.FROM) { + + {{ 'relation.to-entity-type' | translate }} + + {{ relation.toEntityTypeName }} + + + } + @if (direction === directions.FROM) { + + {{ 'relation.to-entity-name' | translate }} + @if(relation.entityURL){ - {{ relation.toName | customTranslate }} + {{ relation.toName | customTranslate }} } @else { {{ relation.toName | customTranslate }} } - - - - {{ 'relation.from-entity-type' | translate }} - - {{ relation.fromEntityTypeName }} - - - - {{ 'relation.from-entity-name' | translate }} - - @if(relation.entityURL){ - + + + } + @if (direction === directions.TO) { + + {{ 'relation.from-entity-type' | translate }} + + {{ relation.fromEntityTypeName }} + + + } + @if (direction === directions.TO) { + + {{ 'relation.from-entity-name' | translate }} + + @if(relation.entityURL){ + + {{ relation.fromName | customTranslate }} + + } @else { {{ relation.fromName | customTranslate }} - - } @else { - {{ relation.fromName | customTranslate }} - } - - + } + + + } @@ -179,16 +189,16 @@ + *matRowDef="let relation; columns: displayedColumns;" (click)="dataSource.selection.toggle(relation)">
{{ 'relation.no-relations-text' }} + class="no-data-found flex items-center justify-center" translate>{{ 'relation.no-relations-text' }}
+ [hidePageSize]="hidePageSize">
diff --git a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts index f5dcd97032..00b10e401a 100644 --- a/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.ts @@ -52,10 +52,11 @@ import { hidePageSizePixelValue } from '@shared/models/constants'; import { FormBuilder } from '@angular/forms'; @Component({ - selector: 'tb-relation-table', - templateUrl: './relation-table.component.html', - styleUrls: ['./relation-table.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-relation-table', + templateUrl: './relation-table.component.html', + styleUrls: ['./relation-table.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class RelationTableComponent extends PageComponent implements AfterViewInit, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html index fa7efc278a..a5757e7e58 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-dialog.component.html @@ -25,9 +25,12 @@ close
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements ErrorStateMatcher, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html index efc49b9d82..b4c2901852 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.html @@ -15,57 +15,67 @@ limitations under the License. --> -
- - - -
- + + +
+ +
-
+}
- + resource.resource-type - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - + @for (resourceType of resourceTypes ; track resourceType) { + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + } - - resource.title - - - {{ 'resource.title-required' | translate }} - - - {{ 'resource.title-max-length' | translate }} - - + @if (entityForm.get('resourceType').value !== resourceType.LWM2M_MODEL || !isAdd) { + + resource.title + + @if (entityForm.get('title').hasError('required')) { + + {{ 'resource.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'resource.title-max-length' | translate }} + + } + + } @if (isAdd || ((isAdd || isEdit) && entityForm.get('resourceType').value === resourceType.GENERAL)) { } @else {
- + resource.file-name diff --git a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts index b826bc6616..535eaf2e5e 100644 --- a/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts +++ b/ui-ngx/src/app/modules/home/components/resources/resources-library.component.ts @@ -35,8 +35,9 @@ import { isDefinedAndNotNull } from '@core/utils'; import { getCurrentAuthState } from '@core/auth/auth.selectors'; @Component({ - selector: 'tb-resources-library', - templateUrl: './resources-library.component.html' + selector: 'tb-resources-library', + templateUrl: './resources-library.component.html', + standalone: false }) export class ResourcesLibraryComponent extends EntityComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.html b/ui-ngx/src/app/modules/home/components/router-tabs.component.html index 0c9668d848..c71256897d 100644 --- a/ui-ngx/src/app/modules/home/components/router-tabs.component.html +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.html @@ -17,18 +17,24 @@ -->
- + @if (!routerOutlet?.activatedRouteData?.hideTabs && !hideCurrentTabs && (tabs$ | async).length > 1) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/router-tabs.component.ts b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts index a4e6a883b6..b75b90e373 100644 --- a/ui-ngx/src/app/modules/home/components/router-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/router-tabs.component.ts @@ -28,9 +28,10 @@ import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; @Component({ - selector: 'tb-router-tabs', - templateUrl: './router-tabs.component.html', - styleUrls: ['./router-tabs.component.scss'] + selector: 'tb-router-tabs', + templateUrl: './router-tabs.component.html', + styleUrls: ['./router-tabs.component.scss'], + standalone: false }) export class RouterTabsComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html index df765fcec8..c753c9cb11 100644 --- a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ labelText | translate }} - - {{ displayRuleChainFn(selectRuleChainFormGroup.get('ruleChainId').value) }} - - + @if (selectRuleChainFormGroup.get('ruleChainId').value && disabled) { + + {{ displayRuleChainFn(selectRuleChainFormGroup.get('ruleChainId').value) }} + + } + @if (selectRuleChainFormGroup.get('ruleChainId').value && !disabled) { + + } - - - - -
-
- rulechain.no-rulechains-text -
- - + @for (ruleChain of filteredRuleChains | async; track ruleChain) { + + + + } + @if (!(filteredRuleChains | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ rulechain.no-rulechains-text +
+ } @else { + {{ translate.get('rulechain.no-rulechains-matching', - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + {entity: truncate.transform(searchText, true, 6, '...')}) | async }} + + } + + rulechain.create-new-rulechain - - - rulechain.create-new-rulechain - -
-
+
+
+ }
- - {{ 'rulechain.rulechain-required' | translate }} - + @if (selectRuleChainFormGroup.get('ruleChainId').hasError('required')) { + + {{ 'rulechain.rulechain-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts index e6176603c3..e3cc793cc8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-chain/rule-chain-autocomplete.component.ts @@ -31,16 +31,18 @@ import { RuleChainService } from '@core/http/rule-chain.service'; import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { RuleChainType } from '@app/shared/models/rule-chain.models'; import { getEntityDetailsPageURL } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-rule-chain-autocomplete', - templateUrl: './rule-chain-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RuleChainAutocompleteComponent), - multi: true - }] + selector: 'tb-rule-chain-autocomplete', + templateUrl: './rule-chain-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleChainAutocompleteComponent), + multi: true + }], + standalone: false }) export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -57,6 +59,9 @@ export class RuleChainAutocompleteComponent implements ControlValueAccessor, OnI @Input() ruleChainType: RuleChainType = RuleChainType.CORE; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html index d4213b95e5..7543feb3ad 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.html @@ -17,7 +17,7 @@ -->
{{ title }}
- + rule-node-config.save-time-series.strategy @for (strategy of processingStrategies; track strategy) { @@ -34,6 +34,7 @@ maxErrorText="{{ 'rule-node-config.save-time-series.deduplication-interval-min-max-range' | translate }}" [minTime]="1" [maxTime]="maxDeduplicateTime" + [appearance]="appearance" formControlName="deduplicationIntervalSecs"> } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts index 7e73655529..c569d3270f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting-row.component.ts @@ -32,25 +32,30 @@ import { } from '@home/components/rule-node/action/timeseries-config.models'; import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-advanced-processing-setting-row', - templateUrl: './advanced-processing-setting-row.component.html', - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), - multi: true - },{ - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), - multi: true - }] + selector: 'tb-advanced-processing-setting-row', + templateUrl: './advanced-processing-setting-row.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdvancedProcessingSettingRowComponent), + multi: true + }], + standalone: false }) export class AdvancedProcessingSettingRowComponent implements ControlValueAccessor, Validator { @Input() title: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + processingSettingRowForm = this.fb.group({ type: [defaultAdvancedProcessingConfig.type], deduplicationIntervalSecs: [{value: 60, disabled: true}] diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 9f51ca5189..0136d08250 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html @@ -19,26 +19,41 @@ + > - - - - - + @if (timeseries) { + + } + @if (attributes) { + + } + @if (latest) { + + } + @if (webSockets) { + + } + @if (calculatedFields) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts index 3cd880cedd..6a8ef33a56 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.ts @@ -30,17 +30,18 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { AttributeAdvancedProcessingStrategy } from '@home/components/rule-node/action/attributes-config.model'; @Component({ - selector: 'tb-advanced-processing-settings', - templateUrl: './advanced-processing-setting.component.html', - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AdvancedProcessingSettingComponent), - multi: true - },{ - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AdvancedProcessingSettingComponent), - multi: true - }] + selector: 'tb-advanced-processing-settings', + templateUrl: './advanced-processing-setting.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdvancedProcessingSettingComponent), + multi: true + }], + standalone: false }) export class AdvancedProcessingSettingComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html index b50241b5cd..64a23c30e6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.html @@ -17,13 +17,15 @@ -->
- + rule-node-config.customer-name-pattern - - {{ 'rule-node-config.customer-name-pattern-required' | translate }} - + @if (assignCustomerConfigForm.get('customerNamePattern').hasError('required') || + assignCustomerConfigForm.get('customerNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.customer-name-pattern-required' | translate }} + + } rule-node-config.customer-name-pattern-hint
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.ts index 0a67f7ba56..c844cd114b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/assign-customer-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-action-node-assign-to-customer-config', - templateUrl: './assign-customer-config.component.html', - styleUrls: [] + selector: 'tb-action-node-assign-to-customer-config', + templateUrl: './assign-customer-config.component.html', + styleUrls: [], + standalone: false }) export class AssignCustomerConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index ef4c5ee8c2..51ac7c34b6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html @@ -28,7 +28,7 @@
@if(!attributesConfigForm.get('processingSettings.isAdvanced').value) { - + rule-node-config.save-attribute.strategy @for (strategy of processingStrategies; track strategy) { @@ -39,6 +39,7 @@ @if(attributesConfigForm.get('processingSettings.type').value === ProcessingType.DEDUPLICATE) {
- + {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
-
- - {{ 'rule-node-config.send-attributes-updated-notification' | translate }} - -
-
- - {{ 'rule-node-config.notify-device' | translate }} - -
+ @if (attributesConfigForm.get('scope').value !== attributeScopeMap.CLIENT_SCOPE) { +
+ + {{ 'rule-node-config.send-attributes-updated-notification' | translate }} + +
+ } + @if (attributesConfigForm.get('scope').value === attributeScopeMap.SHARED_SCOPE) { +
+ + {{ 'rule-node-config.notify-device' | translate }} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts index 13deb3afda..5a3d03ee83 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.ts @@ -33,14 +33,15 @@ import { } from '@home/components/rule-node/action/attributes-config.model'; @Component({ - selector: 'tb-action-node-attributes-config', - templateUrl: './attributes-config.component.html', - styleUrls: [] + selector: 'tb-action-node-attributes-config', + templateUrl: './attributes-config.component.html', + styleUrls: [], + standalone: false }) export class AttributesConfigComponent extends RuleNodeConfigurationComponent { attributeScopeMap = AttributeScope; - attributeScopes = Object.keys(AttributeScope); + attributeScopes = Object.values(AttributeScope); telemetryTypeTranslationsMap = telemetryTypeTranslations; ProcessingType = ProcessingType; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html index 328554136a..ab3e54ccdc 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.html @@ -16,52 +16,60 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
- + rule-node-config.alarm-type - - {{ 'rule-node-config.alarm-type-required' | translate }} - + @if (clearAlarmConfigForm.get('alarmType').hasError('required')) { + + {{ 'rule-node-config.alarm-type-required' | translate }} + + } rule-node-config.general-pattern-hint
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.ts index fdc7883fec..812f50955a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/clear-alarm-config.component.ts @@ -28,9 +28,10 @@ import type { JsFuncComponent } from '@app/shared/components/js-func.component'; import { DebugRuleNodeEventBody } from '@shared/models/event.models'; @Component({ - selector: 'tb-action-node-clear-alarm-config', - templateUrl: './clear-alarm-config.component.html', - styleUrls: [] + selector: 'tb-action-node-clear-alarm-config', + templateUrl: './clear-alarm-config.component.html', + styleUrls: [], + standalone: false }) export class ClearAlarmConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html index f5e933a6d5..6b4ec383a1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.html @@ -19,110 +19,137 @@ {{ 'rule-node-config.use-message-alarm-data' | translate }} - - {{ 'rule-node-config.overwrite-alarm-details' | translate }} - -
- - - - - - - -
- -
-
-
- - rule-node-config.alarm-type - - - {{ 'rule-node-config.alarm-type-required' | translate }} - - rule-node-config.general-pattern-hint - - - {{ 'rule-node-config.use-alarm-severity-pattern' | translate }} - - - rule-node-config.alarm-severity - - - {{ alarmSeverityTranslationMap.get(severity) | translate }} - - - - {{ 'rule-node-config.alarm-severity-required' | translate }} - - - - rule-node-config.alarm-severity-pattern - - - {{ 'rule-node-config.alarm-severity-required' | translate }} - - - - - {{ 'rule-node-config.propagate' | translate }} + @if (createAlarmConfigForm.get('useMessageAlarmData').value === true) { + + {{ 'rule-node-config.overwrite-alarm-details' | translate }} -
- - rule-node-config.relation-types-list - - - {{key}} - close - - - - rule-node-config.relation-types-list-hint + } + @if (createAlarmConfigForm.get('useMessageAlarmData').value === false || + createAlarmConfigForm.get('overwriteAlarmDetails').value === true) { +
+ @if (tbelEnabled) { + + } + @if (createAlarmConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (createAlarmConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + } +
+ +
+
+ } + @if (createAlarmConfigForm.get('useMessageAlarmData').value === false) { +
+ + rule-node-config.alarm-type + + @if (createAlarmConfigForm.get('alarmType').hasError('required')) { + + {{ 'rule-node-config.alarm-type-required' | translate }} + + } + rule-node-config.general-pattern-hint + + {{ 'rule-node-config.use-alarm-severity-pattern' | translate }} + + @if (!createAlarmConfigForm.get('dynamicSeverity').value) { + + rule-node-config.alarm-severity + + @for (severity of alarmSeverities; track severity) { + + {{ alarmSeverityTranslationMap.get(severity) | translate }} + + } + + @if (createAlarmConfigForm.get('severity').hasError('required')) { + + {{ 'rule-node-config.alarm-severity-required' | translate }} + + } + + } + @if (createAlarmConfigForm.get('dynamicSeverity').value) { + + rule-node-config.alarm-severity-pattern + + @if (createAlarmConfigForm.get('severity').hasError('required')) { + + {{ 'rule-node-config.alarm-severity-required' | translate }} + + } + + + } + + {{ 'rule-node-config.propagate' | translate }} + + @if (createAlarmConfigForm.get('propagate').value === true) { +
+ + rule-node-config.relation-types-list + + @for (key of createAlarmConfigForm.get('relationTypes').value; track key) { + + {{key}} + close + + } + + + rule-node-config.relation-types-list-hint + +
+ } + + {{ 'rule-node-config.propagate-to-owner' | translate }} + + + {{ 'rule-node-config.propagate-to-tenant' | translate }} +
- - {{ 'rule-node-config.propagate-to-owner' | translate }} - - - {{ 'rule-node-config.propagate-to-tenant' | translate }} - -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts index f4ec7424aa..06cf94b74d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-alarm-config.component.ts @@ -32,16 +32,17 @@ import { DebugRuleNodeEventBody } from '@shared/models/event.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-action-node-create-alarm-config', - templateUrl: './create-alarm-config.component.html', - styleUrls: [] + selector: 'tb-action-node-create-alarm-config', + templateUrl: './create-alarm-config.component.html', + styleUrls: [], + standalone: false }) export class CreateAlarmConfigComponent extends RuleNodeConfigurationComponent { @ViewChild('jsFuncComponent', {static: false}) jsFuncComponent: JsFuncComponent; @ViewChild('tbelFuncComponent', {static: false}) tbelFuncComponent: JsFuncComponent; - alarmSeverities = Object.keys(AlarmSeverity); + alarmSeverities = Object.values(AlarmSeverity); alarmSeverityTranslationMap = alarmSeverityTranslations; createAlarmConfigForm: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html index b7b72ef390..a50b62b26d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.html @@ -19,15 +19,18 @@
rule-node-config.relation-parameters
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } @@ -38,6 +41,7 @@
rule-node-config.target-entity
- - {{ entityTypeNamePatternTranslation.get(createRelationConfigForm.get('entityType').value) | translate }} - - + @if (createRelationConfigForm.get('entityType').value && + createRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + {{ entityTypeNamePatternTranslation.get(createRelationConfigForm.get('entityType').value) | translate }} + + + } - - rule-node-config.profile-name - - + @if (createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { + + rule-node-config.profile-name + + + }
- + @if (createRelationConfigForm.get('entityType').value === entityType.CUSTOMER || + createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { + + } -
- - {{ 'rule-node-config.create-entity-if-not-exists' | translate }} - -
+ @if (createRelationConfigForm.get('entityType').value === entityType.CUSTOMER || + createRelationConfigForm.get('entityType').value === entityType.DEVICE || + createRelationConfigForm.get('entityType').value === entityType.ASSET) { +
+ + {{ 'rule-node-config.create-entity-if-not-exists' | translate }} + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts index 40fce452a9..1939b4abbf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/create-relation-config.component.ts @@ -21,13 +21,14 @@ import { EntitySearchDirection } from '@app/shared/models/relation.models'; import { EntityType } from '@app/shared/models/entity-type.models'; @Component({ - selector: 'tb-action-node-create-relation-config', - templateUrl: './create-relation-config.component.html', - styleUrls: [] + selector: 'tb-action-node-create-relation-config', + templateUrl: './create-relation-config.component.html', + styleUrls: [], + standalone: false }) export class CreateRelationConfigComponent extends RuleNodeConfigurationComponent { - directionTypes = Object.keys(EntitySearchDirection); + directionTypes = Object.values(EntitySearchDirection); directionTypeTranslations = new Map( [ [EntitySearchDirection.FROM, 'rule-node-config.search-direction-from'], diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html index 165510c657..a260f16b96 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.html @@ -20,16 +20,18 @@
- + {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } - + {{ 'rule-node-config.attributes-scope-value' | translate }}
- + {{ 'rule-node-config.attributes-keys' | translate }} - - {{key}} - close - + @for (key of deleteAttributesConfigForm.get('keys').value; track key) { + + {{key}} + close + + } - {{ 'rule-node-config.attributes-keys-required' | translate }} + @if (deleteAttributesConfigForm.get('keys').hasError('required')) { + {{ 'rule-node-config.attributes-keys-required' | translate }} + } rule-node-config.general-pattern-hint @@ -77,13 +82,14 @@ {{ 'rule-node-config.send-attributes-deleted-notification' | translate }}
-
- - {{ 'rule-node-config.notify-device' | translate }} - -
+ @if (deleteAttributesConfigForm.get('scope').value === attributeScopeMap.SHARED_SCOPE) { +
+ + {{ 'rule-node-config.notify-device' | translate }} + +
+ } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts index 79e27e594f..90818f9ceb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-attributes-config.component.ts @@ -23,16 +23,17 @@ import { AttributeScope, telemetryTypeTranslations } from '@shared/models/teleme import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-action-node-delete-attributes-config', - templateUrl: './delete-attributes-config.component.html', - styleUrls: [] + selector: 'tb-action-node-delete-attributes-config', + templateUrl: './delete-attributes-config.component.html', + styleUrls: [], + standalone: false }) export class DeleteAttributesConfigComponent extends RuleNodeConfigurationComponent { @ViewChild('attributeChipList') attributeChipList: MatChipGrid; deleteAttributesConfigForm: UntypedFormGroup; attributeScopeMap = AttributeScope; - attributeScopes = Object.keys(AttributeScope); + attributeScopes = Object.values(AttributeScope); telemetryTypeTranslationsMap = telemetryTypeTranslations; separatorKeysCodes = [ENTER, COMMA, SEMICOLON]; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html index dd6c2fab93..5c1517804d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.html @@ -19,15 +19,18 @@
rule-node-config.relation-parameters
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } @@ -40,23 +43,31 @@ {{ 'rule-node-config.delete-relation-with-specific-entity' | translate }}
-
-
- - - - {{ entityTypeNamePatternTranslation.get(deleteRelationConfigForm.get('entityType').value) | translate }} - - + @if (deleteRelationConfigForm.get('deleteForSingleEntity').value) { +
+
+ + + @if (deleteRelationConfigForm.get('entityType').value && deleteRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + {{ entityTypeNamePatternTranslation.get(deleteRelationConfigForm.get('entityType').value) | translate }} + + + } +
+ @if (deleteRelationConfigForm.get('entityType').value && deleteRelationConfigForm.get('entityType').value !== entityType.TENANT) { + + }
- -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts index d7839e1f23..d41def61d3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/delete-relation-config.component.ts @@ -21,13 +21,14 @@ import { EntitySearchDirection } from '@app/shared/models/relation.models'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-action-node-delete-relation-config', - templateUrl: './delete-relation-config.component.html', - styleUrls: [] + selector: 'tb-action-node-delete-relation-config', + templateUrl: './delete-relation-config.component.html', + styleUrls: [], + standalone: false }) export class DeleteRelationConfigComponent extends RuleNodeConfigurationComponent { - directionTypes = Object.keys(EntitySearchDirection); + directionTypes = Object.values(EntitySearchDirection); directionTypeTranslations = new Map( [ diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/device-profile-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/device-profile-config.component.ts index 3bd49c65f2..e7397f431c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/device-profile-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/device-profile-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-action-node-device-profile-config', - templateUrl: './device-profile-config.component.html', - styleUrls: [] + selector: 'tb-action-node-device-profile-config', + templateUrl: './device-profile-config.component.html', + styleUrls: [], + standalone: false }) export class DeviceProfileConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html index c2e9a00a6e..bdb412f385 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html @@ -16,12 +16,14 @@ -->
- - {{ 'rule-node-config.select-device-connectivity-event' | translate }} - - - {{ messageTypeNames.get(eventOption) }} - - - + + {{ 'rule-node-config.select-device-connectivity-event' | translate }} + + @for (eventOption of eventOptions; track eventOption) { + + {{ messageTypeNames.get(eventOption) }} + + } + +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.ts index f3176849f1..08a81b8bbb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.ts @@ -27,7 +27,8 @@ import { @Component({ selector: 'tb-action-node-device-state-config', templateUrl: './device-state-config.component.html', - styleUrls: [] + styleUrls: [], + standalone: false }) export class DeviceStateConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html index 4463973874..d0d91e0bb1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html @@ -19,25 +19,33 @@
rule-node-config.generation-parameters
- + rule-node-config.message-count - - {{ 'rule-node-config.message-count-required' | translate }} - - - {{ 'rule-node-config.min-message-count-message' | translate }} - + @if (generatorConfigForm.get('msgCount').hasError('required')) { + + {{ 'rule-node-config.message-count-required' | translate }} + + } + @if (generatorConfigForm.get('msgCount').hasError('min')) { + + {{ 'rule-node-config.min-message-count-message' | translate }} + + } - + rule-node-config.generation-frequency-seconds - - {{ 'rule-node-config.generation-frequency-required' | translate }} - - - {{ 'rule-node-config.min-generation-frequency-message' | translate }} - + @if (generatorConfigForm.get('periodInSeconds').hasError('required')) { + + {{ 'rule-node-config.generation-frequency-required' | translate }} + + } + @if (generatorConfigForm.get('periodInSeconds').hasError('min')) { + + {{ 'rule-node-config.min-generation-frequency-message' | translate }} + + }
@@ -45,6 +53,7 @@
rule-node-config.originator
rule-node-config.generator-function - - - - {{ 'rule-node-config.script-lang-tbel' | translate }} - - - {{ 'rule-node-config.script-lang-js' | translate }} - - - - - - - - {{ 'rule-node-config.script-lang-tbel' | translate }} - - - {{ 'rule-node-config.script-lang-js' | translate }} - - - - + @if (generatorConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + @if (tbelEnabled) { + + + {{ 'rule-node-config.script-lang-tbel' | translate }} + + + {{ 'rule-node-config.script-lang-js' | translate }} + + + } + + + } + @if (generatorConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + {{ 'rule-node-config.script-lang-tbel' | translate }} + + + {{ 'rule-node-config.script-lang-js' | translate }} + + + + + }
- - - - + @if (tbelEnabled) { + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
- - rule-node-config.customer-name-pattern - - - {{ 'rule-node-config.customer-name-pattern-required' | translate }} - - rule-node-config.customer-name-pattern-hint - + @if (unassignCustomerConfigForm.get('unassignFromCustomer').value) { + + rule-node-config.customer-name-pattern + + @if (unassignCustomerConfigForm.get('customerNamePattern').hasError('required') || + unassignCustomerConfigForm.get('customerNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.customer-name-pattern-required' | translate }} + + } + rule-node-config.customer-name-pattern-hint + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.ts index 5fe0c5c1df..ee5c1a5031 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.ts @@ -20,9 +20,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-action-node-un-assign-to-customer-config', - templateUrl: './unassign-customer-config.component.html', - styleUrls: [] + selector: 'tb-action-node-un-assign-to-customer-config', + templateUrl: './unassign-customer-config.component.html', + styleUrls: [], + standalone: false }) export class UnassignCustomerConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/alarm-status-select.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/alarm-status-select.component.ts index bc9de1be09..2ccceb8877 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/alarm-status-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/alarm-status-select.component.ts @@ -20,14 +20,15 @@ import { ControlValueAccessor, FormBuilder, FormGroup, NG_VALUE_ACCESSOR } from import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-alarm-status-select', - templateUrl: './alarm-status-select.component.html', - styleUrls: ['./alarm-status-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AlarmStatusSelectComponent), - multi: true - }] + selector: 'tb-alarm-status-select', + templateUrl: './alarm-status-select.component.html', + styleUrls: ['./alarm-status-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AlarmStatusSelectComponent), + multi: true + }], + standalone: false }) export class AlarmStatusSelectComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html index 9250c5b544..633c169cf1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html @@ -23,101 +23,128 @@ [formGroup]="argumentsFormGroup" (cdkDropListDropped)="onDrop($event)" [cdkDropListDisabled]="disabled"> - -
- -
- {{argumentControl.get('name').value}}. -
- - rule-node-config.argument-source-field-input - - - {{ argumentTypeMap.get(argumentControl.get('type').value)?.name | translate }} - - - {{ argumentTypeMap.get(argument).name | translate }} - - {{ argumentTypeMap.get(argument).description | translate }} - - - - - rule-node-config.argument-source-field-input-required - - -
- - rule-node-config.argument-key-field-input - - - help - - - rule-node-config.argument-key-field-input-required - - - - rule-node-config.constant-value-field-input - - - rule-node-config.constant-value-field-input-required - - - - rule-node-config.default-value-field-input - + @for (argumentControl of argumentsFormArray.controls; track argumentControl) { + +
+ @if (!disabled) { + + } +
+ @if (displayArgumentName) { + {{argumentControl.get('name').value}}. + } +
+ + rule-node-config.argument-source-field-input + + + {{ argumentTypeMap.get(argumentControl.get('type').value)?.name | translate }} + + @for (argument of arguments; track argument) { + + {{ argumentTypeMap.get(argument).name | translate }} + + {{ argumentTypeMap.get(argument).description | translate }} + + + } + + @if (argumentControl.get('type').hasError('required')) { + + rule-node-config.argument-source-field-input-required + + } +
+ @if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { + + rule-node-config.argument-key-field-input + + + help + + @if (argumentControl.get('key').hasError('required')) { + + rule-node-config.argument-key-field-input-required + + } + + } + @if (argumentControl.get('type').value === ArgumentType.CONSTANT) { + + rule-node-config.constant-value-field-input + + @if (argumentControl.get('key').hasError('required')) { + + rule-node-config.constant-value-field-input-required + + } + + } + @if (argumentControl.get('type').value && argumentControl.get('type').value !== ArgumentType.CONSTANT) { + + rule-node-config.default-value-field-input + + + } +
+ @if (argumentControl.get('type').value === ArgumentType.ATTRIBUTE) { + + rule-node-config.attribute-scope-field-input + + @for (scope of attributeScope; track scope) { + + {{ attributeScopeMap.get(scope) | translate }} + + } + + @if (argumentControl.get('attributeScope').hasError('required')) { + + rule-node-config.attribute-scope-field-input-required + + } + + }
- - rule-node-config.attribute-scope-field-input - - - {{ attributeScopeMap.get(scope) | translate }} - - - - rule-node-config.attribute-scope-field-input-required - - + @if (!disabled) { + + }
-
-
- + + }
-
+ @if (!argumentsFormArray.length) { +
rule-node-config.no-arguments-prompt -
+ class="tb-prompt flex items-center justify-center">rule-node-config.no-arguments-prompt +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.ts index c40ab0ff92..1159d1ca9b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.ts @@ -17,9 +17,10 @@ import { Component, Input } from '@angular/core'; @Component({ - selector: 'tb-example-hint', - templateUrl: './example-hint.component.html', - styleUrls: [] + selector: 'tb-example-hint', + templateUrl: './example-hint.component.html', + styleUrls: [], + standalone: false }) export class ExampleHintComponent { @Input() hintText: string; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts new file mode 100644 index 0000000000..98a5140de4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-list-config.component.ts @@ -0,0 +1,84 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, forwardRef, OnInit } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + Validator, + Validators +} from '@angular/forms'; +import { KvMapConfigOldComponent } from '@home/components/rule-node/common/kv-map-config-old.component'; + +@Component({ + selector: 'tb-kv-list-config', + templateUrl: './kv-map-config-old.component.html', + styleUrls: ['./kv-map-config-old.component.scss'], + standalone: false, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KvListConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KvListConfigComponent), + multi: true, + } + ] +}) +export class KvListConfigComponent extends KvMapConfigOldComponent implements ControlValueAccessor, OnInit, Validator { + + override writeValue(kvList: any): void { + const keyValsControls: Array = []; + if (Array.isArray(kvList) && kvList.length > 0) { + for (const property of kvList) { + keyValsControls.push(this.fb.group({ + key: [property.key, [Validators.required]], + value: [property.value, [Validators.required]] + })); + } + } + this.kvListFormGroup.setControl('keyVals', this.fb.array(keyValsControls), {emitEvent: false}); + } + + public override validate() { + const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; + if (!kvList.length && this.required) { + return { + kvMapRequired: true + }; + } + if (!this.kvListFormGroup.valid) { + return { + kvFieldsRequired: true + }; + } + return null; + } + + protected override updateModel() { + const kvList: { key: string; value: string }[] = this.kvListFormGroup.get('keyVals').value; + if (this.required && !kvList.length || !this.kvListFormGroup.valid) { + this.propagateChange(null); + } else { + this.propagateChange(kvList); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html index a0ce66a5a7..831630074a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config-old.component.html @@ -16,40 +16,57 @@ -->
-
- {{ keyText | translate }} - {{ valText | translate }} - -
-
-
- - - - {{ keyRequiredText | translate }} - - - - - - {{ valRequiredText | translate }} - - - + @if (description) { +
+ } + @if (keyValsFormArray().controls.length || !emptyText) { +
+ {{ keyText | translate }} + {{ valText | translate }} +
-
+ } +
+ @for (keyValControl of keyValsFormArray().controls; track $index) { +
+ + + @if (keyValControl.get('key').hasError('required')) { + + {{ keyRequiredText | translate }} + + } + + + + @if (keyValControl.get('value').hasError('required')) { + + {{ valRequiredText | translate }} + + } + + +
+ } @empty { + @if (emptyText) { +
{{emptyText | translate}}
+ } + } + @if (hintText) { +
+ }
{{ labelText }}
-
- {{ requiredText }} -
-
- rule-node-config.map-fields-required -
-
- {{ 'rule-node-config.key-val.unique-key-value-pair-error' | translate: - { - valText: valText, - keyText: keyText - } }} -
+ @if (kvListFormGroup.hasError('kvMapRequired')) { +
+ {{ requiredText }} +
+ } + @if (kvListFormGroup.hasError('required')) { +
+ rule-node-config.map-fields-required +
+ } + @if (kvListFormGroup.hasError('uniqueKeyValuePair')) { +
+ {{ 'rule-node-config.key-val.unique-key-value-pair-error' | translate: + { + valText: valText, + keyText: keyText + } }} +
+ }
@@ -40,26 +46,31 @@
-
- - - - - - -
- + @for (keyValControl of keyValsFormArray().controls; track keyValControl) { +
+ + + + + + +
+ +
-
+ } @empty { + @if (emptyText) { +
{{ emptyText }}
+ } + }
@@ -68,5 +79,7 @@ {{ 'rule-node-config.key-val.add-mapping-entry' | translate }}
- + @if (popupHelpLink || hintText) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts index 9cfb50961f..ac8b62487e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/kv-map-config.component.ts @@ -34,21 +34,22 @@ import { isEqual } from '@core/public-api'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-kv-map-config', - templateUrl: './kv-map-config.component.html', - styleUrls: ['./kv-map-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => KvMapConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => KvMapConfigComponent), - multi: true, - } - ] + selector: 'tb-kv-map-config', + templateUrl: './kv-map-config.component.html', + styleUrls: ['./kv-map-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => KvMapConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => KvMapConfigComponent), + multi: true, + } + ], + standalone: false }) export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Validator { @@ -79,6 +80,8 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid @Input() hintText: string; + @Input() emptyText: string; + @Input() popupHelpLink: string; @Input() @@ -170,7 +173,7 @@ export class KvMapConfigComponent implements ControlValueAccessor, OnInit, Valid }; writeValue(keyValMap: { [key: string]: string }): void { - const keyValuesData = Object.keys(keyValMap).map(key => ({key, value: keyValMap[key]})); + const keyValuesData = Object.keys(keyValMap ?? {}).map(key => ({key, value: keyValMap[key]})); if (this.keyValsFormArray().length === keyValuesData.length) { this.keyValsFormArray().patchValue(keyValuesData, {emitEvent: false}); } else { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html index c342149422..5ae8457030 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + rule-node-config.functions-field-input - + @if (mathFunctionForm.get('operation').value) { + + } - - - - {{ option.description }} - - - - rule-node-config.no-option-found - + @for (option of filteredOptions | async; track option) { + + + + {{ option.description }} + + + } + @if (!(filteredOptions | async)?.length) { + + rule-node-config.no-option-found + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts index 917c4c7a68..8603144e2e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.ts @@ -21,18 +21,20 @@ import { TranslateService } from '@ngx-translate/core'; import { FunctionData, MathFunction, MathFunctionMap } from '../rule-node-config.models'; import { map, tap } from 'rxjs/operators'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-math-function-autocomplete', - templateUrl: './math-function-autocomplete.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MathFunctionAutocompleteComponent), - multi: true - } - ] + selector: 'tb-math-function-autocomplete', + templateUrl: './math-function-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MathFunctionAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class MathFunctionAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -49,6 +51,9 @@ export class MathFunctionAutocompleteComponent implements ControlValueAccessor, @Input() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @ViewChild('operationInput', {static: true}) operationInput: ElementRef; mathFunctionForm: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html index 7ad685d8e2..2ecac54219 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html @@ -15,16 +15,19 @@ limitations under the License. --> - - {{ label }} + + @if (label) { + {{ label }} + } - - {{messageType.name}} - close - + @for (messageType of messageTypes; track messageType) { + + {{messageType.name}} + close + + } - - - - -
-
- rule-node-config.no-message-types-found + @for (messageType of filteredMessageTypes | async; track messageType) { + + + + } + @if ((filteredMessageTypes | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ rule-node-config.no-message-types-found +
+ } @else { + + {{ 'rule-node-config.no-message-type-matching' | translate : + {messageType: truncate.transform(searchText, true, 6, '...')} + }} + + } + + rule-node-config.create-new-message-type +
- - - {{ 'rule-node-config.no-message-type-matching' | translate : - {messageType: truncate.transform(searchText, true, 6, '...')} - }} - - - - rule-node-config.create-new-message-type - -
- + + } help - - {{ 'rule-node-config.select-message-types-required' | translate }} - + matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.message-type' | translate } }}">help + @if (chipList.errorState) { + + {{ 'rule-node-config.select-message-types-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.ts index 6ff08c5f7e..b41af5c78b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.ts @@ -26,16 +26,17 @@ import { TranslateService } from '@ngx-translate/core'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; @Component({ - selector: 'tb-message-types-config', - templateUrl: './message-types-config.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MessageTypesConfigComponent), - multi: true - } - ] + selector: 'tb-message-types-config', + templateUrl: './message-types-config.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MessageTypesConfigComponent), + multi: true + } + ], + standalone: false }) export class MessageTypesConfigComponent extends PageComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.html index 9e7aba378f..14dfd216f5 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.html @@ -18,9 +18,11 @@
{{ labelText }}
- {{ option.name }} + @for (option of selectOptions; track option) { + {{ option.name }} + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.ts index 2d31d016ac..1aca581efc 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/msg-metadata-chip.component.ts @@ -21,13 +21,14 @@ import { TranslateService } from '@ngx-translate/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-msg-metadata-chip', - templateUrl: './msg-metadata-chip.component.html', - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MsgMetadataChipComponent), - multi: true - }] + selector: 'tb-msg-metadata-chip', + templateUrl: './msg-metadata-chip.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MsgMetadataChipComponent), + multi: true + }], + standalone: false }) export class MsgMetadataChipComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html index 412f8af911..035a739918 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.html @@ -16,35 +16,42 @@ -->
- + {{'rule-node-config.output-message-type' | translate}} - - {{msgType.name}} - + @for (msgType of messageTypes; track msgType) { + + {{msgType.name}} + + } - + {{'rule-node-config.message-type-value' | translate}} - - - {{ 'rule-node-config.message-type-value-required' | translate }} - - - {{ 'rule-node-config.message-type-value-max-length' | translate }} - + @if (messageTypeFormGroup.get('messageType').value) { + + } + @if (messageTypeFormGroup.get('messageType').hasError('required')) { + + {{ 'rule-node-config.message-type-value-required' | translate }} + + } + @if (messageTypeFormGroup.get('messageType').hasError('maxlength')) { + + {{ 'rule-node-config.message-type-value-max-length' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts index cf9f54a35a..9395f59d6a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/output-message-type-autocomplete.component.ts @@ -24,7 +24,7 @@ import { Validator, Validators } from '@angular/forms'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; import { coerceBoolean } from '@shared/public-api'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @@ -34,21 +34,22 @@ interface MessageType { } @Component({ - selector: 'tb-output-message-type-autocomplete', - templateUrl: './output-message-type-autocomplete.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OutputMessageTypeAutocompleteComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => OutputMessageTypeAutocompleteComponent), - multi: true - } - ] + selector: 'tb-output-message-type-autocomplete', + templateUrl: './output-message-type-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OutputMessageTypeAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OutputMessageTypeAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class OutputMessageTypeAutocompleteComponent implements ControlValueAccessor, Validator { @@ -60,6 +61,9 @@ export class OutputMessageTypeAutocompleteComponent implements ControlValueAcces @coerceBoolean() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() @coerceBoolean() set required(value) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html index bab5638bd6..e6fbcdc3df 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.html @@ -23,9 +23,11 @@ relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts index c209df227a..b0adb57e2a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config-old.component.ts @@ -22,16 +22,17 @@ import { RelationsQuery } from '../rule-node-config.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-relations-query-config-old', - templateUrl: './relations-query-config-old.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelationsQueryConfigOldComponent), - multi: true - } - ] + selector: 'tb-relations-query-config-old', + templateUrl: './relations-query-config-old.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationsQueryConfigOldComponent), + multi: true + } + ], + standalone: false }) export class RelationsQueryConfigOldComponent extends PageComponent implements ControlValueAccessor, OnInit { @@ -48,7 +49,7 @@ export class RelationsQueryConfigOldComponent extends PageComponent implements C this.requiredValue = coerceBooleanProperty(value); } - directionTypes = Object.keys(EntitySearchDirection); + directionTypes = Object.values(EntitySearchDirection); directionTypeTranslations = entitySearchDirectionTranslations; relationsQueryFormGroup: FormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html index 936a9cde9d..01ec5c5e9a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.html @@ -19,15 +19,17 @@
rule-node-config.relations-query
- + relation.direction - - {{ directionTypeTranslations.get(type) | translate }} rule-node-config.relations-query-config-direction-suffix - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} rule-node-config.relations-query-config-direction-suffix + + } - + rule-node-config.max-relation-level - - {{ 'rule-node-config.max-relation-level-error' | translate }} - - - {{ 'rule-node-config.max-relation-level-invalid' | translate }} - + @if (relationsQueryFormGroup.get('maxLevel').hasError('min')) { + + {{ 'rule-node-config.max-relation-level-error' | translate }} + + } + @if (relationsQueryFormGroup.get('maxLevel').invalid) { + + {{ 'rule-node-config.max-relation-level-invalid' | translate }} + + }
-
- - {{ 'alias.last-level-relation' | translate }} - -
+ @if (relationsQueryFormGroup.get('maxLevel').value > 1) { +
+ + {{ 'alias.last-level-relation' | translate }} + +
+ }
relation.relation-filters
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.ts index a06aeec2c3..cc283afe27 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/relations-query-config.component.ts @@ -22,15 +22,16 @@ import { RelationsQuery } from '../rule-node-config.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-relations-query-config', - templateUrl: './relations-query-config.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelationsQueryConfigComponent), - multi: true - } - ] + selector: 'tb-relations-query-config', + templateUrl: './relations-query-config.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationsQueryConfigComponent), + multi: true + } + ], + standalone: false }) export class RelationsQueryConfigComponent extends PageComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html index 9665fff5ce..ac4cc0eafa 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html @@ -17,43 +17,48 @@ -->
+ [popupHelpLink]="popupHelpLink"> -
- - {{ 'rule-node-config.fetch-latest-telemetry-with-timestamp' | translate }} - -
+ class="tb-form-row no-border no-padding"> + + {{ 'rule-node-config.fetch-latest-telemetry-with-timestamp' | translate }} + +
+ }
help + matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.ts index 10a8658490..bc7bc01a55 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.ts @@ -31,18 +31,19 @@ import { isDefinedAndNotNull } from '@core/public-api'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-select-attributes', - templateUrl: './select-attributes.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SelectAttributesComponent), - multi: true - }, { - provide: NG_VALIDATORS, - useExisting: SelectAttributesComponent, - multi: true - }] + selector: 'tb-select-attributes', + templateUrl: './select-attributes.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SelectAttributesComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: SelectAttributesComponent, + multi: true + }], + standalone: false }) export class SelectAttributesComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.html index b8d804bfea..dddbcac29e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.html @@ -18,12 +18,16 @@
{{ labelText }}
-
- rule-node-config.map-fields-required -
-
- {{ requiredText }} -
+ @if (svListFormGroup.hasError('required')) { +
+ rule-node-config.map-fields-required +
+ } + @if (svListFormGroup.hasError('svMapRequired')) { +
+ {{ requiredText }} +
+ }
@@ -33,30 +37,34 @@
-
- - - - {{option.name}} - - - - - - -
- + @for (keyValControl of keyValsFormArray().controls; track keyValControl) { +
+ + + @for (option of filterSelectOptions(keyValControl); track option) { + + {{option.name}} + + } + + + + + +
+ +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.ts index 61807afa1e..558aabe9ba 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/sv-map-config.component.ts @@ -36,21 +36,22 @@ import { OriginatorFieldsMappingValues, SvMapOption } from '../rule-node-config. import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-sv-map-config', - templateUrl: './sv-map-config.component.html', - styleUrls: ['./sv-map-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SvMapConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SvMapConfigComponent), - multi: true, - } - ] + selector: 'tb-sv-map-config', + templateUrl: './sv-map-config.component.html', + styleUrls: ['./sv-map-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SvMapConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SvMapConfigComponent), + multi: true, + } + ], + standalone: false }) export class SvMapConfigComponent extends PageComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts index 06747e23cf..554fbf0c7b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/empty-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-node-empty-config', - template: '
', - styleUrls: [] + selector: 'tb-node-empty-config', + template: '
', + styleUrls: [], + standalone: false }) export class EmptyConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html index e39b97a19d..6011ea79c5 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html @@ -17,32 +17,40 @@ -->
- + {{ 'rule-node-config.input-value-key' | translate }} - - {{ 'rule-node-config.input-value-key-required' | translate }} - + @if (calculateDeltaConfigForm.get('inputValueKey').hasError('required') || + calculateDeltaConfigForm.get('inputValueKey').hasError('pattern')) { + + {{ 'rule-node-config.input-value-key-required' | translate }} + + } - + {{ 'rule-node-config.output-value-key' | translate }} - - {{ 'rule-node-config.output-value-key-required' | translate }} - + @if (calculateDeltaConfigForm.get('outputValueKey').hasError('required') || + calculateDeltaConfigForm.get('outputValueKey').hasError('pattern')) { + + {{ 'rule-node-config.output-value-key-required' | translate }} + + }
- + {{ 'rule-node-config.number-of-digits-after-floating-point' | translate }} - - {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} - - - {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} - + @if (calculateDeltaConfigForm.get('round').hasError('min')) { + + {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} + + } + @if (calculateDeltaConfigForm.get('round').hasError('max')) { + + {{ 'rule-node-config.number-of-digits-after-floating-point-range' | translate }} + + }
+ class="tb-form-row same-padding"> {{ 'rule-node-config.use-caching' | translate }} @@ -64,26 +72,30 @@ { periodValueKey: (calculateDeltaConfigForm.get('periodValueKey').valid && calculateDeltaConfigForm.get('addPeriodBetweenMsgs').value) ? calculateDeltaConfigForm.get('periodValueKey').value : 'periodInMs' } }}" - class="tb-form-row no-border no-padding"> + class="tb-form-row no-border no-padding"> {{ 'rule-node-config.add-time-difference-between-readings' | translate: { inputValueKey: calculateDeltaConfigForm.get('inputValueKey').valid ? - calculateDeltaConfigForm.get('inputValueKey').value : 'rule-node-config.input-value-key' | translate } }} + calculateDeltaConfigForm.get('inputValueKey').value : 'rule-node-config.input-value-key' | translate } }}
- - {{ 'rule-node-config.period-value-key' | translate }} - - - {{ 'rule-node-config.period-value-key-required' | translate }} - - + @if (calculateDeltaConfigForm.get('addPeriodBetweenMsgs').value) { + + {{ 'rule-node-config.period-value-key' | translate }} + + @if (calculateDeltaConfigForm.get('periodValueKey').hasError('required')) { + + {{ 'rule-node-config.period-value-key-required' | translate }} + + } + + }
+ class="tb-form-row same-padding"> {{ 'rule-node-config.exclude-zero-deltas' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.ts index a5fdc62b1a..c7df00c008 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.ts @@ -22,8 +22,9 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m import { deepTrim, isDefinedAndNotNull } from '@app/core/utils'; @Component({ - selector: 'tb-enrichment-node-calculate-delta-config', - templateUrl: './calculate-delta-config.component.html' + selector: 'tb-enrichment-node-calculate-delta-config', + templateUrl: './calculate-delta-config.component.html', + standalone: false }) export class CalculateDeltaConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.html index e32b62a92e..e26979cce6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.html @@ -20,9 +20,11 @@
- - {{ data.name }} - + @for (data of fetchToData; track data) { + + {{ data.name }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.ts index ae8fb3b1b9..757b9d5add 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/customer-attributes-config.component.ts @@ -22,9 +22,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { DataToFetch, dataToFetchTranslations, FetchTo } from '@home/components/rule-node/rule-node-config.models'; @Component({ - selector: 'tb-enrichment-node-customer-attributes-config', - templateUrl: './customer-attributes-config.component.html', - styleUrls: ['./customer-attributes-config.component.scss'] + selector: 'tb-enrichment-node-customer-attributes-config', + templateUrl: './customer-attributes-config.component.html', + styleUrls: ['./customer-attributes-config.component.scss'], + standalone: false }) export class CustomerAttributesConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/device-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/device-attributes-config.component.ts index c05119c0f5..0520ed1d48 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/device-attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/device-attributes-config.component.ts @@ -22,9 +22,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { FetchTo } from '@home/components/rule-node/rule-node-config.models'; @Component({ - selector: 'tb-enrichment-node-device-attributes-config', - templateUrl: './device-attributes-config.component.html', - styleUrls: [] + selector: 'tb-enrichment-node-device-attributes-config', + templateUrl: './device-attributes-config.component.html', + styleUrls: [], + standalone: false }) export class DeviceAttributesConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html index ff7939cab4..50af390ff8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/entity-details-config.component.html @@ -18,6 +18,7 @@
@@ -26,7 +27,7 @@ tb-help-popup-placement="right" trigger-style="letter-spacing:0.25px; font-size:12px" [tb-help-popup-style]="{maxWidth: '820px'}" - trigger-text="{{ 'rule-node-config.key-val.see-examples' | translate }}">
+ trigger-text="{{ 'rule-node-config.key-val.see-examples' | translate }}">
help @@ -39,144 +40,179 @@ {{ 'rule-node-config.use-metadata-dynamic-interval' | translate }}
-
-
- - {{ 'rule-node-config.interval-start' | translate }} - - - {{ 'rule-node-config.start-interval-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - {{ 'rule-node-config.time-unit' | translate }} - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - -
-
- - {{ 'rule-node-config.interval-end' | translate }} - - - {{ 'rule-node-config.end-interval-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - {{ 'rule-node-config.time-unit' | translate }} - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - -
-
- error_outline -
- - {{ 'rule-node-config.fetch-timeseries-from-to' | translate: - { + @if (getTelemetryFromDatabaseConfigForm.get('useMetadataIntervalPatterns').value === false) { +
+
+ + {{ 'rule-node-config.interval-start' | translate }} + + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('required')) { + + {{ 'rule-node-config.start-interval-value-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.startInterval').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + {{ 'rule-node-config.time-unit' | translate }} + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
+
+ + {{ 'rule-node-config.interval-end' | translate }} + + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('required')) { + + {{ 'rule-node-config.end-interval-value-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('interval.endInterval').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + {{ 'rule-node-config.time-unit' | translate }} + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
+
+ error_outline +
+ @if (getTelemetryFromDatabaseConfigForm.get('interval').valid) { + {{ 'rule-node-config.fetch-timeseries-from-to' | translate: + { startInterval: getTelemetryFromDatabaseConfigForm.get('interval.startInterval').value, endInterval: getTelemetryFromDatabaseConfigForm.get('interval.endInterval').value, startIntervalTimeUnit: getTelemetryFromDatabaseConfigForm.get('interval.startIntervalTimeUnit').value.toLowerCase(), endIntervalTimeUnit: getTelemetryFromDatabaseConfigForm.get('interval.endIntervalTimeUnit').value.toLowerCase() - } }} - - - {{ "rule-node-config.fetch-timeseries-from-to-invalid" | translate }} - + } }} + } @else { + {{ "rule-node-config.fetch-timeseries-from-to-invalid" | translate }} + } +
-
- + } @else {
- + {{ 'rule-node-config.start-interval' | translate }} - - {{ 'rule-node-config.start-interval-required' | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('startIntervalPattern').hasError('required') || + getTelemetryFromDatabaseConfigForm.get('startIntervalPattern').hasError('pattern')) { + + {{ 'rule-node-config.start-interval-required' | translate }} + + } - + {{ 'rule-node-config.end-interval' | translate }} - - {{ 'rule-node-config.end-interval-required' | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('endIntervalPattern').hasError('required') || + getTelemetryFromDatabaseConfigForm.get('endIntervalPattern').hasError('pattern')) { + + {{ 'rule-node-config.end-interval-required' | translate }} + + }
-
+ }
rule-node-config.fetch-strategy
- - {{ data.name }} - + @for (data of headerOptions; track data) { + + {{ data.name }} + + }
{{ deduplicationStrategiesHintTranslations.get(getTelemetryFromDatabaseConfigForm.get('fetchMode').value) | translate }}
-
- - {{ 'aggregation.function' | translate }} - - - {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} - - - -
- - {{ "rule-node-config.order-by-timestamp" | translate }} - - - {{ samplingOrdersTranslate.get(order) | translate }} - + @if (getTelemetryFromDatabaseConfigForm.get('fetchMode').value === fetchMode.ALL) { +
+ + {{ 'aggregation.function' | translate }} + + @for (aggregation of aggregations; track aggregation) { + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + } - - {{ "rule-node-config.limit" | translate }} - - {{ "rule-node-config.limit-hint" | translate }} - - {{ 'rule-node-config.limit-required' | translate }} - - - {{ 'rule-node-config.limit-range' | translate }} - - - {{ 'rule-node-config.limit-range' | translate }} - - + @if (getTelemetryFromDatabaseConfigForm.get('aggregation').value === aggregationTypes.NONE) { +
+ + {{ "rule-node-config.order-by-timestamp" | translate }} + + @for (order of samplingOrders; track order) { + + {{ samplingOrdersTranslate.get(order) | translate }} + + } + + + + {{ "rule-node-config.limit" | translate }} + + {{ "rule-node-config.limit-hint" | translate }} + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('required')) { + + {{ 'rule-node-config.limit-required' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('min')) { + + {{ 'rule-node-config.limit-range' | translate }} + + } + @if (getTelemetryFromDatabaseConfigForm.get('limit').hasError('max')) { + + {{ 'rule-node-config.limit-range' | translate }} + + } + +
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.scss index abc7f75946..2a692d58e2 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.scss @@ -44,10 +44,10 @@ } &.error { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); .description-icon { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); } } } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.ts index 148cc1b758..cea4771021 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.ts @@ -31,9 +31,10 @@ import { } from '../rule-node-config.models'; @Component({ - selector: 'tb-enrichment-node-get-telemetry-from-database', - templateUrl: './get-telemetry-from-database-config.component.html', - styleUrls: ['./get-telemetry-from-database-config.component.scss'] + selector: 'tb-enrichment-node-get-telemetry-from-database', + templateUrl: './get-telemetry-from-database-config.component.html', + styleUrls: ['./get-telemetry-from-database-config.component.scss'], + standalone: false }) export class GetTelemetryFromDatabaseConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-attributes-config.component.ts index 2d6f5bd663..57fa20dc21 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-attributes-config.component.ts @@ -22,9 +22,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { FetchTo } from '@home/components/rule-node/rule-node-config.models'; @Component({ - selector: 'tb-enrichment-node-originator-attributes-config', - templateUrl: './originator-attributes-config.component.html', - styleUrls: [] + selector: 'tb-enrichment-node-originator-attributes-config', + templateUrl: './originator-attributes-config.component.html', + styleUrls: [], + standalone: false }) export class OriginatorAttributesConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-fields-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-fields-config.component.ts index 9173ada5ee..102d61fddf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-fields-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/originator-fields-config.component.ts @@ -22,8 +22,9 @@ import { allowedOriginatorFields, FetchTo, SvMapOption } from '@home/components/ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-enrichment-node-originator-fields-config', - templateUrl: './originator-fields-config.component.html' + selector: 'tb-enrichment-node-originator-fields-config', + templateUrl: './originator-fields-config.component.html', + standalone: false }) export class OriginatorFieldsConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/related-attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/related-attributes-config.component.html index cdce036bbb..d80674e7fb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/related-attributes-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/related-attributes-config.component.html @@ -23,9 +23,11 @@
rule-node-config.data-to-fetch
- - {{ data.name }} - + @for (data of fetchToData; track data) { + + {{ data.name }} + + }
- - {{ data.name }} - + @for (data of fetchToData; track data) { + + {{ data.name }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/tenant-attributes-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/tenant-attributes-config.component.ts index 2d29081184..9635d2d45f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/tenant-attributes-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/tenant-attributes-config.component.ts @@ -22,9 +22,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { DataToFetch, dataToFetchTranslations, FetchTo } from '../rule-node-config.models'; @Component({ - selector: 'tb-enrichment-node-tenant-attributes-config', - templateUrl: './tenant-attributes-config.component.html', - styleUrls: ['./tenant-attributes-config.component.scss'] + selector: 'tb-enrichment-node-tenant-attributes-config', + templateUrl: './tenant-attributes-config.component.html', + styleUrls: ['./tenant-attributes-config.component.scss'], + standalone: false }) export class TenantAttributesConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 73605e0767..9b0a51d7ec 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html @@ -50,28 +50,39 @@ rule-node-config.ai.system-prompt - - {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} - - - {{ 'rule-node-config.ai.system-prompt-blank' | translate }} - + @if (aiConfigForm.get('systemPrompt').hasError('maxlength')) { + + {{ 'rule-node-config.ai.system-prompt-max-length' | translate }} + + } + @if (aiConfigForm.get('systemPrompt').hasError('pattern')) { + + {{ 'rule-node-config.ai.system-prompt-blank' | translate }} + + } rule-node-config.ai.user-prompt - - {{ 'rule-node-config.ai.user-prompt-required' | translate }} - - - {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} - - - {{ 'rule-node-config.ai.user-prompt-blank' | translate }} - + @if (aiConfigForm.get('userPrompt').hasError('required')) { + + {{ 'rule-node-config.ai.user-prompt-required' | translate }} + + } + @if (aiConfigForm.get('userPrompt').hasError('maxlength')) { + + {{ 'rule-node-config.ai.user-prompt-max-length' | translate }} + + } + @if (aiConfigForm.get('userPrompt').hasError('pattern')) { + + {{ 'rule-node-config.ai.user-prompt-blank' | translate }} + + }
- + rule-node-config.topic - - {{ 'rule-node-config.topic-required' | translate }} - + @if (azureIotHubConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.hostname - - {{ 'rule-node-config.hostname-required' | translate }} - + @if (azureIotHubConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.hostname-required' | translate }} + + } - + rule-node-config.device-id - - {{ 'rule-node-config.device-id-required' | translate }} - + @if (azureIotHubConfigForm.get('clientId').hasError('required')) { + + {{ 'rule-node-config.device-id-required' | translate }} + + } - @@ -50,74 +56,81 @@
- + rule-node-config.credentials-type - - {{ azureIotHubCredentialsTypeTranslationsMap.get(credentialsType) | translate }} - + @for (credentialsType of allAzureIotHubCredentialsTypes; track credentialsType) { + + {{ azureIotHubCredentialsTypeTranslationsMap.get(credentialsType) | translate }} + + } - - {{ 'rule-node-config.credentials-type-required' | translate }} - + @if (azureIotHubConfigForm.get('credentials.type').hasError('required')) { + + {{ 'rule-node-config.credentials-type-required' | translate }} + + } -
- - - - - rule-node-config.sas-key - - - - {{ 'rule-node-config.sas-key-required' | translate }} - - - - - - - - - - - - - - rule-node-config.private-key-password - - - - +
+ @switch (azureIotHubConfigForm.get('credentials.type').value) { + @case ('anonymous') {} + @case ('sas') { + + rule-node-config.sas-key + + + @if (azureIotHubConfigForm.get('credentials.sasKey').hasError('required')) { + + {{ 'rule-node-config.sas-key-required' | translate }} + + } + + + + } + @case ('cert.PEM') { + + + + + + + + rule-node-config.private-key-password + + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts index 772f411c44..ac7ecd58cb 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/azure-iot-hub-config.component.ts @@ -25,9 +25,10 @@ import { import { MqttVersion } from '@shared/models/mqtt.models'; @Component({ - selector: 'tb-external-node-azure-iot-hub-config', - templateUrl: './azure-iot-hub-config.component.html', - styleUrls: ['./mqtt-config.component.scss'] + selector: 'tb-external-node-azure-iot-hub-config', + templateUrl: './azure-iot-hub-config.component.html', + styleUrls: ['./mqtt-config.component.scss'], + standalone: false }) export class AzureIotHubConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index 965a0ca8b8..cc02018710 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html @@ -16,82 +16,101 @@ -->
- + rule-node-config.topic-pattern - - {{ 'rule-node-config.topic-pattern-required' | translate }} - + @if (kafkaConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.key-pattern rule-node-config.general-pattern-hint
rule-node-config.key-pattern-hint
- + rule-node-config.bootstrap-servers - - {{ 'rule-node-config.bootstrap-servers-required' | translate }} - + @if (kafkaConfigForm.get('bootstrapServers').hasError('required')) { + + {{ 'rule-node-config.bootstrap-servers-required' | translate }} + + } - + rule-node-config.retries - - {{ 'rule-node-config.min-retries-message' | translate }} - + @if (kafkaConfigForm.get('retries').hasError('min')) { + + {{ 'rule-node-config.min-retries-message' | translate }} + + } - + rule-node-config.batch-size-bytes - - {{ 'rule-node-config.min-batch-size-bytes-message' | translate }} - + @if (kafkaConfigForm.get('batchSize').hasError('min')) { + + {{ 'rule-node-config.min-batch-size-bytes-message' | translate }} + + } - + rule-node-config.linger-ms - - {{ 'rule-node-config.min-linger-ms-message' | translate }} - + @if (kafkaConfigForm.get('linger').hasError('min')) { + + {{ 'rule-node-config.min-linger-ms-message' | translate }} + + } - + rule-node-config.buffer-memory-bytes - - {{ 'rule-node-config.min-buffer-memory-bytes-message' | translate }} - + @if (kafkaConfigForm.get('bufferMemory').hasError('min')) { + + {{ 'rule-node-config.min-buffer-memory-bytes-message' | translate }} + + } - + rule-node-config.acks - - {{ ackValue }} - + @for (ackValue of ackValues; track ackValue) { + + {{ ackValue }} + + } - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.other-properties-empty' | translate"> + {{ 'rule-node-config.add-metadata-key-values-as-kafka-headers' | translate }}
rule-node-config.add-metadata-key-values-as-kafka-headers-hint
- - rule-node-config.charset-encoding - - - {{ ToByteStandartCharsetTypeTranslationMap.get(charset) | translate }} - - - + @if (kafkaConfigForm.get('addMetadataKeyValuesAsKafkaHeaders').value) { + + rule-node-config.charset-encoding + + @for (charset of ToByteStandartCharsetTypesValues; track charset) { + + {{ ToByteStandartCharsetTypeTranslationMap.get(charset) | translate }} + + } + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts index 41fd064314..3b8b07b108 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.ts @@ -23,9 +23,10 @@ import { } from '@home/components/rule-node/rule-node-config.models'; @Component({ - selector: 'tb-external-node-kafka-config', - templateUrl: './kafka-config.component.html', - styleUrls: [] + selector: 'tb-external-node-kafka-config', + templateUrl: './kafka-config.component.html', + styleUrls: [], + standalone: false }) export class KafkaConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html index 1839464bbe..4e80aad77b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html @@ -24,14 +24,16 @@ popupHelpLink="rulenode/node-templatization-doc">
- + {{'rule-node-config.function-name' | translate}} - - {{'rule-node-config.function-name-required' | translate}} - + @if (lambdaConfigForm.get('functionName').hasError('required')) { + + {{'rule-node-config.function-name-required' | translate}} + + } - + {{'rule-node-config.qualifier' | translate}} rule-node-config.qualifier-hint @@ -45,26 +47,32 @@ rule-node-config.aws-credentials
- + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (lambdaConfigForm.get('accessKey').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (lambdaConfigForm.get('secretKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (lambdaConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
@@ -76,31 +84,39 @@
- + rule-node-config.connection-timeout - - {{ 'rule-node-config.connection-timeout-required' | translate }} - - - {{ 'rule-node-config.connection-timeout-min' | translate }} - + @if (lambdaConfigForm.get('connectionTimeout').hasError('required')) { + + {{ 'rule-node-config.connection-timeout-required' | translate }} + + } + @if (lambdaConfigForm.get('connectionTimeout').hasError('min')) { + + {{ 'rule-node-config.connection-timeout-min' | translate }} + + } help + matTooltip="{{ 'rule-node-config.connection-timeout-hint' | translate }}">help - + rule-node-config.request-timeout - - {{ 'rule-node-config.request-timeout-required' | translate }} - - - {{ 'rule-node-config.request-timeout-min' | translate }} - + @if (lambdaConfigForm.get('requestTimeout').hasError('required')) { + + {{ 'rule-node-config.request-timeout-required' | translate }} + + } + @if (lambdaConfigForm.get('requestTimeout').hasError('min')) { + + {{ 'rule-node-config.request-timeout-min' | translate }} + + } help + matTooltip="{{ 'rule-node-config.request-timeout-hint' | translate }}">help
- + rule-node-config.topic-pattern - - {{ 'rule-node-config.topic-pattern-required' | translate }} - + @if (mqttConfigForm.get('topicPattern').hasError('required')) { + + {{ 'rule-node-config.topic-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint
- + rule-node-config.host - - {{ 'rule-node-config.host-required' | translate }} - + @if (mqttConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.host-required' | translate }} + + } - + rule-node-config.port - - {{ 'rule-node-config.port-required' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - + @if (mqttConfigForm.get('port').hasError('required')) { + + {{ 'rule-node-config.port-required' | translate }} + + } + @if (mqttConfigForm.get('port').hasError('min')) { + + {{ 'rule-node-config.port-range' | translate }} + + } + @if (mqttConfigForm.get('port').hasError('max')) { + + {{ 'rule-node-config.port-range' | translate }} + + } - + rule-node-config.connect-timeout - - {{ 'rule-node-config.connect-timeout-required' | translate }} - - - {{ 'rule-node-config.connect-timeout-range' | translate }} - - - {{ 'rule-node-config.connect-timeout-range' | translate }} - + @if (mqttConfigForm.get('connectTimeoutSec').hasError('required')) { + + {{ 'rule-node-config.connect-timeout-required' | translate }} + + } + @if (mqttConfigForm.get('connectTimeoutSec').hasError('min')) { + + {{ 'rule-node-config.connect-timeout-range' | translate }} + + } + @if (mqttConfigForm.get('connectTimeoutSec').hasError('max')) { + + {{ 'rule-node-config.connect-timeout-range' | translate }} + + }
- + rule-node-config.client-id {{'rule-node-config.client-id-hint' | translate}} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.ts index bf0f5d174a..1fe03704dd 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/mqtt-config.component.ts @@ -20,9 +20,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-mqtt-config', - templateUrl: './mqtt-config.component.html', - styleUrls: ['./mqtt-config.component.scss'] + selector: 'tb-external-node-mqtt-config', + templateUrl: './mqtt-config.component.html', + styleUrls: ['./mqtt-config.component.scss'], + standalone: false }) export class MqttConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html index ce937fe7d7..8101d2b2a6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/notification-config.component.html @@ -19,12 +19,14 @@
- + rule-node-config.gcp-project-id - - {{ 'rule-node-config.gcp-project-id-required' | translate }} - + @if (pubSubConfigForm.get('projectId').hasError('required')) { + + {{ 'rule-node-config.gcp-project-id-required' | translate }} + + } - + rule-node-config.pubsub-topic-name - - {{ 'rule-node-config.pubsub-topic-name-required' | translate }} - + @if (pubSubConfigForm.get('topicName').hasError('required')) { + + {{ 'rule-node-config.pubsub-topic-name-required' | translate }} + + }
- - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.ts index 652b8dca5b..4a7d9f972f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-pub-sub-config', - templateUrl: './pubsub-config.component.html', - styleUrls: [] + selector: 'tb-external-node-pub-sub-config', + templateUrl: './pubsub-config.component.html', + styleUrls: [], + standalone: false }) export class PubSubConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html index ff4c4e71e7..71b53b53f6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.html @@ -16,53 +16,63 @@ -->
- + rule-node-config.exchange-name-pattern - + rule-node-config.routing-key-pattern - + rule-node-config.message-properties - - {{ property }} - + @for (property of messageProperties; track property) { + + {{ property }} + + }
- + rule-node-config.host - - {{ 'rule-node-config.host-required' | translate }} - + @if (rabbitMqConfigForm.get('host').hasError('required')) { + + {{ 'rule-node-config.host-required' | translate }} + + } - + rule-node-config.port - - {{ 'rule-node-config.port-required' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - - - {{ 'rule-node-config.port-range' | translate }} - + @if (rabbitMqConfigForm.get('port').hasError('required')) { + + {{ 'rule-node-config.port-required' | translate }} + + } + @if (rabbitMqConfigForm.get('port').hasError('min')) { + + {{ 'rule-node-config.port-range' | translate }} + + } + @if (rabbitMqConfigForm.get('port').hasError('max')) { + + {{ 'rule-node-config.port-range' | translate }} + + }
- + rule-node-config.virtual-host - + rule-node-config.username - + rule-node-config.password @@ -70,27 +80,32 @@ {{ 'rule-node-config.automatic-recovery' | translate }} - + rule-node-config.connection-timeout-ms - - {{ 'rule-node-config.min-connection-timeout-ms-message' | translate }} - + @if (rabbitMqConfigForm.get('connectionTimeout').hasError('min')) { + + {{ 'rule-node-config.min-connection-timeout-ms-message' | translate }} + + } - + rule-node-config.handshake-timeout-ms - - {{ 'rule-node-config.min-handshake-timeout-ms-message' | translate }} - + @if (rabbitMqConfigForm.get('handshakeTimeout').hasError('min')) { + + {{ 'rule-node-config.min-handshake-timeout-ms-message' | translate }} + + } - - + [keyText]="'rule-node-config.key' | translate" + [keyRequiredText]="'rule-node-config.key-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.client-properties-empty' | translate"> +
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.ts index d4fdc4601a..2f44901d52 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rabbit-mq-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-rabbit-mq-config', - templateUrl: './rabbit-mq-config.component.html', - styleUrls: [] + selector: 'tb-external-node-rabbit-mq-config', + templateUrl: './rabbit-mq-config.component.html', + styleUrls: [], + standalone: false }) export class RabbitMqConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html index 1a91cba5c7..f5ec1ba156 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.html @@ -15,116 +15,176 @@ limitations under the License. --> -
- - rule-node-config.endpoint-url-pattern - - - {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} - - rule-node-config.general-pattern-hint - - +
+
+
rule-node-config.request-url
+ + rule-node-config.url + + @if (restApiCallConfigForm.get('restEndpointUrlPattern').hasError('required')) { + + {{ 'rule-node-config.endpoint-url-pattern-required' | translate }} + + } + rule-node-config.url-hint + + + + {{ 'rule-node-config.query-params' | translate }} +
+
+ + + + +
+
+ rule-node-config.request-method - - {{ requestType }} - + @for (requestType of httpRequestTypes; track requestType) { + + {{ requestType }} + + } - - {{ 'rule-node-config.enable-proxy' | translate }} - - - {{ 'rule-node-config.use-simple-client-http-factory' | translate }} - - - {{ 'rule-node-config.parse-to-plain-text' | translate }} - -
rule-node-config.parse-to-plain-text-hint
- - {{ 'rule-node-config.ignore-request-body' | translate }} - -
- - {{ 'rule-node-config.use-system-proxy-properties' | translate }} - -
-
- - rule-node-config.proxy-scheme - - - {{ proxyScheme }} - - +
+ + + {{ 'rule-node-config.headers' | translate }} + + +
+ + +
+
+
+ +
+ + + {{ 'rule-node-config.advanced-settings' | translate }} + + +
+ + {{ 'rule-node-config.enable-proxy' | translate }} + + @if (restApiCallConfigForm.get('enableProxy').value) { +
+ + {{ 'rule-node-config.use-system-proxy-properties' | translate }} + + @if (!restApiCallConfigForm.get('useSystemProxyProperties').value) { +
+
+ + rule-node-config.proxy-host + + @if (restApiCallConfigForm.get('proxyHost').hasError('required')) { + + {{ 'rule-node-config.proxy-host-required' | translate }} + + } + + + rule-node-config.proxy-port + + @if (restApiCallConfigForm.get('proxyPort').hasError('required')) { + + {{ 'rule-node-config.proxy-port-required' | translate }} + + } + @if (restApiCallConfigForm.get('proxyPort').hasError('min') || restApiCallConfigForm.get('proxyPort').hasError('max')) { + + {{ 'rule-node-config.proxy-port-range' | translate }} + + } + +
+ + rule-node-config.proxy-user + + + + rule-node-config.proxy-password + + + +
+ } +
+ } +
+
+ +
+ {{ 'rule-node-config.parse-to-plain-text' | translate }} +
+
+
+
+ + {{ 'rule-node-config.ignore-request-body' | translate }} + + @if(!restApiCallConfigForm.get('ignoreRequestBody').value) { +
+ + rule-node-config.request-body-template + + + } +
+ + rule-node-config.read-timeout + + rule-node-config.read-timeout-hint + @if (restApiCallConfigForm.get('readTimeoutMs').hasError('max')) { + + {{ 'rule-node-config.int-range' | translate }} + + } - - rule-node-config.proxy-host - - - {{ 'rule-node-config.proxy-host-required' | translate }} - + + rule-node-config.max-parallel-requests-count + + rule-node-config.max-parallel-requests-count-hint + @if (restApiCallConfigForm.get('maxParallelRequestsCount').hasError('max')) { + + {{ 'rule-node-config.int-range' | translate }} + + } - - rule-node-config.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} - - - {{ 'rule-node-config.proxy-port-range' | translate }} - + + rule-node-config.max-response-size + + rule-node-config.max-response-size-hint + @if (restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('min') || restApiCallConfigForm.get('maxInMemoryBufferSizeInKb').hasError('max')) { + + {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} + + } -
- - rule-node-config.proxy-user - - - - rule-node-config.proxy-password - - -
+ +
- - rule-node-config.read-timeout - - rule-node-config.read-timeout-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-parallel-requests-count - - rule-node-config.max-parallel-requests-count-hint - - {{ 'rule-node-config.int-range' | translate }} - - - - rule-node-config.max-response-size - - rule-node-config.max-response-size-hint - - {{ 'rule-node-config.memory-buffer-size-range' | translate: { max: MemoryBufferSizeInKbLimit } }} - - - -
- - -
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss new file mode 100644 index 0000000000..a4ad3fe4f8 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss @@ -0,0 +1,32 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host ::ng-deep { + .mat-expansion-panel-content { + .mat-expansion-panel-body { + .tb-kv-map-config { + margin-bottom: 0; + .body { + .mdc-icon-button { + margin-bottom: 22px; + } + } + } + } + } + textarea.tb-enable-vertical-resize { + resize: vertical; + } +} diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts index 3be6a6499b..32ea3d57d9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.ts @@ -18,21 +18,25 @@ import { Component } from '@angular/core'; import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; import { HttpRequestType, IntLimit } from '../rule-node-config.models'; +import { isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-external-node-rest-api-call-config', - templateUrl: './rest-api-call-config.component.html', - styleUrls: [] + selector: 'tb-external-node-rest-api-call-config', + templateUrl: './rest-api-call-config.component.html', + styleUrls: ['./rest-api-call-config.component.scss'], + standalone: false }) export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { restApiCallConfigForm: UntypedFormGroup; - readonly proxySchemes: string[] = ['http', 'https']; readonly httpRequestTypes = Object.keys(HttpRequestType); readonly MemoryBufferSizeInKbLimit = 25000; readonly IntLimit = IntLimit; + isHeadersExpanded = false; + isQueryParamsExpanded = false; + constructor(private fb: UntypedFormBuilder) { super(); } @@ -45,12 +49,11 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { this.restApiCallConfigForm = this.fb.group({ restEndpointUrlPattern: [configuration ? configuration.restEndpointUrlPattern : null, [Validators.required]], requestMethod: [configuration ? configuration.requestMethod : null, [Validators.required]], - useSimpleClientHttpFactory: [configuration ? configuration.useSimpleClientHttpFactory : false, []], parseToPlainText: [configuration ? configuration.parseToPlainText : false, []], ignoreRequestBody: [configuration ? configuration.ignoreRequestBody : false, []], + requestBodyTemplate: [configuration ? configuration.requestBodyTemplate : null, []], enableProxy: [configuration ? configuration.enableProxy : false, []], useSystemProxyProperties: [configuration ? configuration.enableProxy : false, []], - proxyScheme: [configuration ? configuration.proxyHost : null, []], proxyHost: [configuration ? configuration.proxyHost : null, []], proxyPort: [configuration ? configuration.proxyPort : null, []], proxyUser: [configuration ? configuration.proxyUser :null, []], @@ -59,16 +62,27 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { maxParallelRequestsCount: [configuration ? configuration.maxParallelRequestsCount : null, [Validators.min(0), Validators.max(IntLimit)]], headers: [configuration ? configuration.headers : null, []], credentials: [configuration ? configuration.credentials : null, []], - maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]] + maxInMemoryBufferSizeInKb: [configuration ? configuration.maxInMemoryBufferSizeInKb : null, [Validators.min(1), Validators.max(this.MemoryBufferSizeInKbLimit)]], + queryParams: [configuration ? configuration.queryParams : [], []], }); } protected validatorTriggers(): string[] { - return ['useSimpleClientHttpFactory', 'enableProxy', 'useSystemProxyProperties']; + return ['enableProxy', 'useSystemProxyProperties']; + } + + protected prepareInputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + this.isQueryParamsExpanded = configuration && isDefinedAndNotNull(configuration.queryParams) && !!Object.keys(configuration.queryParams).length; + this.isHeadersExpanded = configuration && isDefinedAndNotNull(configuration.headers) && !!Object.keys(configuration.headers).length; + return super.prepareInputConfig(configuration); + } + + protected prepareOutputConfig(configuration: RuleNodeConfiguration): RuleNodeConfiguration { + configuration.queryParams = isDefinedAndNotNull(configuration.queryParams) ? configuration.queryParams : []; + return super.prepareOutputConfig(configuration); } protected updateValidators(emitEvent: boolean) { - const useSimpleClientHttpFactory: boolean = this.restApiCallConfigForm.get('useSimpleClientHttpFactory').value; const enableProxy: boolean = this.restApiCallConfigForm.get('enableProxy').value; const useSystemProxyProperties: boolean = this.restApiCallConfigForm.get('useSystemProxyProperties').value; @@ -79,12 +93,7 @@ export class RestApiCallConfigComponent extends RuleNodeConfigurationComponent { } else { this.restApiCallConfigForm.get('proxyHost').setValidators([]); this.restApiCallConfigForm.get('proxyPort').setValidators([]); - - if (useSimpleClientHttpFactory) { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([]); - } else { - this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); - } + this.restApiCallConfigForm.get('readTimeoutMs').setValidators([Validators.min(0), Validators.max(IntLimit)]); } this.restApiCallConfigForm.get('readTimeoutMs').updateValueAndValidity({emitEvent}); diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html index 98e2c2f686..9f5f9a590f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.html @@ -19,98 +19,126 @@ {{ 'rule-node-config.use-system-smtp-settings' | translate }} -
- - rule-node-config.smtp-protocol - - - {{ smtpProtocol.toUpperCase() }} - - - -
- - rule-node-config.smtp-host - - - {{ 'rule-node-config.smtp-host-required' | translate }} - + @if (sendEmailConfigForm.get('useSystemSmtpSettings').value === false) { +
+ + rule-node-config.smtp-protocol + + @for (smtpProtocol of smtpProtocols; track smtpProtocol) { + + {{ smtpProtocol.toUpperCase() }} + + } + - - rule-node-config.smtp-port - - - {{ 'rule-node-config.smtp-port-required' | translate }} - - - {{ 'rule-node-config.smtp-port-range' | translate }} - - - {{ 'rule-node-config.smtp-port-range' | translate }} - - -
- - rule-node-config.timeout-msec - - - {{ 'rule-node-config.timeout-required' | translate }} - - - {{ 'rule-node-config.min-timeout-msec-message' | translate }} - - - - {{ 'rule-node-config.enable-tls' | translate }} - - - rule-node-config.tls-version - - - {{ tlsVersion }} - - - - - {{ 'rule-node-config.enable-proxy' | translate }} - -
- - rule-node-config.proxy-host - - - {{ 'rule-node-config.proxy-host-required' | translate }} - + + rule-node-config.smtp-host + + @if (sendEmailConfigForm.get('smtpHost').hasError('required')) { + + {{ 'rule-node-config.smtp-host-required' | translate }} + + } + + + rule-node-config.smtp-port + + @if (sendEmailConfigForm.get('smtpPort').hasError('required')) { + + {{ 'rule-node-config.smtp-port-required' | translate }} + + } + @if (sendEmailConfigForm.get('smtpPort').hasError('min')) { + + {{ 'rule-node-config.smtp-port-range' | translate }} + + } + @if (sendEmailConfigForm.get('smtpPort').hasError('max')) { + + {{ 'rule-node-config.smtp-port-range' | translate }} + + } - - rule-node-config.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} +
+ + rule-node-config.timeout-msec + + @if (sendEmailConfigForm.get('timeout').hasError('required')) { + + {{ 'rule-node-config.timeout-required' | translate }} - - {{ 'rule-node-config.proxy-port-range' | translate }} + } + @if (sendEmailConfigForm.get('timeout').hasError('min')) { + + {{ 'rule-node-config.min-timeout-msec-message' | translate }} + } + + + {{ 'rule-node-config.enable-tls' | translate }} + + @if (sendEmailConfigForm.get('enableTls').value === true) { + + rule-node-config.tls-version + + @for (tlsVersion of tlsVersions; track tlsVersion) { + + {{ tlsVersion }} + + } + -
- - rule-node-config.proxy-user - + } + + {{ 'rule-node-config.enable-proxy' | translate }} + + @if (sendEmailConfigForm.get('enableProxy').value) { +
+
+ + rule-node-config.proxy-host + + @if (sendEmailConfigForm.get('proxyHost').hasError('required')) { + + {{ 'rule-node-config.proxy-host-required' | translate }} + + } + + + rule-node-config.proxy-port + + @if (sendEmailConfigForm.get('proxyPort').hasError('required')) { + + {{ 'rule-node-config.proxy-port-required' | translate }} + + } + @if (sendEmailConfigForm.get('proxyPort').hasError('min') || sendEmailConfigForm.get('proxyPort').hasError('max')) { + + {{ 'rule-node-config.proxy-port-range' | translate }} + + } + +
+ + rule-node-config.proxy-user + + + + rule-node-config.proxy-password + + +
+ } + + rule-node-config.username + - - rule-node-config.proxy-password - + + rule-node-config.password + + -
- - rule-node-config.username - - - - rule-node-config.password - - - -
+
+ } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.ts index fd2b587446..98bbdc0ff5 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-email-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-send-email-config', - templateUrl: './send-email-config.component.html', - styleUrls: [] + selector: 'tb-external-node-send-email-config', + templateUrl: './send-email-config.component.html', + styleUrls: [], + standalone: false }) export class SendEmailConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html index 1f6762c082..d9136b3cb6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.html @@ -16,28 +16,33 @@ -->
- + rule-node-config.numbers-to-template - - {{ 'rule-node-config.numbers-to-template-required' | translate }} - + @if (sendSmsConfigForm.get('numbersToTemplate').hasError('required')) { + + {{ 'rule-node-config.numbers-to-template-required' | translate }} + + } - + rule-node-config.sms-message-template - - - {{ 'rule-node-config.sms-message-template-required' | translate }} - + + @if (sendSmsConfigForm.get('smsMessageTemplate').hasError('required')) { + + {{ 'rule-node-config.sms-message-template-required' | translate }} + + } rule-node-config.general-pattern-hint {{ 'rule-node-config.use-system-sms-settings' | translate }} - - + @if (sendSmsConfigForm.get('useSystemSmsSettings').value === false) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.ts index dbf8afb6c6..38c1cee016 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/send-sms-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-send-sms-config', - templateUrl: './send-sms-config.component.html', - styleUrls: [] + selector: 'tb-external-node-send-sms-config', + templateUrl: './send-sms-config.component.html', + styleUrls: [], + standalone: false }) export class SendSmsConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html index 2cf15b05e7..31f91ba86a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html @@ -16,31 +16,40 @@ -->
- + rule-node-config.message-template - - {{ 'rule-node-config.message-template-required' | translate }} - + @if (slackConfigForm.get('messageTemplate').hasError('required')) { + + {{ 'rule-node-config.message-template-required' | translate }} + + } rule-node-config.general-pattern-hint {{ 'rule-node-config.use-system-slack-settings' | translate }} - - rule-node-config.slack-api-token - - - {{ 'rule-node-config.slack-api-token-required' | translate }} - - + @if (!slackConfigForm.get('useSystemSettings').value) { + + rule-node-config.slack-api-token + + @if (slackConfigForm.get('botToken').hasError('required')) { + + {{ 'rule-node-config.slack-api-token-required' | translate }} + + } + + } - - {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} - + @for (slackChanelType of slackChanelTypes; track slackChanelType) { + + {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} + + }
- + rule-node-config.topic-arn-pattern - - {{ 'rule-node-config.topic-arn-pattern-required' | translate }} - + @if (snsConfigForm.get('topicArnPattern').hasError('required')) { + + {{ 'rule-node-config.topic-arn-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (snsConfigForm.get('accessKeyId').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (snsConfigForm.get('secretAccessKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (snsConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sns-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/sns-config.component.ts index 352fda3d70..cc9f18c181 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/sns-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/sns-config.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-external-node-sns-config', - templateUrl: './sns-config.component.html', - styleUrls: [] + selector: 'tb-external-node-sns-config', + templateUrl: './sns-config.component.html', + styleUrls: [], + standalone: false }) export class SnsConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html index 8e3de40601..e803d37921 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html @@ -16,61 +16,78 @@ -->
- + rule-node-config.queue-type - - {{ sqsQueueTypeTranslationsMap.get(type) | translate }} - + @for (type of sqsQueueTypes; track type) { + + {{ sqsQueueTypeTranslationsMap.get(type) | translate }} + + } - + rule-node-config.queue-url-pattern - - {{ 'rule-node-config.queue-url-pattern-required' | translate }} - + @if (sqsConfigForm.get('queueUrlPattern').hasError('required')) { + + {{ 'rule-node-config.queue-url-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - - rule-node-config.delay-seconds - - - {{ 'rule-node-config.min-delay-seconds-message' | translate }} - - - {{ 'rule-node-config.max-delay-seconds-message' | translate }} - - + @if (sqsConfigForm.get('queueType').value === sqsQueueType.STANDARD) { + + rule-node-config.delay-seconds + + @if (sqsConfigForm.get('delaySeconds').hasError('min')) { + + {{ 'rule-node-config.min-delay-seconds-message' | translate }} + + } + @if (sqsConfigForm.get('delaySeconds').hasError('max')) { + + {{ 'rule-node-config.max-delay-seconds-message' | translate }} + + } + + }
- - - + [keyText]="'rule-node-config.name' | translate" + [keyRequiredText]="'rule-node-config.name-required' | translate" + [valText]="'rule-node-config.value' | translate" + [valRequiredText]="'rule-node-config.value-required' | translate" + [emptyText]="'rule-node-config.message-attributes-empty' | translate"> + + rule-node-config.aws-access-key-id - - {{ 'rule-node-config.aws-access-key-id-required' | translate }} - + @if (sqsConfigForm.get('accessKeyId').hasError('required')) { + + {{ 'rule-node-config.aws-access-key-id-required' | translate }} + + } - + rule-node-config.aws-secret-access-key - - {{ 'rule-node-config.aws-secret-access-key-required' | translate }} - + @if (sqsConfigForm.get('secretAccessKey').hasError('required')) { + + {{ 'rule-node-config.aws-secret-access-key-required' | translate }} + + } - + rule-node-config.aws-region - - {{ 'rule-node-config.aws-region-required' | translate }} - + @if (sqsConfigForm.get('region').hasError('required')) { + + {{ 'rule-node-config.aws-region-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts index 7170db813e..7e5f93de67 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.ts @@ -20,16 +20,17 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { SqsQueueType, sqsQueueTypeTranslations } from '@home/components/rule-node/rule-node-config.models'; @Component({ - selector: 'tb-external-node-sqs-config', - templateUrl: './sqs-config.component.html', - styleUrls: [] + selector: 'tb-external-node-sqs-config', + templateUrl: './sqs-config.component.html', + styleUrls: [], + standalone: false }) export class SqsConfigComponent extends RuleNodeConfigurationComponent { sqsConfigForm: UntypedFormGroup; sqsQueueType = SqsQueueType; - sqsQueueTypes = Object.keys(SqsQueueType); + sqsQueueTypes = Object.values(SqsQueueType); sqsQueueTypeTranslationsMap = sqsQueueTypeTranslations; constructor(private fb: UntypedFormBuilder) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-alarm-status.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-alarm-status.component.ts index 082b6c0585..b64f9374fc 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-alarm-status.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-alarm-status.component.ts @@ -20,9 +20,10 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-filter-node-check-alarm-status-config', - templateUrl: './check-alarm-status.component.html', - styleUrls: [] + selector: 'tb-filter-node-check-alarm-status-config', + templateUrl: './check-alarm-status.component.html', + styleUrls: [], + standalone: false }) export class CheckAlarmStatusComponent extends RuleNodeConfigurationComponent { alarmStatusConfigForm: FormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html index c4cd8f18ed..ae5a957bf6 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.html @@ -23,6 +23,7 @@
@@ -30,6 +31,7 @@ matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.field-name' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.ts index 8bad5ac4ca..9f44bf83c1 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-message-config.component.ts @@ -20,9 +20,10 @@ import { FormBuilder, FormGroup, ValidationErrors, ValidatorFn, Validators } fro import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-filter-node-check-message-config', - templateUrl: './check-message-config.component.html', - styleUrls: [] + selector: 'tb-filter-node-check-message-config', + templateUrl: './check-message-config.component.html', + styleUrls: [], + standalone: false }) export class CheckMessageConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html index 07a1d1068a..6aee9bf101 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html @@ -18,15 +18,18 @@
rule-node-config.relation-search-parameters
- + {{ 'relation.direction' | translate }} - - {{ entitySearchDirectionTranslationsMap.get(direction) | translate }} rule-node-config.relations-query-config-direction-suffix - + @for (direction of entitySearchDirection; track direction) { + + {{ entitySearchDirectionTranslationsMap.get(direction) | translate }} rule-node-config.relations-query-config-direction-suffix + + } @@ -36,20 +39,25 @@ {{ 'rule-node-config.check-relation-to-specific-entity' | translate }}
-
- - - - -
+ @if (checkRelationConfigForm.get('checkForSingleEntity').value) { +
+ + + @if (checkRelationConfigForm.get('entityType').value) { + + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.ts index 09196196bf..a4678eef0e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.ts @@ -21,9 +21,10 @@ import { EntitySearchDirection, entitySearchDirectionTranslations } from '@app/s import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-filter-node-check-relation-config', - templateUrl: './check-relation-config.component.html', - styleUrls: ['./check-relation-config.component.scss'] + selector: 'tb-filter-node-check-relation-config', + templateUrl: './check-relation-config.component.html', + styleUrls: ['./check-relation-config.component.scss'], + standalone: false }) export class CheckRelationConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html index ec2a5ba89a..13bc0b15dd 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.html @@ -20,37 +20,43 @@
rule-node-config.coordinate-field-names
- + {{ 'rule-node-config.latitude-field-name' | translate }} - - {{ 'rule-node-config.latitude-field-name-required' | translate }} - + @if (geoFilterConfigForm.get('latitudeKeyName').hasError('required')) { + + {{ 'rule-node-config.latitude-field-name-required' | translate }} + + } - + {{ 'rule-node-config.longitude-field-name' | translate }} - - {{ 'rule-node-config.longitude-field-name-required' | translate }} - + @if (geoFilterConfigForm.get('longitudeKeyName').hasError('required')) { + + {{ 'rule-node-config.longitude-field-name-required' | translate }} + + }
-
rule-node-config.coordinate-field-hint
+
rule-node-config.coordinate-field-hint
rule-node-config.geofence-configuration
- + {{ 'rule-node-config.perimeter-type' | translate }} - - {{ perimeterTypeTranslationMap.get(type) | translate }} - + @for (type of perimeterTypes; track type) { + + {{ perimeterTypeTranslationMap.get(type) | translate }} + + }
+ class="tb-form-row no-border no-padding slide-toggle"> {{ 'rule-node-config.fetch-perimeter-info-from-metadata' | translate }}
- - {{ 'rule-node-config.perimeter-key-name' | translate }} - - - {{ 'rule-node-config.perimeter-key-name-required' | translate }} - - {{ 'rule-node-config.perimeter-key-name-hint' | translate }} - -
-
- - {{ 'rule-node-config.circle-center-latitude' | translate }} - - - {{ 'rule-node-config.circle-center-latitude-required' | translate }} + @if (geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { + + {{ 'rule-node-config.perimeter-key-name' | translate }} + + @if (geoFilterConfigForm.get('perimeterKeyName').hasError('required')) { + + {{ 'rule-node-config.perimeter-key-name-required' | translate }} - - - {{ 'rule-node-config.circle-center-longitude' | translate }} - - - {{ 'rule-node-config.circle-center-longitude-required' | translate }} - - + } + {{ 'rule-node-config.perimeter-key-name-hint' | translate }} + + } + @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.CIRCLE && + !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { +
+
+ + {{ 'rule-node-config.circle-center-latitude' | translate }} + + @if (geoFilterConfigForm.get('centerLatitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-latitude-required' | translate }} + + } + + + {{ 'rule-node-config.circle-center-longitude' | translate }} + + @if (geoFilterConfigForm.get('centerLongitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-longitude-required' | translate }} + + } + +
+
+ + {{ 'rule-node-config.range' | translate }} + + @if (geoFilterConfigForm.get('range').hasError('required')) { + + {{ 'rule-node-config.range-required' | translate }} + + } + + + {{ 'rule-node-config.range-units' | translate }} + + @for (type of rangeUnits; track type) { + + {{ rangeUnitTranslationMap.get(type) | translate }} + + } + + @if (geoFilterConfigForm.get('rangeUnit').hasError('required')) { + + {{ 'rule-node-config.range-units-required' | translate }} + + } + +
-
- - {{ 'rule-node-config.range' | translate }} - - - {{ 'rule-node-config.range-required' | translate }} + } + @if (geoFilterConfigForm.get('perimeterType').value === perimeterType.POLYGON && + !geoFilterConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { + + {{ 'rule-node-config.polygon-definition' | translate }} + + {{ 'rule-node-config.polygon-definition-hint' | translate }} + @if (geoFilterConfigForm.get('polygonsDefinition').hasError('required')) { + + {{ 'rule-node-config.polygon-definition-required' | translate }} - - - {{ 'rule-node-config.range-units' | translate }} - - - {{ rangeUnitTranslationMap.get(type) | translate }} - - - - {{ 'rule-node-config.range-units-required' | translate }} - - -
-
- - {{ 'rule-node-config.polygon-definition' | translate }} - - {{ 'rule-node-config.polygon-definition-hint' | translate }} - - {{ 'rule-node-config.polygon-definition-required' | translate }} - - + } + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.ts index e92af96ee6..17aaf675f3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/gps-geo-filter-config.component.ts @@ -21,9 +21,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { PerimeterType, perimeterTypeTranslations, RangeUnit, rangeUnitTranslations } from '../rule-node-config.models'; @Component({ - selector: 'tb-filter-node-gps-geofencing-config', - templateUrl: './gps-geo-filter-config.component.html', - styleUrls: ['./gps-geo-filter-config.component.scss'] + selector: 'tb-filter-node-gps-geofencing-config', + templateUrl: './gps-geo-filter-config.component.html', + styleUrls: ['./gps-geo-filter-config.component.scss'], + standalone: false }) export class GpsGeoFilterConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/message-type-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/message-type-config.component.ts index 9145719950..34596c141c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/message-type-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/message-type-config.component.ts @@ -20,9 +20,10 @@ import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-filter-node-message-type-config', - templateUrl: './message-type-config.component.html', - styleUrls: [] + selector: 'tb-filter-node-message-type-config', + templateUrl: './message-type-config.component.html', + styleUrls: [], + standalone: false }) export class MessageTypeConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html index a0d90bab4a..b94519d914 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/originator-type-config.component.html @@ -17,6 +17,7 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.ts index 9bbc9601e7..9d1a7d511f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.ts @@ -24,9 +24,10 @@ import type { JsFuncComponent } from '@app/shared/components/js-func.component'; import { DebugRuleNodeEventBody } from '@app/shared/models/event.models'; @Component({ - selector: 'tb-filter-node-script-config', - templateUrl: './script-config.component.html', - styleUrls: [] + selector: 'tb-filter-node-script-config', + templateUrl: './script-config.component.html', + styleUrls: [], + standalone: false }) export class ScriptConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.html index 28ff15604e..42b8f371a9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.html @@ -16,41 +16,47 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (switchConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (switchConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.ts index 26e6d0a21e..8a00fe94e8 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/switch-config.component.ts @@ -27,9 +27,10 @@ import type { JsFuncComponent } from '@app/shared/components/js-func.component'; import { DebugRuleNodeEventBody } from '@shared/models/event.models'; @Component({ - selector: 'tb-filter-node-switch-config', - templateUrl: './switch-config.component.html', - styleUrls: [] + selector: 'tb-filter-node-switch-config', + templateUrl: './switch-config.component.html', + styleUrls: [], + standalone: false }) export class SwitchConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html index c4d813b757..8b8ff8ffa4 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.html @@ -27,6 +27,7 @@ [excludeEntityIds]="[ruleChainId]" [entityType]="entityType.RULE_CHAIN" [entitySubtype]="ruleChainType" + appearance="outline" formControlName="ruleChainId">
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.ts index 87cf38bd7c..0403a37563 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-input.component.ts @@ -20,9 +20,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m import { EntityType } from '@shared/models/entity-type.models'; @Component({ - selector: 'tb-flow-node-rule-chain-input-config', - templateUrl: './rule-chain-input.component.html', - styleUrls: [] + selector: 'tb-flow-node-rule-chain-input-config', + templateUrl: './rule-chain-input.component.html', + styleUrls: [], + standalone: false }) export class RuleChainInputComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-output.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-output.component.ts index f4b2760f04..0505a2be7f 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-output.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/flow/rule-chain-output.component.ts @@ -19,9 +19,10 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-flow-node-rule-chain-output-config', - templateUrl: './rule-chain-output.component.html', - styleUrls: [] + selector: 'tb-flow-node-rule-chain-output-config', + templateUrl: './rule-chain-output.component.html', + styleUrls: [], + standalone: false }) export class RuleChainOutputComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts b/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts index f326e7e9ce..7a2bec7e33 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/rule-node-config.models.ts @@ -827,7 +827,7 @@ export enum AttributeScopeResult { SERVER_SCOPE = 'SERVER_SCOPE' } -export const AttributeScopeMap = new Map([ +export const AttributeScopeMap = new Map([ [AttributeScope.SHARED_SCOPE, 'rule-node-config.shared-scope'], [AttributeScope.SERVER_SCOPE, 'rule-node-config.server-scope'], [AttributeScope.CLIENT_SCOPE, 'rule-node-config.client-scope'] diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html index aecafa4164..efcea2eb7b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.html @@ -16,51 +16,60 @@ -->
- + rule-node-config.new-originator - - {{ originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value) | translate }} - + @if (originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value)) { + + {{ originatorSourceTranslationMap.get(changeOriginatorConfigForm.get('originatorSource').value) | translate }} + + } - - - {{ originatorSourceTranslationMap.get(source) | translate }} - -
- - {{ originatorSourceDescTranslationMap.get(source) | translate }} - -
-
-
-
- - -
- + + {{ originatorSourceTranslationMap.get(source) | translate }} + +
+ + {{ originatorSourceDescTranslationMap.get(source) | translate }} + + + } + + + @if (changeOriginatorConfigForm.get('originatorSource').value === originatorSource.ENTITY) { +
+ + +
+ + + + rule-node-config.entity-name-pattern + + @if (changeOriginatorConfigForm.get('entityNamePattern').hasError('required') || + changeOriginatorConfigForm.get('entityNamePattern').hasError('pattern')) { + + {{ 'rule-node-config.entity-name-pattern-required' | translate }} + + } + +
+
+ } + @if (changeOriginatorConfigForm.get('originatorSource').value === originatorSource.RELATED) { + -
- - rule-node-config.entity-name-pattern - - - {{ 'rule-node-config.entity-name-pattern-required' | translate }} - - -
-
- - -
+ formControlName="relationsQuery"> + + } + diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.ts index b5c63de344..3aeb18a453 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/change-originator-config.component.ts @@ -25,8 +25,9 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar import { EntityType } from '@app/shared/models/entity-type.models'; @Component({ - selector: 'tb-transformation-node-change-originator-config', - templateUrl: './change-originator-config.component.html' + selector: 'tb-transformation-node-change-originator-config', + templateUrl: './change-originator-config.component.html', + standalone: false }) export class ChangeOriginatorConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html index 0fc80e6197..a899a344b3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/copy-keys-config.component.html @@ -23,6 +23,7 @@
- + {{'rule-node-config.interval' | translate}} - - {{'rule-node-config.interval-required' | translate}} - - - {{'rule-node-config.interval-min-error' | translate}} - + @if (deduplicationConfigForm.get('interval').hasError('required')) { + + {{'rule-node-config.interval-required' | translate}} + + } + @if (deduplicationConfigForm.get('interval').hasError('min')) { + + {{'rule-node-config.interval-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.interval-hint' | translate }}">help
rule-node-config.strategy
- - {{ deduplicationStrategiesTranslations.get(strategy) | translate }} - + @for (strategy of deduplicationStrategies; track strategy) { + + {{ deduplicationStrategiesTranslations.get(strategy) | translate }} + + } - - - - - - -
- - -
+ @if (deduplicationConfigForm.get('strategy').value === 'ALL') { + + + } + @if (deduplicationConfigForm.get('strategy').value === 'FIRST') { + + + } + @if (deduplicationConfigForm.get('strategy').value === 'LAST') { + + + } + @if (deduplicationConfigForm.get('strategy').value === deduplicationStrategie.ALL) { +
+ + +
+ }
@@ -61,37 +73,49 @@ rule-node-config.advanced-settings
- + {{'rule-node-config.max-pending-msgs' | translate}} - - {{'rule-node-config.max-pending-msgs-required' | translate}} - - - {{'rule-node-config.max-pending-msgs-max-error' | translate}} - - - {{'rule-node-config.max-pending-msgs-min-error' | translate}} - + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('required')) { + + {{'rule-node-config.max-pending-msgs-required' | translate}} + + } + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('max')) { + + {{'rule-node-config.max-pending-msgs-max-error' | translate}} + + } + @if (deduplicationConfigForm.get('maxPendingMsgs').hasError('min')) { + + {{'rule-node-config.max-pending-msgs-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.max-pending-msgs-hint' | translate }}">help - + {{'rule-node-config.max-retries' | translate}} - - {{'rule-node-config.max-retries-required' | translate}} - - - {{'rule-node-config.max-retries-max-error' | translate}} - - - {{'rule-node-config.max-retries-min-error' | translate}} - + @if (deduplicationConfigForm.get('maxRetries').hasError('required')) { + + {{'rule-node-config.max-retries-required' | translate}} + + } + @if (deduplicationConfigForm.get('maxRetries').hasError('max')) { + + {{'rule-node-config.max-retries-max-error' | translate}} + + } + @if (deduplicationConfigForm.get('maxRetries').hasError('min')) { + + {{'rule-node-config.max-retries-min-error' | translate}} + + } help + matTooltip="{{ 'rule-node-config.max-retries-hint' | translate }}">help
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts index 4661084ef3..1efcc0351a 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.ts @@ -21,16 +21,17 @@ import { deduplicationStrategiesTranslations, FetchMode } from '@home/components import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-transformation-node-deduplication-config', - templateUrl: './deduplication-config.component.html', - styleUrls: [] + selector: 'tb-transformation-node-deduplication-config', + templateUrl: './deduplication-config.component.html', + styleUrls: [], + standalone: false }) export class DeduplicationConfigComponent extends RuleNodeConfigurationComponent { deduplicationConfigForm: FormGroup; deduplicationStrategie = FetchMode; - deduplicationStrategies = Object.keys(this.deduplicationStrategie); + deduplicationStrategies = Object.values(this.deduplicationStrategie); deduplicationStrategiesTranslations = deduplicationStrategiesTranslations; constructor(private fb: FormBuilder) { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html index c5d94739d6..4da3fdf45c 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/delete-keys-config.component.html @@ -22,6 +22,7 @@
- + {{ 'rule-node-config.json-path-expression' | translate }} {{ 'rule-node-config.json-path-expression-hint' | translate }} - {{ 'rule-node-config.json-path-expression-required' | translate }} + @if (jsonPathConfigForm.get('jsonPath').hasError('required')) { + {{ 'rule-node-config.json-path-expression-required' | translate }} + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.ts index e6e5d65d85..27719ccbb9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.ts @@ -19,9 +19,10 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/models/rule-node.models'; @Component({ - selector: 'tb-transformation-node-json-path-config', - templateUrl: './node-json-path-config.component.html', - styleUrls: [] + selector: 'tb-transformation-node-json-path-config', + templateUrl: './node-json-path-config.component.html', + styleUrls: [], + standalone: false }) export class NodeJsonPathConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.html index 89ce705ea9..8aeab13cd9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.html @@ -20,9 +20,11 @@
- - {{ data.name }} - + @for (data of renameIn; track data) { + + {{ data.name }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.ts index c99e6029a5..fc6a677878 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/rename-keys-config.component.ts @@ -23,9 +23,10 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@shared/m @Component({ - selector: 'tb-transformation-node-rename-keys-config', - templateUrl: './rename-keys-config.component.html', - styleUrls: ['./rename-keys-config.component.scss'] + selector: 'tb-transformation-node-rename-keys-config', + templateUrl: './rename-keys-config.component.html', + styleUrls: ['./rename-keys-config.component.scss'], + standalone: false }) export class RenameKeysConfigComponent extends RuleNodeConfigurationComponent { renameKeysConfigForm: FormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/script-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/script-config.component.html index 7b39ba131d..8d73d4c0ff 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/script-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/script-config.component.html @@ -16,41 +16,47 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (scriptConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
@@ -46,94 +48,106 @@
rule-node-config.recipients
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
- + rule-node-config.to-template - - {{ 'rule-node-config.to-template-required' | translate }} - + cdkAutosizeMinRows="1"> + @if (toEmailConfigForm.get('toTemplate').hasError('required')) { + + {{ 'rule-node-config.to-template-required' | translate }} + + } - + rule-node-config.cc-template + cdkAutosizeMinRows="1"> - + rule-node-config.bcc-template + cdkAutosizeMinRows="1">
rule-node-config.message-subject-and-content
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
- + rule-node-config.subject-template - - {{ 'rule-node-config.subject-template-required' | translate }} - + cdkAutosizeMinRows="1"> + @if (toEmailConfigForm.get('subjectTemplate').hasError('required')) { + + {{ 'rule-node-config.subject-template-required' | translate }} + + } - + rule-node-config.mail-body-type - - {{ getBodyTypeName() | translate }} - + @if (toEmailConfigForm.get('mailBodyType').value) { + + {{ getBodyTypeName() | translate }} + + } - - - {{ type.name | translate }} - -
- - {{ type.description | translate }} - -
-
-
- - rule-node-config.body-type-template - - rule-node-config.mail-body-types.after-template-evaluation-hint - - - rule-node-config.body-template - - - {{ 'rule-node-config.body-template-required' | translate }} - - -
-
-
+ @for (type of mailBodyTypes; track type) { + + + {{ type.name | translate }} + +
+ + {{ type.description | translate }} + +
+ } + +
+ @if (toEmailConfigForm.get('mailBodyType').value === 'dynamic') { + + rule-node-config.body-type-template + + rule-node-config.mail-body-types.after-template-evaluation-hint + + } + + rule-node-config.body-template + + @if (toEmailConfigForm.get('bodyTemplate').hasError('required')) { + + {{ 'rule-node-config.body-template-required' | translate }} + + } + + +
+ diff --git a/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.ts index e2c7117ae0..21a1748085 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/to-email-config.component.ts @@ -20,9 +20,10 @@ import { FormBuilder, FormGroup, Validators } from '@angular/forms'; import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shared/models/rule-node.models'; @Component({ - selector: 'tb-transformation-node-to-email-config', - templateUrl: './to-email-config.component.html', - styleUrls: ['./to-email-config.component.scss'] + selector: 'tb-transformation-node-to-email-config', + templateUrl: './to-email-config.component.html', + styleUrls: ['./to-email-config.component.scss'], + standalone: false }) export class ToEmailConfigComponent extends RuleNodeConfigurationComponent { diff --git a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html index 3beaa93ed1..f48f8b2191 100644 --- a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html @@ -16,26 +16,32 @@ --> - + admin.aws-access-key-id - - {{ 'admin.aws-access-key-id-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('accessKeyId').hasError('required')) { + + {{ 'admin.aws-access-key-id-required' | translate }} + + } - + admin.aws-secret-access-key - - {{ 'admin.aws-secret-access-key-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('secretAccessKey').hasError('required')) { + + {{ 'admin.aws-secret-access-key-required' | translate }} + + } - + admin.aws-region - - {{ 'admin.aws-region-required' | translate }} - + @if (awsSnsProviderConfigurationFormGroup.get('region').hasError('required')) { + + {{ 'admin.aws-region-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.ts index 772ae7366a..d864c3ccfd 100644 --- a/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.ts @@ -28,14 +28,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-aws-sns-provider-configuration', - templateUrl: './aws-sns-provider-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AwsSnsProviderConfigurationComponent), - multi: true - }] + selector: 'tb-aws-sns-provider-configuration', + templateUrl: './aws-sns-provider-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AwsSnsProviderConfigurationComponent), + multi: true + }], + standalone: false }) export class AwsSnsProviderConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html index bc1e9430c7..92b986f0c8 100644 --- a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html @@ -17,44 +17,54 @@ -->
- + admin.smpp-provider.smpp-version - - {{smppVersion.value}} - + @for (smppVersion of smppVersions; track smppVersion) { + + {{smppVersion.value}} + + } - + admin.smpp-provider.smpp-host - - {{'admin.smpp-provider.smpp-host-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('host').hasError('required')) { + + {{'admin.smpp-provider.smpp-host-required' | translate}} + + } - + admin.smpp-provider.smpp-port - - {{'admin.smpp-provider.smpp-port-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('port').hasError('required')) { + + {{'admin.smpp-provider.smpp-port-required' | translate}} + + }
- + admin.smpp-provider.system-id - - {{'admin.smpp-provider.system-id-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('systemId').hasError('required')) { + + {{'admin.smpp-provider.system-id-required' | translate}} + + } - + admin.smpp-provider.password - - {{'admin.smpp-provider.password-required' | translate}} - + @if (smppSmsProviderConfigurationFormGroup.get('password').hasError('required')) { + + {{'admin.smpp-provider.password-required' | translate}} + + }
@@ -65,19 +75,21 @@ - + admin.smpp-provider.system-type - + admin.smpp-provider.bind-type - - {{bindTypesTranslation.get(bindType) | translate}} - + @for (bindType of bindTypes; track bindType) { + + {{bindTypesTranslation.get(bindType) | translate}} + + } - + admin.smpp-provider.service-type @@ -90,24 +102,28 @@ - + admin.smpp-provider.source-address - + admin.smpp-provider.source-ton - - {{typeOfNumberMap.get(sourceTon).name | translate}} - + @for (sourceTon of typeOfNumber; track sourceTon) { + + {{typeOfNumberMap.get(sourceTon).name | translate}} + + } - + admin.smpp-provider.source-npi - - {{numberingPlanIdentificationMap.get(sourceNpi).name | translate}} - + @for (sourceNpi of numberingPlanIdentification; track sourceNpi) { + + {{numberingPlanIdentificationMap.get(sourceNpi).name | translate}} + + } @@ -119,20 +135,24 @@ - + admin.smpp-provider.destination-ton - - {{typeOfNumberMap.get(destinationTon).name | translate}} - + @for (destinationTon of typeOfNumber; track destinationTon) { + + {{typeOfNumberMap.get(destinationTon).name | translate}} + + } - + admin.smpp-provider.destination-npi - - {{numberingPlanIdentificationMap.get(destinationNpi).name | translate}} - + @for (destinationNpi of numberingPlanIdentification; track destinationNpi) { + + {{numberingPlanIdentificationMap.get(destinationNpi).name | translate}} + + } @@ -144,16 +164,18 @@ - + admin.smpp-provider.address-range - + admin.smpp-provider.coding-scheme - - {{codingSchemesMap.get(codingScheme).name | translate}} - + @for (codingScheme of codingSchemes; track codingScheme) { + + {{codingSchemesMap.get(codingScheme).name | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts index 04ea006af4..4a4341364b 100644 --- a/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.ts @@ -36,14 +36,15 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-smpp-sms-provider-configuration', - templateUrl: './smpp-sms-provider-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SmppSmsProviderConfigurationComponent), - multi: true - }] + selector: 'tb-smpp-sms-provider-configuration', + templateUrl: './smpp-sms-provider-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SmppSmsProviderConfigurationComponent), + multi: true + }], + standalone: false }) export class SmppSmsProviderConfigurationComponent implements ControlValueAccessor, OnInit{ @@ -67,16 +68,16 @@ export class SmppSmsProviderConfigurationComponent implements ControlValueAcces smppVersions = smppVersions; - bindTypes = Object.keys(BindTypes); + bindTypes = Object.values(BindTypes); bindTypesTranslation = bindTypesTranslationMap; - typeOfNumber = Object.keys(TypeOfNumber); + typeOfNumber = Object.values(TypeOfNumber); typeOfNumberMap = typeOfNumberMap; - numberingPlanIdentification = Object.keys(NumberingPlanIdentification); + numberingPlanIdentification = Object.values(NumberingPlanIdentification); numberingPlanIdentificationMap = numberingPlanIdentificationMap; - codingSchemes = Object.keys(CodingSchemes); + codingSchemes = Object.values(CodingSchemes); codingSchemesMap = codingSchemesMap; private propagateChange = (v: any) => { }; diff --git a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html index 544f8866f0..9d4d540a04 100644 --- a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.html @@ -16,35 +16,41 @@ -->
- + admin.sms-provider-type - - {{smsProviderTypeTranslations.get(type) | translate}} - + @for (type of smsProviderTypes; track type) { + + {{smsProviderTypeTranslations.get(type) | translate}} + + } - - {{ 'admin.sms-provider-type-required' | translate }} - + @if (smsProviderConfigurationFormGroup.get('type').hasError('required')) { + + {{ 'admin.sms-provider-type-required' | translate }} + + } -
- - - - - - - - - - - - +
+ @switch (smsProviderConfigurationFormGroup.get('type').value) { + @case (smsProviderType.AWS_SNS) { + + + } + @case (smsProviderType.TWILIO) { + + + } + @case (smsProviderType.SMPP) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts index 807b39f93f..86f07cfaaf 100644 --- a/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/sms-provider-configuration.component.ts @@ -34,19 +34,20 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-sms-provider-configuration', - templateUrl: './sms-provider-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SmsProviderConfigurationComponent), - multi: true - }] + selector: 'tb-sms-provider-configuration', + templateUrl: './sms-provider-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SmsProviderConfigurationComponent), + multi: true + }], + standalone: false }) export class SmsProviderConfigurationComponent implements ControlValueAccessor, OnInit { smsProviderType = SmsProviderType; - smsProviderTypes = Object.keys(SmsProviderType); + smsProviderTypes = Object.values(SmsProviderType); smsProviderTypeTranslations = smsProviderTypeTranslationMap; smsProviderConfigurationFormGroup: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html index 99d64302ec..b82e951fe2 100644 --- a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html +++ b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.html @@ -16,30 +16,38 @@ --> - + admin.number-from - - {{ 'admin.number-from-required' | translate }} - - - {{ 'admin.phone-number-pattern-twilio' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('numberFrom').hasError('required')) { + + {{ 'admin.number-from-required' | translate }} + + } + @if (twilioSmsProviderConfigurationFormGroup.get('numberFrom').hasError('pattern')) { + + {{ 'admin.phone-number-pattern-twilio' | translate }} + + } - + admin.twilio-account-sid - - {{ 'admin.twilio-account-sid-required' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('accountSid').hasError('required')) { + + {{ 'admin.twilio-account-sid-required' | translate }} + + } - + admin.twilio-account-token - - {{ 'admin.twilio-account-token-required' | translate }} - + @if (twilioSmsProviderConfigurationFormGroup.get('accountToken').hasError('required')) { + + {{ 'admin.twilio-account-token-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts index 550a2d8112..1041672c50 100644 --- a/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/components/sms/twilio-sms-provider-configuration.component.ts @@ -29,14 +29,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-twilio-sms-provider-configuration', - templateUrl: './twilio-sms-provider-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TwilioSmsProviderConfigurationComponent), - multi: true - }] + selector: 'tb-twilio-sms-provider-configuration', + templateUrl: './twilio-sms-provider-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TwilioSmsProviderConfigurationComponent), + multi: true + }], + standalone: false }) export class TwilioSmsProviderConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html index 548a7b79c3..00d1f9f782 100644 --- a/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/auto-commit-settings.component.html @@ -24,81 +24,92 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
admin.auto-commit-entities
-
- - -
- -
-
-
-
- - -
-
- -
- -
-
- - -
- + + +
+ +
+
+
+
+ + +
+
+ +
+ +
+
+ - + formControlName="entityType" + required + [filterAllowedEntityTypes]="false" + [allowedEntityTypes]="allowedEntityTypes(entityTypeFormGroup)"> + +
+ + +
+
+
+ @if (entityTypeFormGroup.get('entityType').value === entityTypes.DEVICE) { + + {{ 'version-control.export-credentials' | translate }} + + } + + {{ 'version-control.export-attributes' | translate }} + + + {{ 'version-control.export-relations' | translate }} + + @if (typesWithCalculatedFields.has(entityTypeFormGroup.get('entityType').value)) { + + {{ 'version-control.export-calculated-fields' | translate }} + + }
-
-
- - {{ 'version-control.export-credentials' | translate }} - - - {{ 'version-control.export-attributes' | translate }} - - - {{ 'version-control.export-relations' | translate }} - - - {{ 'version-control.export-calculated-fields' | translate }} -
-
- - -
-
- admin.no-auto-commit-entities-prompt -
+ + +
+ } + @if (!entityTypesFormGroupArray().length) { +
+ admin.no-auto-commit-entities-prompt +
+ }
-
version-control.auto-commit-settings-read-only-hint
+ @if (isReadOnly | async) { +
version-control.auto-commit-settings-read-only-hint
+ }
- - - {{ 'version-control.differences' | translate : {count: diffCount} }} - - - - -
-
{{ 'version-control.current' | translate }}
-
-
{{ versionIdContent() }}
-
- + @if (contentReady) { + +

{{ 'version-control.diff-entity-with-version' | translate: {versionName} }}

+ + + + + {{ 'version-control.differences' | translate : {count: diffCount} }} + + + +
+ } + @if (contentReady) { +
+
{{ 'version-control.current' | translate }}
+
+
{{ versionIdContent() }}
+
+ } + @if (!contentReady) { + + }
-
- -
+ @if (contentReady) { +
+ +
+ } diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.ts index 84480efb35..6baa6355dc 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-diff.component.ts @@ -49,10 +49,11 @@ interface DiffInfo { } @Component({ - selector: 'tb-entity-version-diff', - templateUrl: './entity-version-diff.component.html', - styleUrls: ['./entity-version-diff.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-entity-version-diff', + templateUrl: './entity-version-diff.component.html', + styleUrls: ['./entity-version-diff.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EntityVersionDiffComponent extends PageComponent implements OnInit, OnDestroy { @@ -125,6 +126,7 @@ export class EntityVersionDiffComponent extends PageComponent implements OnInit, { element: this.diffViewerElmRef.nativeElement, mode: 'ace/mode/json', + lockScrolling: false, left: { copyLinkEnabled: false, editable: false, diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts index c48648dacf..06b5f60441 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-version-restore.component.ts @@ -36,9 +36,10 @@ import { parseHttpErrorMessage } from '@core/utils'; import { EntityType } from '@shared/models/entity-type.models'; @Component({ - selector: 'tb-entity-version-restore', - templateUrl: './entity-version-restore.component.html', - styleUrls: ['./version-control.scss'] + selector: 'tb-entity-version-restore', + templateUrl: './entity-version-restore.component.html', + styleUrls: ['./version-control.scss'], + standalone: false }) export class EntityVersionRestoreComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html index cc3fb23da5..32a39a713e 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html @@ -32,21 +32,17 @@
-
- - +
+ @if (singleEntityMode) { + + }
+ @if (!singleEntityMode) { + + }
@@ -127,31 +133,37 @@ + [class.min-w-10]="!singleEntityMode" [class.max-w-10]="!singleEntityMode" [class.w-10]="!singleEntityMode">
- - - + @if (singleEntityMode) { + + } + @if (singleEntityMode) { + + } + @if (!singleEntityMode) { + + }
@@ -160,12 +172,12 @@ {{ - singleEntityMode - ? 'version-control.no-entity-versions-text' - : 'version-control.no-versions-text' - }} + singleEntityMode + ? 'version-control.no-entity-versions-text' + : 'version-control.no-versions-text' + }} {{ 'common.loading' | translate }} + class="no-data-found flex items-center justify-center">{{ 'common.loading' | translate }}
+ showFirstLastButtons>
diff --git a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.ts b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.ts index 96f28e2b4a..f93cde1fb6 100644 --- a/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.ts @@ -47,7 +47,7 @@ import { BranchAutocompleteComponent } from '@shared/components/vc/branch-autoco import { isNotEmptyStr } from '@core/utils'; import { TbPopoverService } from '@shared/components/popover.service'; import { EntityVersionCreateComponent } from '@home/components/vc/entity-version-create.component'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatIconButton } from '@angular/material/button'; import { EntityVersionRestoreComponent } from '@home/components/vc/entity-version-restore.component'; import { EntityVersionDiffComponent } from '@home/components/vc/entity-version-diff.component'; import { ComplexVersionCreateComponent } from '@home/components/vc/complex-version-create.component'; @@ -57,9 +57,10 @@ import { AdminService } from '@core/http/admin.service'; import { FormBuilder } from '@angular/forms'; @Component({ - selector: 'tb-entity-versions-table', - templateUrl: './entity-versions-table.component.html', - styleUrls: ['./entity-versions-table.component.scss'] + selector: 'tb-entity-versions-table', + templateUrl: './entity-versions-table.component.html', + styleUrls: ['./entity-versions-table.component.scss'], + standalone: false }) export class EntityVersionsTableComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy { @@ -277,7 +278,7 @@ export class EntityVersionsTableComponent extends PageComponent implements OnIni } } - toggleShowVersionDiff($event: Event, diffVersionButton: MatButton, entityVersion: EntityVersion) { + toggleShowVersionDiff($event: Event, diffVersionButton: MatIconButton, entityVersion: EntityVersion) { if ($event) { $event.stopPropagation(); } @@ -307,7 +308,7 @@ export class EntityVersionsTableComponent extends PageComponent implements OnIni } } - toggleRestoreEntityVersion($event: Event, restoreVersionButton: MatButton, entityVersion: EntityVersion) { + toggleRestoreEntityVersion($event: Event, restoreVersionButton: MatIconButton, entityVersion: EntityVersion) { if ($event) { $event.stopPropagation(); } @@ -341,7 +342,7 @@ export class EntityVersionsTableComponent extends PageComponent implements OnIni } } - toggleRestoreEntitiesVersion($event: Event, restoreEntitiesVersionButton: MatButton, entityVersion: EntityVersion) { + toggleRestoreEntitiesVersion($event: Event, restoreEntitiesVersionButton: MatIconButton, entityVersion: EntityVersion) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.ts b/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.ts index b5590cd841..dd9baf20e4 100644 --- a/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/remove-other-entities-confirm.component.ts @@ -23,9 +23,10 @@ import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; @Component({ - selector: 'tb-remove-other-entities-confirm', - templateUrl: './remove-other-entities-confirm.component.html', - styleUrls: [] + selector: 'tb-remove-other-entities-confirm', + templateUrl: './remove-other-entities-confirm.component.html', + styleUrls: [], + standalone: false }) export class RemoveOtherEntitiesConfirmComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html index ba9d850ab0..be7a8cda56 100644 --- a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.html @@ -26,20 +26,26 @@
- - -
+ @if (!hideLoadingBar && isLoading$ | async) { + + + } + @if (hideLoadingBar || (isLoading$ | async) === false) { +
+ }
- + admin.repository-url - - admin.repository-url-required - + @if (repositorySettingsForm.get('repositoryUri').hasError('required')) { + + admin.repository-url-required + + } - + admin.default-branch @@ -53,31 +59,37 @@
admin.authentication-settings - + admin.auth-method - - {{repositoryAuthMethodTranslations.get(method) | translate}} - + @for ( method of repositoryAuthMethods; track method) { + + {{repositoryAuthMethodTranslations.get(method) | translate}} + + }
- + common.username - - {{ 'admin.change-password-access-token' | translate }} - - - admin.password-access-token - - - - + @if (showChangePassword) { + + {{ 'admin.change-password-access-token' | translate }} + + } + @if (changePassword || !showChangePassword) { + + admin.password-access-token + + + + + }
- - {{ 'admin.change-passphrase' | translate }} - - - admin.passphrase - - - + @if (showChangePrivateKeyPassword) { + + {{ 'admin.change-passphrase' | translate }} + + } + @if (changePrivateKeyPassword || !showChangePrivateKeyPassword) { + + admin.passphrase + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts index f4959fa761..88ffbd9171 100644 --- a/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/repository-settings.component.ts @@ -38,9 +38,10 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-repository-settings', - templateUrl: './repository-settings.component.html', - styleUrls: ['./repository-settings.component.scss', './../../pages/admin/settings-card.scss'] + selector: 'tb-repository-settings', + templateUrl: './repository-settings.component.html', + styleUrls: ['./repository-settings.component.scss', './../../pages/admin/settings-card.scss'], + standalone: false }) export class RepositorySettingsComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.component.html b/ui-ngx/src/app/modules/home/components/vc/version-control.component.html index e02052c2b1..4a71456920 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.component.html +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.component.html @@ -15,13 +15,14 @@ limitations under the License. --> - - - +@if (!(hasRepository$ | async)) { + + +} @else { - + (versionRestored)="versionRestored.emit()"> +} diff --git a/ui-ngx/src/app/modules/home/components/vc/version-control.component.ts b/ui-ngx/src/app/modules/home/components/vc/version-control.component.ts index 4a2c944e2c..f3f4863653 100644 --- a/ui-ngx/src/app/modules/home/components/vc/version-control.component.ts +++ b/ui-ngx/src/app/modules/home/components/vc/version-control.component.ts @@ -26,9 +26,10 @@ import { Observable } from 'rxjs'; import { TbPopoverComponent } from '@shared/components/popover.component'; @Component({ - selector: 'tb-version-control', - templateUrl: './version-control.component.html', - styleUrls: ['./version-control.component.scss'] + selector: 'tb-version-control', + templateUrl: './version-control.component.html', + styleUrls: ['./version-control.component.scss'], + standalone: false }) export class VersionControlComponent implements OnInit, HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html index 7df9a4893f..8b0ca22543 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts index 8779085003..2da5066816 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/manage-widget-actions.component.ts @@ -57,16 +57,17 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-manage-widget-actions', - templateUrl: './manage-widget-actions.component.html', - styleUrls: ['./manage-widget-actions.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ManageWidgetActionsComponent), - multi: true - } - ] + selector: 'tb-manage-widget-actions', + templateUrl: './manage-widget-actions.component.html', + styleUrls: ['./manage-widget-actions.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ManageWidgetActionsComponent), + multi: true + } + ], + standalone: false }) export class ManageWidgetActionsComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html index 4ff90e43aa..1fc69be0a6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.html @@ -25,9 +25,13 @@ close - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
@@ -35,57 +39,66 @@
{{'widget-config.action-source' | translate}}*
- - {{ actionSourceName(actionSourceItem.value) }} - + @for (actionSourceItem of data.actionsData.actionSources | keyvalue; track actionSourceItem) { + + {{ actionSourceName(actionSourceItem.value) }} + + } - - warning - - -
-
-
{{'widget-config.column-index' | translate}}*
- - - - {{ getCellClickColumnInfo($index, column) | customTranslate }} - - - - warning - + @if (widgetActionFormGroup.get('actionSourceId').hasError('required') + && widgetActionFormGroup.get('actionSourceId').touched) { + + warning + + }
+ @if (widgetActionFormGroup.get('actionSourceId').value === 'cellClick') { +
+
{{'widget-config.column-index' | translate}}*
+ + + @for (column of configuredColumns; track column) { + + {{ getCellClickColumnInfo($index, column) | customTranslate }} + + } + + @if (widgetActionFormGroup.get('columnIndex').hasError('required') && + widgetActionFormGroup.get('columnIndex').touched) { + + warning + + } + +
+ }
{{'widget-config.action-name' | translate}}*
- - warning - + class="tb-error"> + warning + + }
@if (widgetActionFormGroup.get('actionSourceId').value === 'headerButton') { @@ -95,9 +108,11 @@
widget-config.header-button.button-type
- - {{ widgetHeaderActionButtonTypeTranslationMap.get(widgetHeaderActionButtonType[button]) | translate }} - + @for (button of widgetHeaderActionButtonTypes; track button) { + + {{ widgetHeaderActionButtonTypeTranslationMap.get(widgetHeaderActionButtonType[button]) | translate }} + + }
@@ -156,35 +171,37 @@
} -
- - - - - {{ 'widget-config.show-hide-action-using-function' | translate }} - - - - - - - -
+ @if (displayShowWidgetActionForm()) { +
+ + + + + {{ 'widget-config.show-hide-action-using-function' | translate }} + + + + + + + +
+ } + formControlName="widgetAction" + [callbacks]="data.callbacks" + [widgetType]="data.widgetType" + [additionalWidgetActionTypes]="data.additionalWidgetActionTypes">
diff --git a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts index 5549531100..b652f76e06 100644 --- a/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/action/widget-action-dialog.component.ts @@ -66,10 +66,11 @@ export interface WidgetActionDialogData { } @Component({ - selector: 'tb-widget-action-dialog', - templateUrl: './widget-action-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: WidgetActionDialogComponent}], - styleUrls: [] + selector: 'tb-widget-action-dialog', + templateUrl: './widget-action-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: WidgetActionDialogComponent }], + styleUrls: [], + standalone: false }) export class WidgetActionDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts index 73edce98ee..6d049826d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarm-count-basic-config.component.ts @@ -33,9 +33,10 @@ import { } from '@home/components/widget/lib/count/count-widget.models'; @Component({ - selector: 'tb-alarm-count-basic-config', - templateUrl: './alarm-count-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-alarm-count-basic-config', + templateUrl: './alarm-count-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class AlarmCountBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.ts index a2f25da117..802c1bb0bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/alarm/alarms-table-basic-config.component.ts @@ -29,9 +29,10 @@ import { } from '@home/components/widget/config/timewindow-config-panel.component'; @Component({ - selector: 'tb-alarms-table-basic-config', - templateUrl: './alarms-table-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-alarms-table-basic-config', + templateUrl: './alarms-table-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class AlarmsTableBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts index b703da79f6..095b122af6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/basic-widget-config.module.ts @@ -150,6 +150,9 @@ import { ValueStepperBasicConfigComponent } from '@home/components/widget/config/basic/rpc/value-stepper-basic-config.component'; import { MapBasicConfigComponent } from '@home/components/widget/config/basic/map/map-basic-config.component'; +import { + HtmlContainerBasicConfigComponent +} from '@home/components/widget/config/basic/html/html-container-basic-config.component'; @NgModule({ declarations: [ @@ -201,7 +204,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, ScadaSymbolBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ], imports: [ CommonModule, @@ -255,7 +259,8 @@ import { MapBasicConfigComponent } from '@home/components/widget/config/basic/ma LabelCardBasicConfigComponent, LabelValueCardBasicConfigComponent, UnreadNotificationBasicConfigComponent, - MapBasicConfigComponent + MapBasicConfigComponent, + HtmlContainerBasicConfigComponent ] }) export class BasicWidgetConfigModule { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html index 5bab97a12d..454082ff54 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.html @@ -16,14 +16,15 @@ --> - - + @if (!widgetEditMode) { + + + }
widgets.action-button.behavior
@@ -44,7 +45,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="activatedState"> + formControlName="activatedState">
widgets.button-state.disabled-state
@@ -55,7 +56,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.ts index b7edac8efb..93b9d03d73 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/action-button-basic-config.component.ts @@ -38,9 +38,10 @@ import { } from '@home/components/widget/lib/button/action-button-widget.models'; @Component({ - selector: 'tb-action-button-basic-config', - templateUrl: './action-button-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-action-button-basic-config', + templateUrl: './action-button-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ActionButtonBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html index 34ce199e65..e39d57b64e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.command-button.behavior
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.ts index ab5ef0df91..b909cd0e57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/command-button-basic-config.component.ts @@ -29,9 +29,10 @@ import { } from '@home/components/widget/lib/button/command-button-widget.models'; @Component({ - selector: 'tb-command-button-basic-config', - templateUrl: './command-button-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-command-button-basic-config', + templateUrl: './command-button-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class CommandButtonBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html index 51ebab7a92..8d05bad66d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.power-button.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.power-button.power-on
@@ -39,7 +39,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.power-button.power-off
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -59,7 +59,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -70,12 +70,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> - - {{ powerButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> + @for (layout of powerButtonLayouts; track layout) { + + {{ powerButtonLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts index 871807ec3e..92d808f239 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/power-button-basic-config.component.ts @@ -34,9 +34,10 @@ import { import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings.models'; @Component({ - selector: 'tb-power-button-basic-config', - templateUrl: './power-button-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-power-button-basic-config', + templateUrl: './power-button-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class PowerButtonBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html index f748c13f81..fd4790749c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.html @@ -16,14 +16,15 @@ --> - - + @if (!widgetEditMode) { + + + }
widgets.action-button.behavior
@@ -37,7 +38,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
@@ -68,7 +69,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -80,12 +81,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> - - {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> + @for (layout of segmentedButtonLayouts; track layout) { + + {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} + + }
@@ -224,16 +227,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
@@ -265,16 +270,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts index 895848a933..532d543719 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/segmented-button-basic-config.component.ts @@ -38,9 +38,10 @@ import { } from '@home/components/widget/lib/button/segmented-button-widget.models'; @Component({ - selector: 'tb-segmented-button-basic-config', - templateUrl: './segmented-button-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-segmented-button-basic-config', + templateUrl: './segmented-button-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class SegmentedButtonBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html index d506fe4e8b..905e9e3bac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.toggle-button.behavior
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.ts index 17e6ddf0c9..5e72be0946 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/button/toggle-button-basic-config.component.ts @@ -33,9 +33,10 @@ import { type ButtonAppearanceType = 'checked' | 'unchecked'; @Component({ - selector: 'tb-toggle-button-basic-config', - templateUrl: './toggle-button-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-toggle-button-basic-config', + templateUrl: './toggle-button-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ToggleButtonBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts index aba164f60e..8751ced932 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-key-row.component.ts @@ -56,17 +56,18 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { getSourceTbUnitSymbol, TbUnit } from '@shared/models/unit.models'; @Component({ - selector: 'tb-aggregated-data-key-row', - templateUrl: './aggregated-data-key-row.component.html', - styleUrls: ['./aggregated-data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AggregatedDataKeyRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-aggregated-data-key-row', + templateUrl: './aggregated-data-key-row.component.html', + styleUrls: ['./aggregated-data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class AggregatedDataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html index 8b29fe82aa..25e9bd78da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.html @@ -28,17 +28,23 @@
widgets.aggregated-value-card.arrow
-
-
- - + @if (keysFormArray().controls.length) { +
+ @for (keyControl of keysFormArray().controls; track trackByKey($index, keyControl)) { +
+ + +
+ }
-
+ } @else { + {{ 'widgets.aggregated-value-card.no-values' | translate }} + }
- - {{ 'widgets.aggregated-value-card.no-values' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts index 2931c6eeee..d2cba50bb8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-data-keys-panel.component.ts @@ -40,17 +40,18 @@ import { aggregatedValueCardDefaultKeySettings } from '@home/components/widget/l import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-aggregated-data-keys-panel', - templateUrl: './aggregated-data-keys-panel.component.html', - styleUrls: ['./aggregated-data-keys-panel.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AggregatedDataKeysPanelComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-aggregated-data-keys-panel', + templateUrl: './aggregated-data-keys-panel.component.html', + styleUrls: ['./aggregated-data-keys-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregatedDataKeysPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class AggregatedDataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts index 91666396b9..75c938dd23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/aggregated-value-card-basic-config.component.ts @@ -52,9 +52,10 @@ import { } from '@shared/models/time/time.models'; @Component({ - selector: 'tb-aggregated-value-card-basic-config', - templateUrl: './aggregated-value-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-aggregated-value-card-basic-config', + templateUrl: './aggregated-value-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class AggregatedValueCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts index 7c782da2be..59a8f07307 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-card-basic-config.component.ts @@ -29,9 +29,10 @@ import { } from '@home/components/widget/lib/cards/label-card-widget.models'; @Component({ - selector: 'tb-label-card-basic-config', - templateUrl: './label-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-label-card-basic-config', + templateUrl: './label-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class LabelCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html index 90cb150339..e4daba1f6d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/label-value-card-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - -
-
- - {{ 'admin.mobile-app.use-system-settings' | translate }} - -
-
-
-
-
widget-config.appearance
-
{{ 'widgets.mobile-app-qr-code.configuration-hint' | translate }}
-
- - {{ 'admin.mobile-app.badges' | translate }} - - - - - {{ badgePosition.value | translate }} - - - -
-
- - {{ 'admin.mobile-app.label' | translate }} +@if (mobileAppQrCodeWidgetConfigForm) { + +
+
+ + {{ 'admin.mobile-app.use-system-settings' | translate }} - - -
-
- - {{ 'widgets.liquid-level-card.title' | translate }} - -
- - - - - - - +
+ @if (!mobileAppQrCodeWidgetConfigForm.get('useSystemSettings').value) { +
+
+
widget-config.appearance
+
{{ 'widgets.mobile-app-qr-code.configuration-hint' | translate }}
+
+ + {{ 'admin.mobile-app.badges' | translate }} + + + + @for (badgePosition of badgePositionTranslationsMap | keyvalue; track badgePosition) { + + {{ badgePosition.value | translate }} + + } + + +
+
+ + {{ 'admin.mobile-app.label' | translate }} + + + + +
+
+ + {{ 'widgets.liquid-level-card.title' | translate }} + +
+ + + + + + + +
+
+
+ + {{ 'widgets.liquid-level-card.icon' | translate }} + +
+ + + + + + + + +
+
-
-
- - {{ 'widgets.liquid-level-card.icon' | translate }} - -
- - - - - - - - +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
+ +
-
-
-
widget-config.card-appearance
-
-
{{ 'widgets.background.background' | translate }}
- - -
-
-
widget-config.show-card-buttons
- - {{ 'fullscreen.fullscreen' | translate }} - -
-
-
{{ 'widget-config.card-border-radius' | translate }}
- - - -
-
-
{{ 'widget-config.card-padding' | translate }}
- - - -
-
- - -
- + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/mobile-app-qr-code-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/mobile-app-qr-code-basic-config.component.ts index cb7331721d..6aa3f4196a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/mobile-app-qr-code-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/mobile-app-qr-code-basic-config.component.ts @@ -31,9 +31,10 @@ import { import { badgePositionTranslationsMap } from '@app/shared/models/mobile-app.models'; @Component({ - selector: 'tb-mobile-app-qr-code-basic-config', - templateUrl: './mobile-app-qr-code-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-mobile-app-qr-code-basic-config', + templateUrl: './mobile-app-qr-code-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class MobileAppQrCodeBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html index 120ccf6457..756b7443c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - - {{ progressBarLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout"> + @for (layout of progressBarLayouts; track layout) { + + {{ progressBarLayoutTranslationMap.get(layout) | translate }} + + }
@@ -126,20 +130,22 @@
-
- - {{ 'widgets.progress-bar.range-ticks' | translate }} - -
- - - - + @if (progressBarWidgetConfigForm.get('layout').value === progressBarLayout.default) { +
+ + {{ 'widgets.progress-bar.range-ticks' | translate }} + +
+ + + + +
-
+ }
widgets.progress-bar.bar
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.ts index 1f8347a50e..3e184bae7c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/progress-bar-basic-config.component.ts @@ -46,9 +46,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-progress-bar-basic-config', - templateUrl: './progress-bar-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-progress-bar-basic-config', + templateUrl: './progress-bar-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ProgressBarBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html index 1e28c9eb85..9e0f15d1f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } widget-config.appearance
widgets.simple-card.label
- + >
widgets.simple-card.label-position
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.ts index 3594c8dc98..4e05996186 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/simple-card-basic-config.component.ts @@ -37,9 +37,10 @@ import { isUndefined } from '@core/utils'; import { getLabel, setLabel } from '@shared/models/widget-settings.models'; @Component({ - selector: 'tb-simple-card-basic-config', - templateUrl: './simple-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-simple-card-basic-config', + templateUrl: './simple-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class SimpleCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.ts index 24434ea1d6..c7533f6932 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/timeseries-table-basic-config.component.ts @@ -30,9 +30,10 @@ import { } from '@home/components/widget/config/timewindow-config-panel.component'; @Component({ - selector: 'tb-timeseries-table-basic-config', - templateUrl: './timeseries-table-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-timeseries-table-basic-config', + templateUrl: './timeseries-table-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class TimeseriesTableBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts index 7b6347c9a5..a75a84fcca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/unread-notification-basic-config.component.ts @@ -30,9 +30,10 @@ import { } from '@home/components/widget/lib/cards/unread-notification-widget.models'; @Component({ - selector: 'tb-unread-notification-basic-config', - templateUrl: './unread-notification-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-unread-notification-basic-config', + templateUrl: './unread-notification-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class UnreadNotificationBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html index 9a4a6196c8..90bbca8421 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - - {{ valueCardLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout"> + @for (layout of valueCardLayouts; track layout) { + + {{ valueCardLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts index ef3fbabbb0..2c210b90f0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-card-basic-config.component.ts @@ -46,9 +46,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-value-card-basic-config', - templateUrl: './value-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-value-card-basic-config', + templateUrl: './value-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ValueCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.html index c7943ec0a7..2ec1b80253 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.html @@ -34,12 +34,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> - - {{ valueChartCardLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> + @for (layout of valueChartCardLayouts; track layout) { + + {{ valueChartCardLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.ts index d48266e72d..acbce131d2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/cards/value-chart-card-basic-config.component.ts @@ -39,9 +39,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-value-chart-card-basic-config', - templateUrl: './value-chart-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-value-chart-card-basic-config', + templateUrl: './value-chart-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ValueChartCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-basic-config.component.ts index dccbb62f7d..99aed7565a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-basic-config.component.ts @@ -31,9 +31,10 @@ import { } from '@home/components/widget/config/basic/chart/latest-chart-basic-config.component'; @Component({ - selector: 'tb-bar-chart-basic-config', - templateUrl: './latest-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-bar-chart-basic-config', + templateUrl: './latest-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class BarChartBasicConfigComponent extends LatestChartBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html index 1aa4c2be3d..f927340691 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.html @@ -99,7 +99,7 @@
+ formControlName="grid">
@@ -167,41 +167,41 @@
+ formControlName="barBackgroundSettings" + titleText="widgets.chart.background" + fillNoneTitle="widgets.chart.fill-type-solid"> + stroked + formControlName="noAggregationBarWidthSettings">
widgets.time-series-chart.axis.y-axis
+ formControlName="yAxis" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + axisType="yAxis" + hideUnits + hideDecimals>
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + axisType="xAxis">
+ formControlName="thresholds" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + [widgetConfig]="widgetConfig?.config" + hideYAxis>
@@ -217,9 +217,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -280,7 +282,7 @@
+ formControlName="tooltipDateFormat"> @@ -316,7 +318,7 @@
+ formControlName="animation">
widget-config.card-appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts index 1436275c7d..d569bcf37b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/bar-chart-with-labels-basic-config.component.ts @@ -45,13 +45,17 @@ import { barChartWithLabelsDefaultSettings, BarChartWithLabelsWidgetSettings } from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models'; -import { TimeSeriesChartType } from '@home/components/widget/lib/chart/time-series-chart.models'; +import { + TimeSeriesChartType, + updateLatestDataKeys +} from '@home/components/widget/lib/chart/time-series-chart.models'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-bar-chart-with-labels-basic-config', - templateUrl: './bar-chart-with-labels-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-bar-chart-with-labels-basic-config', + templateUrl: './bar-chart-with-labels-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigComponent { @@ -75,7 +79,7 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom tooltipDatePreviewFn = this._tooltipDatePreviewFn.bind(this); predefinedValues = widgetTitleAutocompleteValues; - + constructor(protected store: Store, protected widgetConfigComponent: WidgetConfigComponent, private $injector: Injector, @@ -166,6 +170,11 @@ export class BarChartWithLabelsBasicConfigComponent extends BasicWidgetConfigCom }); } + protected onConfigChanged(widgetConfig: WidgetConfigComponentData) { + updateLatestDataKeys([widgetConfig.config.settings.yAxis], this.datasource, this.callbacks); + super.onConfigChanged(widgetConfig); + } + protected prepareOutputConfig(config: any): WidgetConfigComponentData { setTimewindowConfig(this.widgetConfig.config, config.timewindowConfig); this.widgetConfig.config.datasources = config.datasources; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts index e7ff98eed8..add7085af1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-key-row.component.ts @@ -32,17 +32,18 @@ import { deepClone } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-comparison-key-row', - templateUrl: './comparison-key-row.component.html', - styleUrls: ['./comparison-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ComparisonKeyRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-comparison-key-row', + templateUrl: './comparison-key-row.component.html', + styleUrls: ['./comparison-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ComparisonKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ComparisonKeyRowComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.html index 4515802dfa..7bac268c2b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.html @@ -22,16 +22,19 @@
datakey.label
datakey.color
-
-
- - + @if (keysFormArray().controls.length) { +
+ @for (keyControl of keysFormArray().controls; track trackByKey($index, keyControl)) { +
+ + +
+ }
-
+ } @else { + {{ 'widgets.chart.no-series' | translate }} + }
- - {{ 'widgets.chart.no-series' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.ts index 162f55ae9e..2409c9af57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/comparison-keys-table.component.ts @@ -27,17 +27,18 @@ import { DataKey, DatasourceType } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-comparison-keys-table', - templateUrl: './comparison-keys-table.component.html', - styleUrls: ['./comparison-keys-table.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ComparisonKeysTableComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-comparison-keys-table', + templateUrl: './comparison-keys-table.component.html', + styleUrls: ['./comparison-keys-table.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ComparisonKeysTableComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ComparisonKeysTableComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts index c9d0af5dfd..ebd5fb8d69 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/doughnut-basic-config.component.ts @@ -32,9 +32,10 @@ import { } from '@home/components/widget/config/basic/chart/latest-chart-basic-config.component'; @Component({ - selector: 'tb-doughnut-basic-config', - templateUrl: './latest-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-doughnut-basic-config', + templateUrl: './latest-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class DoughnutBasicConfigComponent extends LatestChartBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/flot-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/flot-basic-config.component.ts index f674487500..bb65fb2e27 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/flot-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/flot-basic-config.component.ts @@ -30,9 +30,10 @@ import { } from '@home/components/widget/config/timewindow-config-panel.component'; @Component({ - selector: 'tb-flot-basic-config', - templateUrl: './flot-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-flot-basic-config', + templateUrl: './flot-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class FlotBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html index b0f108ba58..f03c43286b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/latest-chart-basic-config.component.html @@ -16,32 +16,34 @@ --> - - + @if (displayTimewindowConfig) { + + + } + [configMode]="basicMode" + hideDatasourceLabel + hideDataKeys + forceSingleDatasource + formControlName="datasources"> + panelTitle="{{ 'widgets.chart.series' | translate }}" + addKeyTitle="{{ 'widgets.chart.add-series' | translate }}" + keySettingsTitle="{{ 'widgets.chart.series-settings' | translate }}" + removeKeyTitle="{{ 'widgets.chart.remove-series' | translate }}" + noKeysText="{{ 'widgets.chart.no-series' | translate }}" + requiredKeysText="{{ 'widgets.chart.no-series-error' | translate }}" + hideUnits + hideDecimals + hideDataKeyUnits + hideDataKeyDecimals + [datasourceType]="datasource?.type" + [deviceId]="datasource?.deviceId" + [entityAliasId]="datasource?.entityAliasId" + formControlName="series">
widget-config.appearance
@@ -121,9 +123,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -174,9 +178,11 @@
- - {{ latestChartTooltipValueTypeTranslationMap.get(type) | translate }} - + @for (type of latestChartTooltipValueTypes; track type) { + + {{ latestChartTooltipValueTypeTranslationMap.get(type) | translate }} + + } @@ -210,7 +216,7 @@
+ formControlName="animation">
widget-config.card-appearance
@@ -239,7 +245,7 @@
+ formControlName="actions"> @@ -254,9 +260,11 @@
- - {{ pieChartLabelPositionTranslationMap.get(position) | translate }} - + @for (position of pieChartLabelPositions; track position) { + + {{ pieChartLabelPositionTranslationMap.get(position) | translate }} + + } - - {{ doughnutLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.doughnut.layout' | translate }}" formControlName="layout"> + @for (layout of doughnutLayouts; track layout) { + + {{ doughnutLayoutTranslationMap.get(layout) | translate }} + + }
@@ -346,8 +356,8 @@
widgets.bar-chart.bar-appearance
+ formControlName="barSettings" + [series]="false">
@@ -385,9 +395,9 @@
widgets.bar-chart.bar-appearance
+ formControlName="barSettings" + pieLabelPosition + [series]="false">
@@ -434,9 +444,11 @@
{{ 'widgets.radar-chart.shape' | translate }}
- - {{ radarChartShapeTranslations.get(shape) | translate }} - + @for (shape of radarChartShapes; track shape) { + + {{ radarChartShapeTranslations.get(shape) | translate }} + + }
@@ -454,9 +466,11 @@
- - {{ chartLineTypeTranslations.get(type) | translate }} - + @for (type of chartLineTypes; track type) { + + {{ chartLineTypeTranslations.get(type) | translate }} + + } @@ -472,9 +486,11 @@
- - {{ chartShapeTranslations.get(shape) | translate }} - + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + } @@ -490,9 +506,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + }
+ formControlName="fillAreaSettings">
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/pie-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/pie-chart-basic-config.component.ts index 1f18fbb3a8..741a25638d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/pie-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/pie-chart-basic-config.component.ts @@ -31,9 +31,10 @@ import { } from '@home/components/widget/config/basic/chart/latest-chart-basic-config.component'; @Component({ - selector: 'tb-pie-chart-basic-config', - templateUrl: './latest-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-pie-chart-basic-config', + templateUrl: './latest-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class PieChartBasicConfigComponent extends LatestChartBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/polar-area-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/polar-area-chart-basic-config.component.ts index 3050b0550d..59864ce5e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/polar-area-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/polar-area-chart-basic-config.component.ts @@ -31,9 +31,10 @@ import { } from '@home/components/widget/config/basic/chart/latest-chart-basic-config.component'; @Component({ - selector: 'tb-polar-area-chart-basic-config', - templateUrl: './latest-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-polar-area-chart-basic-config', + templateUrl: './latest-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class PolarAreaChartBasicConfigComponent extends LatestChartBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts index 6af8b7de34..22656ca16a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/radar-chart-basic-config.component.ts @@ -31,9 +31,10 @@ import { } from '@home/components/widget/config/basic/chart/latest-chart-basic-config.component'; @Component({ - selector: 'tb-radar-chart-basic-config', - templateUrl: './latest-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-radar-chart-basic-config', + templateUrl: './latest-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class RadarChartBasicConfigComponent extends LatestChartBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html index 7d3195e7f4..9f597fb501 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.html @@ -83,7 +83,7 @@
+ formControlName="grid">
@@ -118,11 +118,11 @@ {{ 'widgets.range-chart.show-range-thresholds' | translate }} + boxButton + hideYAxis + [widgetConfig]="widgetConfig?.config" + title="widgets.range-chart.range-thresholds-settings" + formControlName="rangeThreshold">
@@ -150,9 +150,11 @@ - - {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} - + @for (stepType of lineSeriesStepTypes; track stepType) { + + {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} + + }
@@ -165,9 +167,11 @@
widgets.time-series-chart.line-type
- - {{ chartLineTypeTranslations.get(lineType) | translate }} - + @for (lineType of chartLineTypes; track lineType) { + + {{ chartLineTypeTranslations.get(lineType) | translate }} + + }
@@ -194,9 +198,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + } widgets.time-series-chart.series.point.point-shape
- - {{ chartShapeTranslations.get(shape) | translate }} - + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + }
@@ -241,29 +249,29 @@
widgets.time-series-chart.axis.y-axis
+ formControlName="yAxis" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + axisType="yAxis" + hideUnits + hideDecimals>
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + axisType="xAxis">
+ formControlName="thresholds" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + [widgetConfig]="widgetConfig?.config" + hideYAxis>
@@ -279,9 +287,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -336,76 +346,76 @@
- - {{ 'tooltip.date' | translate }} - -
- - - - - -
-
-
- -
- {{ 'tooltip.show-date-time-interval' | translate }} -
-
-
-
-
{{ 'tooltip.background-color' | translate }}
+ + {{ 'tooltip.date' | translate }} + +
+ + + + formControlName="tooltipDateColor">
-
-
{{ 'tooltip.background-blur' | translate }}
- - -
px
-
-
- - +
+
+ +
+ {{ 'tooltip.show-date-time-interval' | translate }} +
+
+
+
+
{{ 'tooltip.background-color' | translate }}
+ + +
+
+
{{ 'tooltip.background-blur' | translate }}
+ + +
px
+
+
+ + +
+ + +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ +
- - -
-
widget-config.card-appearance
-
-
{{ 'widgets.background.background' | translate }}
- - -
-
-
widget-config.show-card-buttons
- - {{ 'fullscreen.fullscreen' | translate }} - -
-
-
{{ 'widget-config.card-border-radius' | translate }}
- - - -
-
-
{{ 'widget-config.card-padding' | translate }}
- - - -
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + +
- - +
+ + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts index d93a77e879..95dbabfbfc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/range-chart-basic-config.component.ts @@ -47,7 +47,7 @@ import { } from '@home/components/widget/lib/chart/range-chart-widget.models'; import { lineSeriesStepTypes, - lineSeriesStepTypeTranslations + lineSeriesStepTypeTranslations, updateLatestDataKeys } from '@home/components/widget/lib/chart/time-series-chart.models'; import { chartLabelPositions, @@ -60,9 +60,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-range-chart-basic-config', - templateUrl: './range-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-range-chart-basic-config', + templateUrl: './range-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class RangeChartBasicConfigComponent extends BasicWidgetConfigComponent { @@ -288,6 +289,11 @@ export class RangeChartBasicConfigComponent extends BasicWidgetConfigComponent { return this.widgetConfig; } + protected onConfigChanged(widgetConfig: WidgetConfigComponentData) { + updateLatestDataKeys([widgetConfig.config.settings.yAxis], this.datasource, this.callbacks); + super.onConfigChanged(widgetConfig); + } + protected validatorTriggers(): string[] { return ['showTitle', 'showIcon', 'showRangeThresholds', 'fillArea', 'showLine', 'step', 'showPointLabel', 'enablePointLabelBackground', 'showLegend', 'showTooltip', 'tooltipShowDate']; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html index 85ef776718..0602e55fc9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.html @@ -19,11 +19,11 @@ + [configMode]="basicMode" + hideDatasourceLabel + hideDataKeys + forceSingleDatasource + formControlName="datasources">
@@ -36,8 +36,8 @@ {{ 'widgets.time-series-chart.comparison.comparison' | translate }}
- - -
- - {{ 'widgets.time-series-chart.comparison.comparison' | translate }} - -
- - - - {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} - - - {{ 'widgets.chart.time-for-comparison-days' | translate }} - - - {{ 'widgets.chart.time-for-comparison-weeks' | translate }} - - - {{ 'widgets.chart.time-for-comparison-months' | translate }} - - - {{ 'widgets.chart.time-for-comparison-years' | translate }} - - - {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} - - - - - - - + } + @if (seriesMode === 'comparison') { +
+ + {{ 'widgets.time-series-chart.comparison.comparison' | translate }} + +
+ + + + {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} + + + {{ 'widgets.chart.time-for-comparison-days' | translate }} + + + {{ 'widgets.chart.time-for-comparison-weeks' | translate }} + + + {{ 'widgets.chart.time-for-comparison-months' | translate }} + + + {{ 'widgets.chart.time-for-comparison-years' | translate }} + + + {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} + + + + + + + - + +
-
- - + + }
- - + + }
+ formControlName="grid">
@@ -193,10 +198,11 @@ axisType="xAxis">
- - + @if (chartType === TimeSeriesChartType.default || chartType === TimeSeriesChartType.bar) { + + + }
@@ -268,9 +274,9 @@
{{ 'tooltip.trigger' | translate }}
{{ 'tooltip.trigger-point' | translate }} + [value]="EChartsTooltipTrigger.point">{{ 'tooltip.trigger-point' | translate }} {{ 'tooltip.trigger-axis' | translate }} + [value]="EChartsTooltipTrigger.axis">{{ 'tooltip.trigger-axis' | translate }}
@@ -305,7 +311,7 @@
+ formControlName="tooltipDateFormat"> @@ -325,7 +331,7 @@
- {{ 'tooltip.hide-zero-tooltip-values' | translate }} + {{ 'tooltip.hide-zero-tooltip-values' | translate }}
@@ -380,6 +386,6 @@
+ formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts index 0d16ca6bee..5b1410e5d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/chart/time-series-chart-basic-config.component.ts @@ -56,9 +56,10 @@ import { TimeSeriesChartTooltipTrigger } from '@home/components/widget/lib/chart import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-time-series-chart-basic-config', - templateUrl: './time-series-chart-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-time-series-chart-basic-config', + templateUrl: './time-series-chart-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class TimeSeriesChartBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html index d23896289e..b8ea5924a5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.html @@ -16,12 +16,14 @@ -->
- - - {{ 'datakey.timeseries' | translate }} - {{ 'datakey.latest' | translate }} - - + @if (hasAdditionalLatestDataKeys) { + + + {{ 'datakey.timeseries' | translate }} + {{ 'datakey.latest' | translate }} + + + } - - - -
- - - - - {{ timeSeriesChartSeriesTypeIcons.get(keyRowFormGroup.get('timeSeriesType').value) }} - - - - {{ timeSeriesChartSeriesTypeIcons.get(type) }} - {{ timeSeriesChartSeriesTypeTranslations.get(type) | translate }} - - - -
-
- - - - {{ yAxis }} - - - -
-
- - -
-
- - -
-
- - -
widget-config.decimals-suffix
+ @if (!hideDataKeyLabel) { + + -
-
-
-
+ } + @if (timeSeriesChart) { +
+ @if (displayUnitsOrDigits) { + + + @for (yAxis of yAxisIds; track yAxis) { + + {{ yAxis }} + + } + + + } +
+ } + @if (!hideDataKeyColor) { +
+ + +
+ } + @if (!hideUnits && !hideDataKeyUnits && (!singleRow || displayUnitsOrDigits)) { +
+ @if (displayUnitsOrDigits) { + + + } +
+ } + @if (!hideDecimals && !hideDataKeyDecimals && (!singleRow || displayUnitsOrDigits)) { +
+ @if (displayUnitsOrDigits) { + + + @if (singleRow) { +
widget-config.decimals-suffix
+ } +
+ } +
+ } + @if (!singleRow) { +
+
+ @if (modelValue) { + + } +
+
- -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts index bca32ef235..e84587c758 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-key-row.component.ts @@ -76,17 +76,18 @@ export const dataKeyRowValidator = (control: AbstractControl): ValidationErrors }; @Component({ - selector: 'tb-data-key-row', - templateUrl: './data-key-row.component.html', - styleUrls: ['./data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeyRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-data-key-row', + templateUrl: './data-key-row.component.html', + styleUrls: ['./data-key-row.component.scss', '../../../lib/settings/common/key/data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DataKeyRowComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html index d2d8df7139..393ab69ad8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.html @@ -18,61 +18,83 @@
-
{{ panelTitle }}
+ @if (!hidePanel) { +
{{ panelTitle }}
+ }
-
datakey.source
+ @if (hasAdditionalLatestDataKeys) { +
datakey.source
+ }
datakey.key
datakey.label
-
widgets.time-series-chart.series.type
-
widgets.time-series-chart.axis.y-axis
-
datakey.color
-
widget-config.units-short
-
widget-config.decimals-short
+ @if (timeSeriesChart && showTimeSeriesType) { +
widgets.time-series-chart.series.type
+ } + @if (timeSeriesChart) { +
widgets.time-series-chart.axis.y-axis
+ } + @if (!hideDataKeyColor) { +
datakey.color
+ } + @if (!hideUnits) { +
widget-config.units-short
+ } + @if (!hideDecimals) { +
widget-config.decimals-short
+ }
-
-
- - -
- -
+ @if (keysFormArray().controls.length) { +
+ @for (keyControl of keysFormArray().controls; track trackByKey($index, keyControl)) { +
+ + +
+ +
+
+ }
-
- + } @else { + {{ noKeysText }} + } + @if (errorText) { + + }
- - {{ noKeysText }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts index d0b6ca68d9..a42b046bb8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/data-keys-panel.component.ts @@ -50,22 +50,23 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-data-keys-panel', - templateUrl: './data-keys-panel.component.html', - styleUrls: ['./data-keys-panel.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeysPanelComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DataKeysPanelComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-data-keys-panel', + templateUrl: './data-keys-panel.component.html', + styleUrls: ['./data-keys-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeysPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataKeysPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DataKeysPanelComponent implements ControlValueAccessor, OnInit, OnChanges, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html index 72f4b0f1d8..b493696d4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.html @@ -19,18 +19,24 @@
widget-config.actions
- - - {{ widgetAction.icon }} - {{ widgetAction.name }} - - + @for (actionSourceId of widgetActionSourceIds; track actionSourceId) { + @for (widgetAction of widgetActionsByActionSourceId(actionSourceId); track widgetAction) { + + {{ widgetAction.icon }} + {{ widgetAction.name }} + + } + } - - + @if (!hasWidgetActions) { + + } + @if (hasWidgetActions) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts index f63825a285..0320dc7fd1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/common/widget-actions-panel.component.ts @@ -28,16 +28,17 @@ import { MatDialog } from '@angular/material/dialog'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-widget-actions-panel', - templateUrl: './widget-actions-panel.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetActionsPanelComponent), - multi: true - } - ] + selector: 'tb-widget-actions-panel', + templateUrl: './widget-actions-panel.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetActionsPanelComponent), + multi: true + } + ], + standalone: false }) export class WidgetActionsPanelComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/entity/entities-table-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/entity/entities-table-basic-config.component.html index 27ad183dfa..8acae32fa7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/entity/entities-table-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/entity/entities-table-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } + > - - + @if (displayTimewindowConfig) { + + + }
-
-
widgets.gauge.progress-bar
-
-
widgets.gauge.progress-bar-color-start
- - -
-
-
widgets.gauge.progress-bar-color-end
- - -
-
-
widgets.gauge.bar-stroke-color
- - + @if (gaugeType === 'linear') { +
+
widgets.gauge.progress-bar
+
+
widgets.gauge.progress-bar-color-start
+ + +
+
+
widgets.gauge.progress-bar-color-end
+ + +
+
+
widgets.gauge.bar-stroke-color
+ + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/compass-gauge-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/compass-gauge-basic-config.component.html index a5dbfac219..c2470210fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/compass-gauge-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/compass-gauge-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - - + @if (displayTimewindowConfig) { + + + } - - {{ digitalGaugeLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.gauge.simple-gauge-type' | translate }}" + formControlName="gaugeType"> + @for (layout of digitalGaugeLayouts; track layout) { + + {{ digitalGaugeLayoutTranslationMap.get(layout) | translate }} + + } -
-
{{ 'widgets.gauge.donut-start-angle' | translate }}
- - - -
+ @if (simpleGaugeWidgetConfigForm.get('gaugeType').value === digitalGaugeType.donut) { +
+
{{ 'widgets.gauge.donut-start-angle' | translate }}
+ + + +
+ }
@@ -76,14 +82,15 @@
widgets.gauge.max-value-short
- - warning - + @if (simpleGaugeWidgetConfigForm.get('maxValue').hasError('maxValue')) { + + warning + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts index c90c19aca8..e0ca65cdea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/digital-simple-gauge-basic-config.component.ts @@ -45,9 +45,10 @@ import { ColorSettings, ColorType } from '@shared/models/widget-settings.models' import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-digital-simple-gauge-basic-config', - templateUrl: './digital-simple-gauge-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-digital-simple-gauge-basic-config', + templateUrl: './digital-simple-gauge-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class DigitalSimpleGaugeBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/radial-gauge-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/radial-gauge-basic-config.component.ts index 4b832956e6..e156c0bbfc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/radial-gauge-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/radial-gauge-basic-config.component.ts @@ -25,9 +25,10 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { WidgetConfigComponentData } from '@home/models/widget-component.models'; @Component({ - selector: 'tb-radial-gauge-basic-config', - templateUrl: './analog-gauge-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-radial-gauge-basic-config', + templateUrl: './analog-gauge-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class RadialGaugeBasicConfigComponent extends GaugeBasicConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/thermometer-scale-gauge-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/thermometer-scale-gauge-basic-config.component.ts index c8d560370e..d173b9bce9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/thermometer-scale-gauge-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/gauge/thermometer-scale-gauge-basic-config.component.ts @@ -25,9 +25,10 @@ import { WidgetConfigComponent } from '@home/components/widget/widget-config.com import { WidgetConfigComponentData } from '@home/models/widget-component.models'; @Component({ - selector: 'tb-thermometer-scale-gauge-basic-config', - templateUrl: './analog-gauge-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-thermometer-scale-gauge-basic-config', + templateUrl: './analog-gauge-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ThermometerScaleGaugeBasicConfigComponent extends GaugeBasicConfigComponent { diff --git a/rest-client/src/main/resources/logback.xml b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html similarity index 58% rename from rest-client/src/main/resources/logback.xml rename to ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html index f8c9b51bd0..298bdb6e61 100644 --- a/rest-client/src/main/resources/logback.xml +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.html @@ -1,4 +1,3 @@ - - - - - - - %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts new file mode 100644 index 0000000000..d057acf057 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/html/html-container-basic-config.component.ts @@ -0,0 +1,62 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, HostBinding } from '@angular/core'; +import { UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; +import { Store } from '@ngrx/store'; +import { AppState } from '@core/core.state'; +import { BasicWidgetConfigComponent } from '@home/components/widget/config/widget-config.component.models'; +import { WidgetConfigComponentData } from '@home/models/widget-component.models'; +import { WidgetConfigComponent } from '@home/components/widget/widget-config.component'; +import { + htmlContainerDefaultSettings, + HtmlContainerWidgetSettings +} from '@home/components/widget/lib/html/html-container-widget.models'; + +@Component({ + selector: 'tb-html-container-basic-config', + templateUrl: './html-container-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false +}) +export class HtmlContainerBasicConfigComponent extends BasicWidgetConfigComponent { + + @HostBinding('style.height') height = '100%'; + + htmlContainerWidgetConfigForm: UntypedFormGroup; + + constructor(protected store: Store, + protected widgetConfigComponent: WidgetConfigComponent, + private fb: UntypedFormBuilder) { + super(store, widgetConfigComponent); + } + + protected configForm(): UntypedFormGroup { + return this.htmlContainerWidgetConfigForm; + } + + protected onConfigSet(configData: WidgetConfigComponentData) { + const settings: HtmlContainerWidgetSettings = {...htmlContainerDefaultSettings, ...(configData.config.settings || {})}; + this.htmlContainerWidgetConfigForm = this.fb.group({ + settings: [settings, []] + }); + } + + protected prepareOutputConfig(config: any): WidgetConfigComponentData { + this.widgetConfig.config.settings = {...(this.widgetConfig.config.settings || {}), ...config.settings}; + return this.widgetConfig; + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html index e36e315b3e..8c8655a0f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - - {{ batteryLevelLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.battery-level.layout' | translate }}" formControlName="layout"> + @for (layout of batteryLevelLayouts; track layout) { + + {{ batteryLevelLayoutTranslationMap.get(layout) | translate }} + + } -
-
{{ 'widgets.battery-level.sections-count' | translate }}
- - - -
+ @if (sectionsCountEnabled) { +
+
{{ 'widgets.battery-level.sections-count' | translate }}
+ + + +
+ }
{{ 'widget-config.title' | translate }} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.ts index f83f7cee34..1ac5875abf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/battery-level-basic-config.component.ts @@ -47,9 +47,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-battery-level-basic-config', - templateUrl: './battery-level-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-battery-level-basic-config', + templateUrl: './battery-level-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class BatteryLevelBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html index 473bdb0b4b..7ecb3a0441 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.html @@ -15,339 +15,364 @@ limitations under the License. --> - - - - - -
-
widget-config.appearance
-
- - {{ 'widgets.liquid-level-card.title' | translate }} - -
- - - - - -
-
-
- - {{ 'widgets.liquid-level-card.icon' | translate }} - -
- - - - - - - - +@if (levelCardWidgetConfigForm) { + + @if (displayTimewindowConfig) { + + + } + + +
+
widget-config.appearance
+
+ + {{ 'widgets.liquid-level-card.title' | translate }} + +
+ + + + + +
-
-
-
-
widgets.liquid-level-card.shape
- - - {{ DataSourceTypeTranslations.get(type) | translate }} - - +
+ + {{ 'widgets.liquid-level-card.icon' | translate }} + +
+ + + + + + + + +
- +
+
widgets.liquid-level-card.shape
+ + @for (type of DataSourceTypes; track type) { + + {{ DataSourceTypeTranslations.get(type) | translate }} + + } + +
+ - - {{ ShapesTranslationMap.get(shape) | translate }} - - - -
-
widgets.liquid-level-card.shape-attribute-name
- - -
-
-
widgets.liquid-level-card.shape-by-attribute
-
+ style="width: 100%;" + label="{{ 'widgets.liquid-level-card.shape-type' | translate }}" formControlName="selectedShape"> + @for (shape of shapes; track shape) { + + {{ ShapesTranslationMap.get(shape) | translate }} + + } + + @if (levelCardWidgetConfigForm.get('tankSelectionType').value === DataSourceType.attribute) { +
+
widgets.liquid-level-card.shape-attribute-name
+ +
-
- -
- - - {{ LevelCardLayoutTranslationMap.get(layout) | translate }} - - -
-
-
widgets.liquid-level-card.units
-
-
widgets.liquid-level-card.datasource-units
-
- - +
+
widgets.liquid-level-card.shape-by-attribute
+
+
+
+ }
+ + @for (layout of LevelCardLayouts; track layout) { + + {{ LevelCardLayoutTranslationMap.get(layout) | translate }} + + } +
-
-
widgets.liquid-level-card.widget-units
-
- - - - {{ DataSourceTypeTranslations.get(type) | translate }} - - - - - - - - - +
+
widgets.liquid-level-card.units
+
+
widgets.liquid-level-card.datasource-units
+
+ + +
-
-
-
widgets.liquid-level-card.total-volume
-
- - - - {{ DataSourceTypeTranslations.get(type) | translate }} - - - - - - - warning - - - - - - + @if (levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute) { +
+
widgets.liquid-level-card.widget-units
+
+ + + @for (type of DataSourceTypes; track type) { + + {{ DataSourceTypeTranslations.get(type) | translate }} + + } + + + @if (levelCardWidgetConfigForm.get('widgetUnitsSource').value === DataSourceType.static) { + + + } @else { + + + } +
+
+ } +
+
widgets.liquid-level-card.total-volume
+
+ + + @for (type of DataSourceTypes; track type) { + + {{ DataSourceTypeTranslations.get(type) | translate }} + + } + + + @if (levelCardWidgetConfigForm.get('volumeSource').value === DataSourceType.static) { + + + @if (levelCardWidgetConfigForm.get('volumeConstant').hasError('required')) { + + warning + + } + + } @else { + + + } +
-
-
-
widgets.liquid-level-card.total-volume-units
-
- - - - {{ DataSourceTypeTranslations.get(type) | translate }} - - - - - - - - - +
+
widgets.liquid-level-card.total-volume-units
+
+ + + @for (type of DataSourceTypes; track type) { + + {{ DataSourceTypeTranslations.get(type) | translate }} + + } + + + @if (levelCardWidgetConfigForm.get('volumeUnitsSource').value === DataSourceType.static) { + + + } @else { + + + } +
-
-
-
widgets.liquid-level-card.color-and-font
-
-
widgets.liquid-level-card.tank-color
- - -
-
-
widgets.liquid-level-card.liquid-color
- - -
-
-
widgets.liquid-level-card.value
-
- - -
widget-config.decimals-suffix
-
- - - +
+
widgets.liquid-level-card.color-and-font
+
+
widgets.liquid-level-card.tank-color
+
-
-
-
widgets.liquid-level-card.total-volume
-
- - - - +
+
widgets.liquid-level-card.liquid-color
+ +
-
-
-
- - - - - {{ 'widget-config.tooltip' | translate }} - - - - -
- - {{ 'widgets.liquid-level-card.level' | translate }} - + @if (levelCardWidgetConfigForm.get('layout').value !== LevelCardLayout.simple) { +
+
widgets.liquid-level-card.value
- - - - + +
widget-config.decimals-suffix
- + - +
-
- - {{ 'widgets.value-card.date' | translate }} - -
- - + } + @if (levelCardWidgetConfigForm.get('layout').value === LevelCardLayout.absolute) { +
+
widgets.liquid-level-card.total-volume
+
+ - +
-
-
widgets.liquid-level-card.tooltip-background
- - -
-
-
widgets.liquid-level-card.background-blur
- - -
px
-
-
- - -
-
-
widget-config.card-appearance
-
-
{{ 'widgets.background.background' | translate }}
- - + }
-
-
widget-config.show-card-buttons
- - {{ 'fullscreen.fullscreen' | translate }} - -
-
-
{{ 'widget-config.card-border-radius' | translate }}
- - - +
+ + + + + {{ 'widget-config.tooltip' | translate }} + + + + +
+ + {{ 'widgets.liquid-level-card.level' | translate }} + +
+ + + + +
widget-config.decimals-suffix
+
+ + + + +
+
+
+ + {{ 'widgets.value-card.date' | translate }} + +
+ + + + + +
+
+
+
widgets.liquid-level-card.tooltip-background
+ + +
+
+
widgets.liquid-level-card.background-blur
+ + +
px
+
+
+
+
-
-
{{ 'widget-config.card-padding' | translate }}
- - - +
+
widget-config.card-appearance
+
+
{{ 'widgets.background.background' | translate }}
+ + +
+
+
widget-config.show-card-buttons
+ + {{ 'fullscreen.fullscreen' | translate }} + +
+
+
{{ 'widget-config.card-border-radius' | translate }}
+ + + +
+
+
{{ 'widget-config.card-padding' | translate }}
+ + + +
-
- - - + + + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts index c232d966e2..fb63143c98 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/liquid-level-card-basic-config.component.ts @@ -67,9 +67,10 @@ import { UtilsService } from '@core/services/utils.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-liquid-level-card-basic-config', - templateUrl: './liquid-level-card-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-liquid-level-card-basic-config', + templateUrl: './liquid-level-card-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class LiquidLevelCardBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html index ed034aaf89..c34a73d6ac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - - {{ signalStrengthLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.signal-strength.layout' | translate }}" formControlName="layout"> + @for (layout of signalStrengthLayouts; track layout) { + + {{ signalStrengthLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.ts index 20ff2d8a0c..f1d3b99969 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/signal-strength-basic-config.component.ts @@ -50,9 +50,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-signal-strength-basic-config', - templateUrl: './signal-strength-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-signal-strength-basic-config', + templateUrl: './signal-strength-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class SignalStrengthBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html index 2d596fd468..c64a4e23d9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.status-widget.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.rpc-state.disabled-state
@@ -41,7 +41,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -52,12 +52,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.status-widget.layout' | translate }}" formControlName="layout"> - - {{ statusWidgetLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.status-widget.layout' | translate }}" formControlName="layout"> + @for (layout of statusWidgetLayouts; track layout) { + + {{ statusWidgetLayoutTranslationMap.get(layout) | translate }} + + }
@@ -69,16 +71,18 @@ {{ 'widgets.status-widget.off' | translate }}
- - - + } + @if (cardStyleMode === 'off') { + - + + }
widget-config.card-appearance
@@ -102,6 +106,6 @@
+ formControlName="actions"> diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts index 8abefc479f..3048f509ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/indicator/status-widget-basic-config.component.ts @@ -33,9 +33,10 @@ import { } from '@home/components/widget/lib/indicator/status-widget.models'; @Component({ - selector: 'tb-status-widget-basic-config', - templateUrl: './status-widget-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-status-widget-basic-config', + templateUrl: './status-widget-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class StatusWidgetBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html index 3d1f05fb4b..bfbdc25d9e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/map/map-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @if (displayTimewindowConfig) { + + + } - +
widgets.single-switch.behavior
@@ -30,7 +30,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.rpc-state.turn-on
@@ -39,7 +39,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.rpc-state.turn-off
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -59,7 +59,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -70,12 +70,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> - - {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> + @for (layout of singleSwitchLayouts; track layout) { + + {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts index f464cdb66c..c45fd8bb05 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/single-switch-basic-config.component.ts @@ -33,9 +33,10 @@ import { import { ValueType } from '@shared/models/constants'; @Component({ - selector: 'tb-single-switch-basic-config', - templateUrl: './single-switch-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-single-switch-basic-config', + templateUrl: './single-switch-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class SingleSwitchBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html index b6566c181e..ff5397ea16 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.slider.behavior
@@ -27,7 +27,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.slider.on-value-change
@@ -37,7 +37,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="valueChange"> + formControlName="valueChange">
widgets.rpc-state.disabled-state
@@ -48,7 +48,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -59,12 +59,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> - - {{ sliderLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> + @for (layout of sliderLayouts; track layout) { + + {{ sliderLayoutTranslationMap.get(layout) | translate }} + + }
@@ -117,25 +119,27 @@
-
- - {{ 'widgets.slider.value' | translate }} - -
- - - -
widget-config.decimals-suffix
-
- - - - + @if (sliderWidgetConfigForm.get('layout').value !== sliderLayout.simplified) { +
+ + {{ 'widgets.slider.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
-
+ }
{{ 'widgets.slider.range' | translate }}
@@ -213,44 +217,48 @@
-
-
- {{ 'widgets.slider.left-icon' | translate }} -
-
- - - - - - - - -
-
-
-
- {{ 'widgets.slider.right-icon' | translate }} + @if (sliderWidgetConfigForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.left-icon' | translate }} +
+
+ + + + + + + + +
-
- - - - - - - - + } + @if (sliderWidgetConfigForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.right-icon' | translate }} +
+
+ + + + + + + + +
-
+ }
widget-config.card-appearance
@@ -279,6 +287,6 @@
+ formControlName="actions">
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts index ccd8ca835a..481936aee3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.ts @@ -36,9 +36,10 @@ import { cssSizeToStrSize, resolveCssSize } from '@shared/models/widget-settings import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-slider-basic-config', - templateUrl: './slider-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-slider-basic-config', + templateUrl: './slider-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class SliderBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html index d9c654f17b..986af5d80e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
widgets.value-stepper.behavior
@@ -27,7 +27,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.value-stepper.left-button-click
@@ -37,7 +37,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="leftButtonClick"> + formControlName="leftButtonClick">
widgets.value-stepper.right-button-click
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="rightButtonClick"> + formControlName="rightButtonClick">
widgets.button-state.disabled-state
@@ -58,7 +58,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -69,12 +69,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> - - {{ valueStepperTypeTranslationMap.get(type) | translate }} - + label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> + @for (type of valueStepperTypes; track type) { + + {{ valueStepperTypeTranslationMap.get(type) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts index a149118743..d9806967d3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/value-stepper-basic-config.component.ts @@ -36,9 +36,10 @@ import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; type ButtonAppearanceType = 'left' | 'right'; @Component({ - selector: 'tb-value-stepper-basic-config', - templateUrl: './value-stepper-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-value-stepper-basic-config', + templateUrl: './value-stepper-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class ValueStepperBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html index 03cdee6b95..fa9780cd06 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/scada/scada-symbol-basic-config.component.html @@ -16,7 +16,7 @@ --> - +
scada.symbol.symbol
- - + @if (displayTimewindowConfig) { + + + } - - - - + @if (!windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + } + @if (!windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + }
@@ -64,15 +70,19 @@ hideDataKeyLabel hideDataKeyColor> - - - - + @if (windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + } + @if (windSpeedDirectionWidgetConfigForm.get('centerValueKey').value) { + + + }
@@ -83,12 +93,14 @@ breakpoints: { 'lt-md': 2 }}" - label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> - - {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> + @for (layout of windSpeedDirectionLayouts; track layout) { + + {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts index 003ccef095..3b8c86fe5e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.ts @@ -47,9 +47,10 @@ import { import { getSourceTbUnitSymbol, TbUnit } from '@shared/models/unit.models'; @Component({ - selector: 'tb-wind-speed-direction-basic-config', - templateUrl: './wind-speed-direction-basic-config.component.html', - styleUrls: ['../basic-config.scss'] + selector: 'tb-wind-speed-direction-basic-config', + templateUrl: './wind-speed-direction-basic-config.component.html', + styleUrls: ['../basic-config.scss'], + standalone: false }) export class WindSpeedDirectionBasicConfigComponent extends BasicWidgetConfigComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html index 1dacf0cd22..0a276dcbfd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.html @@ -16,106 +16,126 @@ -->
- - widget-config.datasource-type - - - {{ datasourceTypesTranslations.get(datasourceType) | translate }} - - - -
- - - datasource.label - - - - - - - - - - - - - + @if (!basicMode) { + + widget-config.datasource-type + + @for (datasourceType of datasourceTypes; track datasourceType) { + + {{ datasourceTypesTranslations.get(datasourceType) | translate }} + + } + + + } +
+ @switch (datasourceFormGroup.get('type').value) { + @case (datasourceType.function) { + @if (!hideDatasourceLabel) { + + datasource.label + + + } + } + @case (datasourceFormGroup.get('type').value === datasourceType.device || + datasourceFormGroup.get('type').value === datasourceType.entity || + datasourceFormGroup.get('type').value === datasourceType.entityCount || + datasourceFormGroup.get('type').value === datasourceType.alarmCount ? datasourceFormGroup.get('type').value : '') { + @if (datasourceFormGroup.get('type').value === datasourceType.alarmCount && !hideAlarmFilter) { + + } + @if (datasourceFormGroup.get('type').value === datasourceType.device) { + + + } + @if (datasourceFormGroup.get('type').value !== datasourceType.device) { + + + } + @if (!hideDatasourceLabel && [datasourceType.entityCount, datasourceType.alarmCount].includes(datasourceFormGroup.get('type').value)) { + + + + } + } + }
-
- - - - -
- - + @if (!hideDataKeys) { +
+ + + @if (hasAdditionalLatestDataKeys) { + + + } +
+ } + @if ((!basicMode || displayDatasourceFilterForBasicMode) && ![datasourceType.function].includes(datasourceFormGroup.get('type').value)) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts index dcec6497da..6dcfee5146 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasource.component.ts @@ -47,21 +47,22 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-datasource', - templateUrl: './datasource.component.html', - styleUrls: ['./datasource.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatasourceComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DatasourceComponent), - multi: true, - } - ] + selector: 'tb-datasource', + templateUrl: './datasource.component.html', + styleUrls: ['./datasource.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatasourceComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DatasourceComponent), + multi: true, + } + ], + standalone: false }) export class DatasourceComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.html b/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.html index f1f80b9248..f77c7c3ab4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.html @@ -19,23 +19,30 @@
{{ (singleDatasource ? (isAlarmSource ? 'widget-config.alarm-source' : 'widget-config.datasource') : 'widget-config.datasources') | translate }}
-
- {{ 'widget-config.timeseries-key-error' | translate }} -
- - {{ 'device.device' | translate }} - {{ 'entity.entity-alias' | translate }} - + @if (timeseriesKeyError) { +
+ {{ 'widget-config.timeseries-key-error' | translate }} +
+ } + @if (basicMode && !hideDatasourcesMode) { + + {{ 'device.device' | translate }} + {{ 'entity.entity-alias' | translate }} + + }
-
{{ 'widget-config.maximum-datasources' | translate:{count: maxDatasources} }}
+ @if (maxDatasources > 1) { +
{{ 'widget-config.maximum-datasources' | translate:{count: maxDatasources} }}
+ }
-
- datasource.add-datasource-prompt -
- + @if (datasourcesFormArray.length === 0) { +
+ datasource.add-datasource-prompt +
+ } @else {
- -
-
-
{{$index + 1}}
-
-
-
- - -
- - + @if (!dragDisabled) { + + drag_indicator + + } +
+ }
+
-
-
- + + }
- -
- -
+ } + @if (showAddDatasource) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts index dd518177d0..36dcbbd9f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/datasources.component.ts @@ -46,21 +46,22 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-datasources', - templateUrl: './datasources.component.html', - styleUrls: ['./datasources.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatasourcesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DatasourcesComponent), - multi: true, - } - ] + selector: 'tb-datasources', + templateUrl: './datasources.component.html', + styleUrls: ['./datasources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatasourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DatasourcesComponent), + multi: true, + } + ], + standalone: false }) export class DatasourcesComponent implements ControlValueAccessor, OnInit, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html index d44c668be8..1d47c35261 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html @@ -23,16 +23,21 @@ {{ 'entity.entity-alias' | translate }}
- - - - + @if (targetDeviceFormGroup.get('type').value === targetDeviceType.device) { + + + } + @if (targetDeviceFormGroup.get('type').value === targetDeviceType.entity) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts index 3b68b52668..8336cf338a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.ts @@ -33,23 +33,25 @@ import { EntityType } from '@shared/models/entity-type.models'; import { IAliasController } from '@core/api/widget-api.models'; import { EntityAliasSelectCallbacks } from '@home/components/widget/lib/settings/common/alias/entity-alias-select.component.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-target-device', - templateUrl: './target-device.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TargetDeviceComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TargetDeviceComponent), - multi: true, - } - ] + selector: 'tb-target-device', + templateUrl: './target-device.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TargetDeviceComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TargetDeviceComponent), + multi: true, + } + ], + standalone: false }) export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Validator { @@ -72,6 +74,9 @@ export class TargetDeviceComponent implements ControlValueAccessor, OnInit, Vali @Input() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + widgetEditMode = this.utils.widgetEditMode; targetDeviceFormGroup: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.ts index 1c1ecf9e2f..03a7804658 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-config-panel.component.ts @@ -49,16 +49,17 @@ export const setTimewindowConfig = (config: WidgetConfig, data: TimewindowConfig }; @Component({ - selector: 'tb-timewindow-config-panel', - templateUrl: './timewindow-config-panel.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimewindowConfigPanelComponent), - multi: true - } - ] + selector: 'tb-timewindow-config-panel', + templateUrl: './timewindow-config-panel.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimewindowConfigPanelComponent), + multi: true + } + ], + standalone: false }) export class TimewindowConfigPanelComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts index 0a3dc78902..4370dfc358 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style-panel.component.ts @@ -26,11 +26,12 @@ import { deepClone } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-timewindow-style-panel', - templateUrl: './timewindow-style-panel.component.html', - providers: [], - styleUrls: ['./timewindow-style-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-timewindow-style-panel', + templateUrl: './timewindow-style-panel.component.html', + providers: [], + styleUrls: ['./timewindow-style-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimewindowStylePanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style.component.ts b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style.component.ts index 03abbf7c2a..d0e0eef7e2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/config/timewindow-style.component.ts @@ -23,16 +23,17 @@ import { Timewindow } from '@shared/models/time/time.models'; import { TimewindowStylePanelComponent } from '@home/components/widget/config/timewindow-style-panel.component'; @Component({ - selector: 'tb-timewindow-style', - templateUrl: './timewindow-style.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimewindowStyleComponent), - multi: true - } - ] + selector: 'tb-timewindow-style', + templateUrl: './timewindow-style.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimewindowStyleComponent), + multi: true + } + ], + standalone: false }) export class TimewindowStyleComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts index 3c05469d04..31c63187ae 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/custom-dialog-container.component.ts @@ -44,8 +44,9 @@ export interface CustomDialogContainerData { } @Component({ - selector: 'tb-custom-dialog-container-component', - template: '' + selector: 'tb-custom-dialog-container-component', + template: '', + standalone: false }) export class CustomDialogContainerComponent extends DialogComponent implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html index 75179512c9..a31ad603f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/dialog/embed-dashboard-dialog.component.html @@ -23,12 +23,15 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
+ [currentState]="state" [dashboard]="dashboard" [parentDashboard]="parentDashboard" style="width: 100%; height: 100%;">
- + @if (allowAcknowledgment) { + + } + @if (ctx.settings.allowClear) { + + }
+ matSort [matSortActive]="sortOrderProperty" [matSortDirection]="pageLinkSortDirection()" matSortDisableClear> - - - {{ column.title }} - - - - - - + + {{ column.title }} + + + + @if (column.entityKey.key === 'assignee') { + + @if (alarm.assigneeId && checkAssigneeHasName(alarm.assignee)) { + - - {{ getUserInitials(alarm.assignee) }} - - - {{ getUserDisplayName(alarm.assignee) }} - - - - - {{ alarm.assigneeId ? 'no_accounts' : 'account_circle' }} - - {{ (alarm.assigneeId ? 'alarm.user-deleted' : 'alarm.unassigned') | translate }} + + {{ getUserInitials(alarm.assignee) }} + + + {{ getUserDisplayName(alarm.assignee) }} + - - - - - - - + } @else { + + {{ alarm.assigneeId ? 'no_accounts' : 'account_circle' }} + + {{ (alarm.assigneeId ? 'alarm.user-deleted' : 'alarm.unassigned') | translate }} + + + } + @if (allowAssign) { + + } + + } + + + } - + @if (alarmsDatasource.countCellButtonAction) {
-
+ }
- + @if (alarmsDatasource.countCellButtonAction) {
- - - - -
-
- - - - - - + } + }
-
+ @if (alarm.hasActions) { +
+ + + @for (actionDescriptor of alarm.actionCellButtons; track trackByActionCellDescriptionId($index, actionDescriptor)) { + @if (actionDescriptor.icon) { + + } + } + +
+ } + }
@@ -172,20 +188,24 @@ [class.tb-pointer]="hasRowAction" *matRowDef="let alarm; columns: displayedColumns; let row = index" [style]="rowStyle(alarm, row) | async" - (click)="onRowClick($event, alarm)"> + (click)="onRowClick($event, alarm)">
{{ noDataDisplayMessageText }} + class="no-data-found flex items-center justify-center">{{ noDataDisplayMessageText }} {{ 'common.loading' | translate }} + class="no-data-found flex items-center justify-center" >{{ 'common.loading' | translate }}
- - + @if (displayPagination) { + + } + @if (displayPagination) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts index fa5402f808..06e0cb73ad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/alarm/alarms-table-widget.component.ts @@ -158,9 +158,10 @@ interface AlarmWidgetActionDescriptor extends TableCellButtonActionDescriptor { } @Component({ - selector: 'tb-alarms-table-widget', - templateUrl: './alarms-table-widget.component.html', - styleUrls: ['./alarms-table-widget.component.scss', './../table-widget.scss'] + selector: 'tb-alarms-table-widget', + templateUrl: './alarms-table-widget.component.html', + styleUrls: ['./alarms-table-widget.component.scss', './../table-widget.scss'], + standalone: false }) export class AlarmsTableWidgetComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/analogue-gauge.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/analogue-gauge.models.ts index a4e220888b..f77a854a77 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/analogue-gauge.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/analogue-gauge.models.ts @@ -26,6 +26,7 @@ import { TbUnit } from '@shared/models/unit.models'; import { ValueFormatProcessor } from '@shared/models/widget-settings.models'; import { UnitService } from '@core/services/unit.service'; import { DataKey } from '@shared/models/widget.models'; +import { CustomTranslatePipe } from '@shared/pipe/custom-translate.pipe'; export type AnimationRule = 'linear' | 'quad' | 'quint' | 'cycle' | 'bounce' | 'elastic' | 'dequad' | 'dequint' @@ -201,7 +202,7 @@ export abstract class TbAnalogueGauge 0 ? + this.ctx.$injector.get(CustomTranslatePipe).transform(settings.unitTitle && settings.unitTitle.length > 0 ? settings.unitTitle : dataKey.label) : ''), borders: settings.showBorder !== false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.ts index 9a397eaf9b..eaa723acd3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/action-button-widget.component.ts @@ -26,10 +26,11 @@ import { import { WidgetButtonAppearance } from '@shared/components/button/widget-button.models'; @Component({ - selector: 'tb-action-button-widget', - templateUrl: './action-button-widget.component.html', - styleUrls: ['../action/action-widget.scss', './action-button-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-action-button-widget', + templateUrl: './action-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './action-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ActionButtonWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html index 23e6d9d7dd..067b2b182f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.html @@ -26,5 +26,7 @@ [ctx]="ctx" (clicked)="onClick($event)"> - + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.ts index ca863f9108..70298808da 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/command-button-widget.component.ts @@ -26,10 +26,11 @@ import { } from '@home/components/widget/lib/button/command-button-widget.models'; @Component({ - selector: 'tb-command-button-widget', - templateUrl: './command-button-widget.component.html', - styleUrls: ['../action/action-widget.scss', './command-button-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-command-button-widget', + templateUrl: './command-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './command-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CommandButtonWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.html index 37a9bc5ed3..92a0d87f45 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.html @@ -23,12 +23,14 @@ [class.horizontal-fill]="horizontalFill" [class.vertical-fill]="verticalFill"> + [appearance]="appearance" + [autoScale]="autoScale" + [disabled]="disabled || (loading$ | async)" + [ctx]="ctx" + (clicked)="onClick($event)">
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.ts index 9c20a279ed..a1601a6fb8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/toggle-button-widget.component.ts @@ -28,10 +28,11 @@ import { Observable } from 'rxjs'; import { backgroundStyle, ComponentStyle, overlayStyle } from '@shared/models/widget-settings.models'; @Component({ - selector: 'tb-toggle-button-widget', - templateUrl: './toggle-button-widget.component.html', - styleUrls: ['../action/action-widget.scss', './toggle-button-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-toggle-button-widget', + templateUrl: './toggle-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './toggle-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ToggleButtonWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html index f1aee8b5cc..e95506704d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.html @@ -29,6 +29,8 @@ (clicked)="onClick($event)">
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts index efff97c29c..46382dab77 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/button/two-segment-button-widget.component.ts @@ -28,10 +28,11 @@ import { ComponentStyle } from '@shared/models/widget-settings.models'; import { MatButtonToggleChange } from '@angular/material/button-toggle'; @Component({ - selector: 'tb-two-segment-button-widget', - templateUrl: './two-segment-button-widget.component.html', - styleUrls: ['../action/action-widget.scss', './two-segment-button-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-two-segment-button-widget', + templateUrl: './two-segment-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './two-segment-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TwoSegmentButtonWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html index cca727e061..1490b9074b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.html @@ -21,69 +21,83 @@
- + @if (showValues) { - + }
-
{{ subtitle$ | async }}
+ @if (showSubtitle) { +
{{ subtitle$ | async }}
+ }
- + @if (values[aggregatedValueCardKeyPosition.leftTop]) { - - + } + @if (values[aggregatedValueCardKeyPosition.leftBottom]) { - + }
- + @if (values[aggregatedValueCardKeyPosition.center]) { - + }
- + @if (values[aggregatedValueCardKeyPosition.rightTop]) { - - + } + @if (values[aggregatedValueCardKeyPosition.rightBottom]) { - + }
-
-
-
{{tickMax$ | async}}
-
{{tickMin$ | async}}
-
-
-
-
-
-
-
+ @if (showChart) { +
+
+
{{tickMax$ | async}}
+
{{tickMin$ | async}}
+
+
+
+
+
+
+
+
-
+ } -
+ @if (showDate) { +
+ }
-
- - arrow_upward - arrow_downward -
+ @if (value.showArrow) { +
+ @if (!value.upArrow && !value.downArrow) { + + } + @if (value.upArrow) { + arrow_upward + } + @if (value.downArrow) { + arrow_downward + } +
+ }
{{ value.value }} {{ value.units }} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts index 3a4a836c5e..c4b07619e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/aggregated-value-card-widget.component.ts @@ -63,9 +63,10 @@ const valuesLayoutHeight = 66; const valuesLayoutVerticalPadding = 16; @Component({ - selector: 'tb-aggregated-value-card-widget', - templateUrl: './aggregated-value-card-widget.component.html', - styleUrls: ['./aggregated-value-card-widget.component.scss'] + selector: 'tb-aggregated-value-card-widget', + templateUrl: './aggregated-value-card-widget.component.html', + styleUrls: ['./aggregated-value-card-widget.component.scss'], + standalone: false }) export class AggregatedValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss index b3ea52c2a8..fbd2ba3c3f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.scss @@ -50,8 +50,8 @@ $warning-color: #FAA405; color: $enabled-color; } .mat-mdc-progress-bar { - --mdc-linear-progress-track-color: #{rgba($enabled-color, 0.06)}; - --mdc-linear-progress-active-indicator-color: #{$enabled-color}; + --mat-progress-bar-track-color: #{rgba($enabled-color, 0.06)}; + --mat-progress-bar-active-indicator-color: #{$enabled-color}; } } &.disabled { @@ -59,8 +59,8 @@ $warning-color: #FAA405; color: $disabled-color; } .mat-mdc-progress-bar { - --mdc-linear-progress-track-color: #{rgba($disabled-color, 0.06)}; - --mdc-linear-progress-active-indicator-color: #{$disabled-color}; + --mat-progress-bar-track-color: #{rgba($disabled-color, 0.06)}; + --mat-progress-bar-active-indicator-color: #{$disabled-color}; } } &.warning { @@ -68,8 +68,8 @@ $warning-color: #FAA405; color: $warning-color; } .mat-mdc-progress-bar { - --mdc-linear-progress-track-color: #{rgba($warning-color, 0.06)}; - --mdc-linear-progress-active-indicator-color: #{$warning-color}; + --mat-progress-bar-track-color: #{rgba($warning-color, 0.06)}; + --mat-progress-bar-active-indicator-color: #{$warning-color}; } } @@ -120,8 +120,8 @@ $warning-color: #FAA405; font-size: 14px; } &-progress { - --mdc-linear-progress-track-height: 8px; - --mdc-linear-progress-active-indicator-height: 8px; + --mat-progress-bar-track-height: 8px; + --mat-progress-bar-active-indicator-height: 8px; padding: 4px 0; .mat-mdc-progress-bar { border-radius: 6px; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts index 26c3901e4b..97efac2cc8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/api-usage-widget.component.ts @@ -32,10 +32,11 @@ import { } from '@home/components/widget/lib/settings/cards/api-usage-settings.component.models'; @Component({ - selector: 'tb-api-usage-widget', - templateUrl: './api-usage-widget.component.html', - styleUrls: ['api-usage-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-api-usage-widget', + templateUrl: './api-usage-widget.component.html', + styleUrls: ['api-usage-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ApiUsageWidgetComponent implements OnInit, OnDestroy { @@ -103,12 +104,16 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { }; this.ctx.subscriptionApi.createSubscription(apiUsageSubscriptionOptions, true).subscribe(); - this.currentState = this.ctx.stateController.getStateId(); - this.ctx.stateController.stateId().subscribe((state) => { - this.ctx.updateParamsFromData(true); - this.currentState = state; - this.cd.markForCheck(); - }); + const simulated = this.ctx.utilsService.widgetEditMode || this.ctx.isPreview; + + if (!simulated) { + this.currentState = this.ctx.stateController.getStateId(); + this.ctx.stateController.stateId().subscribe((state) => { + this.ctx.updateParamsFromData(true); + this.currentState = state; + this.cd.markForCheck(); + }); + } this.backgroundStyle$ = backgroundStyle(this.settings.background, this.imagePipe, this.sanitizer); this.overlayStyle = overlayStyle(this.settings.background.overlay); this.padding = this.settings.background.overlay.enabled ? undefined : this.settings.padding; @@ -120,7 +125,7 @@ export class ApiUsageWidgetComponent implements OnInit, OnDestroy { updateState($event: MouseEvent, stateName: string) { $event?.preventDefault(); - if (stateName?.length) { + if (stateName?.length && this.ctx.stateController) { this.ctx.stateController.updateState(stateName, this.ctx.stateController.getStateParams(), this.ctx.isMobile); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html index c5084fd21a..fd56942e6c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.html @@ -23,7 +23,9 @@
- {{ icon }} + @if (showIcon) { + {{ icon }} + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts index 195e683c57..52905a0469 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-card-widget.component.ts @@ -45,10 +45,11 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-label-card-widget', - templateUrl: './label-card-widget.component.html', - styleUrls: ['./label-card-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-label-card-widget', + templateUrl: './label-card-widget.component.html', + styleUrls: ['./label-card-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class LabelCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html index 289b54bb56..ea20b3521b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.html @@ -23,11 +23,17 @@
-
- {{ icon }} -
-
+ @if (showIcon || showLabel) { +
+ @if (showIcon) { + {{ icon }} + } + @if (showLabel) { +
+ } +
+ }
{{ valueText }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts index 978d52d854..04e2c03679 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/label-value-card-widget.component.ts @@ -50,10 +50,11 @@ import { import { isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-label-value-card-widget', - templateUrl: './label-value-card-widget.component.html', - styleUrls: ['./label-value-card-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-label-value-card-widget', + templateUrl: './label-value-card-widget.component.html', + styleUrls: ['./label-value-card-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class LabelValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html index f486fd0594..e0a38d6eca 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.html @@ -21,13 +21,14 @@
widgets.notification.notification-types
- - {{ notificationTypesTranslateMap.get(type).name | translate }} - close - + @for (type of selectedNotificationTypes; track type) { + + {{ notificationTypesTranslateMap.get(type).name | translate }} + close + + } - - - + @for (type of filteredNotificationTypesList | async; track type) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts index 30976c8442..a39044c759 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/notification-type-filter-panel.component.ts @@ -32,9 +32,10 @@ export interface NotificationTypeFilterPanelData { } @Component({ - selector: 'tb-notification-type-filter-panel', - templateUrl: './notification-type-filter-panel.component.html', - styleUrls: ['notification-type-filter-panel.component.scss'] + selector: 'tb-notification-type-filter-panel', + templateUrl: './notification-type-filter-panel.component.html', + styleUrls: ['notification-type-filter-panel.component.scss'], + standalone: false }) export class NotificationTypeFilterPanelComponent implements OnInit{ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.html index 0b9acd06fd..9b99295ca8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.html @@ -18,24 +18,30 @@
- + @if (layout === progressBarLayout.default) { - + }
-
- - - -
{{ valueText }}
-
+ @if (showTitleValueRow) { +
+ @if (layout === progressBarLayout.simplified) { + + } + @if (showValue) { +
{{ valueText }}
+ } +
+ }
-
-
{{ settings.tickMin }}
-
{{ settings.tickMax }}
-
+ @if (showTicks) { +
+
{{ settings.tickMin }}
+
{{ settings.tickMax }}
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.ts index 66f5d90254..bc4180b0e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/progress-bar-widget.component.ts @@ -52,10 +52,11 @@ const defaultAspect = defaultLayoutHeight / 150; const simplifiedAspect = simplifiedLayoutHeight / 150; @Component({ - selector: 'tb-progress-bar-widget', - templateUrl: './progress-bar-widget.component.html', - styleUrls: ['./progress-bar-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-progress-bar-widget', + templateUrl: './progress-bar-widget.component.html', + styleUrls: ['./progress-bar-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ProgressBarWidgetComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html index 2cc7e023d2..2f9fdb4ca9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.html @@ -18,38 +18,44 @@
-
- {{ count$ | async }} -
+ @if (showCounter) { +
+ {{ count$ | async }} +
+ }
- -
-
-
- - - -
-
-
-
- -
- - - -
- notification.no-notifications-yet -
- + @if (loadNotification) { + @if (notifications.length) { +
+
+ @for (notification of notifications; track notification.id.id; let last = $last) { +
+ + + @if (!last) { + + } +
+ } +
+
+ } @else { +
+ + + +
+ notification.no-notifications-yet + } + } @else {
notification.loading-notifications
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts index 450d59e22e..157dc0deec 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/unread-notification-widget.component.ts @@ -37,7 +37,7 @@ import { unreadNotificationDefaultSettings, UnreadNotificationWidgetSettings } from '@home/components/widget/lib/cards/unread-notification-widget.models'; -import { Notification, NotificationRequest, NotificationType } from '@shared/models/notification.models'; +import { Notification, NotificationType } from '@shared/models/notification.models'; import { NotificationSubscriber } from '@shared/models/telemetry/telemetry.models'; import { NotificationWebsocketService } from '@core/ws/notification-websocket.service'; import { distinctUntilChanged, map, share, skip, take, tap } from 'rxjs/operators'; @@ -54,10 +54,11 @@ import { select, Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-unread-notification-widget', - templateUrl: './unread-notification-widget.component.html', - styleUrls: ['unread-notification-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-unread-notification-widget', + templateUrl: './unread-notification-widget.component.html', + styleUrls: ['unread-notification-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class UnreadNotificationWidgetComponent implements OnInit, OnDestroy { @@ -229,10 +230,6 @@ export class UnreadNotificationWidgetComponent implements OnInit, OnDestroy { } } - trackById(index: number, item: NotificationRequest): string { - return item.id.id; - } - private editNotificationTypeFilter($event: Event) { if ($event) { $event.stopPropagation(); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html index e2bc8fe87f..6000ef6d3e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.html @@ -15,41 +15,41 @@ limitations under the License. --> -
+
-
- - +
+ @switch (layout) { + @case (valueCardLayout.square) { - - + } + @case (valueCardLayout.vertical) { - - + } + @case (valueCardLayout.centered) {
-
- + } + @case (valueCardLayout.simplified) { - - + } + @case (layout === valueCardLayout.horizontal || + layout === valueCardLayout.horizontal_reversed ? layout : '') {
-
- + } + }
@@ -62,13 +62,19 @@
- {{ icon }} + @if (showIcon) { + {{ icon }} + } -
{{ label$ | async }}
+ @if (showLabel) { +
{{ label$ | async }}
+ }
-
+ @if (showDate) { +
+ }
{{ valueText }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts index 4db8f11ba2..b994ff8b23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-card-widget.component.ts @@ -52,9 +52,10 @@ const squareLayoutSize = 160; const horizontalLayoutHeight = 80; @Component({ - selector: 'tb-value-card-widget', - templateUrl: './value-card-widget.component.html', - styleUrls: ['./value-card-widget.component.scss'] + selector: 'tb-value-card-widget', + templateUrl: './value-card-widget.component.html', + styleUrls: ['./value-card-widget.component.scss'], + standalone: false }) export class ValueCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html index 8d276d1231..4950936073 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.html @@ -19,9 +19,11 @@
-
-
{{ valueText }}
-
+ @if (showValue) { +
+
{{ valueText }}
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts index 0198fdf6e3..5da48a00d3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/cards/value-chart-card-widget.component.ts @@ -62,10 +62,11 @@ const layoutHeight = 56; const valueRelativeWidth = 0.35; @Component({ - selector: 'tb-value-chart-card-widget', - templateUrl: './value-chart-card-widget.component.html', - styleUrls: ['./value-chart-card-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-value-chart-card-widget', + templateUrl: './value-chart-card-widget.component.html', + styleUrls: ['./value-chart-card-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ValueChartCardWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.component.ts index b6c88a7d3a..3fb90f4f21 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-widget.component.ts @@ -30,10 +30,11 @@ import { import { TbBarsChart } from '@home/components/widget/lib/chart/bars-chart'; @Component({ - selector: 'tb-bar-chart-widget', - templateUrl: './latest-chart-widget.component.html', - styleUrls: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-bar-chart-widget', + templateUrl: './latest-chart-widget.component.html', + styleUrls: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BarChartWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.html index c8a7626cda..adbec193db 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.html @@ -28,16 +28,20 @@
-
-
-
-
-
{{ legendKey.label }}
-
+ @if (showLegend) { +
+ @for (legendKey of legendKeys; track legendKey) { +
+
+
+
{{ legendKey.label }}
+
+
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.scss index 5eef2bf05f..b00409f41b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.scss @@ -20,9 +20,7 @@ display: flex; flex-direction: column; gap: 8px; - &.overlay { - padding: 20px 24px 24px 24px; - } + padding: 20px 24px 24px 24px; > div:not(.tb-bar-chart-overlay) { z-index: 1; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts index f9bd213fdb..0287b775c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts @@ -43,10 +43,11 @@ import { DataKey } from '@shared/models/widget.models'; import { WidgetComponent } from '@home/components/widget/widget.component'; @Component({ - selector: 'tb-bar-chart-with-labels-widget', - templateUrl: './bar-chart-with-labels-widget.component.html', - styleUrls: ['./bar-chart-with-labels-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-bar-chart-with-labels-widget', + templateUrl: './bar-chart-with-labels-widget.component.html', + styleUrls: ['./bar-chart-with-labels-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BarChartWithLabelsWidgetComponent implements OnInit, OnDestroy, AfterViewInit { @@ -133,6 +134,12 @@ export class BarChartWithLabelsWidgetComponent implements OnInit, OnDestroy, Aft } } + public onLatestDataUpdated() { + if (this.timeSeriesChart) { + this.timeSeriesChart.latestUpdated(); + } + } + public onLegendKeyEnter(key: DataKey) { this.timeSeriesChart.keyEnter(key); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts index 00cc814246..64b4f2caf2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/doughnut-widget.component.ts @@ -31,10 +31,11 @@ import { } from '@home/components/widget/lib/chart/latest-chart.component'; @Component({ - selector: 'tb-doughnut-widget', - templateUrl: './latest-chart-widget.component.html', - styleUrls: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-doughnut-widget', + templateUrl: './latest-chart-widget.component.html', + styleUrls: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DoughnutWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.html index 8e98d5f150..c840dc799e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.html @@ -28,17 +28,21 @@
-
-
-
-
-
{{ legendItem.label }}
-
-
{{ legendItem.value }}
+ @if (showLegend) { +
+ @for (legendItem of legendItems; track legendItem) { +
+
+
+
{{ legendItem.label }}
+
+
{{ legendItem.value }}
+
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts index 634c6f5a1e..b6576f8a3a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/latest-chart.component.ts @@ -49,10 +49,11 @@ export interface LatestChartComponentCallbacks { } @Component({ - selector: 'tb-latest-chart', - templateUrl: './latest-chart.component.html', - styleUrls: ['./latest-chart.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-latest-chart', + templateUrl: './latest-chart.component.html', + styleUrls: ['./latest-chart.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class LatestChartComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.component.ts index 2ea605739c..3da64540e3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/pie-chart-widget.component.ts @@ -30,10 +30,11 @@ import { } from '@home/components/widget/lib/chart/pie-chart-widget.models'; @Component({ - selector: 'tb-pie-chart-widget', - templateUrl: './latest-chart-widget.component.html', - styleUrls: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-pie-chart-widget', + templateUrl: './latest-chart-widget.component.html', + styleUrls: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class PieChartWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.component.ts index adc74628c5..d54ced555b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/polar-area-widget.component.ts @@ -30,10 +30,11 @@ import { } from '@home/components/widget/lib/chart/polar-area-widget.models'; @Component({ - selector: 'tb-polar-area-chart-widget', - templateUrl: './latest-chart-widget.component.html', - styleUrls: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-polar-area-chart-widget', + templateUrl: './latest-chart-widget.component.html', + styleUrls: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class PolarAreaWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.component.ts index 305ae940ca..7bf770e3ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/radar-chart-widget.component.ts @@ -30,10 +30,11 @@ import { import { TbRadarChart } from '@home/components/widget/lib/chart/radar-chart'; @Component({ - selector: 'tb-radar-chart-widget', - templateUrl: './latest-chart-widget.component.html', - styleUrls: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-radar-chart-widget', + templateUrl: './latest-chart-widget.component.html', + styleUrls: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class RadarChartWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html index 48fe64d4a2..e001d9c56d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.html @@ -28,14 +28,18 @@
-
-
-
-
-
{{ rangeItem.label }}
-
+ @if (showLegend) { +
+ @for (rangeItem of visibleRangeItems; track rangeItem) { +
+
+
+
{{ rangeItem.label }}
+
+
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss index 1dfc386930..c1445ee6d0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.scss @@ -20,9 +20,7 @@ display: flex; flex-direction: column; gap: 8px; - &.overlay { - padding: 20px 24px 24px 24px; - } + padding: 20px 24px 24px 24px; > div:not(.tb-range-chart-overlay) { z-index: 1; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts index 27fe86a393..4ad45c85a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts @@ -54,10 +54,11 @@ import { TbUnit } from '@shared/models/unit.models'; import { UnitService } from '@core/services/unit.service'; @Component({ - selector: 'tb-range-chart-widget', - templateUrl: './range-chart-widget.component.html', - styleUrls: ['./range-chart-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-range-chart-widget', + templateUrl: './range-chart-widget.component.html', + styleUrls: ['./range-chart-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewInit { @@ -161,6 +162,12 @@ export class RangeChartWidgetComponent implements OnInit, OnDestroy, AfterViewIn } } + public onLatestDataUpdated() { + if (this.timeSeriesChart) { + this.timeSeriesChart.latestUpdated(); + } + } + public toggleRangeItem(item: RangeItem) { item.enabled = !item.enabled; this.timeSeriesChart.toggleVisualMapRange(item.index); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html index a7d1d6eb6e..7ae4145eb6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.html @@ -28,83 +28,107 @@
-
- - - - - -
+ @if (showLegend) { +
+ @if (displayLegendValues) { + @if (horizontalLegendPosition) { + + } @else { + + + + + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + } + + + + @for (legendKey of legendKeys; track legendKey) { + + + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + } + + } + +
{{ 'legend.Min' | translate }}{{ 'legend.Max' | translate }}{{ 'legend.Avg' | translate }}{{ 'legend.Total' | translate }}{{ 'legend.Latest' | translate }}
+ + + + + + +
+ } + } @else { + @for (legendKey of legendKeys; track legendKey) { + + } + } +
+ } - - - - - - - - - - - - - - - - - - - - - - -
- -
-
- - - - - - - - - - - + + + @for (legendKey of legendKeys; track legendKey) { + + } + - - - - - - - - + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + }
{{ 'legend.Min' | translate }}{{ 'legend.Max' | translate }}{{ 'legend.Avg' | translate }}{{ 'legend.Total' | translate }}{{ 'legend.Latest' | translate }}
+ +
- - - - - - -
- - - - -
@@ -120,9 +144,11 @@ {{ label | translate }} - - + @for (legendKey of legendKeys; track legendKey) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts index b96ff3996f..aa94ee717c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart-widget.component.ts @@ -46,10 +46,11 @@ import { mergeDeep } from '@core/utils'; import { WidgetComponent } from '@home/components/widget/widget.component'; @Component({ - selector: 'tb-time-series-chart-widget', - templateUrl: './time-series-chart-widget.component.html', - styleUrls: ['./time-series-chart-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-widget', + templateUrl: './time-series-chart-widget.component.html', + styleUrls: ['./time-series-chart-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartWidgetComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts index 4816852768..e815eefe11 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.models.ts @@ -99,6 +99,8 @@ import { TimeSeriesChartTooltipWidgetSettings } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; import { TbUnit, TbUnitConverter } from '@shared/models/unit.models'; +import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; +import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; type TimeSeriesChartDataEntry = [number, any, number, number]; @@ -1495,3 +1497,101 @@ const createSeriesLabelOption = (item: TimeSeriesChartDataItem, show: boolean, } return labelOption; }; + +export const checkLatestDataKeys = (yAxes: TimeSeriesChartYAxes, datasource: Datasource): TimeSeriesChartYAxes => { + const latestKeys = datasource?.latestDataKeys || []; + const result: TimeSeriesChartYAxes = {}; + + for (const [id, axis] of Object.entries(yAxes)) { + axis.min = normalizeAxisLimit(axis.min); + axis.max = normalizeAxisLimit(axis.max); + const minCfg = axis.min; + const maxCfg = axis.max; + + const minValid = !!minCfg && ( + minCfg.type !== ValueSourceType.latestKey || + latestKeys.some(k => isYAxisKey(k, minCfg)) + ); + + const maxValid = !!maxCfg && ( + maxCfg.type !== ValueSourceType.latestKey || + latestKeys.some(k => isYAxisKey(k, maxCfg)) + ); + + if (minValid && maxValid) { + result[id] = axis; + } + } + + return result; +} + +export const updateLatestDataKeys = (yAxes: TimeSeriesChartYAxisSettings[], datasource: Datasource, dataKeyCallbacks: DataKeysCallbacks)=> { + if (datasource) { + let latestKeys = datasource.latestDataKeys; + if (!latestKeys) { + latestKeys = []; + datasource.latestDataKeys = latestKeys; + } + const existingYAxisKeys = latestKeys.filter(k => k.settings?.__yAxisMinKey === true || k.settings?.__yAxisMaxKey === true); + const foundYAxisKeys: DataKey[] = []; + + for(const yAxis of yAxes) { + const min = yAxis.min as ValueSourceConfig; + const max = yAxis.max as ValueSourceConfig; + if (min && min.type === ValueSourceType.latestKey) { + const found = existingYAxisKeys.find(k => isYAxisKey(k, min)); + if (!found) { + const newKey = dataKeyCallbacks.generateDataKey(min.latestKey, min.latestKeyType, + null, true, null); + newKey.settings.__yAxisMinKey = true; + latestKeys.push(newKey); + } else if (foundYAxisKeys.indexOf(found) === -1) { + foundYAxisKeys.push(found); + } + } + if (max && max.type === ValueSourceType.latestKey) { + const found = existingYAxisKeys.find(k => isYAxisKey(k, max)); + if (!found) { + const newKey = dataKeyCallbacks.generateDataKey(max.latestKey, max.latestKeyType, + null, true, null); + newKey.settings.__yAxisMaxKey = true; + latestKeys.push(newKey); + } else if (foundYAxisKeys.indexOf(found) === -1) { + foundYAxisKeys.push(found); + } + } + } + const toRemove = existingYAxisKeys.filter(k => foundYAxisKeys.indexOf(k) === -1); + for (const key of toRemove) { + const index = latestKeys.indexOf(key); + if (index > -1) { + latestKeys.splice(index, 1); + } + } + } +} + +export const isYAxisKey = (d: DataKey, limit: ValueSourceConfig): boolean => { + return (d.type === DataKeyType.function && d.label === limit.latestKey) || + (d.type !== DataKeyType.function && d.name === limit.latestKey && + d.type === limit.latestKeyType); +} + +export const normalizeAxisLimit = (limit: string | number | ValueSourceConfig): ValueSourceConfig => { + if (!limit) { + return { + type: ValueSourceType.constant, + value: null, + entityAlias: null + }; + } else if (typeof limit === 'number' || typeof limit === 'string') { + return { + type: ValueSourceType.constant, + value: Number(limit), + entityAlias: null + }; + } + return limit; +} + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts index 21f5f2d68c..d1928d75f0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/chart/time-series-chart.ts @@ -23,7 +23,7 @@ import { createTimeSeriesYAxis, defaultTimeSeriesChartYAxisSettings, generateChartData, - LineSeriesStepType, + LineSeriesStepType, normalizeAxisLimit, parseThresholdData, TimeSeriesChartAxis, TimeSeriesChartDataItem, @@ -581,8 +581,8 @@ export class TbTimeSeriesChart { yAxisSettingsList.sort((a1, a2) => a1.order - a2.order); const axisLimitDatasources: Datasource[] = []; for (const yAxisSettings of yAxisSettingsList) { - yAxisSettings.min = this.normalizeAxisLimit(yAxisSettings.min); - yAxisSettings.max = this.normalizeAxisLimit(yAxisSettings.max); + yAxisSettings.min = normalizeAxisLimit(yAxisSettings.min); + yAxisSettings.max = normalizeAxisLimit(yAxisSettings.max); const axisSettings = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings, yAxisSettings); const units = isNotEmptyTbUnits(axisSettings.units) ? axisSettings.units : this.ctx.units; @@ -1080,21 +1080,4 @@ export class TbTimeSeriesChart { this.timeSeriesChart.setOption(this.timeSeriesChartOptions); } } - - private normalizeAxisLimit(limit: string | number | ValueSourceConfig): string | number | ValueSourceConfig { - if (!limit) { - return { - type: ValueSourceType.constant, - value: null, - entityAlias: null - }; - } else if (typeof limit === 'number' || typeof limit === 'string') { - return { - type: ValueSourceType.constant, - value: Number(limit), - entityAlias: null - }; - } - return limit; - } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.html index 37eb1f807a..a607300147 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.html @@ -20,20 +20,32 @@
-
-
-
+ @if (showIconBackground || showIcon) { +
+ @if (showIconBackground) { +
+
+
+ } + @if (showIcon) { + {{ icon }} + }
- {{ icon }} -
+ }
-
{{ label }}
-
+ @if (showLabel) { +
{{ label }}
+ } + @if (layout === countCardLayout.row) { +
+ }
{{ valueText }}
- chevron_right + @if (showChevron) { + chevron_right + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.ts index 611223c5df..01a652b03f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/count/count-widget.component.ts @@ -48,9 +48,10 @@ const layoutHeightWithTitle = 60; const layoutPadding = 24; @Component({ - selector: 'tb-count-widget', - templateUrl: './count-widget.component.html', - styleUrls: ['./count-widget.component.scss'] + selector: 'tb-count-widget', + templateUrl: './count-widget.component.html', + styleUrls: ['./count-widget.component.scss'], + standalone: false }) export class CountWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html index b3c95cc73b..ec0071243b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator-panel.component.html @@ -16,20 +16,24 @@ -->
- - - + @if (settings.showTemplate) { + + + + } -
- - -
+ @if (!settings.autoConfirm) { +
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html index f5f43a5346..5cdc878b57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/date-range-navigator/date-range-navigator.component.html @@ -19,23 +19,31 @@ [class.short-mode]="ctx.width < 400" [class.labels-hidden]="settings.hideLabels" [class.long-mode]="ctx.width >= 400"> - - widgets.date-range-navigator.localizationMap.Date picker + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Date picker + } {{advancedModel.chosenLabel}} - - widgets.date-range-navigator.localizationMap.Interval + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Interval + } - - {{'widgets.date-range-navigator.localizationMap.' + customInterval.label | translate}} - - - {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} - + @if (customInterval) { + + {{'widgets.date-range-navigator.localizationMap.' + customInterval.label | translate}} + + } + @for (date of datesMap | keyvalue: originalOrder; track date) { + + {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} + + } @@ -43,12 +51,16 @@ - - widgets.date-range-navigator.localizationMap.Step size + + @if (!settings.hideLabels) { + widgets.date-range-navigator.localizationMap.Step size + } - - {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} - + @for (date of datesMap | keyvalue: originalOrder; track date) { + + {{'widgets.date-range-navigator.localizationMap.' + date.value.label | translate}} + + } - +@if (addMode || editMode) { +
+ + @if (editMode) { +
+ + +
+ }
-
- - -
-
- +} @else {
- +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts index 73c91d64b4..17f96f3b74 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-link.component.ts @@ -29,17 +29,18 @@ import { DocumentationLink } from '@shared/models/user-settings.models'; import { ErrorStateMatcher } from '@angular/material/core'; @Component({ - selector: 'tb-doc-link', - templateUrl: './doc-link.component.html', - styleUrls: ['./link.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DocLinkComponent), - multi: true - }, - {provide: ErrorStateMatcher, useExisting: DocLinkComponent} - ] + selector: 'tb-doc-link', + templateUrl: './doc-link.component.html', + styleUrls: ['./link.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DocLinkComponent), + multi: true + }, + { provide: ErrorStateMatcher, useExisting: DocLinkComponent } + ], + standalone: false }) export class DocLinkComponent extends PageComponent implements OnInit, ControlValueAccessor, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html index 1f3756431c..fc2199b679 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/doc-links-widget.component.html @@ -27,17 +27,19 @@
- - -
- - - - + @if (!editMode && !addMode) { + + } + @if (addMode) { + @switch (mode) { + @case ('docs') { - - + } + @case ('quickLinks') { + [ngModelOptions]="{standalone: true}" + (quickLinkAdded)="linkAdded($event)" + (quickLinkAddCanceled)="addMode = false"> - - - + } + } + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-links-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-links-dialog.component.ts index 224d963a23..6e33e8e645 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-links-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/edit-links-dialog.component.ts @@ -32,9 +32,10 @@ export interface EditLinksDialogData { } @Component({ - selector: 'tb-edit-links-dialog', - templateUrl: './edit-links-dialog.component.html', - styleUrls: ['./edit-links-dialog.component.scss'] + selector: 'tb-edit-links-dialog', + templateUrl: './edit-links-dialog.component.html', + styleUrls: ['./edit-links-dialog.component.scss'], + standalone: false }) export class EditLinksDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts index 13356e6a1c..9d21b73dd3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-completed-dialog.component.ts @@ -23,10 +23,11 @@ import { DialogComponent } from '@shared/components/dialog.component'; import { Router } from '@angular/router'; @Component({ - selector: 'tb-getting-started-completed-dialog', - templateUrl: './getting-started-completed-dialog.component.html', - styleUrls: ['./getting-started-completed-dialog.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-getting-started-completed-dialog', + templateUrl: './getting-started-completed-dialog.component.html', + styleUrls: ['./getting-started-completed-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class GettingStartedCompletedDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html index 582070ad33..5247c09ffd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.html @@ -22,191 +22,213 @@ {{ index + 1 }} - - + @switch (authUser.authority) { + @case (authority.SYS_ADMIN) {
widgets.getting-started.sys-admin.step1.title
- {{ 'tenant.tenants' | translate }} + @if (matStepper.selectedIndex === 0) { + {{ 'tenant.tenants' | translate }} + }
description{{ 'widgets.getting-started.sys-admin.step1.how-to-create-tenant' | translate }} -
- - -
-
widgets.getting-started.sys-admin.step2.title
- {{ 'admin.settings' | translate }} -
-
-
- - description{{ 'widgets.getting-started.sys-admin.step2.how-to-configure-mail-server' | translate }} -
- - -
-
widgets.getting-started.sys-admin.step3.title
- {{ 'admin.settings' | translate }} -
-
-
- - description{{ 'widgets.getting-started.sys-admin.step3.how-to-configure-sms-provider' | translate }} -
- - -
-
widgets.getting-started.sys-admin.step4.title
- {{ 'admin.settings' | translate }} -
-
-
- - description{{ 'widgets.getting-started.sys-admin.step4.how-to-configure-2fa' | translate }} -
- - -
-
widgets.getting-started.sys-admin.step5.title
- {{ 'admin.settings' | translate }} -
-
-
- - description{{ 'widgets.getting-started.sys-admin.step5.how-to-configure-oauth2' | translate }} -
- - -
-
widgets.getting-started.sys-admin.step6.title
- {{ 'admin.settings' | translate }} -
-
-
- - description{{ 'widgets.getting-started.sys-admin.step6.how-to-configure-notifications' | translate }} -
-
- - - -
-
widgets.getting-started.tenant-admin.step1.title
- {{ 'device.devices' | translate }} -
-
-
- - description{{ 'widgets.getting-started.tenant-admin.step1.how-to-create-device' | translate }} -
- - -
-
widgets.getting-started.tenant-admin.step2.title
-
-
-
-
- - Ubuntu - MacOS - Windows - - - -

+ + + +
+
widgets.getting-started.sys-admin.step2.title
+ @if (matStepper.selectedIndex === 1) { + {{ 'admin.settings' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.sys-admin.step2.how-to-configure-mail-server' | translate }} +
+ + +
+
widgets.getting-started.sys-admin.step3.title
+ @if (matStepper.selectedIndex === 2) { + {{ 'admin.settings' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.sys-admin.step3.how-to-configure-sms-provider' | translate }} +
+ + +
+
widgets.getting-started.sys-admin.step4.title
+ @if (matStepper.selectedIndex === 3) { + {{ 'admin.settings' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.sys-admin.step4.how-to-configure-2fa' | translate }} +
+ + +
+
widgets.getting-started.sys-admin.step5.title
+ @if (matStepper.selectedIndex === 4) { + {{ 'admin.settings' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.sys-admin.step5.how-to-configure-oauth2' | translate }} +
+ + +
+
widgets.getting-started.sys-admin.step6.title
+ @if (matStepper.selectedIndex === 5) { + {{ 'admin.settings' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.sys-admin.step6.how-to-configure-notifications' | translate }} +
+ } + @case (authority.TENANT_ADMIN) { + + +
+
widgets.getting-started.tenant-admin.step1.title
+ @if (matStepper.selectedIndex === 0) { + {{ 'device.devices' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.tenant-admin.step1.how-to-create-device' | translate }} +
+ + +
+
widgets.getting-started.tenant-admin.step2.title
+
+
+
+
+ + Ubuntu + MacOS + Windows + + @switch (publishCommandSteps.value) { + @case ('ubuntu') { +

- - -

+ } + @case ('macos') { +

-
- -

-
- -

+ } + @case ('windows') { +

+ } + } +

-
-
- - description{{ 'widgets.getting-started.tenant-admin.step2.how-to-connect-device' | translate }} -
- - -
-
widgets.getting-started.tenant-admin.step3.title
- {{ 'dashboard.dashboards' | translate }} -
-
-
- - description{{ 'widgets.getting-started.tenant-admin.step3.how-to-create-dashboard' | translate }} -
- - -
-
widgets.getting-started.tenant-admin.step4.title
- {{ 'widgets.getting-started.tenant-admin.step4.alarm-rules' | translate }} -
-
-
- - description{{ 'widgets.getting-started.tenant-admin.step4.how-to-configure-alarm-rules' | translate }} -
- - -
-
widgets.getting-started.tenant-admin.step5.title
-
-
-
-

+
+
+ + description{{ 'widgets.getting-started.tenant-admin.step2.how-to-connect-device' | translate }} +
+ + +
+
widgets.getting-started.tenant-admin.step3.title
+ @if (matStepper.selectedIndex === 2) { + {{ 'dashboard.dashboards' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.tenant-admin.step3.how-to-create-dashboard' | translate }} +
+ + +
+
widgets.getting-started.tenant-admin.step4.title
+ @if (matStepper.selectedIndex === 3) { + {{ 'widgets.getting-started.tenant-admin.step4.alarm-rules' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.tenant-admin.step4.how-to-configure-alarm-rules' | translate }} +
+ + +
+
widgets.getting-started.tenant-admin.step5.title
+
+
+
+

-
- - description{{ 'widgets.getting-started.tenant-admin.step5.how-to-create-alarm' | translate }} -
- - -
-
widgets.getting-started.tenant-admin.step6.title
- {{ 'customer.customers' | translate }} -
-
-
- - description{{ 'widgets.getting-started.tenant-admin.step6.how-to-create-customer-and-assign-dashboard' | translate }} -
-
-
- -
- -
-
+
+ + description{{ 'widgets.getting-started.tenant-admin.step5.how-to-create-alarm' | translate }} + + + +
+
widgets.getting-started.tenant-admin.step6.title
+ @if (matStepper.selectedIndex === 5) { + {{ 'customer.customers' | translate }} + } +
+
+
+ + description{{ 'widgets.getting-started.tenant-admin.step6.how-to-create-customer-and-assign-dashboard' | translate }} +
+ } + } + + @if (allCompleted) { +
+ +
+ } +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts index a6d901c2af..6c13054b67 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/getting-started-widget.component.ts @@ -33,9 +33,10 @@ import { first } from 'rxjs/operators'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-getting-started-widget', - templateUrl: './getting-started-widget.component.html', - styleUrls: ['./getting-started-widget.component.scss'] + selector: 'tb-getting-started-widget', + templateUrl: './getting-started-widget.component.html', + styleUrls: ['./getting-started-widget.component.scss'], + standalone: false }) export class GettingStartedWidgetComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html index 9fa8ea6b13..beb03851a3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html @@ -15,52 +15,69 @@ limitations under the License. --> -
-
-
-
-
- - widgets.version-info.available-version - widgets.version-info.available - + @if (updateMessage?.updateAvailable) { +
+ -
{{ updateMessage?.latestVersion }}
- -
- {{ 'widgets.version-info.upgrade' | translate }} + } @else { +
+
check
+
widgets.version-info.version-is-up-to-date
-
+ }
- -
-
check
-
widgets.version-info.version-is-up-to-date
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts index 5d9fc59c6e..f3cf60d3e1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/version-info.component.ts @@ -25,9 +25,10 @@ import { Authority } from '@shared/models/authority.enum'; import { of } from 'rxjs'; @Component({ - selector: 'tb-version-info', - templateUrl: './version-info.component.html', - styleUrls: ['./home-page-widget.scss', './version-info.component.scss'] + selector: 'tb-version-info', + templateUrl: './version-info.component.html', + styleUrls: ['./home-page-widget.scss', './version-info.component.scss'], + standalone: false }) export class VersionInfoComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts new file mode 100644 index 0000000000..f7bae926bc --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.component.ts @@ -0,0 +1,318 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + Component, + ElementRef, + Inject, + Injector, + Input, + OnInit, + Optional, + Type, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { + htmlContainerDefaultSettings, + HtmlContainerWidgetSettings, + HtmlContainerWidgetType, + WidgetContainerAngularFunction, + WidgetContainerPlainFunction +} from '@home/components/widget/lib/html/html-container-widget.models'; +import { hashCode, isNotEmptyStr, parseTbFunction } from '@core/utils'; +import { CompiledTbFunction, isNotEmptyTbFunction } from '@shared/models/js-function.models'; +import { catchError, forkJoin, map, Observable, of, switchMap, throwError } from 'rxjs'; +import cssjs from '@core/css/css'; +import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; +import { DynamicComponentFactoryService } from '@core/services/dynamic-component-factory.service'; +import { HOME_COMPONENTS_MODULE_TOKEN, WIDGET_COMPONENTS_MODULE_TOKEN } from '@home/components/tokens'; +import { ExceptionData } from '@shared/models/error.models'; +import { UtilsService } from '@core/services/utils.service'; +import { + flatModulesWithComponents, + ModulesWithComponents, + modulesWithComponentsToTypes, + ResourcesService +} from '@core/services/resources.service'; +import { MODULES_MAP } from '@shared/models/constants'; +import { IModulesMap } from '@modules/common/modules-map.models'; +import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; + +@Component({ + selector: 'tb-html-container-widget', + template: '
' + + '@if (widgetErrorData) {
\n' + + ' \n' + + '
}', + styles: '.tb-widget-error {\n' + + ' display: flex;\n' + + ' align-items: center;\n' + + ' justify-content: center;\n' + + ' background: rgba(255, 255, 255, .5);\n' + + '\n' + + ' span {\n' + + ' color: #f00;\n' + + ' }\n' + + ' }', + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class HtmlContainerWidgetComponent implements OnInit { + + @ViewChild('container', {static: true}) + containerElmRef: ElementRef; + + @ViewChild('angularContainer', {static: true}) + angularContainer: TbAnchorComponent; + + @Input() + ctx: WidgetContext; + + private containerInstanceComponentType: Type; + + private settings: HtmlContainerWidgetSettings; + + widgetErrorData: ExceptionData; + + constructor(private elementRef: ElementRef, + @Optional() @Inject(MODULES_MAP) private modulesMap: IModulesMap, + @Inject(SHARED_MODULE_TOKEN) private sharedModule: Type, + @Inject(WIDGET_COMPONENTS_MODULE_TOKEN) private widgetComponentsModule: Type, + @Inject(HOME_COMPONENTS_MODULE_TOKEN) private homeComponentsModule: Type, + private dynamicComponentFactoryService: DynamicComponentFactoryService, + private utils: UtilsService, + private resources: ResourcesService) {} + + ngOnInit(): void { + this.settings = {...htmlContainerDefaultSettings, ...(this.ctx.settings || {})}; + this.loadWidgetResources().subscribe( + { + next: () => { + if (this.settings.type === HtmlContainerWidgetType.PLAIN) { + this.initPlain(); + } else if (this.settings.type === HtmlContainerWidgetType.ANGULAR) { + this.initAngular(); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + } + + private initPlain(): void { + try { + if (isNotEmptyStr(this.settings.css)) { + const cssParser = new cssjs(); + cssParser.testMode = false; + const namespace = 'html-container-' + hashCode(this.settings.css); + cssParser.cssPreviewNamespace = namespace; + cssParser.createStyleElement(namespace, this.settings.css); + $(this.elementRef.nativeElement).addClass(namespace); + } + if (isNotEmptyStr(this.settings.html)) { + $(this.containerElmRef.nativeElement).html(this.settings.html); + } + this.compileAndExecutePlainFunction(); + } catch (e) { + this.handleWidgetException(e); + } + } + + private compileAndExecutePlainFunction(): void { + if (isNotEmptyTbFunction(this.settings.js)) { + const jsFunction: Observable> = parseTbFunction(this.ctx.http, this.settings.js, ['ctx', 'container']); + jsFunction.subscribe({ + next: (containerFunction) => { + try { + containerFunction.execute(this.ctx, this.containerElmRef.nativeElement); + } catch (e) { + this.handleWidgetException(e); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + }); + } + } + + private initAngular(): void { + this.loadAngularModules().subscribe( + { + next: (imports) => { + this.compileAngularFunction().subscribe( + { + next: (containerFunction) => { + try { + this.initAngularComponent(imports, containerFunction); + } catch (e) { + this.handleWidgetException(e); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + }, + error: (e) => { + this.handleWidgetException(e); + } + } + ); + } + + private compileAngularFunction(): Observable> { + if (isNotEmptyTbFunction(this.settings.js)) { + return parseTbFunction(this.ctx.http, this.settings.js, ['ctx']); + } else { + return of(null); + } + } + + private initAngularComponent(imports?: Type[], containerFunction?: CompiledTbFunction): void { + this.angularContainer.viewContainerRef.clear(); + const destroyContainerInstanceResources = this.destroyContainerInstanceResources.bind(this); + const template = this.settings.html || ''; + const styles: string[] = []; + if (isNotEmptyStr(this.settings.css)) { + styles.push(this.settings.css); + } + let compileModules = [this.sharedModule, this.widgetComponentsModule, this.homeComponentsModule]; + if (imports && imports.length) { + compileModules = compileModules.concat(imports); + } + const self = () => this; + this.dynamicComponentFactoryService.createDynamicComponent( + class TbContainerInstance { + ngOnInit(): void { + if (containerFunction) { + const instance = self(); + try { + containerFunction.apply(this, [instance.ctx]); + } catch (e) { + instance.handleWidgetException(e); + } + } + } + ngOnDestroy(): void { + destroyContainerInstanceResources(); + } + }, + template, + compileModules, + true, styles + ).subscribe({ + next: (componentType) => { + this.containerInstanceComponentType = componentType; + const injector: Injector = Injector.create({providers: [], parent: this.angularContainer.viewContainerRef.injector}); + try { + this.angularContainer.viewContainerRef.createComponent(this.containerInstanceComponentType, + {index: 0, injector}); + + } catch (error) { + this.handleWidgetException(error); + } + }, + error: (e) => { + this.handleWidgetException(e); + } + }); + } + + private destroyContainerInstanceResources() { + if (this.containerInstanceComponentType) { + this.dynamicComponentFactoryService.destroyDynamicComponent(this.containerInstanceComponentType); + this.containerInstanceComponentType = null; + } + } + + private handleWidgetException(e: any) { + console.error(e); + this.widgetErrorData = this.utils.processWidgetException(e); + this.ctx.detectChanges(); + } + + private loadWidgetResources(): Observable { + const resourceTasks: Observable[] = []; + this.settings.resources.filter(r => !r.isModule).forEach( + (resource) => { + resourceTasks.push( + this.resources.loadResource(resource.url).pipe( + catchError(() => of(`Failed to load widget resource: '${resource.url}'`)) + ) + ); + } + ); + if (resourceTasks.length) { + return forkJoin(resourceTasks).pipe( + switchMap(msgs => { + let errors: string[]; + if (msgs && msgs.length) { + errors = msgs.filter(msg => msg && msg.length > 0); + } + if (errors && errors.length) { + return throwError(() => new Error(errors.join('
'))); + } else { + return of(null); + } + } + )); + } else { + return of(null); + } + } + + private loadAngularModules(): Observable[]> { + const modulesTasks: Observable[] = []; + this.settings.resources.filter(r => r.isModule).forEach( + (resource) => { + modulesTasks.push( + this.resources.loadModulesWithComponents(resource.url, this.modulesMap).pipe( + catchError((e: Error) => of(e?.message ? e.message : `Failed to load widget resource module: '${resource.url}'`)) + ) + ); + } + ); + if (modulesTasks.length) { + return forkJoin(modulesTasks).pipe( + map(res => { + const msg = res.find(r => typeof r === 'string'); + if (msg) { + return msg as string; + } else { + const modulesWithComponentsList = res as ModulesWithComponents[]; + return flatModulesWithComponents(modulesWithComponentsList); + } + }), + switchMap(modulesWithComponentsList => { + if (typeof modulesWithComponentsList === 'string') { + return throwError(() => new Error(modulesWithComponentsList)); + } else { + const modules = modulesWithComponentsToTypes(modulesWithComponentsList); + return of(modules); + } + }) + ); + } else { + return of(null); + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts new file mode 100644 index 0000000000..b974e15fb2 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/html/html-container-widget.models.ts @@ -0,0 +1,68 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { TbFunction } from '@shared/models/js-function.models'; +import { WidgetContext } from '@home/models/widget-component.models'; +import { TbEditorCompleter, TbEditorCompletions } from '@shared/models/ace/completion.models'; +import { widgetContextCompletions } from '@shared/models/ace/widget-completion.models'; +import { WidgetResource } from '@shared/models/widget.models'; + +export enum HtmlContainerWidgetType { + PLAIN = 'PLAIN', + ANGULAR = 'ANGULAR' +} + +export interface HtmlContainerWidgetSettings { + type: HtmlContainerWidgetType; + html: string; + css: string; + js: TbFunction; + resources: WidgetResource[]; +} + +export const htmlContainerDefaultSettings: HtmlContainerWidgetSettings = { + type: HtmlContainerWidgetType.PLAIN, + html: '', + css: '', + js: '', + resources: [], +}; + +export type WidgetContainerPlainFunction = (ctx: WidgetContext, container: HTMLElement) => void; +export type WidgetContainerAngularFunction = (ctx: WidgetContext) => void; + +const containerFunctionCompletions: TbEditorCompletions = { + ...{ + ctx: { + meta: 'argument', + type: widgetContextCompletions.ctx.type, + description: widgetContextCompletions.ctx.description, + children: widgetContextCompletions.ctx.children + } + } +}; + +export const AngularContainerFunctionEditorCompleter = new TbEditorCompleter(containerFunctionCompletions); + +export const HTMLContainerFunctionEditorCompleter = new TbEditorCompleter( + {...containerFunctionCompletions, + container: { + meta: 'argument', + type: 'HTMLElement', + description: 'Container element of the widget' + }} +); + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.html index 025dbf3e33..c47fd94f75 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.html @@ -24,21 +24,26 @@
-
-
+ @if (solid) { +
+
+ } @else { + @for (section of batterySections; track trackBySection($index)) { +
+ } + }
-
-
{{ valueText }}
-
+ @if (showValue) { +
+
{{ valueText }}
+
+ }
- -
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts index ff95a4882f..47550763d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/battery-level-widget.component.ts @@ -74,10 +74,11 @@ const horizontalBatteryDimensions = { }; @Component({ - selector: 'tb-battery-level-widget', - templateUrl: './battery-level-widget.component.html', - styleUrls: ['./battery-level-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-battery-level-widget', + templateUrl: './battery-level-widget.component.html', + styleUrls: ['./battery-level-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BatteryLevelWidgetComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html index 9c2803d117..d9cf483025 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.html @@ -20,11 +20,15 @@
-
-
-
- {{ error }} + @if (errorsMsg.length) { +
+
+ @for (error of errorsMsg; track error) { +
+ {{ error }} +
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts index 2003d8a4cb..fad6b59172 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/liquid-level-widget.component.ts @@ -71,10 +71,11 @@ import { UnitService } from '@core/services/unit.service'; import ITooltipsterInstance = JQueryTooltipster.ITooltipsterInstance; @Component({ - selector: 'tb-liquid-level-widget', - templateUrl: './liquid-level-widget.component.html', - styleUrls: ['./liquid-level-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-liquid-level-widget', + templateUrl: './liquid-level-widget.component.html', + styleUrls: ['./liquid-level-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class LiquidLevelWidgetComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html index 0dbd05d6bc..43c92c9c73 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.html @@ -17,25 +17,35 @@ -->
-
-
- -
+
+ @if (widgetComponent.dashboardWidget.showWidgetTitlePanel || showDate) { +
+ + @if (showDate) { +
+ }
-
-
-
-
-
-
-
widgets.signal-strength.value
-
{{ tooltipValueText }}
-
-
-
widgets.signal-strength.last-update
-
-
+ } +
+
+
+ @if (showTooltip) { +
+
+ @if (showTooltipValue) { +
+
widgets.signal-strength.value
+
{{ tooltipValueText }}
+ } + @if (showTooltipDate) { +
+
widgets.signal-strength.last-update
+
+
+ }
-
+
+ } +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts index 9baa7e5e10..be4d992330 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/signal-strength-widget.component.ts @@ -76,10 +76,11 @@ const shapeHeight = 113; const shapeAspect = shapeWidth / shapeHeight; @Component({ - selector: 'tb-signal-strength-widget', - templateUrl: './signal-strength-widget.component.html', - styleUrls: ['./signal-strength-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-signal-strength-widget', + templateUrl: './signal-strength-widget.component.html', + styleUrls: ['./signal-strength-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class SignalStrengthWidgetComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html index ff3d709352..29e51327db 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html @@ -20,14 +20,20 @@
-
+
{{ icon }}
-
{{ label$ | async }}
-
{{ status$ | async }}
+ @if (showLabel) { +
{{ label$ | async }}
+ } + @if (showStatus) { +
{{ status$ | async }}
+ }
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts index dd0a776939..e59570d44a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.ts @@ -48,10 +48,11 @@ import { ValueType } from '@shared/models/constants'; const initialStatusWidgetSize = 147; @Component({ - selector: 'tb-status-widget', - templateUrl: './status-widget.component.html', - styleUrls: ['../action/action-widget.scss', './status-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-status-widget', + templateUrl: './status-widget.component.html', + styleUrls: ['../action/action-widget.scss', './status-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class StatusWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html index e12bb74d9e..42786f7a58 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.html @@ -16,43 +16,45 @@ -->
-
-
-
- -
-
- - + @if (attributeUpdateFormGroup) { + + @if (datasourceDetected && !errorMessage) { +
+
+ +
+
+ + +
-
- - -
- {{ errorMessage | translate }} -
-
- + } @else { +
+ {{ errorMessage | translate }} +
+ } + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts index 3aa5f0cd71..f6a6bb0ad3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/json-input-widget.component.ts @@ -49,9 +49,10 @@ interface JsonInputWidgetSettings { } @Component({ - selector: 'tb-json-input-widget ', - templateUrl: './json-input-widget.component.html', - styleUrls: ['./json-input-widget.component.scss'] + selector: 'tb-json-input-widget ', + templateUrl: './json-input-widget.component.html', + styleUrls: ['./json-input-widget.component.scss'], + standalone: false }) export class JsonInputWidgetComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.html index cd50cb6174..4c74e0e668 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.html @@ -17,72 +17,120 @@ --> - - - - - - - - + @if (!isRowDirection) { + + + @if (legendConfig.showMin === true) { + + } + @if (legendConfig.showMax === true) { + + } + @if (legendConfig.showAvg === true) { + + } + @if (legendConfig.showTotal === true) { + + } + @if (legendConfig.showLatest === true) { + + } + + } - - - - - + + @if (legendConfig.showMin === true) { + - - - - + } + + } + } + @if (isRowDirection) { + + + @for (legendKey of legendKeys(); track legendKey) { + + } - - - - - - - - + @for (legendKey of legendKeys(); track legendKey) { + - - - - + + @for (legendKey of legendKeys(); track legendKey) { + - - - - + + @for (legendKey of legendKeys(); track legendKey) { + - - - - + + @for (legendKey of legendKeys(); track legendKey) { + - - - - + + @for (legendKey of legendKeys(); track legendKey) { + - + } + + }
{{ 'legend.min' | translate }}{{ 'legend.max' | translate }}{{ 'legend.avg' | translate }}{{ 'legend.total' | translate }}{{ 'legend.latest' | translate }}
{{ 'legend.min' | translate }}{{ 'legend.max' | translate }}{{ 'legend.avg' | translate }}{{ 'legend.total' | translate }}{{ 'legend.latest' | translate }}
- + +
+ + + +
- - - -
{{ 'legend.min' | translate }} + {{ 'legend.min' | translate }}
{{ 'legend.max' | translate }} + } + @if (isRowDirection && legendConfig.showMax === true) { +
{{ 'legend.max' | translate }}
{{ 'legend.avg' | translate }} + } + @if (isRowDirection && legendConfig.showAvg === true) { +
{{ 'legend.avg' | translate }}
{{ 'legend.total' | translate }} + } + @if (isRowDirection && legendConfig.showTotal === true) { +
{{ 'legend.total' | translate }}
{{ 'legend.latest' | translate }} + } + @if (isRowDirection && legendConfig.showLatest === true) { +
{{ 'legend.latest' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.ts index 41551b98f3..b570910fb7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/legend.component.ts @@ -18,9 +18,10 @@ import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; import { LegendConfig, LegendData, LegendDirection, LegendKey, LegendPosition } from '@shared/models/widget.models'; @Component({ - selector: 'tb-legend', - templateUrl: './legend.component.html', - styleUrls: ['./legend.component.scss'] + selector: 'tb-legend', + templateUrl: './legend.component.html', + styleUrls: ['./legend.component.scss'], + standalone: false }) export class LegendComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html index 485141bb03..afc11a7633 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.html @@ -23,16 +23,21 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
entity.entity - - {{ entity.entityParseName }} - + @for (entity of data.entities; track entity) { + + {{ entity.entityParseName }} + + }
widgets.maps.select-entity-hint
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts index d79a6d4dbd..71cfb95ebe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/dialogs/select-entity-dialog.component.ts @@ -28,9 +28,10 @@ export interface SelectEntityDialogData { } @Component({ - selector: 'tb-select-entity-dialog', - templateUrl: './select-entity-dialog.component.html', - styleUrls: ['./select-entity-dialog.component.scss'] + selector: 'tb-select-entity-dialog', + templateUrl: './select-entity-dialog.component.html', + styleUrls: ['./select-entity-dialog.component.scss'], + standalone: false }) export class SelectEntityDialogComponent extends DialogComponent { selectEntityFormGroup: FormGroup; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts index 0b00c5970e..ba40af770c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps-legacy/leaflet-map.ts @@ -289,7 +289,7 @@ export default abstract class LeafletMap { } private toggleDrawMode(type: string) { - this.map.pm.Draw[type].toggle(); + (this.map.pm.Draw[type] as any).toggle(); } addEditControl() { @@ -373,7 +373,7 @@ export default abstract class LeafletMap { }, // @ts-ignore afterClick: (e, ctx) => { - this.map.pm.Draw[ctx.button._button.jsClass].toggle({ + (this.map.pm.Draw[ctx.button._button.jsClass] as any).toggle({ snappable: this.options.snappable, cursorMarker: true, allowSelfIntersection: false, diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts index bec45b32e0..dcaf63c2ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/circles-data-layer.ts @@ -100,8 +100,9 @@ class TbCircleDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updateCircleShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts index df642a97cf..5c500335f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/data-layer/polygons-data-layer.ts @@ -105,8 +105,9 @@ class TbPolygonDataLayerItem extends TbLatestDataLayerItem, _dsData: FormattedData[]): void { + protected doInvalidateCoordinates(data: FormattedData, dsData: FormattedData[]): void { this.updatePolygonShape(data); + this.updateLabel(data, dsData); } protected addItemClass(clazz: string): void { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts index 15079c8c0e..3b8a8f04c5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/image-map.ts @@ -83,8 +83,6 @@ export class TbImageMap extends TbMap { this.onResize(true); } else { this.onResize(); - this.initMapSubject.next(this.map); - this.initMapSubject.complete(); } }); return this.initMapSubject.asObservable(); @@ -227,6 +225,8 @@ export class TbImageMap extends TbMap { attributionControl: false }); this.updateMaxBounds(updateImage); + this.initMapSubject.next(this.map); + this.initMapSubject.complete(); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts index 2953c609ce..c45a3c0ca1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-layer.ts @@ -20,12 +20,15 @@ import { defaultGoogleMapLayerSettings, defaultHereMapLayerSettings, defaultLayerTitle, + defaultOpenFreeMapLayerSettings, defaultOpenStreetMapLayerSettings, defaultTencentMapLayerSettings, GoogleMapLayerSettings, HereMapLayerSettings, MapLayerSettings, MapProvider, + OpenFreeMapLayerSettings, + OpenFreeMapStyleType, OpenStreetMapLayerSettings, ReferenceLayerType, TencentMapLayerSettings @@ -61,6 +64,8 @@ export abstract class TbMapLayer { inputSettings: DeepPartial) { switch (inputSettings.provider) { + case MapProvider.openfreemap: + return new TbOpenFreeMapLayer(ctx, inputSettings); case MapProvider.openstreet: return new TbOpenStreetMapLayer(ctx, inputSettings); case MapProvider.google: @@ -219,6 +224,32 @@ class TbOpenStreetMapLayer extends TbMapLayer { } +const openFreeMapStyleUrl = (style: OpenFreeMapStyleType): string => + `https://tiles.openfreemap.org/styles/${style}`; + +class TbOpenFreeMapLayer extends TbMapLayer { + + constructor(protected ctx: WidgetContext, + protected inputSettings: DeepPartial) { + super(ctx, inputSettings); + } + + protected defaultSettings(): OpenFreeMapLayerSettings { + return defaultOpenFreeMapLayerSettings; + } + + protected createLayer(): Observable { + const styleUrl = openFreeMapStyleUrl(this.settings.layerType); + const layer = L.TB.MapLibreGL.mapLibreGLLayer({ + style: styleUrl, + attributionControl: { + customAttribution: 'OpenFreeMap, © OpenMapTiles, Data from OpenStreetMap' + } + }); + return of(layer); + } +} + class TbGoogleMapLayer extends TbMapLayer { static loadedApiKeysGlobal: {[key: string]: boolean} = {}; @@ -318,7 +349,20 @@ class TbCustomMapLayer extends TbMapLayer { } protected createLayer(): Observable { - const layer = L.tileLayer(this.settings.tileUrl); + if (this.settings.vectorTiles) { + const options: any = { + style: this.settings.tileUrl + }; + if (this.settings.customAttribution) { + options.attributionControl = { + customAttribution: this.settings.customAttribution + }; + } + return of(L.TB.MapLibreGL.mapLibreGLLayer(options)); + } + const layer = L.tileLayer(this.settings.tileUrl, this.settings.customAttribution ? { + attribution: this.settings.customAttribution + } : undefined); return of(layer); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts index ac55e9e5ab..e9b0e761f9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map-widget.component.ts @@ -40,10 +40,11 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-map-widget', - templateUrl: './map-widget.component.html', - styleUrls: ['./map-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-widget', + templateUrl: './map-widget.component.html', + styleUrls: ['./map-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapWidgetComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss index 2cd4868e7f..8387b1aff7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/map.scss @@ -416,7 +416,7 @@ div.tb-widget .tb-widget-content.tb-no-interaction { min-height: 20px; margin: 4px; cursor: pointer; - accent-color: var(--mdc-checkbox-selected-icon-color, var(--mat-app-primary)); + accent-color: var(--mat-checkbox-selected-icon-color, var(--mat-app-primary)); } .tb-group-label { padding-left: 12px; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html index 870583125b..c85272dd90 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.html @@ -25,7 +25,9 @@
-
+ @if (settings.showTimestamp) { +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss index 13ebe12166..c9d109b736 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.scss @@ -36,9 +36,9 @@ } .tb-timeline-buttons { .mat-mdc-mini-fab.mat-primary { - --mdc-fab-small-container-color: rgba(0,0,0,0); + --mat-fab-small-container-color: rgba(0,0,0,0); --mat-fab-small-foreground-color: rgba(0,0,0,0.54); - --mdc-fab-small-container-elevation-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.15); + --mat-fab-small-container-elevation-shadow: 4px 4px 4px 0px rgba(0, 0, 0, 0.15); &:before { opacity: 0.1; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts index fceec947af..30ba8f0f40 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/map-timeline-panel.component.ts @@ -34,10 +34,11 @@ import { filter } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-map-timeline-panel', - templateUrl: './map-timeline-panel.component.html', - styleUrls: ['./map-timeline-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-timeline-panel', + templateUrl: './map-timeline-panel.component.html', + styleUrls: ['./map-timeline-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapTimelinePanelComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html index 1da11e665e..d407ba95e0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.html @@ -19,9 +19,11 @@ {{ (selectEntityFormGroup.get('entity').value ? 'entity.entity' : 'widgets.maps.data-layer.select-entity') | translate }} - - {{ entity.entity.entityDisplayName }} - + @for (entity of entities; track entity) { + + {{ entity.entity.entityDisplayName }} + + } widgets.maps.data-layer.select-entity-hint diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts index 6165a2da91..ffeca2728b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/maps/panels/select-map-entity-panel.component.ts @@ -20,11 +20,12 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { UnplacedMapDataItem } from '@home/components/widget/lib/maps/data-layer/latest-map-data-layer'; @Component({ - selector: 'tb-select-map-entity-panel', - templateUrl: './select-map-entity-panel.component.html', - providers: [], - styleUrls: ['./select-map-entity-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-select-map-entity-panel', + templateUrl: './select-map-entity-panel.component.html', + providers: [], + styleUrls: ['./select-map-entity-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class SelectMapEntityPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts index faeeb0bbf2..5f653c38d9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/markdown-widget.component.ts @@ -50,8 +50,9 @@ interface MarkdownWidgetSettings { type MarkdownTextFunction = (data: FormattedData[], ctx: WidgetContext) => string; @Component({ - selector: 'tb-markdown-widget', - templateUrl: './markdown-widget.component.html' + selector: 'tb-markdown-widget', + templateUrl: './markdown-widget.component.html', + standalone: false }) export class MarkdownWidgetComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html index 4cfc970feb..a5be42db25 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.html @@ -26,21 +26,29 @@ [class.row]="mobileAppSettings?.qrCodeConfig.badgePosition === badgePosition.RIGHT" [class.row-reverse]="mobileAppSettings?.qrCodeConfig.badgePosition === badgePosition.LEFT">
-
- - {{ 'widgets.mobile-app-qr-code.download-on-the-app-store' | translate }} - - - {{ 'widgets.mobile-app-qr-code.get-it-on-google-play' | translate }} - + @if ((mobileAppSettings?.iosEnabled || mobileAppSettings?.androidEnabled) && + mobileAppSettings?.qrCodeConfig.badgeEnabled && showBadgeContainer) { +
+ @if (mobileAppSettings?.iosEnabled) { + + {{ 'widgets.mobile-app-qr-code.download-on-the-app-store' | translate }} + + } + @if (mobileAppSettings?.androidEnabled) { + + {{ 'widgets.mobile-app-qr-code.get-it-on-google-play' | translate }} + + } +
+ } +
+ @if (mobileAppSettings?.qrCodeConfig.qrCodeLabelEnabled) { +
+ {{ mobileAppSettings.qrCodeConfig.qrCodeLabel }} +
+ }
-
- {{ mobileAppSettings.qrCodeConfig.qrCodeLabel }} -
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts index f0a843688c..7324d16672 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/mobile-app-qrcode-widget.component.ts @@ -30,9 +30,10 @@ import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-mobile-app-qrcode-widget', - templateUrl: './mobile-app-qrcode-widget.component.html', - styleUrls: ['./mobile-app-qrcode-widget.component.scss'] + selector: 'tb-mobile-app-qrcode-widget', + templateUrl: './mobile-app-qrcode-widget.component.html', + styleUrls: ['./mobile-app-qrcode-widget.component.scss'], + standalone: false }) export class MobileAppQrcodeWidgetComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html index c03ac0c603..9f6b755431 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.html @@ -19,218 +19,281 @@ [formGroup]="multipleInputFormGroup" tb-toast toastTarget="{{ toastTargetId }}" (ngSubmit)="saveForm()" novalidate autocomplete="off"> -
-
- {{ getGroupTitle(source.datasource) }} - -
- - - {{key.label}} - - - - - - {{ getErrorMessageText(key.settings, 'required') }} - - - - {{key.label}} - - - - - - {{ getErrorMessageText(key.settings,'required') }} - - - {{ getErrorMessageText(key.settings,'min') }} - - - {{ getErrorMessageText(key.settings,'max') }} - - - - {{key.label}} - - - - - - - {{ getErrorMessageText(key.settings,'required') }} - - - {{ getErrorMessageText(key.settings,'invalidJSON') | translate }} - - - - {{key.label}} - - - - - {{ getErrorMessageText(key.settings, 'required') }} - - - {{ getErrorMessageText(key.settings, 'invalidDate') }} - - - - {{key.label}} - - - {{ getCustomTranslationText(option.label ? option.label : option.value) }} - - - - - - - {{ getErrorMessageText(key.settings, 'required') }} - - - -
- {{key.label}} - - - + @if (entityDetected && isAllParametersValid) { +
+ @for (source of sources; track source) { +
+ @if (settings.showGroupTitle) { + {{ getGroupTitle(source.datasource) }} + + } +
+ @for (key of visibleKeys(source); track key) { + @if (key.settings.dataKeyValueType === 'string') { + + {{key.label}} + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + @if (multipleInputFormGroup.get(key.formId).hasError('required')) { + + {{ getErrorMessageText(key.settings, 'required') }} + + } + + } + @if (['double', 'integer'].includes(key.settings.dataKeyValueType)) { + + {{key.label}} + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + @if (multipleInputFormGroup.get(key.formId).hasError('required')) { + + {{ getErrorMessageText(key.settings,'required') }} + + } + @if (multipleInputFormGroup.get(key.formId).hasError('min')) { + + {{ getErrorMessageText(key.settings,'min') }} + + } + @if (multipleInputFormGroup.get(key.formId).hasError('max')) { + + {{ getErrorMessageText(key.settings,'max') }} + + } + + } + @if (key.settings.dataKeyValueType === 'JSON') { + + {{key.label}} + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + + @if (multipleInputFormGroup.get(key.formId).hasError('required') && !multipleInputFormGroup.get(key.formId).hasError('invalidJSON')) { + + {{ getErrorMessageText(key.settings,'required') }} + + } + @if (multipleInputFormGroup.get(key.formId).hasError('invalidJSON')) { + + {{ getErrorMessageText(key.settings,'invalidJSON') | translate }} + + } + + } + @if (['dateTime','date', 'time'].includes(key.settings.dataKeyValueType)) { + + {{key.label}} + + + + @if (multipleInputFormGroup.get(key.formId).hasError('required')) { + + {{ getErrorMessageText(key.settings, 'required') }} + + } + @if (multipleInputFormGroup.get(key.formId).hasError('matDatepickerParse')) { + + {{ getErrorMessageText(key.settings, 'invalidDate') }} + + } + + } + @if (key.settings.dataKeyValueType === 'select') { + + {{key.label}} + + @for (option of key.settings.selectOptions; track option) { + + {{ getCustomTranslationText(option.label ? option.label : option.value) }} + + } + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + @if (multipleInputFormGroup.get(key.formId).hasError('required')) { + + {{ getErrorMessageText(key.settings, 'required') }} + + } + + } + @if (key.settings.dataKeyValueType === 'radio') { +
+ {{key.label}} + + @for (option of key.settings.selectOptions; track option) { + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + + {{ getCustomTranslationText(option.label ? option.label : option.value) }} + + + } + +
+ } + @if (key.settings.dataKeyValueType === 'color') { + + {{key.label}} + + @if (key.settings.icon || key.settings.customIconUrl) { + + + + } + + + @if (multipleInputFormGroup.get(key.formId).hasError('required')) { + + {{ getErrorMessageText(key.settings, 'required') }} + + } + + } + @if (key.settings.dataKeyValueType === 'booleanCheckbox') { + + @if (key.settings.icon || key.settings.customIconUrl) { + + } + {{key.label}} + + } + @if (key.settings.dataKeyValueType === 'booleanSwitch') { + + @if (key.settings.icon || key.settings.customIconUrl) { - - - {{ getCustomTranslationText(option.label ? option.label : option.value) }} - - - + } + {{key.label}} + + } + }
- - {{key.label}} - - - - - - - - {{ getErrorMessageText(key.settings, 'required') }} - - - - - - - {{key.label}} - - - - - - {{key.label}} - - -
-
-
-
- - -
-
-
- {{ 'widgets.input-widgets.no-entity-selected' | translate }} + + }
-
- {{ 'widgets.input-widgets.not-allowed-entity' | translate }} + } + @if (entityDetected && isAllParametersValid && settings.showActionButtons) { +
+ + +
+ } + @if ((!entityDetected || !isAllParametersValid) && datasourceDetected) { +
+
+ {{ 'widgets.input-widgets.no-entity-selected' | translate }} +
+
+ {{ 'widgets.input-widgets.not-allowed-entity' | translate }} +
-
+ } - {{key.settings.icon}} - + @if (!key.settings.customIconUrl) { + {{key.settings.icon}} + } @else { icon - + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts index b09d5f9972..8096eb2172 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/multiple-input-widget.component.ts @@ -134,9 +134,10 @@ interface MultipleInputWidgetSource { } @Component({ - selector: 'tb-multiple-input-widget ', - templateUrl: './multiple-input-widget.component.html', - styleUrls: ['./multiple-input-widget.component.scss'] + selector: 'tb-multiple-input-widget ', + templateUrl: './multiple-input-widget.component.html', + styleUrls: ['./multiple-input-widget.component.scss'], + standalone: false }) export class MultipleInputWidgetComponent extends PageComponent implements OnInit, OnDestroy { @@ -572,7 +573,7 @@ export class MultipleInputWidgetComponent extends PageComponent implements OnIni public radioButtonSelectedColor(radioColor: string) { if (isDefinedAndNotNull(radioColor)) { - return `--mdc-radio-selected-icon-color: ${radioColor}; --mdc-radio-selected-focus-icon-color: ${radioColor}; --mdc-radio-selected-hover-icon-color: ${radioColor}; --mdc-radio-selected-pressed-icon-color: ${radioColor}; --mat-radio-checked-ripple-color: ${radioColor};` + return `--mat-radio-selected-icon-color: ${radioColor}; --mat-radio-selected-focus-icon-color: ${radioColor}; --mat-radio-selected-hover-icon-color: ${radioColor}; --mat-radio-selected-pressed-icon-color: ${radioColor}; --mat-radio-checked-ripple-color: ${radioColor};` } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.ts index 6dc1dfe292..089c5e11d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-card-widget.component.ts @@ -29,9 +29,10 @@ interface NavigationCardWidgetSettings { } @Component({ - selector: 'tb-navigation-card-widget', - templateUrl: './navigation-card-widget.component.html', - styleUrls: ['./navigation-card-widget.component.scss'] + selector: 'tb-navigation-card-widget', + templateUrl: './navigation-card-widget.component.html', + styleUrls: ['./navigation-card-widget.component.scss'], + standalone: false }) export class NavigationCardWidgetComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html index 2ca0650502..2f1f53968e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.html @@ -15,22 +15,28 @@ limitations under the License. --> - - - - - {{section.name}} - - - - - - {{place.icon}} - {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} - - - - - - - +@if (cols) { + + @for (section of showHomeSections$| async; track section) { + + + + {{section.name}} + + + + @for (place of sectionPlaces(section); track place) { + + + {{place.icon}} + {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} + + + } + + + + + } + +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts index f2953630e5..fc1b60f096 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/navigation-cards-widget.component.ts @@ -30,9 +30,10 @@ interface NavigationCardsWidgetSettings { } @Component({ - selector: 'tb-navigation-cards-widget', - templateUrl: './navigation-cards-widget.component.html', - styleUrls: ['./navigation-cards-widget.component.scss'] + selector: 'tb-navigation-cards-widget', + templateUrl: './navigation-cards-widget.component.html', + styleUrls: ['./navigation-cards-widget.component.scss'], + standalone: false }) export class NavigationCardsWidgetComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html index 36b7d038b0..6b1d18fe43 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.html @@ -16,50 +16,56 @@ -->
-
-
-
- widgets.input-widgets.no-image - last photo -
- -
-
-
- -
-
- - - -
+ @if (isEntityDetected && dataKeyDetected && isCameraSupport && isProtocolHttps) { +
+
+
+ widgets.input-widgets.no-image + last photo
-
-
- preview photo - -
-
- - -
+ } +
+
+
+ +
+
+ + + +
+
+
+
+ preview photo + +
+
+ + +
+
-
-
- {{ textMessage | translate }} -
+ } + @if (textMessage) { +
+ {{ textMessage | translate }} +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts index caeb87cfa5..060a74aebd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/photo-camera-input.component.ts @@ -51,10 +51,11 @@ interface PhotoCameraInputWidgetSettings { // @dynamic @Component({ - selector: 'tb-photo-camera-widget', - templateUrl: './photo-camera-input.component.html', - styleUrls: ['./photo-camera-input.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-photo-camera-widget', + templateUrl: './photo-camera-input.component.html', + styleUrls: ['./photo-camera-input.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class PhotoCameraInputWidgetComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.html index 01313aa0d0..87495e0477 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.html @@ -17,6 +17,10 @@ -->
-
entity.no-data
-
widgets.invalid-qr-code-text
+ @if (!qrCodeText && !invalidQrCodeText) { +
entity.no-data
+ } + @if (invalidQrCodeText) { +
widgets.invalid-qr-code-text
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts index e253485b1b..5426266e35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/qrcode-widget.component.ts @@ -44,9 +44,10 @@ interface QrCodeWidgetSettings { type QrCodeTextFunction = (data: FormattedData[]) => string; @Component({ - selector: 'tb-qrcode-widget', - templateUrl: './qrcode-widget.component.html', - styleUrls: [] + selector: 'tb-qrcode-widget', + templateUrl: './qrcode-widget.component.html', + styleUrls: [], + standalone: false }) export class QrCodeWidgetComponent extends PageComponent implements OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts index 127306db01..cdaff8fd15 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/knob.component.ts @@ -30,9 +30,10 @@ import { BasicActionWidgetComponent, ValueSetter } from '@home/components/widget import GenericOptions = CanvasGauges.GenericOptions; @Component({ - selector: 'tb-knob', - templateUrl: './knob.component.html', - styleUrls: ['./knob.component.scss'] + selector: 'tb-knob', + templateUrl: './knob.component.html', + styleUrls: ['./knob.component.scss'], + standalone: false }) export class KnobComponent extends BasicActionWidgetComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts index 8c480e70ef..6230020aa0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/led-indicator.component.ts @@ -46,9 +46,10 @@ interface LedIndicatorSettings { } @Component({ - selector: 'tb-led-indicator', - templateUrl: './led-indicator.component.html', - styleUrls: ['./led-indicator.component.scss'] + selector: 'tb-led-indicator', + templateUrl: './led-indicator.component.html', + styleUrls: ['./led-indicator.component.scss'], + standalone: false }) export class LedIndicatorComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html index c1caf24348..f98ef7e8c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -35,18 +38,22 @@ {{ rpcMessageTypeText }}
-
- +
+ widgets.persistent-table.method - - {{'widgets.persistent-table.method-error' | translate}} - - - {{'widgets.persistent-table.white-space-error' | translate}} - + @if (persistentFormGroup.get('method').hasError('required')) { + + {{'widgets.persistent-table.method-error' | translate}} + + } + @if (persistentFormGroup.get('method').hasError('pattern')) { + + {{'widgets.persistent-table.white-space-error' | translate}} + + } - + widgets.persistent-table.retries @@ -71,7 +78,9 @@
-
+ @if (!(isLoading$ | async)) { +
+ }
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + widgets.persistent-table.created-time - + widgets.persistent-table.expiration-time
- + widgets.persistent-table.message-type - + widgets.persistent-table.status - + widgets.persistent-table.method - - widgets.persistent-table.retries - - + @if (persistentFormGroup.get('retries').value) { + + widgets.persistent-table.retries + + + }
@@ -69,7 +73,7 @@ {{ 'widgets.persistent-table.response' | translate }} - + @@ -98,14 +102,15 @@
- + @if (allowDelete) { + + }
- -
-
- - - - - - + }
+ @if (actionCellButtonAction.length) { +
+ + + @for (actionDescriptor of actionCellButtonAction; track actionDescriptor) { + @if (actionDescriptor.icon) { + + } + } + +
+ } + [class.!hidden]="persistentDatasource.dataLoading"> {{ noDataDisplayMessageText }} + class="no-data-found flex items-center justify-center">{{ noDataDisplayMessageText }} {{ 'common.loading' | translate }} -
- - + class="no-data-found flex items-center justify-center">{{ 'common.loading' | translate }} +
+ @if (displayPagination) { + + } + @if (displayPagination) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts index 0452a07422..225cfe6a4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-table.component.ts @@ -98,9 +98,10 @@ interface PersistentTableWidgetActionDescriptor extends TableCellButtonActionDes } @Component({ - selector: 'tb-persistent-table-widget', - templateUrl: './persistent-table.component.html', - styleUrls: ['./persistent-table.component.scss' , '../table-widget.scss'] + selector: 'tb-persistent-table-widget', + templateUrl: './persistent-table.component.html', + styleUrls: ['./persistent-table.component.scss', '../table-widget.scss'], + standalone: false }) export class PersistentTableComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html index faf054957c..83dd2c5b3a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.html @@ -22,5 +22,7 @@
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts index 4b7bb9922b..acfb9b209e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.component.ts @@ -42,10 +42,11 @@ import { SVG, Svg } from '@svgdotjs/svg.js'; import { MatIconRegistry } from '@angular/material/icon'; @Component({ - selector: 'tb-power-button-widget', - templateUrl: './power-button-widget.component.html', - styleUrls: ['../action/action-widget.scss', './power-button-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-power-button-widget', + templateUrl: './power-button-widget.component.html', + styleUrls: ['../action/action-widget.scss', './power-button-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class PowerButtonWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts index f538e8cfc8..6b8fcad4fe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/power-button-widget.models.ts @@ -28,7 +28,7 @@ import { Circle, Effect, Element, G, Gradient, Path, Runner, Svg, Text, Timeline import '@svgdotjs/svg.filter.js'; import tinycolor from 'tinycolor2'; import { WidgetContext } from '@home/models/widget-component.models'; -import { Observable, of } from 'rxjs'; +import { from, Observable, of, shareReplay } from 'rxjs'; import { isSvgIcon, splitIconName } from '@shared/models/icon.models'; import { catchError, map, take } from 'rxjs/operators'; import { MatIconRegistry } from '@angular/material/icon'; @@ -336,6 +336,11 @@ export abstract class PowerButtonShape { protected onPowerSymbolCircle: Path; protected onPowerSymbolLine: Path; + private onIcon$: Observable; + private offIcon$: Observable; + protected onIconOffsetX: number = 0; + protected onIconOffsetY: number = 0; + protected constructor(protected widgetContext: WidgetContext, protected svgShape: Svg, protected iconRegistry: MatIconRegistry, @@ -360,10 +365,14 @@ export abstract class PowerButtonShape { take(1), map((svgElement) => { const element = new Element(svgElement.firstChild); + const iconGroup = this.svgShape.group(); + element.addTo(iconGroup); const box = element.bbox(); const scale = size / box.height; element.scale(scale); - return element; + const scaledBox = iconGroup.bbox(); + iconGroup.translate(-scaledBox.cx, -scaledBox.cy); + return iconGroup; }), catchError(() => of(null) )); @@ -383,7 +392,15 @@ export abstract class PowerButtonShape { tspan.attr({ 'dominant-baseline': 'hanging' }); - return of(textElement); + return from(document.fonts.ready).pipe( + map(() => { + const iconGroup = this.svgShape.group(); + textElement.addTo(iconGroup); + const box = iconGroup.bbox(); + iconGroup.translate(-box.cx, -box.cy); + return iconGroup; + }) + ); } } @@ -414,8 +431,15 @@ export abstract class PowerButtonShape { public drawOffShape(centerGroup: G, label: boolean, labelWeight?: string, circleStroke?: boolean) { if (this.icons.offButtonIcon.showIcon) { - this.createIconElement(this.icons.offButtonIcon.icon, this.icons.offButtonIcon.iconSize).subscribe(icon => - this.offPowerSymbolIcon = icon.center(cx, cy).addTo(centerGroup)); + this.offIcon$ = this.createIconElement(this.icons.offButtonIcon.icon, this.icons.offButtonIcon.iconSize) + .pipe(shareReplay(1)); + + this.offIcon$.pipe(take(1)).subscribe(icon => { + this.offPowerSymbolIcon = icon; + icon.translate(cx, cy); + centerGroup.add(icon); + }); + } else { if (label) { this.offLabelShape = this.createOffLabel(labelWeight).addTo(centerGroup); @@ -428,10 +452,16 @@ export abstract class PowerButtonShape { public drawOnShape(onCenterGroup?: G, label?: boolean, labelWeight?: string, circleStroke?: boolean, mask?: Circle) { if (this.icons.onButtonIcon.showIcon) { - this.createIconElement(this.icons.onButtonIcon.icon, this.icons.onButtonIcon.iconSize).subscribe(icon => { - this.onPowerSymbolIcon = icon.center(cx, cy); + this.onIcon$ = this.createIconElement(this.icons.onButtonIcon.icon, this.icons.onButtonIcon.iconSize) + .pipe(shareReplay(1)); + + this.onIcon$.subscribe(icon => { + const iconBox = icon.bbox(); + this.onIconOffsetX = iconBox.cx; + this.onIconOffsetY = iconBox.cy; + this.onPowerSymbolIcon = icon.translate(cx, cy); if (isDefinedAndNotNull(onCenterGroup)) { - this.onPowerSymbolIcon.addTo(onCenterGroup); + onCenterGroup.add(this.onPowerSymbolIcon); } if (isDefinedAndNotNull(mask)) { this.createMask(mask, [this.onPowerSymbolIcon]); @@ -462,7 +492,9 @@ export abstract class PowerButtonShape { public onCenterTimeLine(timeline: Timeline, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - this.onPowerSymbolIcon.timeline(timeline); + if (this.onIcon$) { + this.onIcon$.subscribe(icon => icon.timeline(timeline)); + } } else { if (label) { this.onLabelShape.timeline(timeline); @@ -475,7 +507,7 @@ export abstract class PowerButtonShape { public offCenterColor(mainColor: PowerButtonColor, label: boolean) { if (this.icons.offButtonIcon.showIcon) { - this.offPowerSymbolIcon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.offIcon$.subscribe(icon => icon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity})) } else { if (label) { this.offLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); @@ -488,7 +520,7 @@ export abstract class PowerButtonShape { public onCenterColor(mainColor: PowerButtonColor, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - this.onPowerSymbolIcon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); + this.onIcon$.subscribe((icon)=> icon.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity})) } else { if (label) { this.onLabelShape.attr({ fill: mainColor.hex, 'fill-opacity': mainColor.opacity}); @@ -501,7 +533,12 @@ export abstract class PowerButtonShape { public buttonAnimation(scale: number, label: boolean) { if (this.icons.onButtonIcon.showIcon) { - powerButtonAnimation(this.onPowerSymbolIcon).transform({scale}); + const translateX = cx - this.onIconOffsetX; + const translateY = cy - this.onIconOffsetY; + powerButtonAnimation(this.onPowerSymbolIcon).transform({ + scale: scale, + translate: [translateX, translateY] + }); } else { if (label) { powerButtonAnimation(this.onLabelShape).transform({scale, origin: {x: cx, y: cy}}); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts index 2d9b7b3d19..d397b47e1c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/round-switch.component.ts @@ -42,9 +42,10 @@ interface RoundSwitchSettings { } @Component({ - selector: 'tb-round-switch', - templateUrl: './round-switch.component.html', - styleUrls: ['./round-switch.component.scss'] + selector: 'tb-round-switch', + templateUrl: './round-switch.component.html', + styleUrls: ['./round-switch.component.scss'], + standalone: false }) export class RoundSwitchComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html index 5a8715adf9..421668d0f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html @@ -20,21 +20,33 @@
-
-
- {{ icon }} -
{{ label$ | async }}
-
+
+ @if (showIcon || showLabel) { +
+ @if (showIcon) { + {{ icon }} + } + @if (showLabel) { +
{{ label$ | async }}
+ } +
+ }
-
{{ offLabel }}
+ @if (showOffLabel) { +
{{ offLabel }}
+ } -
{{ onLabel }}
+ @if (showOnLabel) { +
{{ onLabel }}
+ }
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts index 1026d1f73e..ef9c1ff826 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.ts @@ -50,10 +50,11 @@ const horizontalLayoutPadding = 48; const verticalLayoutPadding = 36; @Component({ - selector: 'tb-single-switch-widget', - templateUrl: './single-switch-widget.component.html', - styleUrls: ['../action/action-widget.scss', './single-switch-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-single-switch-widget', + templateUrl: './single-switch-widget.component.html', + styleUrls: ['../action/action-widget.scss', './single-switch-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class SingleSwitchWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html index 60523571fd..afd2bb085c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.html @@ -17,18 +17,24 @@ -->
-
- -
-
-
-
{{ valueText }}
+ @if (showWidgetTitlePanel) { +
+
-
-
- {{ leftIcon }} + } +
+ @if (showValue) { +
+
{{ valueText }}
+ } +
+ @if (showLeftRightIcon) { +
+ {{ leftIcon }} +
+ }
-
-
-
{{ tickMinText }}
-
-
-
{{ tickMaxText }}
+ @if (showTicks) { +
+
+
{{ tickMinText }}
+
+
+
{{ tickMaxText }}
+
-
-
-
- {{ rightIcon }} + }
+ @if (showLeftRightIcon) { +
+ {{ rightIcon }} +
+ }
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss index 90eae89139..be6ca5255c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.scss @@ -25,27 +25,27 @@ $backgroundColorDisabled: var(--tb-slider-background-color-disabled, #D5D7E5); .tb-slider-panel { .mat-mdc-slider.mat-primary.tb-slider { - --mdc-slider-active-track-color: #{$mainColor}; - --mdc-slider-handle-color: #{$mainColor}; - --mdc-slider-focus-handle-color: #{$mainColor}; - --mdc-slider-hover-handle-color: #{$mainColor}; + --mat-slider-active-track-color: #{$mainColor}; + --mat-slider-handle-color: #{$mainColor}; + --mat-slider-focus-handle-color: #{$mainColor}; + --mat-slider-hover-handle-color: #{$mainColor}; - --mdc-slider-with-tick-marks-inactive-container-color: #{$tickMarksColor}; + --mat-slider-with-tick-marks-inactive-container-color: #{$tickMarksColor}; --mat-mdc-slider-ripple-color: #{$mainColor}; --mat-mdc-slider-hover-ripple-color: #{$hoverRippleColor}; --mat-mdc-slider-focus-ripple-color: #{$focusRippleColor}; - --mdc-slider-inactive-track-color: #{$backgroundColor}; + --mat-slider-inactive-track-color: #{$backgroundColor}; - --mdc-slider-disabled-active-track-color: #{$mainColorDisabled}; - --mdc-slider-disabled-handle-color: #{$mainColorDisabled}; + --mat-slider-disabled-active-track-color: #{$mainColorDisabled}; + --mat-slider-disabled-handle-color: #{$mainColorDisabled}; - --mdc-slider-disabled-inactive-track-color: #{$backgroundColorDisabled}; - --mdc-slider-with-tick-marks-disabled-container-color: #{$mainColorDisabled}; + --mat-slider-disabled-inactive-track-color: #{$backgroundColorDisabled}; + --mat-slider-with-tick-marks-disabled-container-color: #{$mainColorDisabled}; - --mdc-slider-handle-width: 16px; - --mdc-slider-handle-height: 16px; + --mat-slider-handle-width: 16px; + --mat-slider-handle-height: 16px; } width: 100%; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts index 14278c98e4..a1c25cbd39 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/slider-widget.component.ts @@ -50,10 +50,11 @@ import tinycolor from 'tinycolor2'; import { UnitService } from '@core/services/unit.service'; @Component({ - selector: 'tb-slider-widget', - templateUrl: './slider-widget.component.html', - styleUrls: ['../action/action-widget.scss', './slider-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-slider-widget', + templateUrl: './slider-widget.component.html', + styleUrls: ['../action/action-widget.scss', './slider-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class SliderWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html index d681981e01..4eef6a39b0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.html @@ -15,40 +15,45 @@ limitations under the License. --> -
-
- {{ error }} -
-
- {{title}} -
-
+
+ {{ error }} +
+
+ {{title}} +
+
-
- - -
-
- OFF - - ON - + id="switch-container"> +
+ + +
+
+ OFF + + ON + +
+
-
-
- +} @else {
{{title}} - -
+ @if (executingUpdateValue) { + + } + @if (!executingUpdateValue) { +
+ } + [error]="error">
-
+} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts index 52c1149808..d7056558a3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/switch.component.ts @@ -51,9 +51,10 @@ interface SwitchSettings { } @Component({ - selector: 'tb-switch', - templateUrl: './switch.component.html', - styleUrls: ['./switch.component.scss'] + selector: 'tb-switch', + templateUrl: './switch.component.html', + styleUrls: ['./switch.component.scss'], + standalone: false }) export class SwitchComponent extends PageComponent implements AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.html index d70dda0019..c9c24439ad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.html @@ -31,7 +31,7 @@
{{ valueText }}
+ [style]="valueStyle">{{ valueText }}
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts index 4c462a5e56..8ccda39a54 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/value-stepper-widget.component.ts @@ -57,10 +57,11 @@ import { UtilsService } from '@core/services/utils.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-value-stepper-widget', - templateUrl: './value-stepper-widget.component.html', - styleUrls: ['../action/action-widget.scss', './value-stepper-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-value-stepper-widget', + templateUrl: './value-stepper-widget.component.html', + styleUrls: ['../action/action-widget.scss', './value-stepper-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ValueStepperWidgetComponent extends BasicActionWidgetComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html index 830a62b484..6a0acbdf25 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.html @@ -17,13 +17,17 @@ -->
- + @if (widgetComponent.dashboardWidget.showWidgetTitlePanel) { - + }
-
scada.no-symbol-selected
+ @if (noScadaSymbol) { +
scada.no-symbol-selected
+ }
- + @if (loading$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts index 2edf94b94f..5e13120800 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/scada/scada-symbol-widget.component.ts @@ -44,10 +44,11 @@ import { MatIconRegistry } from '@angular/material/icon'; import { RafService } from '@core/services/raf.service'; @Component({ - selector: 'tb-scada-symbol-widget', - templateUrl: './scada-symbol-widget.component.html', - styleUrls: ['../action/action-widget.scss', './scada-symbol-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-widget', + templateUrl: './scada-symbol-widget.component.html', + styleUrls: ['../action/action-widget.scss', './scada-symbol-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolWidgetComponent implements OnInit, AfterViewInit, OnDestroy, ScadaSymbolObjectCallbacks { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarm-count-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarm-count-widget-settings.component.ts index 449319df59..4c7d90bf77 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarm-count-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarm-count-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { countDefaultSettings } from '@home/components/widget/lib/count/count-widget.models'; @Component({ - selector: 'tb-alarm-count-widget-settings', - templateUrl: './alarm-count-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-alarm-count-widget-settings', + templateUrl: './alarm-count-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AlarmCountWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts index 3e183bcba6..08cc62b33f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-key-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-alarms-table-key-settings', - templateUrl: './alarms-table-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-alarms-table-key-settings', + templateUrl: './alarms-table-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AlarmsTableKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html index e81eed3b4a..64183bd00b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.html @@ -101,34 +101,36 @@ - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + }
@@ -136,9 +138,11 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts index 7dabd06ea1..79fc3d1f59 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/alarm/alarms-table-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ - selector: 'tb-alarms-table-widget-settings', - templateUrl: './alarms-table-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-alarms-table-widget-settings', + templateUrl: './alarms-table-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AlarmsTableWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts index cc7a73294a..6c85eb8e22 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/action-button-widget-settings.component.ts @@ -24,9 +24,10 @@ import { getTargetDeviceFromDatasources } from '@shared/models/widget-settings.m import { actionButtonDefaultSettings } from '@home/components/widget/lib/button/action-button-widget.models'; @Component({ - selector: 'tb-action-button-widget-settings', - templateUrl: './action-button-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-action-button-widget-settings', + templateUrl: './action-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class ActionButtonWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts index 304d4b91a0..9f82a93285 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/command-button-widget-settings.component.ts @@ -23,9 +23,10 @@ import { ValueType } from '@shared/models/constants'; import { commandButtonDefaultSettings } from '@home/components/widget/lib/button/command-button-widget.models'; @Component({ - selector: 'tb-command-button-widget-settings', - templateUrl: './command-button-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-command-button-widget-settings', + templateUrl: './command-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class CommandButtonWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html index fc89c9840e..82f1de831d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.html @@ -29,7 +29,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.power-button.power-on
@@ -38,7 +38,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.power-button.power-off
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -58,7 +58,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -69,12 +69,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> - - {{ powerButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.power-button.layout' | translate }}" formControlName="layout"> + @for (layout of powerButtonLayouts; track layout) { + + {{ powerButtonLayoutTranslationMap.get(layout) | translate }} + + }
{{ 'widgets.background.background' | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts index df83f077d9..4c95654fdb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/power-button-widget-settings.component.ts @@ -28,9 +28,10 @@ import { } from '@home/components/widget/lib/rpc/power-button-widget.models'; @Component({ - selector: 'tb-power-button-widget-settings', - templateUrl: './power-button-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-power-button-widget-settings', + templateUrl: './power-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class PowerButtonWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html index fc27e782e2..e6331aded8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.html @@ -29,7 +29,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
@@ -60,7 +60,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -72,12 +72,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> - - {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.segmented-button.layout' | translate }}" formControlName="layout"> + @for (layout of segmentedButtonLayouts; track layout) { + + {{ segmentedButtonLayoutTranslationMap.get(layout) | translate }} + + }
@@ -216,16 +218,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
@@ -257,16 +261,18 @@ -
-
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonToggleStates; track state) { +
+
{{ widgetButtonToggleStatesTranslationsMap.get(state) | translate }}
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts index 540688c18e..629ddd179e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/segmented-button-widget-settings.component.ts @@ -41,9 +41,10 @@ import { import { ValueCardLayout } from '@home/components/widget/lib/cards/value-card-widget.models'; @Component({ - selector: 'tb-segmented-button-widget-settings', - templateUrl: './segmented-button-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-segmented-button-widget-settings', + templateUrl: './segmented-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class SegmentedButtonWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts index c14616ee7f..e8bb057b70 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/button/toggle-button-widget-settings.component.ts @@ -25,9 +25,10 @@ import { toggleButtonDefaultSettings } from '@home/components/widget/lib/button/ type ButtonAppearanceType = 'checked' | 'unchecked'; @Component({ - selector: 'tb-toggle-button-widget-settings', - templateUrl: './toggle-button-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-toggle-button-widget-settings', + templateUrl: './toggle-button-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class ToggleButtonWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html index 2b579b4750..45dcb56a5e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.html @@ -22,9 +22,11 @@
{{ 'widgets.aggregated-value-card.position' | translate }}
- - {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} - + @for (position of aggregatedValueCardKeyPositions; track position) { + + {{ aggregatedValueCardKeyPositionTranslationMap.get(position) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts index c1088cecab..0744461c2d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-key-settings.component.ts @@ -26,9 +26,10 @@ import { } from '@home/components/widget/lib/cards/aggregated-value-card.models'; @Component({ - selector: 'tb-aggregated-value-card-key-settings', - templateUrl: './aggregated-value-card-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-aggregated-value-card-key-settings', + templateUrl: './aggregated-value-card-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AggregatedValueCardKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-widget-settings.component.ts index 5bc3ac954d..3afdc7e6cc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/aggregated-value-card-widget-settings.component.ts @@ -23,9 +23,10 @@ import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-s import { aggregatedValueCardDefaultSettings } from '@home/components/widget/lib/cards/aggregated-value-card.models'; @Component({ - selector: 'tb-aggregated-value-card-widget-settings', - templateUrl: './aggregated-value-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-aggregated-value-card-widget-settings', + templateUrl: './aggregated-value-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class AggregatedValueCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts index aed354bb69..3ffff5309f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-data-key-row.component.ts @@ -42,17 +42,18 @@ import { import { Observable, of } from "rxjs"; @Component({ - selector: 'tb-api-usage-data-key-row', - templateUrl: './api-usage-data-key-row.component.html', - styleUrls: ['./api-usage-data-key-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ApiUsageDataKeyRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-api-usage-data-key-row', + templateUrl: './api-usage-data-key-row.component.html', + styleUrls: ['./api-usage-data-key-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ApiUsageDataKeyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ApiUsageDataKeyRowComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts index 9c516be2bd..09d1d73743 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-settings.component.models.ts @@ -89,7 +89,7 @@ export const apiUsageDefaultSettings: ApiUsageWidgetSettings = { generateDataKey('{i18n:api-usage.data-points-storage-days}', 'dbApiState', 'storageDataPointsLimit', 'storageDataPointsCount'), generateDataKey('{i18n:api-usage.alarms-created}', 'alarmApiState', 'createdAlarmsLimit', 'createdAlarmsCount'), generateDataKey('{i18n:api-usage.emails}', 'emailApiState', 'emailLimit', 'emailCount'), - generateDataKey('{i18n:api-usage.sms}', 'notificationApiState', 'smsLimit', 'smsCount'), + generateDataKey('{i18n:api-usage.sms}', 'smsApiState', 'smsLimit', 'smsCount'), ], targetDashboardState: 'default', background: { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html index 046d76d351..a718b11669 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/api-usage-widget-settings.component.html @@ -38,31 +38,37 @@
widgets.api-usage.current-number
-
-
-
- - -
- + @if (dataKeysFormArray().controls?.length) { +
+ @for (dataKeyControl of dataKeysFormArray().controls; track trackByDataKey($index)) { +
+
+ + +
+ +
+
-
+ }
-
+ } @else { + {{ 'widgets.api-usage.no-key' | translate }} + }
- - {{ 'widgets.api-usage.no-key' | translate }} - ApiUsageWidgetSettingsComponent), - multi: true - } - ], + selector: 'tb-api-usage-widget-settings', + templateUrl: './api-usage-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss', 'api-usage-widget-settings.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ApiUsageWidgetSettingsComponent), + multi: true + } + ], + standalone: false }) export class ApiUsageWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html index 3a8fb61d99..93db3741f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.html @@ -18,23 +18,27 @@
widgets.dashboard-state.dashboard-state-settings - + - + @if (dashboardStateWidgetSettingsForm.get('stateId').value) { + + } - - - + @for (state of filteredDashboardStates | async; track state) { + + + + } @@ -47,12 +51,13 @@ {{ 'widgets.dashboard-state.autofill-state-layout' | translate }} - + widgets.dashboard-state.default-margin diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.ts index 5fa4a4b5fa..181ef7ddaf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/dashboard-state-widget-settings.component.ts @@ -23,9 +23,10 @@ import { Observable, of } from 'rxjs'; import { map, mergeMap, startWith } from 'rxjs/operators'; @Component({ - selector: 'tb-dashboard-state-widget-settings', - templateUrl: './dashboard-state-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-dashboard-state-widget-settings', + templateUrl: './dashboard-state-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class DashboardStateWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.ts index 448476f707..6c6691d65c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/edge-quick-overview-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-edge-quick-overview-widget-settings', - templateUrl: './edge-quick-overview-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-edge-quick-overview-widget-settings', + templateUrl: './edge-quick-overview-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class EdgeQuickOverviewWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.ts index 0fa63f4cd1..4cdb47b1dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/html-card-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-html-card-widget-settings', - templateUrl: './html-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-html-card-widget-settings', + templateUrl: './html-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class HtmlCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts index 51342a4944..1472a58ab1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-card-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { labelCardWidgetDefaultSettings } from '@home/components/widget/lib/cards/label-card-widget.models'; @Component({ - selector: 'tb-label-card-widget-settings', - templateUrl: './label-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-label-card-widget-settings', + templateUrl: './label-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class LabelCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts index d90bf9571b..5bf0ace732 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-value-card-widget-settings.component.ts @@ -24,9 +24,10 @@ import { formatValue } from '@core/utils'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-label-value-card-widget-settings', - templateUrl: './label-value-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-label-value-card-widget-settings', + templateUrl: './label-value-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class LabelValueCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html index ae477eeb6d..dc5bd4eab3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.html @@ -24,35 +24,39 @@
- + @if (!disabled) { + + }
- + widgets.label-widget.label-pattern - - {{ 'widgets.label-widget.label-pattern-required' | translate }} - + @if (labelWidgetLabelFormGroup.get('pattern').hasError('required')) { + + {{ 'widgets.label-widget.label-pattern-required' | translate }} + + }
widgets.label-widget.label-position -
- +
+ widgets.label-widget.x-pos - + widgets.label-widget.y-pos @@ -60,6 +64,7 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.ts index 26b995c7a2..f0a6bd1ce4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-label.component.ts @@ -32,16 +32,17 @@ export interface LabelWidgetLabel { } @Component({ - selector: 'tb-label-widget-label', - templateUrl: './label-widget-label.component.html', - styleUrls: ['./label-widget-label.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LabelWidgetLabelComponent), - multi: true - } - ] + selector: 'tb-label-widget-label', + templateUrl: './label-widget-label.component.html', + styleUrls: ['./label-widget-label.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LabelWidgetLabelComponent), + multi: true + } + ], + standalone: false }) export class LabelWidgetLabelComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.html index 7dad480482..52e3f39fa3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/label-widget-settings.component.html @@ -17,27 +17,30 @@ -->
+ label="{{ 'widgets.label-widget.background-image' | translate }}" + formControlName="backgroundImageUrl">
widgets.label-widget.labels
-
- - -
-
-
- widgets.label-widget.no-labels + @for (labelControl of typedSelectOptions; track trackByLabelControl($index, labelControl); let isLast = $last) { +
+ + +
+ }
+ @if (!labelsFormArray().controls.length) { +
+ widgets.label-widget.no-labels +
+ }
-
-
-
widget-config.card-style
-
{{ 'widgets.mobile-app-qr-code.configuration-hint' | translate }}
-
-
- - {{ 'admin.mobile-app.badges' | translate }} - - - - - {{ badgePosition.value | translate }} - - - + @if (!mobileAppQRCodeWidgetSettingsForm.get('useSystemSettings').value) { +
+
+
widget-config.card-style
+
{{ 'widgets.mobile-app-qr-code.configuration-hint' | translate }}
+
+
+ + {{ 'admin.mobile-app.badges' | translate }} + + + + @for (badgePosition of badgePositionTranslationsMap | keyvalue; track badgePosition) { + + {{ badgePosition.value | translate }} + + } + + +
+
+
+
+ + {{ 'admin.mobile-app.label' | translate }} + + + + +
-
-
- - {{ 'admin.mobile-app.label' | translate }} - - - - +
+
+
{{ 'widgets.background.background' | translate }}
+ +
-
-
-
{{ 'widgets.background.background' | translate }}
- - +
{{ 'widget-config.card-padding' | translate }}
+ + +
-
-
{{ 'widget-config.card-padding' | translate }}
- - - -
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts index a4ad7933f3..82aea1e4a1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/mobile-app-qr-code-widget-settings.component.ts @@ -23,9 +23,10 @@ import { badgePositionTranslationsMap } from '@shared/models/mobile-app.models'; import { mobileAppQrCodeWidgetDefaultSettings } from '@home/components/widget/lib/cards/mobile-app-qr-code-widget.models'; @Component({ - selector: 'tb-mobile-app-qr-code-widget-settings', - templateUrl: './mobile-app-qr-code-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-mobile-app-qr-code-widget-settings', + templateUrl: './mobile-app-qr-code-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class MobileAppQrCodeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html index bd16e4a467..9c222e2379 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/progress-bar-widget-settings.component.html @@ -23,12 +23,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout"> - - {{ progressBarLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.progress-bar.layout' | translate }}" formControlName="layout"> + @for (layout of progressBarLayouts; track layout) { + + {{ progressBarLayoutTranslationMap.get(layout) | translate }} + + }
@@ -63,20 +65,22 @@
-
- - {{ 'widgets.progress-bar.range-ticks' | translate }} - -
- - - - + @if (progressBarWidgetSettingsForm.get('layout').value === progressBarLayout.default) { +
+ + {{ 'widgets.progress-bar.range-ticks' | translate }} + +
+ + + + +
-
+ }
{{ 'widgets.progress-bar.bar-color' | translate }}
{{ 'widgets.qr-code.use-qr-code-text-function' | translate }} - + widgets.qr-code.qr-code-text-pattern {{ 'widgets.qr-code.qr-code-text-pattern-hint' | translate }} - - {{ 'widgets.qr-code.qr-code-text-pattern-required' | translate }} - + @if (qrCodeWidgetSettingsForm.get('qrCodeTextPattern').hasError('required')) { + + {{ 'widgets.qr-code.qr-code-text-pattern-required' | translate }} + + } - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + }
@@ -103,9 +105,11 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + }
@@ -115,23 +119,23 @@ {{ 'widgets.table.use-entity-label-tab-name' | translate }} -
-
widgets.table.sort-by
-
- - - {{ entityFields.createdTime.name | translate }} - {{ entityFields.name.name | translate }} - - - - - {{ 'common.sort-asc' | translate }} - {{ 'common.sort-desc' | translate }} - - -
-
+
+
widgets.table.sort-by
+
+ + + {{ entityFields.createdTime.name | translate }} + {{ entityFields.name.name | translate }} + + + + + {{ 'common.sort-asc' | translate }} + {{ 'common.sort-desc' | translate }} + + +
+
widgets.table.rows
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts index a2b86cab55..32d2b1b087 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/timeseries-table-widget-settings.component.ts @@ -24,9 +24,10 @@ import { Direction } from '@shared/models/page/sort-order'; import { entityFields } from '@shared/models/entity.models'; @Component({ - selector: 'tb-timeseries-table-widget-settings', - templateUrl: './timeseries-table-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-timeseries-table-widget-settings', + templateUrl: './timeseries-table-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class TimeseriesTableWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts index 6a4482012e..c0baba8794 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/unread-notification-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { unreadNotificationDefaultSettings } from '@home/components/widget/lib/cards/unread-notification-widget.models'; @Component({ - selector: 'tb-unread-notification-widget-settings', - templateUrl: './unread-notification-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-unread-notification-widget-settings', + templateUrl: './unread-notification-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UnreadNotificationWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html index 15832b67a3..e605e97c89 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.html @@ -23,12 +23,14 @@ breakpoints: { 'lt-md': horizontal ? 1 : 2 }}" - label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout"> - - {{ valueCardLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.value-card.layout' | translate }}" formControlName="layout"> + @for (layout of valueCardLayouts; track layout) { + + {{ valueCardLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts index 18ba0bdca9..00614b7f40 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-card-widget-settings.component.ts @@ -32,9 +32,10 @@ import { DateFormatProcessor, DateFormatSettings, getLabel } from '@shared/model import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-value-card-widget-settings', - templateUrl: './value-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-value-card-widget-settings', + templateUrl: './value-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class ValueCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html index 1f042ab1ff..a03ec60e35 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.html @@ -23,12 +23,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> - - {{ valueChartCardLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.value-chart-card.layout' | translate }}" formControlName="layout"> + @for (layout of valueChartCardLayouts; track layout) { + + {{ valueChartCardLayoutTranslationMap.get(layout) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.ts index 46878d0fcd..d218757030 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/cards/value-chart-card-widget-settings.component.ts @@ -29,9 +29,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-value-chart-card-widget-settings', - templateUrl: './value-chart-card-widget-settings.component.html', - styleUrls: [] + selector: 'tb-value-chart-card-widget-settings', + templateUrl: './value-chart-card-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class ValueChartCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-widget-settings.component.ts index ace61904cc..432007de53 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-widget-settings.component.ts @@ -28,9 +28,10 @@ import { } from '@home/components/widget/lib/settings/chart/latest-chart-widget-settings.component'; @Component({ - selector: 'tb-bar-chart-widget-settings', - templateUrl: './latest-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-bar-chart-widget-settings', + templateUrl: './latest-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class BarChartWidgetSettingsComponent extends LatestChartWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html index 8ab61ea509..bda194828e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.html @@ -24,7 +24,7 @@
+ formControlName="grid">
widgets.bar-chart.bar-appearance
@@ -78,43 +78,43 @@
+ formControlName="barBackgroundSettings" + titleText="widgets.chart.background" + fillNoneTitle="widgets.chart.fill-type-solid"> + stroked + formControlName="noAggregationBarWidthSettings">
widgets.time-series-chart.axis.y-axis
+ formControlName="yAxis" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + axisType="yAxis" + advanced + hideUnits + hideDecimals>
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + axisType="xAxis" + advanced>
+ formControlName="thresholds" + [aliasController]="aliasController" + [dataKeyCallbacks]="dataKeyCallbacks" + [datasource]="datasource" + [widgetConfig]="widgetConfig?.config" + hideYAxis>
@@ -130,9 +130,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -193,7 +195,7 @@
+ formControlName="tooltipDateFormat"> @@ -229,7 +231,7 @@
+ formControlName="animation">
widget-config.card-appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts index cc0c5c03cd..55ed3d5035 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/bar-chart-with-labels-widget-settings.component.ts @@ -31,11 +31,13 @@ import { barChartWithLabelsDefaultSettings } from '@home/components/widget/lib/chart/bar-chart-with-labels-widget.models'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; +import { updateLatestDataKeys } from '@home/components/widget/lib/chart/time-series-chart.models'; @Component({ - selector: 'tb-bar-chart-with-labels-widget-settings', - templateUrl: './bar-chart-with-labels-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-bar-chart-with-labels-widget-settings', + templateUrl: './bar-chart-with-labels-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsComponent { @@ -122,6 +124,11 @@ export class BarChartWithLabelsWidgetSettingsComponent extends WidgetSettingsCom }); } + protected onSettingsChanged(updated: WidgetSettings) { + updateLatestDataKeys([updated.yAxis], this.datasource, this.dataKeyCallbacks); + super.onSettingsChanged(updated); + } + protected validatorTriggers(): string[] { return ['showBarLabel', 'showBarValue', 'showBarBorder', 'showLegend', 'showTooltip', 'tooltipShowDate']; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.ts index 7152a6a39e..50d407948b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/chart-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-chart-widget-settings', - templateUrl: './chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-chart-widget-settings', + templateUrl: './chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class ChartWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.ts index 12e39a52d7..88226f4c28 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-chart-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-doughnut-chart-widget-settings', - templateUrl: './doughnut-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-doughnut-chart-widget-settings', + templateUrl: './doughnut-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class DoughnutChartWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts index 6ba0508c95..17a0d60aab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/doughnut-widget-settings.component.ts @@ -29,9 +29,10 @@ import { } from '@home/components/widget/lib/settings/chart/latest-chart-widget-settings.component'; @Component({ - selector: 'tb-doughnut-widget-settings', - templateUrl: './latest-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-doughnut-widget-settings', + templateUrl: './latest-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class DoughnutWidgetSettingsComponent extends LatestChartWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.ts index bc2e7c22db..b2c0a34596 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-key-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { flotDataKeyDefaultSettings } from '@home/components/widget/lib/settings/chart/flot-key-settings.component'; @Component({ - selector: 'tb-flot-bar-key-settings', - templateUrl: './flot-bar-key-settings.component.html', - styleUrls: [] + selector: 'tb-flot-bar-key-settings', + templateUrl: './flot-bar-key-settings.component.html', + styleUrls: [], + standalone: false }) export class FlotBarKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.ts index c6720ecd13..c74fad3cda 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-bar-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { flotDefaultSettings } from '@home/components/widget/lib/settings/chart/flot-widget-settings.component'; @Component({ - selector: 'tb-flot-bar-widget-settings', - templateUrl: './flot-bar-widget-settings.component.html', - styleUrls: [] + selector: 'tb-flot-bar-widget-settings', + templateUrl: './flot-bar-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class FlotBarWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.html index 87a84493c2..9bbfc28921 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.html @@ -202,16 +202,19 @@ widgets.chart.add-threshold
-
-
- - + @if (thresholdsFormArray().length) { +
+ @for (thresholdControl of thresholdsFormArray().controls; track trackByThreshold($index, thresholdControl)) { +
+ + +
+ }
-
+ }
widgets.chart.comparison-settings
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.ts index e5711e4af3..cd46662ae5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-key-settings.component.ts @@ -91,21 +91,22 @@ export function flotDataKeyDefaultSettings(chartType: ChartType): TbFlotKeySetti } @Component({ - selector: 'tb-flot-key-settings', - templateUrl: './flot-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlotKeySettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FlotKeySettingsComponent), - multi: true, - } - ] + selector: 'tb-flot-key-settings', + templateUrl: './flot-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FlotKeySettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FlotKeySettingsComponent), + multi: true, + } + ], + standalone: false }) export class FlotKeySettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.ts index cc7842386e..78897b5db0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-latest-key-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-flot-latest-key-settings', - templateUrl: './flot-latest-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-flot-latest-key-settings', + templateUrl: './flot-latest-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class FlotLatestKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.ts index 74b6ae9839..2b972a0d89 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-key-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { flotDataKeyDefaultSettings } from '@home/components/widget/lib/settings/chart/flot-key-settings.component'; @Component({ - selector: 'tb-flot-line-key-settings', - templateUrl: './flot-line-key-settings.component.html', - styleUrls: [] + selector: 'tb-flot-line-key-settings', + templateUrl: './flot-line-key-settings.component.html', + styleUrls: [], + standalone: false }) export class FlotLineKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.ts index ca01b3cbc5..3c4882a3c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-line-widget-settings.component.ts @@ -23,9 +23,10 @@ import { flotDefaultSettings } from '@home/components/widget/lib/settings/chart/ import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-flot-line-widget-settings', - templateUrl: './flot-line-widget-settings.component.html', - styleUrls: [] + selector: 'tb-flot-line-widget-settings', + templateUrl: './flot-line-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class FlotLineWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.ts index 9d8674dddf..39f6b3333a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-key-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-flot-pie-key-settings', - templateUrl: './flot-pie-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-flot-pie-key-settings', + templateUrl: './flot-pie-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class FlotPieKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.html index b458e20387..a9034b9e12 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.html @@ -22,23 +22,29 @@ widgets.chart.radius - - {{ 'widgets.chart.common-pie-settings-range-error' | translate }} - + @if (flotPieWidgetSettingsForm.get('radius').hasError('min') || flotPieWidgetSettingsForm.get('radius').hasError('max')) { + + {{ 'widgets.chart.common-pie-settings-range-error' | translate }} + + } widgets.chart.inner-radius - - {{ 'widgets.chart.common-pie-settings-range-error' | translate }} - + @if (flotPieWidgetSettingsForm.get('innerRadius').hasError('min') || flotPieWidgetSettingsForm.get('innerRadius').hasError('max')) { + + {{ 'widgets.chart.common-pie-settings-range-error' | translate }} + + } widgets.chart.tilt - - {{ 'widgets.chart.common-pie-settings-range-error' | translate }} - + @if (flotPieWidgetSettingsForm.get('tilt').hasError('min') || flotPieWidgetSettingsForm.get('tilt').hasError('max')) { + + {{ 'widgets.chart.common-pie-settings-range-error' | translate }} + + } @@ -56,9 +62,9 @@ + formControlName="color" + icon="format_color_fill" + label="{{ 'widgets.chart.color' | translate }}" openOnInput colorClearButton> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.ts index 78147a4d52..98ac711499 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-pie-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-flot-pie-widget-settings', - templateUrl: './flot-pie-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-flot-pie-widget-settings', + templateUrl: './flot-pie-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class FlotPieWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.html index e97d45fdd1..ad8b865391 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.html @@ -18,13 +18,14 @@
- - - - - - + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.ts index 0ebe86668b..b8accc637b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-threshold.component.ts @@ -40,17 +40,18 @@ import { TbFlotKeyThreshold } from '@home/components/widget/lib/flot-widget.mode import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-flot-threshold', - templateUrl: './flot-threshold.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FlotThresholdComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-flot-threshold', + templateUrl: './flot-threshold.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FlotThresholdComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class FlotThresholdComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.html index 0ae52d9993..2079d5e35f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.html @@ -24,43 +24,53 @@ {{ 'widgets.chart.enable-selection-mode' | translate }} - - {{ 'widgets.chart.display-smooth-lines' | translate }} - -
-
widgets.chart.line-shadow-size
- - - -
-
-
widgets.chart.default-bar-width
- - - -
-
-
{{ 'widgets.chart.bar-alignment' | translate }}
- - - - {{ 'widgets.chart.bar-alignment-left' | translate }} - - - {{ 'widgets.chart.bar-alignment-right' | translate }} - - - {{ 'widgets.chart.bar-alignment-center' | translate }} - - - -
-
-
widgets.chart.thresholds-line-width
- - - -
+ @if (chartType === 'graph') { + + {{ 'widgets.chart.display-smooth-lines' | translate }} + + } + @if (chartType === 'graph') { +
+
widgets.chart.line-shadow-size
+ + + +
+ } + @if (chartType === 'bar') { +
+
widgets.chart.default-bar-width
+ + + +
+ } + @if (chartType === 'bar') { +
+
{{ 'widgets.chart.bar-alignment' | translate }}
+ + + + {{ 'widgets.chart.bar-alignment-left' | translate }} + + + {{ 'widgets.chart.bar-alignment-right' | translate }} + + + {{ 'widgets.chart.bar-alignment-center' | translate }} + + + +
+ } + @if (chartType === 'graph' || chartType === 'bar') { +
+
widgets.chart.thresholds-line-width
+ + + +
+ }
{{ 'widgets.chart.default-font' | translate }}
@@ -74,25 +84,27 @@
-
-
widget-config.legend
- - - - - {{ 'widget-config.legend' | translate }} - - - - widget-config.advanced-settings - - - - - - - -
+ @if (chartType === 'graph' || chartType === 'bar') { +
+
widget-config.legend
+ + + + + {{ 'widget-config.legend' | translate }} + + + + widget-config.advanced-settings + + + + + + + +
+ }
widgets.chart.axis
@@ -267,124 +279,131 @@
-
-
widgets.chart.comparison-settings
- - - - - {{ 'widgets.chart.enable-comparison' | translate }} - - - - widget-config.advanced-settings - - - -
-
{{ 'widgets.chart.time-for-comparison' | translate }}
- - - - {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} - - - {{ 'widgets.chart.time-for-comparison-days' | translate }} - - - {{ 'widgets.chart.time-for-comparison-weeks' | translate }} - - - {{ 'widgets.chart.time-for-comparison-months' | translate }} - - - {{ 'widgets.chart.time-for-comparison-years' | translate }} - - - {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} - - - -
-
-
widgets.chart.custom-interval-value
- - - -
-
-
widgets.chart.comparison-x-axis-settings
-
-
widgets.chart.axis-title
- - - -
-
- - {{ 'widgets.chart.show-tick-labels' | translate }} + @if (chartType === 'graph' || chartType === 'bar') { +
+
widgets.chart.comparison-settings
+ + + + + {{ 'widgets.chart.enable-comparison' | translate }} -
+ + + widget-config.advanced-settings + + +
-
{{ 'widgets.chart.axis-position' | translate }}
+
{{ 'widgets.chart.time-for-comparison' | translate }}
- - - {{ 'widgets.chart.axis-position-top' | translate }} + + + {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} - - {{ 'widgets.chart.axis-position-bottom' | translate }} + + {{ 'widgets.chart.time-for-comparison-days' | translate }} + + + {{ 'widgets.chart.time-for-comparison-weeks' | translate }} + + + {{ 'widgets.chart.time-for-comparison-months' | translate }} + + + {{ 'widgets.chart.time-for-comparison-years' | translate }} + + + {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }}
-
- - -
-
-
widgets.chart.custom-legend-settings
- - - - - {{ 'widgets.chart.enable-custom-legend' | translate }} - - - - widget-config.advanced-settings - - - -
-
widgets.chart.label-keys-list
-
-
-
- - -
+
+
widgets.chart.custom-interval-value
+ + + +
+
+
widgets.chart.comparison-x-axis-settings
+
+
widgets.chart.axis-title
+ + +
-
- widgets.chart.no-label-keys +
+ + {{ 'widgets.chart.show-tick-labels' | translate }} +
-
- +
+
{{ 'widgets.chart.axis-position' | translate }}
+ + + + {{ 'widgets.chart.axis-position-top' | translate }} + + + {{ 'widgets.chart.axis-position-bottom' | translate }} + + +
-
- - -
+ + +
+ } + @if (chartType === 'graph' || chartType === 'bar') { +
+
widgets.chart.custom-legend-settings
+ + + + + {{ 'widgets.chart.enable-custom-legend' | translate }} + + + + widget-config.advanced-settings + + + +
+
widgets.chart.label-keys-list
+
+
+ @for (labelDataKeyControl of typedSelectOptions; track trackByLabelDataKey($index, labelDataKeyControl); let isLast = $last) { +
+ + +
+ } +
+ @if (!dataKeysListForLabelsFormArray().controls.length) { +
+ widgets.chart.no-label-keys +
+ } +
+ +
+
+
+
+
+
+ } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts index 73b5248f04..4b3af4a63e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/flot-widget-settings.component.ts @@ -106,21 +106,22 @@ export const flotDefaultSettings = (chartType: ChartType): Partial FlotWidgetSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FlotWidgetSettingsComponent), - multi: true, - } - ] + selector: 'tb-flot-widget-settings', + templateUrl: './flot-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FlotWidgetSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FlotWidgetSettingsComponent), + multi: true, + } + ], + standalone: false }) export class FlotWidgetSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { @@ -423,6 +424,10 @@ export class FlotWidgetSettingsComponent extends PageComponent implements OnInit return this.flotSettingsFormGroup.get('dataKeysListForLabels') as UntypedFormArray; } + get typedSelectOptions() { + return this.dataKeysListForLabelsFormArray().controls as (AbstractControl & { new?: boolean })[]; + } + public trackByLabelDataKey(index: number, labelDataKeyControl: AbstractControl): any { return labelDataKeyControl; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.html index e9ca4d51ea..9be7ec3da7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.html @@ -24,13 +24,15 @@
- + @if (!disabled) { + + }
@@ -41,9 +43,11 @@ widgets.chart.key-name - - {{ 'widgets.chart.key-name-required' | translate }} - + @if (labelDataKeyFormGroup.get('name').hasError('required')) { + + {{ 'widgets.chart.key-name-required' | translate }} + + } widgets.chart.key-type diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.ts index 6d2b50824b..71e1163d16 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/label-data-key.component.ts @@ -46,16 +46,17 @@ export function labelDataKeyValidator(control: AbstractControl): ValidationError } @Component({ - selector: 'tb-label-data-key', - templateUrl: './label-data-key.component.html', - styleUrls: ['./label-data-key.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LabelDataKeyComponent), - multi: true - } - ] + selector: 'tb-label-data-key', + templateUrl: './label-data-key.component.html', + styleUrls: ['./label-data-key.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LabelDataKeyComponent), + multi: true + } + ], + standalone: false }) export class LabelDataKeyComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html index 8d7ba9a603..fcc637f7b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/latest-chart-widget-settings.component.html @@ -31,9 +31,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -84,9 +86,11 @@
- - {{ latestChartTooltipValueTypeTranslationMap.get(type) | translate }} - + @for (type of latestChartTooltipValueTypes; track type) { + + {{ latestChartTooltipValueTypeTranslationMap.get(type) | translate }} + + } @@ -120,7 +124,7 @@
+ formControlName="animation">
widget-config.card-appearance
@@ -149,9 +153,11 @@
- - {{ pieChartLabelPositionTranslationMap.get(position) | translate }} - + @for (position of pieChartLabelPositions; track position) { + + {{ pieChartLabelPositionTranslationMap.get(position) | translate }} + + } - - {{ doughnutLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.doughnut.layout' | translate }}" formControlName="layout"> + @for (layout of doughnutLayouts; track layout) { + + {{ doughnutLayoutTranslationMap.get(layout) | translate }} + + }
@@ -256,8 +264,8 @@
widgets.bar-chart.bar-appearance
+ formControlName="barSettings" + [series]="false">
@@ -303,9 +311,9 @@
widgets.bar-chart.bar-appearance
+ formControlName="barSettings" + pieLabelPosition + [series]="false">
@@ -360,9 +368,11 @@
{{ 'widgets.radar-chart.shape' | translate }}
- - {{ radarChartShapeTranslations.get(shape) | translate }} - + @for (shape of radarChartShapes; track shape) { + + {{ radarChartShapeTranslations.get(shape) | translate }} + + }
@@ -380,9 +390,11 @@
- - {{ chartLineTypeTranslations.get(type) | translate }} - + @for (type of chartLineTypes; track type) { + + {{ chartLineTypeTranslations.get(type) | translate }} + + } @@ -398,9 +410,11 @@
- - {{ chartShapeTranslations.get(shape) | translate }} - + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + } @@ -416,9 +430,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + }
+ formControlName="fillAreaSettings">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/pie-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/pie-chart-widget-settings.component.ts index 85db032513..9a7c2318bf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/pie-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/pie-chart-widget-settings.component.ts @@ -28,9 +28,10 @@ import { } from '@home/components/widget/lib/settings/chart/latest-chart-widget-settings.component'; @Component({ - selector: 'tb-pie-chart-widget-settings', - templateUrl: './latest-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-pie-chart-widget-settings', + templateUrl: './latest-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class PieChartWidgetSettingsComponent extends LatestChartWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/polar-area-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/polar-area-chart-widget-settings.component.ts index f452fee0bf..e941e27f13 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/polar-area-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/polar-area-chart-widget-settings.component.ts @@ -28,9 +28,10 @@ import { } from '@home/components/widget/lib/settings/chart/latest-chart-widget-settings.component'; @Component({ - selector: 'tb-polar-area-chart-widget-settings', - templateUrl: './latest-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-polar-area-chart-widget-settings', + templateUrl: './latest-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class PolarAreaChartWidgetSettingsComponent extends LatestChartWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts index 6e15a4c2bc..f9d3128027 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/radar-chart-widget-settings.component.ts @@ -28,9 +28,10 @@ import { } from '@home/components/widget/lib/settings/chart/latest-chart-widget-settings.component'; @Component({ - selector: 'tb-radar-chart-widget-settings', - templateUrl: './latest-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-radar-chart-widget-settings', + templateUrl: './latest-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class RadarChartWidgetSettingsComponent extends LatestChartWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html index ded6a1a7b4..2ff773c61c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.html @@ -24,7 +24,7 @@
+ formControlName="grid">
widgets.range-chart.range-chart-appearance
@@ -45,11 +45,11 @@ {{ 'widgets.range-chart.show-range-thresholds' | translate }} + boxButton + hideYAxis + [widgetConfig]="widgetConfig?.config" + title="widgets.range-chart.range-thresholds-settings" + formControlName="rangeThreshold">
@@ -77,9 +77,11 @@ - - {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} - + @for (stepType of lineSeriesStepTypes; track stepType) { + + {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} + + }
@@ -92,9 +94,11 @@
widgets.time-series-chart.line-type
- - {{ chartLineTypeTranslations.get(lineType) | translate }} - + @for (lineType of chartLineTypes; track lineType) { + + {{ chartLineTypeTranslations.get(lineType) | translate }} + + }
@@ -121,9 +125,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + } widgets.time-series-chart.series.point.point-shape
- - {{ chartShapeTranslations.get(shape) | translate }} - + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + }
@@ -168,29 +176,29 @@
widgets.time-series-chart.axis.y-axis
+ formControlName="yAxis" + [aliasController]="aliasController" + [dataKeyCallbacks]="callbacks" + [datasource]="datasource" + axisType="yAxis" + hideUnits + hideDecimals>
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + axisType="xAxis">
+ formControlName="thresholds" + [aliasController]="aliasController" + [dataKeyCallbacks]="dataKeyCallbacks" + [datasource]="datasource" + [widgetConfig]="widgetConfig?.config" + hideYAxis>
@@ -206,9 +214,11 @@
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslationMap.get(pos) | translate }} - + @for (pos of legendPositions; track pos) { + + {{ legendPositionTranslationMap.get(pos) | translate }} + + }
@@ -269,7 +279,7 @@
+ formControlName="tooltipDateFormat"> @@ -305,7 +315,7 @@
+ formControlName="animation">
widget-config.card-appearance
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts index 5b1b20c2fd..053b6858bc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/range-chart-widget-settings.component.ts @@ -30,7 +30,7 @@ import { rangeChartDefaultSettings } from '@home/components/widget/lib/chart/ran import { DateFormatProcessor, DateFormatSettings } from '@shared/models/widget-settings.models'; import { lineSeriesStepTypes, - lineSeriesStepTypeTranslations + lineSeriesStepTypeTranslations, updateLatestDataKeys } from '@home/components/widget/lib/chart/time-series-chart.models'; import { chartLabelPositions, @@ -43,9 +43,10 @@ import { import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-range-chart-widget-settings', - templateUrl: './range-chart-widget-settings.component.html', - styleUrls: [] + selector: 'tb-range-chart-widget-settings', + templateUrl: './range-chart-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent { @@ -268,6 +269,11 @@ export class RangeChartWidgetSettingsComponent extends WidgetSettingsComponent { } } + protected onSettingsChanged(updated: WidgetSettings) { + updateLatestDataKeys([updated.yAxis], this.datasource, this.dataKeyCallbacks); + super.onSettingsChanged(updated); + } + private _pointLabelPreviewFn(): string { const units = getSourceTbUnitSymbol(this.widgetConfig.config.units); const decimals: number = this.widgetConfig.config.decimals; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html index fcc2faf05f..d8706aa2d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.html @@ -34,67 +34,74 @@
widgets.time-series-chart.axis.y-axis
- - {{ yAxis }} - + @for (yAxis of yAxisIds; track yAxis) { + + {{ yAxis }} + + }
-
-
widgets.time-series-chart.series.series-type
- - {{ timeSeriesChartSeriesTypeTranslations.get(type) | translate }} - -
+ @if (chartType === TimeSeriesChartType.default) { +
+
widgets.time-series-chart.series.series-type
+ + @for (type of timeSeriesChartSeriesTypes; track type) { + {{ timeSeriesChartSeriesTypeTranslations.get(type) | translate }} + } + +
+ } @else { +
{{ timeSeriesChartTypeTranslations.get(chartType) | translate }}
+ } + formControlName="barSettings">
widgets.chart.tooltip-settings
+ formControlName="tooltipValueFormatter" + [globalVariables]="functionScopeVariables" + [functionArgs]="['value', 'latestData']" + functionTitle="{{ 'widgets.chart.tooltip-value-format-function' | translate }}" + helpId="widget/lib/flot/tooltip_value_format_fn">
-
-
widgets.time-series-chart.comparison.settings
- - - - - {{ 'widgets.time-series-chart.comparison.show-values-for-comparison' | translate }} - - - - -
-
widgets.time-series-chart.comparison.comparison-values-label
- - - -
-
-
{{ 'widgets.time-series-chart.comparison.comparison-data-color' | translate }}
- - -
-
-
-
+ @if (comparisonEnabled) { +
+
widgets.time-series-chart.comparison.settings
+ + + + + {{ 'widgets.time-series-chart.comparison.show-values-for-comparison' | translate }} + + + + +
+
widgets.time-series-chart.comparison.comparison-values-label
+ + + +
+
+
{{ 'widgets.time-series-chart.comparison.comparison-data-color' | translate }}
+ + +
+
+
+
+ } - -
{{ timeSeriesChartTypeTranslations.get(chartType) | translate }}
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts index 3c99b96c07..34b0edb6ae 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-key-settings.component.ts @@ -35,9 +35,10 @@ import { TimeSeriesChartWidgetSettings } from '@home/components/widget/lib/chart import { WidgetService } from '@core/http/widget.service'; @Component({ - selector: 'tb-time-series-chart-key-settings', - templateUrl: './time-series-chart-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-time-series-chart-key-settings', + templateUrl: './time-series-chart-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class TimeSeriesChartKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html index 9a562cec25..b607679be2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.html @@ -16,56 +16,124 @@ --> -
-
widgets.time-series-chart.series.line.line
-
- - {{ 'widgets.time-series-chart.series.line.show-line' | translate }} - + @if (chartType !== TimeSeriesChartType.point) { +
+
widgets.time-series-chart.series.line.line
+ @if (chartType !== TimeSeriesChartType.state) { +
+ + {{ 'widgets.time-series-chart.series.line.show-line' | translate }} + +
+ } +
+ + {{ 'widgets.time-series-chart.series.line.step-line' | translate }} + + + + @for (stepType of lineSeriesStepTypes; track stepType) { + + {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} + + } + + +
+ @if (chartType !== TimeSeriesChartType.state) { +
+ + {{ 'widgets.time-series-chart.series.line.smooth-line' | translate }} + +
+ } +
+
widgets.time-series-chart.line-type
+ + + @for (lineType of chartLineTypes; track lineType) { + + {{ chartLineTypeTranslations.get(lineType) | translate }} + + } + + +
+
+
widgets.time-series-chart.line-width
+ + + +
-
- - {{ 'widgets.time-series-chart.series.line.step-line' | translate }} + } + @if (chartType !== TimeSeriesChartType.point) { +
+
widgets.time-series-chart.series.point.points
+
+ + {{ 'widgets.time-series-chart.series.point.show-points' | translate }} + +
+ +
+ } @else { +
+ +
+ {{ 'widgets.time-series-chart.series.point.point-label' | translate }} +
- - - - {{ lineSeriesStepTypeTranslations.get(stepType) | translate }} - - - +
+ + + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + } + + + + + + +
-
- - {{ 'widgets.time-series-chart.series.line.smooth-line' | translate }} +
+ + {{ 'widgets.time-series-chart.series.point.point-label-background' | translate }} + +
-
widgets.time-series-chart.line-type
+
widgets.time-series-chart.series.point.point-shape
- - - {{ chartLineTypeTranslations.get(lineType) | translate }} - + + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + }
-
widgets.time-series-chart.line-width
+
widgets.time-series-chart.series.point.point-size
- +
-
-
-
widgets.time-series-chart.series.point.points
-
- - {{ 'widgets.time-series-chart.series.point.show-points' | translate }} - -
- -
+ } @@ -80,9 +148,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + } widgets.time-series-chart.series.point.point-shape
- - {{ chartShapeTranslations.get(shape) | translate }} - + @for (shape of chartShapes; track shape) { + + {{ chartShapeTranslations.get(shape) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts index 05afef0af8..c2e024bb55 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-line-settings.component.ts @@ -45,16 +45,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { getSourceTbUnitSymbol, isNotEmptyTbUnits } from '@shared/models/unit.models'; @Component({ - selector: 'tb-time-series-chart-line-settings', - templateUrl: './time-series-chart-line-settings.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartLineSettingsComponent), - multi: true - } - ] + selector: 'tb-time-series-chart-line-settings', + templateUrl: './time-series-chart-line-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartLineSettingsComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesChartLineSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html index 36bf2aea89..b49d8ad902 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.html @@ -56,10 +56,11 @@
- - + + } + formControlName="thresholds" + [aliasController]="aliasController" + [dataKeyCallbacks]="dataKeyCallbacks" + [datasource]="datasource" + [widgetConfig]="widgetConfig?.config" + [yAxisIds]="yAxisIds" + [supportsUnitConversion]="widgetConfig.typeParameters.supportsUnitConversion">
widgets.time-series-chart.chart-style
@@ -86,7 +87,7 @@
+ formControlName="grid">
@@ -98,15 +99,16 @@
widgets.time-series-chart.axis.x-axis
+ formControlName="xAxis" + advanced + axisType="xAxis">
- - + @if (chartType === TimeSeriesChartType.default || chartType === TimeSeriesChartType.bar) { + + + }
@@ -178,9 +180,9 @@
{{ 'tooltip.trigger' | translate }}
{{ 'tooltip.trigger-point' | translate }} + [value]="EChartsTooltipTrigger.point">{{ 'tooltip.trigger-point' | translate }} {{ 'tooltip.trigger-axis' | translate }} + [value]="EChartsTooltipTrigger.axis">{{ 'tooltip.trigger-axis' | translate }}
@@ -210,11 +212,11 @@
+ formControlName="tooltipValueFormatter" + [globalVariables]="functionScopeVariables" + [functionArgs]="['value', 'latestData']" + functionTitle="{{ 'widgets.chart.tooltip-value-format-function' | translate }}" + helpId="widget/lib/flot/tooltip_value_format_fn">
@@ -222,7 +224,7 @@
+ formControlName="tooltipDateFormat"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts index 4abc0bf437..8a96f31445 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/chart/time-series-chart-widget-settings.component.ts @@ -42,9 +42,10 @@ import { WidgetService } from '@core/http/widget.service'; import { TimeSeriesChartTooltipTrigger } from '@home/components/widget/lib/chart/time-series-chart-tooltip.models'; @Component({ - selector: 'tb-time-series-chart-widget-settings', - templateUrl: './time-series-chart-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-time-series-chart-widget-settings', + templateUrl: './time-series-chart-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class TimeSeriesChartWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html index 229fb79a62..b6ddd18d6d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.html @@ -17,31 +17,36 @@ -->
- - + @if (!fullscreen) { + + } + @if (fullscreen) { + + }
-
- - -
- + @if (!fullscreen) { +
+ + +
+ } @else {
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts index 97d7bfed25..2537cbe310 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-editor.component.ts @@ -32,17 +32,18 @@ import { } from '@home/components/widget/lib/settings/common/action/custom-action.models'; @Component({ - selector: 'tb-custom-action-pretty-editor', - templateUrl: './custom-action-pretty-editor.component.html', - styleUrls: ['./custom-action-pretty-editor.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CustomActionPrettyEditorComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-custom-action-pretty-editor', + templateUrl: './custom-action-pretty-editor.component.html', + styleUrls: ['./custom-action-pretty-editor.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomActionPrettyEditorComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CustomActionPrettyEditorComponent implements AfterViewInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html index 6d36ee7e24..683b97c588 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.html @@ -19,29 +19,31 @@
-
- - - - {{ 'widget.resource-is-extension' | translate }} - - -
+ @for (resource of action.customResources; track resource; let i = $index) { +
+ + + + {{ 'widget.resource-is-extension' | translate }} + + +
+ }
- - - - + @if (hasCustomFunction) { + + + + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.ts index 7cc295e94e..dfd03e406b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/custom-action-pretty-resources-tabs.component.ts @@ -41,10 +41,11 @@ import { getAce } from '@shared/models/ace/ace.models'; import { beautifyCss, beautifyHtml } from '@shared/models/beautify.models'; @Component({ - selector: 'tb-custom-action-pretty-resources-tabs', - templateUrl: './custom-action-pretty-resources-tabs.component.html', - styleUrls: ['./custom-action-pretty-resources-tabs.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-custom-action-pretty-resources-tabs', + templateUrl: './custom-action-pretty-resources-tabs.component.html', + styleUrls: ['./custom-action-pretty-resources-tabs.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CustomActionPrettyResourcesTabsComponent extends PageComponent implements OnInit, OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html index 0e009341ee..e04426ce1b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/get-value-action-settings-panel.component.html @@ -22,40 +22,43 @@
{{ 'widgets.value-action.action' | translate }}
- - {{ getValueActionTranslationsMap.get(action) | translate }} - + @for (action of getValueActions; track action) { + + {{ getValueActionTranslationsMap.get(action) | translate }} + + }
- - + @switch (getValueSettingsFormGroup.get('action').value) { + @case (getValueAction.DO_NOTHING) {
widgets.value-action.value
+ formControlName="defaultValue">
-
- + } + @case (getValueAction.EXECUTE_RPC) {
{{ 'widgets.value-action.method' | translate }}*
- - warning - + @if (getValueSettingsFormGroup.get('executeRpc').get('method').hasError('required') + && getValueSettingsFormGroup.get('executeRpc').get('method').touched) { + + warning + + }
-
- + } + @case (getValueAction.GET_ATTRIBUTE) {
{{ 'widgets.value-action.attribute-scope' | translate }}
@@ -64,9 +67,11 @@ {{ 'attribute.scope-any' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + }
@@ -85,8 +90,8 @@
- - + } + @case (getValueAction.GET_TIME_SERIES) {
{{ 'widgets.value-action.time-series-key' | translate }}*
@@ -102,15 +107,17 @@
-
- + } + @case (getValueAction.GET_ALARM_STATUS) {
alarm.alarm-severity
- - {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} - + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + }
@@ -124,101 +131,111 @@
-
- -
-
-
widgets.value-action.action-result-converter
- - {{ 'widgets.value-action.converter-none' | translate }} - {{ 'widgets.value-action.converter-function' | translate }} - -
- - -
-
{{ 'widgets.value-action.state-when-result-is' | translate:{state: stateLabel} }}
- +
+
widgets.value-action.action-result-converter
+ + {{ 'widgets.value-action.converter-none' | translate }} + {{ 'widgets.value-action.converter-function' | translate }} + +
+ @if (getValueSettingsFormGroup.get('dataToValue').get('type').value === dataToValueType.FUNCTION) { + + + } + @if (valueType === ValueType.BOOLEAN) { +
+
{{ 'widgets.value-action.state-when-result-is' | translate:{state: stateLabel} }}
+ - + layout="column" + formControlName="compareToValue"> + +
+ }
-
-
- - - - - - widget-config.advanced-settings - - - -
-
{{ 'widgets.value-action.request-timeout-ms' | translate }}
- - - + + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.value-action.request-timeout-ms' | translate }}
+ + + @if (getValueSettingsFormGroup.get('executeRpc').get('requestTimeout').touched && + getValueSettingsFormGroup.get('executeRpc').get('requestTimeout').invalid) { + - warning - - -
-
- - - - - {{ 'widgets.value-action.request-persistent' | translate }} - - - - -
-
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
- - - + warning + + } + +
+
+ + + + + {{ 'widgets.value-action.request-persistent' | translate }} + + + + +
+
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
+ + + @if (getValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').touched && + getValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').invalid) { + - warning - - -
-
-
-
-
-
-
+ class="tb-error"> + warning +
+ } +
+
+
+
+
+ + +
+ }
@@ -50,9 +53,11 @@
{{ 'widget-action.mobile.provision-type' | translate }}*
- - {{ provisionTypeTranslationMap.get(type) | translate }} - + @for (type of provisionTypes; track type) { + + {{ provisionTypeTranslationMap.get(type) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts index 553e3c38a1..3e6a55ead6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/mobile-action-editor.component.ts @@ -50,14 +50,15 @@ import { TbFunction } from '@shared/models/js-function.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-mobile-action-editor', - templateUrl: './mobile-action-editor.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MobileActionEditorComponent), - multi: true - }] + selector: 'tb-mobile-action-editor', + templateUrl: './mobile-action-editor.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MobileActionEditorComponent), + multi: true + }], + standalone: false }) export class MobileActionEditorComponent implements ControlValueAccessor, OnInit { @@ -75,7 +76,7 @@ export class MobileActionEditorComponent implements ControlValueAccessor, OnInit actionConfig: ActionConfig[]; commonActionConfig: ActionConfig[]; - provisionTypes: string[] = Object.keys(ProvisionType); + provisionTypes = Object.values(ProvisionType); provisionTypeTranslationMap = provisionTypeTranslationMap; private requiredValue: boolean; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html index 001d2817ab..3b30491f8c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/set-value-action-settings-panel.component.html @@ -22,39 +22,44 @@
{{ 'widgets.value-action.action' | translate }}
- - {{ setValueActionTranslationsMap.get(action) | translate }} - + @for (action of setValueActions; track action) { + + {{ setValueActionTranslationsMap.get(action) | translate }} + + }
- - + @switch (setValueSettingsFormGroup.get('action').value) { + @case (setValueAction.EXECUTE_RPC) {
{{ 'widgets.value-action.method' | translate }}*
- - warning - + @if (setValueSettingsFormGroup.get('executeRpc').get('method').hasError('required') + && setValueSettingsFormGroup.get('executeRpc').get('method').touched) { + + warning + + }
-
- + } + @case (setValueAction.SET_ATTRIBUTE) {
{{ 'widgets.value-action.attribute-scope' | translate }}
- - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + }
@@ -73,8 +78,8 @@
- - + } + @case (setValueAction.ADD_TIME_SERIES) {
{{ 'widgets.value-action.time-series-key' | translate }}*
@@ -90,99 +95,113 @@
-
- + } + }
{{ (setValueSettingsFormGroup.get('action').value === setValueAction.EXECUTE_RPC ? - 'widgets.value-action.parameters' : 'widgets.value-action.value') | translate }}
+ 'widgets.value-action.parameters' : 'widgets.value-action.value') | translate }}
- {{ 'widgets.value-action.converter-value' | translate }} - {{ 'widgets.value-action.converter-constant' | translate }} + @if (valueType !== ValueType.BOOLEAN) { + {{ 'widgets.value-action.converter-value' | translate }} + } + @if (valueType === ValueType.BOOLEAN) { + {{ 'widgets.value-action.converter-constant' | translate }} + } {{ 'widgets.value-action.converter-function' | translate }} - {{ 'widgets.value-action.converter-none' | translate }} + @if (setValueSettingsFormGroup.get('action').value === setValueAction.EXECUTE_RPC) { + {{ 'widgets.value-action.converter-none' | translate }} + }
- - - - + formControlName="constantValue"> + + } + @if (setValueSettingsFormGroup.get('valueToData').get('type').value === valueToDataType.FUNCTION) { + + + }
-
- - - - - - widget-config.advanced-settings - - - -
-
{{ 'widgets.value-action.request-timeout-ms' | translate }}
- - - + + + + + + widget-config.advanced-settings + + + +
+
{{ 'widgets.value-action.request-timeout-ms' | translate }}
+ + + @if (setValueSettingsFormGroup.get('executeRpc').get('requestTimeout').touched && + setValueSettingsFormGroup.get('executeRpc').get('requestTimeout').invalid) { + - warning - - -
-
- - - - - {{ 'widgets.value-action.request-persistent' | translate }} - - - - -
-
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
- - - + warning + + } + +
+
+ + + + + {{ 'widgets.value-action.request-persistent' | translate }} + + + + +
+
{{ 'widgets.value-action.persistent-polling-interval' | translate }}
+ + + @if (setValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').touched && + setValueSettingsFormGroup.get('executeRpc').get('persistentPollingInterval').invalid) { + - warning - - -
-
-
-
-
-
-
+ class="tb-error"> + warning +
+ } +
+
+
+
+
+ + +
+ }
- - warning - - - - - - - -
- - -
-
{{ 'widget-action.URL' | translate }}
- - - - warning - - -
-
- -
- - {{ 'widget-action.open-right-layout' | translate }} - -
-
- -
- - {{ 'widget-action.open-new-browser-tab' | translate }} - -
-
- -
- - {{ 'widget-action.set-entity-from-widget' | translate }} - -
-
-
{{ 'alias.state-entity-parameter-name' | translate }}
- - - -
-
- -
-
{{ 'widget-action.state-display-type' | translate }}
- - - - {{ stateDisplayTypeName(displayType) }} - - - -
- - -
-
{{ 'widget-action.dialog-title' | translate }}
- - - -
-
- - {{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }} - + } + @case (widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState || + widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState || + widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ? + widgetActionFormGroup.get('type').value : '') { +
+
+ {{ 'widget-action.target-dashboard-state' | translate }} + @if (widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState) { + * + }
-
-
{{ 'widget-action.dialog-width' | translate }}
- - + + + @if (actionTypeFormGroup.get('targetDashboardStateId').value) { + + } + @if (actionTypeFormGroup.get('targetDashboardStateId').hasError('required') + && actionTypeFormGroup.get('targetDashboardStateId').touched) { warning - -
-
-
{{ 'widget-action.dialog-height' | translate }}
- - + } + + @for (state of filteredDashboardStates | async; track state) { + + + + } + + +
+ } + @case (widgetActionType.openURL) { +
+
{{ 'widget-action.URL' | translate }}
+ + + @if (actionTypeFormGroup.get('url').invalid && actionTypeFormGroup.get('url').touched) { warning - -
- - -
-
{{ 'widget-action.popover-preferred-placement' | translate }}
- - - - {{ popoverPlacementName(placement) }} - - - -
+ } + +
+ } + @case (widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState || + widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState ? + widgetActionFormGroup.get('type').value : '') { +
+ + {{ 'widget-action.open-right-layout' | translate }} + +
+ } + @case (widgetActionFormGroup.get('type').value === widgetActionType.openDashboard || + widgetActionFormGroup.get('type').value === widgetActionType.openURL ? + widgetActionFormGroup.get('type').value : '') { +
+ + {{ 'widget-action.open-new-browser-tab' | translate }} + +
+ } + @case (widgetActionFormGroup.get('type').value === widgetActionType.openDashboardState || + widgetActionFormGroup.get('type').value === widgetActionType.updateDashboardState || + widgetActionFormGroup.get('type').value === widgetActionType.openDashboard ? + widgetActionFormGroup.get('type').value : '') { + @if (widgetType !== WidgetType.static) {
- - {{ 'widget-action.popover-hide-on-click-outside' | translate }} + + {{ 'widget-action.set-entity-from-widget' | translate }}
+ } + @if (actionTypeFormGroup.get('setEntityId').value) {
- - {{ 'widget-action.popover-hide-dashboard-toolbar' | translate }} - -
-
-
{{ 'widget-action.popover-width' | translate }}
- - -
-
-
{{ 'widget-action.popover-height' | translate }}
- - +
{{ 'alias.state-entity-parameter-name' | translate }}
+ + +
- - - - - - - - +
{{ 'widget-action.state-display-type' | translate }}
+ + + @for (displayType of allStateDisplayTypes; track displayType) { + + {{ stateDisplayTypeName(displayType) }} + + } + + +
+ + @switch (actionTypeFormGroup.get('stateDisplayType').value) { + @case ('separateDialog') { +
+
{{ 'widget-action.dialog-title' | translate }}
+ + + +
+
+ + {{ 'widget-action.dialog-hide-dashboard-toolbar' | translate }} + +
+
+
{{ 'widget-action.dialog-width' | translate }}
+ + + @if (stateDisplayTypeFormGroup.get('dialogWidth').invalid && stateDisplayTypeFormGroup.get('dialogWidth').touched) { + + warning + + } + +
+
+
{{ 'widget-action.dialog-height' | translate }}
+ + + @if (stateDisplayTypeFormGroup.get('dialogHeight').invalid && stateDisplayTypeFormGroup.get('dialogHeight').touched) { + + warning + + } + +
+ } + @case ('popover') { +
+
{{ 'widget-action.popover-preferred-placement' | translate }}
+ + + @for (placement of allPopoverPlacements; track placement) { + + {{ popoverPlacementName(placement) }} + + } + + +
+
+ + {{ 'widget-action.popover-hide-on-click-outside' | translate }} + +
+
+ + {{ 'widget-action.popover-hide-dashboard-toolbar' | translate }} + +
+
+
{{ 'widget-action.popover-width' | translate }}
+ + +
+
+
{{ 'widget-action.popover-height' | translate }}
+ + +
+ + } + @case ('normal') { + } + } +
+ } + @case (widgetActionType.custom) { + -
- -
-
{{ 'widget-action.map-item-type' | translate }}
- - - @for(type of mapItemTypes; track type) { - {{ mapItemTypeTranslationMap.get(type) | translate }} - } - - -
- - -
- - - - - - - - + > + } + @case (widgetActionType.placeMapItem) { +
+
{{ 'widget-action.map-item-type' | translate }}
+ + + @for(type of mapItemTypes; track type) { + {{ mapItemTypeTranslationMap.get(type) | translate }} + } + + +
+ + + } + @case (widgetActionFormGroup.get('type').value === widgetActionType.customPretty + || widgetActionFormGroup.get('type').value === widgetActionType.placeMapItem + ? widgetActionFormGroup.get('type').value : '') { + + + } + @case (widgetActionType.mobileAction) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts index 09bddd26dd..9efb80081e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/action/widget-action.component.ts @@ -66,21 +66,22 @@ const stateDisplayTypesTranslations = new Map( ); @Component({ - selector: 'tb-widget-action', - templateUrl: './widget-action.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetActionComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => WidgetActionComponent), - multi: true, - } - ] + selector: 'tb-widget-action', + templateUrl: './widget-action.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetActionComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => WidgetActionComponent), + multi: true, + } + ], + standalone: false }) export class WidgetActionComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.html index a1d2ff2243..03548a16f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.html @@ -45,12 +45,14 @@ formControlName="color">
- + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts index c1e5114eb6..d2f1544014 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/advanced-range.component.ts @@ -32,16 +32,17 @@ import { Datasource } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-advanced-range', - templateUrl: './advanced-range.component.html', - styleUrls: ['./advanced-range.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AdvancedRangeComponent), - multi: true - } - ] + selector: 'tb-advanced-range', + templateUrl: './advanced-range.component.html', + styleUrls: ['./advanced-range.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdvancedRangeComponent), + multi: true + } + ], + standalone: false }) export class AdvancedRangeComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html index 4acda6d6b4..255003caa4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.html @@ -21,7 +21,9 @@ class="mat-block" [appearance]="inlineField ? 'outline' : appearance" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ 'entity.entity-alias' | translate }} + @if (showLabel && !inlineField) { + {{ 'entity.entity-alias' | translate }} + }
- - - - warning - + @if (selectEntityAliasFormGroup.get('entityAlias').value) { + + } + @if (selectEntityAliasFormGroup.get('entityAlias').value?.id) { + + } + @if (inlineField && !modelValue && tbRequired + && selectEntityAliasFormGroup.get('entityAlias').touched) { + + warning + + }
- + @if (!inlineField && !selectEntityAliasFormGroup.get('entityAlias').value && !disabled) { + + } - - - - -
-
- entity.no-aliases-found -
- + @for (entityAlias of filteredEntityAliases | async; track entityAlias) { + + + + } + @if (!(filteredEntityAliases | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ entity.no-aliases-found +
+ } @else { + + {{ 'entity.no-alias-matching' | translate : {alias: (searchText | truncate: true: 6: '...')} }} + + } - {{ 'entity.no-alias-matching' | translate : {alias: (searchText | truncate: true: 6: '...')} }} + entity.create-new-alias - - - entity.create-new-alias - -
-
+
+
+ }
- - {{ 'entity.alias-required' | translate }} - + @if (!inlineField && !modelValue && tbRequired) { + + {{ 'entity.alias-required' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts index 0836ad8e15..561541981f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/alias/entity-alias-select.component.ts @@ -39,14 +39,15 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-entity-alias-select', - templateUrl: './entity-alias-select.component.html', - styleUrls: ['./entity-alias-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityAliasSelectComponent), - multi: true - }] + selector: 'tb-entity-alias-select', + templateUrl: './entity-alias-select.component.html', + styleUrls: ['./entity-alias-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAliasSelectComponent), + multi: true + }], + standalone: false }) export class EntityAliasSelectComponent implements ControlValueAccessor, OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html index 16cb7a8e19..9964e6b375 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/auto-date-format-settings-panel.component.html @@ -18,17 +18,19 @@
date.time-granularity-formats
-
-
{{ formatTimeUnitTranslations.get(unit) | translate }}
-
- - -
-
- -
{{ previewText[unit] }}
+ @for (unit of formatTimeUnits; track unit) { +
+
{{ formatTimeUnitTranslations.get(unit) | translate }}
+
+ + +
+
+ +
{{ previewText[unit] }}
+
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts index 88b01c19c5..914eb0abb2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/axis-scale-row.component.ts @@ -36,20 +36,21 @@ import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/k import { merge } from 'rxjs'; @Component({ - selector: 'tb-axis-scale-row', - templateUrl: './axis-scale-row.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AxisScaleRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AxisScaleRowComponent), - multi: true - }, - ] + selector: 'tb-axis-scale-row', + templateUrl: './axis-scale-row.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AxisScaleRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AxisScaleRowComponent), + multi: true + }, + ], + standalone: false }) export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Validator { @@ -96,7 +97,7 @@ export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Vali this.limitForm = this.fb.group({ type: [ValueSourceType.constant], value: [null], - entityAlias: [null] + entityAlias: [null, [Validators.required]] }); this.latestKeyFormControl = this.fb.control(null, [Validators.required]); this.entityKeyFormControl = this.fb.control(null, [Validators.required]); @@ -168,23 +169,24 @@ export class AxisScaleRowComponent implements ControlValueAccessor, OnInit, Vali } private updateValidators() { - const axisTypeControl = this.limitForm.get('type'); - if (axisTypeControl && this.entityKeyFormControl && this.latestKeyFormControl) { - const type = axisTypeControl.value; - if (type === ValueSourceType.latestKey) { - this.latestKeyFormControl.setValidators([Validators.required]); - this.entityKeyFormControl.clearValidators(); - } else if (type === ValueSourceType.entity) { - this.latestKeyFormControl.clearValidators(); - this.limitForm.get('entityAlias').setValidators([Validators.required]); - this.entityKeyFormControl.setValidators([Validators.required]); - } else { - this.latestKeyFormControl.clearValidators(); - this.entityKeyFormControl.clearValidators(); - } - this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false }); - this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false }); - } + const type = this.limitForm.get('type')?.value; + const entityAliasCtr = this.limitForm.get('entityAlias'); + + const isLatestKey = type === ValueSourceType.latestKey; + const isEntity = type === ValueSourceType.entity; + + isLatestKey ? this.latestKeyFormControl.enable({ emitEvent: false }) + : this.latestKeyFormControl.disable({ emitEvent: false }); + + isEntity ? this.entityKeyFormControl.enable({ emitEvent: false }) + : this.entityKeyFormControl.disable({ emitEvent: false }); + + isEntity ? entityAliasCtr.enable({ emitEvent: false }) + : entityAliasCtr.disable({ emitEvent: false }); + + this.latestKeyFormControl.updateValueAndValidity({ emitEvent: false }); + this.entityKeyFormControl.updateValueAndValidity({ emitEvent: false }); + entityAliasCtr.updateValueAndValidity({ emitEvent: false }); } private updateModel() { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html index d0cffac423..d9a69ad111 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.html @@ -22,14 +22,16 @@
widgets.background.background
- - {{ backgroundTypeTranslationsMap.get(type) | translate }} - + @for (type of backgroundTypes; track type) { + + {{ backgroundTypeTranslationsMap.get(type) | translate }} + + }
+ formControlName="imageUrl">
widgets.color.color
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts index b1ee8ff332..323241e0f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings-panel.component.ts @@ -42,11 +42,12 @@ import { DomSanitizer } from '@angular/platform-browser'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-background-settings-panel', - templateUrl: './background-settings-panel.component.html', - providers: [], - styleUrls: ['./background-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-background-settings-panel', + templateUrl: './background-settings-panel.component.html', + providers: [], + styleUrls: ['./background-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BackgroundSettingsPanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.ts index 94737fa3b1..fd15e6da1a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/background-settings.component.ts @@ -43,17 +43,18 @@ import { DomSanitizer } from '@angular/platform-browser'; import { tap } from 'rxjs/operators'; @Component({ - selector: 'tb-background-settings', - templateUrl: './background-settings.component.html', - styleUrls: ['./background-settings.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => BackgroundSettingsComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-background-settings', + templateUrl: './background-settings.component.html', + styleUrls: ['./background-settings.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BackgroundSettingsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BackgroundSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html index a1d60da276..a9cfb92e70 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.html @@ -21,18 +21,22 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> - - {{ widgetButtonTypeTranslationMap.get(type) | translate }} - + label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> + @for (type of widgetButtonTypes; track type) { + + {{ widgetButtonTypeTranslationMap.get(type) | translate }} + + } -
- - {{ 'widgets.button.auto-scale' | translate }} - -
+ @if (withAutoScale) { +
+ + {{ 'widgets.button.auto-scale' | translate }} + +
+ }
{{ 'widgets.button.label' | translate }} @@ -63,10 +67,12 @@
-
-
{{ 'widgets.button.border-radius' | translate }}
- -
+ @if (withBorderRadius) { +
+
{{ 'widgets.button.border-radius' | translate }}
+ +
+ }
{{ 'widgets.button.color-palette' | translate }}
@@ -93,16 +99,18 @@ -
-
{{ widgetButtonStateTranslationMap.get(state) | translate }}
- - -
+ @for (state of widgetButtonStates; track state) { +
+
{{ widgetButtonStateTranslationMap.get(state) | translate }}
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts index c3bd4e3579..ccd1468b17 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-appearance.component.ts @@ -29,17 +29,18 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { widgetTitleAutocompleteValues } from '@shared/models/widget.models'; @Component({ - selector: 'tb-widget-button-appearance', - templateUrl: './widget-button-appearance.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetButtonAppearanceComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-widget-button-appearance', + templateUrl: './widget-button-appearance.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetButtonAppearanceComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetButtonAppearanceComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html index 4307c8b90b..6333f0416b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.html @@ -61,19 +61,23 @@
- + @if (copyFromStates?.length) { + + }
- + @for (state of copyFromStates; track state) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts index f4d11a9d2f..1fc3c4f20c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style-panel.component.ts @@ -46,11 +46,12 @@ import { WidgetButtonComponent } from '@shared/components/button/widget-button.c import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-widget-button-custom-style-panel', - templateUrl: './widget-button-custom-style-panel.component.html', - providers: [], - styleUrls: ['./widget-button-custom-style-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-widget-button-custom-style-panel', + templateUrl: './widget-button-custom-style-panel.component.html', + providers: [], + styleUrls: ['./widget-button-custom-style-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetButtonCustomStylePanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html index 4c4544467c..f1bd3f92d2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-custom-style.component.html @@ -27,14 +27,16 @@ [activated]="state === widgetButtonState.activated" [disabled]="state === widgetButtonState.disabled"> - + @if (modelValue) { + + }
- + @if (copyFromStates?.length) { + + }
- + @for (state of copyFromStates; track state) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts index 6ba1021b5c..6502548bf2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style-panel.component.ts @@ -48,11 +48,12 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-widget-button-toggle-custom-style-panel', - templateUrl: './widget-button-toggle-custom-style-panel.component.html', - providers: [], - styleUrls: ['./widget-button-toggle-custom-style-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-widget-button-toggle-custom-style-panel', + templateUrl: './widget-button-toggle-custom-style-panel.component.html', + providers: [], + styleUrls: ['./widget-button-toggle-custom-style-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetButtonToggleCustomStylePanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html index f750ffbd19..ba2c744211 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/button/widget-button-toggle-custom-style.component.html @@ -26,14 +26,16 @@ [hovered]="state === widgetButtonToggleState.hovered" [disabled]="state === widgetButtonToggleState.disabled"> - + @if (modelValue) { + + }
@@ -69,9 +71,11 @@
widgets.chart.animation.update-animation-easing
- - {{ easing }} - + @for (easing of chartAnimationEasings; track easing) { + + {{ easing }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-animation-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-animation-settings.component.ts index d5cab573ed..61374113bf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-animation-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-animation-settings.component.ts @@ -26,16 +26,17 @@ import { chartAnimationEasings, ChartAnimationSettings } from '@home/components/ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-chart-animation-settings', - templateUrl: './chart-animation-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ChartAnimationSettingsComponent), - multi: true - } - ] + selector: 'tb-chart-animation-settings', + templateUrl: './chart-animation-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ChartAnimationSettingsComponent), + multi: true + } + ], + standalone: false }) export class ChartAnimationSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-bar-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-bar-settings.component.html index dbfaa2a8fc..6ccc5d6834 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-bar-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-bar-settings.component.html @@ -33,13 +33,15 @@
-
-
widgets.chart.bar.bar-width
- - -
%
-
-
+ @if (!series) { +
+
widgets.chart.bar.bar-width
+ + +
%
+
+
+ }
@@ -49,9 +51,11 @@
- - {{ chartLabelPositionTranslations.get(position) | translate }} - + @for (position of chartLabelPositions; track position) { + + {{ chartLabelPositionTranslations.get(position) | translate }} + + } ChartBarSettingsComponent), - multi: true - } - ] + selector: 'tb-chart-bar-settings', + templateUrl: './chart-bar-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ChartBarSettingsComponent), + multi: true + } + ], + standalone: false }) export class ChartBarSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html index 1226eda814..95ced2bb6c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.html @@ -20,10 +20,12 @@
{{ titleText | translate }}
- {{ chartFillTypeTranslationMap.get(type) | translate }} + @for (type of chartFillTypes; track type) { + {{ chartFillTypeTranslationMap.get(type) | translate }} + }
- + @if (fillSettingsFormGroup.get('type').value === ChartFillType.opacity) {
widgets.chart.opacity
@@ -31,23 +33,25 @@ step="0.1" placeholder="{{ 'widget-config.set' | translate }}"/>
-
- -
-
widgets.chart.gradient-stops
-
-
widgets.chart.gradient-start
- - - -
widgets.chart.gradient-end
- - - + } + @if (fillSettingsFormGroup.get('type').value === ChartFillType.gradient) { + +
+
widgets.chart.gradient-stops
+
+
widgets.chart.gradient-start
+ + + +
widgets.chart.gradient-end
+ + + +
-
- + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts index e85c8bb226..f980264f4a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/chart-fill-settings.component.ts @@ -33,16 +33,17 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-chart-fill-settings', - templateUrl: './chart-fill-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ChartFillSettingsComponent), - multi: true - } - ] + selector: 'tb-chart-fill-settings', + templateUrl: './chart-fill-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ChartFillSettingsComponent), + multi: true + } + ], + standalone: false }) export class ChartFillSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-button.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-button.component.ts index de1ef16a43..5aad0ab4f1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-button.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-button.component.ts @@ -25,16 +25,17 @@ import { } from '@home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component'; @Component({ - selector: 'tb-time-series-chart-axis-settings-button', - templateUrl: './time-series-chart-axis-settings-button.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartAxisSettingsButtonComponent), - multi: true - } - ] + selector: 'tb-time-series-chart-axis-settings-button', + templateUrl: './time-series-chart-axis-settings-button.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartAxisSettingsButtonComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesChartAxisSettingsButtonComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts index bb800696f1..7c55510579 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings-panel.component.ts @@ -27,11 +27,12 @@ import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/k import { Datasource } from '@shared/models/widget.models'; @Component({ - selector: 'tb-time-series-chart-axis-settings-panel', - templateUrl: './time-series-chart-axis-settings-panel.component.html', - providers: [], - styleUrls: ['./time-series-chart-axis-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-axis-settings-panel', + templateUrl: './time-series-chart-axis-settings-panel.component.html', + providers: [], + styleUrls: ['./time-series-chart-axis-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartAxisSettingsPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html index aa0d5b1204..64ea74bea8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.html @@ -49,44 +49,54 @@
widgets.time-series-chart.axis.position
- - {{ timeSeriesAxisPositionTranslations.get(position) | translate }} - + @for (position of axisPositions; track position) { + + {{ timeSeriesAxisPositionTranslations.get(position) | translate }} + + }
-
-
widget-config.units-short
- - -
-
-
widget-config.decimals-short
- - - -
-
- - -
+ @if (axisType === 'yAxis' && !hideUnits) { +
+
widget-config.units-short
+ + +
+ } + @if (axisType === 'yAxis' && !hideDecimals) { +
+
widget-config.decimals-short
+ + + +
+ } + @if (axisType === 'yAxis' && advanced) { +
+ + +
+ }
widgets.time-series-chart.axis.tick-labels
- - + @if (axisType === 'xAxis') { + + + }
-
- - -
+ @if (axisType === 'yAxis' && advanced) { +
+ + +
+ }
@@ -139,55 +151,61 @@ formControlName="splitLinesColor">
-
-
- {{ 'widgets.time-series-chart.axis.ticks-interval' | translate }} + @if (axisType === 'yAxis') { +
+
+ {{ 'widgets.time-series-chart.axis.ticks-interval' | translate }} +
+ + +
- - - -
-
-
- {{ 'widgets.time-series-chart.axis.split-number' | translate }} + } + @if (axisType === 'yAxis') { +
+
+ {{ 'widgets.time-series-chart.axis.split-number' | translate }} +
+ + +
- - - -
-
-
-
widgets.chart.chart-axis.scale-limits
-
-
-
widgets.chart.chart-axis.limit
-
widgets.chart.chart-axis.source
-
widgets.chart.chart-axis.key-value
-
-
- - - - - + } + @if (axisType === 'yAxis') { +
+
+
widgets.chart.chart-axis.scale-limits
+
+
+
widgets.chart.chart-axis.limit
+
widgets.chart.chart-axis.source
+
widgets.chart.chart-axis.key-value
+
+
+ + + + + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts index f31775f22f..2f23666574 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-axis-settings.component.ts @@ -25,7 +25,7 @@ import { Validators } from '@angular/forms'; import { - AxisPosition, defaultXAxisTicksFormat, + AxisPosition, defaultXAxisTicksFormat, normalizeAxisLimit, timeSeriesAxisPositionTranslations, TimeSeriesChartAxisSettings, TimeSeriesChartXAxisSettings, TimeSeriesChartYAxisSettings @@ -39,21 +39,22 @@ import { Datasource } from '@app/shared/public-api'; import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; @Component({ - selector: 'tb-time-series-chart-axis-settings', - templateUrl: './time-series-chart-axis-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent), - multi: true - } - ] + selector: 'tb-time-series-chart-axis-settings', + templateUrl: './time-series-chart-axis-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartAxisSettingsComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValueAccessor, Validator { @@ -137,8 +138,8 @@ export class TimeSeriesChartAxisSettingsComponent implements OnInit, ControlValu this.axisSettingsFormGroup.addControl('ticksGenerator', this.fb.control(null, [])); this.axisSettingsFormGroup.addControl('interval', this.fb.control(null, [Validators.min(0)])); this.axisSettingsFormGroup.addControl('splitNumber', this.fb.control(null, [Validators.min(1)])); - this.axisSettingsFormGroup.addControl('min', this.fb.control(null, [])); - this.axisSettingsFormGroup.addControl('max', this.fb.control(null, [])); + this.axisSettingsFormGroup.addControl('min', this.fb.control(normalizeAxisLimit(null), [])); + this.axisSettingsFormGroup.addControl('max', this.fb.control(normalizeAxisLimit(null), [])); } else if (this.axisType === 'xAxis') { this.axisSettingsFormGroup.addControl('ticksFormat', this.fb.control(null, [])); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-grid-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-grid-settings.component.ts index 1ad240f8b0..7dd1354cd0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-grid-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-grid-settings.component.ts @@ -27,16 +27,17 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-time-series-chart-grid-settings', - templateUrl: './time-series-chart-grid-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartGridSettingsComponent), - multi: true - } - ] + selector: 'tb-time-series-chart-grid-settings', + templateUrl: './time-series-chart-grid-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartGridSettingsComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesChartGridSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html index bde8d623d4..8027f4d689 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-state-row.component.html @@ -24,31 +24,36 @@ - - {{ timeSeriesStateSourceTypeTranslations.get(type) | translate }} - + @for (type of timeSeriesStateSourceTypes; track type) { + + {{ timeSeriesStateSourceTypeTranslations.get(type) | translate }} + + }
- - - + } + @if (stateFormGroup.get('sourceType').value === TimeSeriesChartStateSourceType.range) { + - - - + + } + @if (stateFormGroup.get('sourceType').value === TimeSeriesChartStateSourceType.range) { + - - + + + }
-
-
- - - + @if (statesFormArray().controls.length) { +
+ @for (stateControl of statesFormArray().controls; track trackByState($index, stateControl)) { +
+ + + @if (!$last) { + + } +
+ }
-
+ } @else { + {{ 'widgets.time-series-chart.state.no-states' | translate }} + }
- - {{ 'widgets.time-series-chart.state.no-states' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts index 1de5e8019f..aaa9d6d9f0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-states-panel.component.ts @@ -35,22 +35,23 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-time-series-chart-states-panel', - templateUrl: './time-series-chart-states-panel.component.html', - styleUrls: ['./time-series-chart-states-panel.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-states-panel', + templateUrl: './time-series-chart-states-panel.component.html', + styleUrls: ['./time-series-chart-states-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartStatesPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartStatesPanelComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html index b603a8ddaa..30ec805710 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-row.component.html @@ -19,69 +19,81 @@
- - {{ timeSeriesThresholdTypeTranslations.get(type) | translate }} - + @for (type of timeSeriesThresholdTypes; track type) { + + {{ timeSeriesThresholdTypeTranslations.get(type) | translate }} + + } - - + @if (thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.entity) { + + + }
- - - - warning - - - - - - -
-
- - - - {{ yAxis }} - - - + @if (thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.constant) { + + + @if (thresholdFormGroup.get('value').hasError('required') + && thresholdFormGroup.get('value').touched) { + + warning + + } + + } + @if (thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.latestKey) { + + + } + @if (thresholdFormGroup.get('type').value === TimeSeriesChartThresholdType.entity) { + + + }
+ @if (!hideYAxis) { +
+ + + @for (yAxis of yAxisIds; track yAxis) { + + {{ yAxis }} + + } + + +
+ }
+ [widgetConfig]="widgetConfig" + [yAxisIds]="yAxisIds" + [hideYAxis]="hideYAxis" + icon="settings" + [formControl]="thresholdSettingsFormControl"> - +@if (boxButton) { + +} @else { - +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component.ts index 1757b14b77..1df07ffceb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings.component.ts @@ -16,7 +16,7 @@ import { Component, forwardRef, Input, OnInit, Renderer2, ViewContainerRef } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { deepClone } from '@core/utils'; import { coerceBoolean } from '@shared/decorators/coercion'; @@ -30,16 +30,17 @@ import { } from '@home/components/widget/lib/settings/common/chart/time-series-chart-threshold-settings-panel.component'; @Component({ - selector: 'tb-time-series-chart-threshold-settings', - templateUrl: './time-series-chart-threshold-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartThresholdSettingsComponent), - multi: true - } - ] + selector: 'tb-time-series-chart-threshold-settings', + templateUrl: './time-series-chart-threshold-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartThresholdSettingsComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesChartThresholdSettingsComponent implements OnInit, ControlValueAccessor { @@ -92,7 +93,7 @@ export class TimeSeriesChartThresholdSettingsComponent implements OnInit, Contro this.modelValue = value; } - openThresholdSettingsPopup($event: Event, matButton: MatButton) { + openThresholdSettingsPopup($event: Event, matButton: MatIconButton) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html index 448a31fec0..c2dd3ffab4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.html @@ -21,24 +21,34 @@
widgets.time-series-chart.threshold.source
widgets.time-series-chart.threshold.key-value
-
widgets.time-series-chart.axis.y-axis
+ @if (!hideYAxis) { +
widgets.time-series-chart.axis.y-axis
+ }
widgets.color.color
widget-config.units-short
widget-config.decimals-short
-
-
- - - + @if (thresholdsFormArray().controls.length) { +
+ @for (thresholdControl of thresholdsFormArray().controls; track trackByThreshold($index, thresholdControl)) { +
+ + + @if (!$last) { + + } +
+ }
-
+ } @else { + {{ 'widgets.time-series-chart.threshold.no-thresholds' | translate }} + }
- - {{ 'widgets.time-series-chart.threshold.no-thresholds' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts index f7b9e157e1..944c28b4d8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-thresholds-panel.component.ts @@ -43,22 +43,23 @@ import { ValueSourceType } from '@shared/models/widget-settings.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-time-series-chart-thresholds-panel', - templateUrl: './time-series-chart-thresholds-panel.component.html', - styleUrls: ['./time-series-chart-thresholds-panel.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-thresholds-panel', + templateUrl: './time-series-chart-thresholds-panel.component.html', + styleUrls: ['./time-series-chart-thresholds-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartThresholdsPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartThresholdsPanelComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html index 0272dd60d2..86c54762c4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.html @@ -33,27 +33,29 @@ cdkDropList cdkDropListOrientation="vertical" [cdkDropListDisabled]="!dragEnabled" (cdkDropListDropped)="axisDrop($event)"> -
- - -
- + @for (axisControl of axesFormArray().controls; track trackByAxis($index, axisControl)) { +
+ + +
+ +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts index 1d4b93c712..662214a1df 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axes-panel.component.ts @@ -36,13 +36,15 @@ import { Validator } from '@angular/forms'; import { + checkLatestDataKeys, defaultTimeSeriesChartYAxisSettings, getNextTimeSeriesYAxisId, TimeSeriesChartYAxes, TimeSeriesChartYAxisId, TimeSeriesChartYAxisSettings, timeSeriesChartYAxisValid, - timeSeriesChartYAxisValidator + timeSeriesChartYAxisValidator, + updateLatestDataKeys } from '@home/components/widget/lib/chart/time-series-chart.models'; import { mergeDeep } from '@core/utils'; import { CdkDragDrop } from '@angular/cdk/drag-drop'; @@ -50,25 +52,26 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { IAliasController } from '@app/core/public-api'; import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; -import { DataKey, DataKeyType, Datasource, ValueSourceConfig, ValueSourceType } from '@app/shared/public-api'; +import { Datasource } from '@app/shared/public-api'; @Component({ - selector: 'tb-time-series-chart-y-axes-panel', - templateUrl: './time-series-chart-y-axes-panel.component.html', - styleUrls: ['./time-series-chart-y-axes-panel.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-y-axes-panel', + templateUrl: './time-series-chart-y-axes-panel.component.html', + styleUrls: ['./time-series-chart-y-axes-panel.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeSeriesChartYAxesPanelComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, OnInit, Validator { @@ -126,7 +129,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, for (const axis of axes) { yAxes[axis.id] = axis; } - this.updateLatestDataKeys(Object.values(yAxes)); + updateLatestDataKeys(Object.values(yAxes), this.datasource, this.dataKeyCallbacks); this.propagateChange(yAxes); } ); @@ -149,7 +152,7 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, } writeValue(value: TimeSeriesChartYAxes | undefined): void { - const yAxes: TimeSeriesChartYAxes = this.checkLatestDataKeys(value || {}); + const yAxes: TimeSeriesChartYAxes = checkLatestDataKeys(value || {}, this.datasource); if (!yAxes.default) { yAxes.default = mergeDeep({} as TimeSeriesChartYAxisSettings, defaultTimeSeriesChartYAxisSettings, {id: 'default', order: 0} as TimeSeriesChartYAxisSettings); @@ -196,8 +199,6 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, const axes: TimeSeriesChartYAxisSettings[] = this.yAxesFormGroup.get('axes').value; axis.id = getNextTimeSeriesYAxisId(axes); axis.order = axes.length; - axis.min = this.normalizeAxisLimit(axis.min); - axis.max = this.normalizeAxisLimit(axis.max); const axesArray = this.yAxesFormGroup.get('axes') as UntypedFormArray; const axisControl = this.fb.control(axis, [timeSeriesChartYAxisValidator]); axesArray.push(axisControl); @@ -211,100 +212,4 @@ export class TimeSeriesChartYAxesPanelComponent implements ControlValueAccessor, return this.fb.array(axesControls); } - private checkLatestDataKeys(yAxes: TimeSeriesChartYAxes): TimeSeriesChartYAxes { - const latestKeys = this.datasource?.latestDataKeys || []; - const result: TimeSeriesChartYAxes = {}; - - for (const [id, axis] of Object.entries(yAxes)) { - axis.min = this.normalizeAxisLimit(axis.min); - axis.max = this.normalizeAxisLimit(axis.max); - const minCfg = axis.min; - const maxCfg = axis.max; - - const minValid = !!minCfg && ( - minCfg.type !== ValueSourceType.latestKey || - latestKeys.some(k => this.isYAxisKey(k, minCfg)) - ); - - const maxValid = !!maxCfg && ( - maxCfg.type !== ValueSourceType.latestKey || - latestKeys.some(k => this.isYAxisKey(k, maxCfg)) - ); - - if (minValid && maxValid) { - result[id] = axis; - } - } - - return result; - } - - private updateLatestDataKeys(yAxes: TimeSeriesChartYAxisSettings[]) { - if (this.datasource) { - let latestKeys = this.datasource.latestDataKeys; - if (!latestKeys) { - latestKeys = []; - this.datasource.latestDataKeys = latestKeys; - } - const existingYAxisKeys = latestKeys.filter(k => k.settings?.__yAxisMinKey === true || k.settings?.__yAxisMaxKey === true); - const foundYAxisKeys: DataKey[] = []; - - for(const yAxis of yAxes) { - const min = yAxis.min as ValueSourceConfig; - const max = yAxis.max as ValueSourceConfig; - if (min.type === ValueSourceType.latestKey) { - const found = existingYAxisKeys.find(k => this.isYAxisKey(k, min)); - if (!found) { - const newKey = this.dataKeyCallbacks.generateDataKey(min.latestKey, min.latestKeyType, - null, true, null); - newKey.settings.__yAxisMinKey = true; - latestKeys.push(newKey); - } else if (foundYAxisKeys.indexOf(found) === -1) { - foundYAxisKeys.push(found); - } - } - if (max.type === ValueSourceType.latestKey) { - const found = existingYAxisKeys.find(k => this.isYAxisKey(k, max)); - if (!found) { - const newKey = this.dataKeyCallbacks.generateDataKey(max.latestKey, max.latestKeyType, - null, true, null); - newKey.settings.__yAxisMaxKey = true; - latestKeys.push(newKey); - } else if (foundYAxisKeys.indexOf(found) === -1) { - foundYAxisKeys.push(found); - } - } - } - const toRemove = existingYAxisKeys.filter(k => foundYAxisKeys.indexOf(k) === -1); - for (const key of toRemove) { - const index = latestKeys.indexOf(key); - if (index > -1) { - latestKeys.splice(index, 1); - } - } - } - } - - private isYAxisKey(d: DataKey, limit: ValueSourceConfig): boolean { - return (d.type === DataKeyType.function && d.label === limit.latestKey) || - (d.type !== DataKeyType.function && d.name === limit.latestKey && - d.type === limit.latestKeyType); - } - - private normalizeAxisLimit(limit: string | number | ValueSourceConfig): ValueSourceConfig { - if (!limit) { - return { - type: ValueSourceType.constant, - value: null, - entityAlias: null - }; - } else if (typeof limit === 'number' || typeof limit === 'string') { - return { - type: ValueSourceType.constant, - value: Number(limit), - entityAlias: null - }; - } - return limit; - } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html index 6d6f492619..ceb34f83c7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.html @@ -25,9 +25,11 @@ - - {{ timeSeriesAxisPositionTranslations.get(position) | translate }} - + @for (position of axisPositions; track position) { + + {{ timeSeriesAxisPositionTranslations.get(position) | translate }} + + }
@@ -59,14 +61,16 @@ settings
- + @if (modelValue.id !== 'default') { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts index 54d54d1a98..93e91124d5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-chart-y-axis-row.component.ts @@ -29,11 +29,11 @@ import { } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, UntypedFormBuilder, UntypedFormGroup } from '@angular/forms'; import { - AxisPosition, + AxisPosition, normalizeAxisLimit, timeSeriesAxisPositionTranslations, TimeSeriesChartYAxisSettings } from '@home/components/widget/lib/chart/time-series-chart.models'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { coerceBoolean } from '@shared/decorators/coercion'; import { @@ -46,17 +46,18 @@ import { TimeSeriesChartYAxesPanelComponent } from '@home/components/widget/lib/ import { ValueSourceType } from '@shared/models/widget-settings.models'; @Component({ - selector: 'tb-time-series-chart-y-axis-row', - templateUrl: './time-series-chart-y-axis-row.component.html', - styleUrls: ['./time-series-chart-y-axis-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesChartYAxisRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-time-series-chart-y-axis-row', + templateUrl: './time-series-chart-y-axis-row.component.html', + styleUrls: ['./time-series-chart-y-axis-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesChartYAxisRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, OnInit { @@ -135,8 +136,8 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O writeValue(value: TimeSeriesChartYAxisSettings): void { this.modelValue = value; - const min = this.normalizeLimit(value.min); - const max = this.normalizeLimit(value.max); + const min = normalizeAxisLimit(value.min); + const max = normalizeAxisLimit(value.max); this.axisFormGroup.patchValue({ label: value.label, @@ -152,7 +153,7 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O this.cd.markForCheck(); } - editAxis($event: Event, matButton: MatButton) { + editAxis($event: Event, matButton: MatIconButton) { if ($event) { $event.stopPropagation(); } @@ -251,27 +252,4 @@ export class TimeSeriesChartYAxisRowComponent implements ControlValueAccessor, O entityKeyType: [null, []] }); } - - private normalizeLimit(limit: any) { - const base = { - type: ValueSourceType.constant, - value: null, - latestKey: null, - latestKeyType: null, - entityAlias: null, - entityKey: null, - entityKeyType: null - }; - - if (limit == null) return base; - - if (typeof limit === 'number' || typeof limit === 'string') { - return { ...base, type: ValueSourceType.constant, value: Number(limit) }; - } - - return { - ...base, - ...limit, - }; - } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html index eb3f84eb0e..58e1a108f0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.html @@ -19,12 +19,14 @@
widgets.time-series-chart.no-aggregation-bar-width-strategy
- - {{ timeSeriesChartNoAggregationBarWidthStrategyTranslations.get(strategy) | translate }} - + @for (strategy of timeSeriesChartNoAggregationBarWidthStrategies; track strategy) { + + {{ timeSeriesChartNoAggregationBarWidthStrategyTranslations.get(strategy) | translate }} + + }
- + @if (barWidthSettingsFormGroup.get('strategy').value === TimeSeriesChartNoAggregationBarWidthStrategy.group) { - - + } + @if (barWidthSettingsFormGroup.get('strategy').value === TimeSeriesChartNoAggregationBarWidthStrategy.separate) { - + }
@@ -51,8 +53,8 @@ {{ (formGroup.get('relative').value ? - 'widgets.time-series-chart.bar-width-relative' : - 'widgets.time-series-chart.bar-width-absolute') | translate }} + 'widgets.time-series-chart.bar-width-relative' : + 'widgets.time-series-chart.bar-width-absolute') | translate }} {{ 'widgets.time-series-chart.bar-width-relative' | translate }} @@ -62,14 +64,18 @@ - - - % - - - - ms - + @if (formGroup.get('relative').value) { + + + % + + } + @if (!formGroup.get('relative').value) { + + + ms + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts index 320b239c22..1e078d995f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/chart/time-series-no-aggregation-bar-width-settings.component.ts @@ -33,16 +33,17 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-time-series-no-aggregation-bar-width-settings', - templateUrl: './time-series-no-aggregation-bar-width-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeSeriesNoAggregationBarWidthSettingsComponent), - multi: true - } - ] + selector: 'tb-time-series-no-aggregation-bar-width-settings', + templateUrl: './time-series-no-aggregation-bar-width-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeSeriesNoAggregationBarWidthSettingsComponent), + multi: true + } + ], + standalone: false }) export class TimeSeriesNoAggregationBarWidthSettingsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html index 83aee39785..d92497ed52 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-list.component.html @@ -16,94 +16,96 @@ --> -
-
widgets.color.settings-type
- - - {{ 'widgets.color.basic-mode' | translate }} - - - {{ 'widgets.color.advanced-mode' | translate}} - - -
- - + @if (advancedMode) {
-
{{ panelTitle }}
+
widgets.color.settings-type
+ + + {{ 'widgets.color.basic-mode' | translate }} + + + {{ 'widgets.color.advanced-mode' | translate}} + +
-
- -
- -
-
-
-
-
widgets.color.from
- - - + } @else { +
+ @if (panelTitle) { +
{{ panelTitle }}
+ } +
+ } -
widgets.color.to
- - - - - +
+ @switch (colorRangeListFormGroup.get('advancedMode').value) { + @case (false) { +
+ @for (rangeFormGroup of rangeListFormGroups; track trackByRange($index, rangeFormGroup); let isLast = $last) { +
+
+
+
widgets.color.from
+ + + +
widgets.color.to
+ + + + + +
+ +
+
- -
- + }
-
- - - -
-
- - - + } + @case (true) { +
+ @for (advancedRangeControl of advancedRangeControls; track trackByAdvancedRange($index, advancedRangeControl); let isLast = $last) { +
+ + + +
+ }
-
- - - + } + }
- - - -
- -
-
-
- - - + } + + +
+ @for (comp of settingsComponents; track comp) { + + } +
+
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts index 68485e8572..b6f1c02bfb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-panel.component.ts @@ -27,11 +27,12 @@ import { } from '@home/components/widget/lib/settings/common/color-range-settings.component'; @Component({ - selector: 'tb-color-range-panel', - templateUrl: './color-range-panel.component.html', - providers: [], - styleUrls: ['./color-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-color-range-panel', + templateUrl: './color-range-panel.component.html', + providers: [], + styleUrls: ['./color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ColorRangePanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts index 85e248e811..0c0022b252 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-range-settings.component.ts @@ -58,16 +58,17 @@ export class ColorRangeSettingsComponentService { } @Component({ - selector: 'tb-color-range-settings', - templateUrl: './color-range-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ColorRangeSettingsComponent), - multi: true - } - ] + selector: 'tb-color-range-settings', + templateUrl: './color-range-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorRangeSettingsComponent), + multi: true + } + ], + standalone: false }) export class ColorRangeSettingsComponent implements OnInit, ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html index f48319af75..2c69cf1fe6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.html @@ -19,10 +19,12 @@
widgets.color.color-settings
- - {{ colorTypeTranslationsMap.get(type) | translate }} - + @for (type of colorTypes; track type) { + + {{ colorTypeTranslationsMap.get(type) | translate }} + + }
@@ -60,19 +62,23 @@
- + @if (settingsComponents?.length) { + + }
- + @for (comp of settingsComponents; track comp) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts index 7b34cc5967..0c0e504d74 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings-panel.component.ts @@ -34,13 +34,15 @@ import { IAliasController } from '@core/api/widget-api.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/key/data-keys.component.models'; import { Datasource } from '@shared/models/widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-color-settings-panel', - templateUrl: './color-settings-panel.component.html', - providers: [], - styleUrls: ['./color-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-color-settings-panel', + templateUrl: './color-settings-panel.component.html', + providers: [], + styleUrls: ['./color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ColorSettingsPanelComponent extends PageComponent implements OnInit { @@ -106,6 +108,12 @@ export class ColorSettingsPanelComponent extends PageComponent implements OnInit colorFunction: [this.colorSettings?.colorFunction, []] } ); + this.colorSettingsFormGroup.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateValidators(); + setTimeout(() => {this.popover?.updatePosition();}, 0); + }); this.updateValidators(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.html index 6306af7c15..101b022685 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.html @@ -21,10 +21,11 @@ [disabled]="disabled" #matButton (click)="openColorSettingsPopup($event, matButton)"> - mdi:function-variant + @if (modelValue?.type === colorType.function) { + mdi:function-variant + } @else { +
+
+
+ } - -
-
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts index 811a0a4f60..e4744b8918 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/color-settings.component.ts @@ -65,16 +65,17 @@ export class ColorSettingsComponentService { } @Component({ - selector: 'tb-color-settings', - templateUrl: './color-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ColorSettingsComponent), - multi: true - } - ] + selector: 'tb-color-settings', + templateUrl: './color-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorSettingsComponent), + multi: true + } + ], + standalone: false }) export class ColorSettingsComponent implements OnInit, ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html index 08a0246217..993bea21a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.html @@ -21,12 +21,14 @@ breakpoints: { 'lt-md': 1 }}" - label="{{ 'widgets.count.layout' | translate }}" formControlName="layout"> - - {{ countCardLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.count.layout' | translate }}" formControlName="layout"> + @for (layout of countCardLayouts; track layout) { + + {{ countCardLayoutTranslationMap.get(layout) | translate }} + + }
@@ -42,16 +44,16 @@ - }@else { + }@else { } + formControlName="icon"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.ts index dfa39e58c5..b78da31004 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/count-widget-settings.component.ts @@ -41,16 +41,17 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-count-widget-settings', - templateUrl: './count-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CountWidgetSettingsComponent), - multi: true - } - ] + selector: 'tb-count-widget-settings', + templateUrl: './count-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CountWidgetSettingsComponent), + multi: true + } + ], + standalone: false }) export class CountWidgetSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.html index 1881fa7336..008f63544d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.html @@ -19,16 +19,17 @@ - - warning - + class="tb-error"> + warning + + } + [width]="flex ? '100%' : ''" formControlName="unit">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts index 41361112f5..5dc81ff163 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-size-input.component.ts @@ -31,21 +31,22 @@ import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-css-size-input', - templateUrl: './css-size-input.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CssSizeInputComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CssSizeInputComponent), - multi: true, - } - ] + selector: 'tb-css-size-input', + templateUrl: './css-size-input.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CssSizeInputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CssSizeInputComponent), + multi: true, + } + ], + standalone: false }) export class CssSizeInputComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html index 3ca5657b2a..61d1d54684 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.html @@ -17,8 +17,12 @@ --> - - - {{ cssUnit }} + @if (allowEmpty) { + + + } + @for (cssUnit of cssUnitsList; track cssUnit) { + {{ cssUnit }} + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts index 1d5ab7f12e..861bf64044 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/css-unit-select.component.ts @@ -21,16 +21,17 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-css-unit-select', - templateUrl: './css-unit-select.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CssUnitSelectComponent), - multi: true - } - ] + selector: 'tb-css-unit-select', + templateUrl: './css-unit-select.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CssUnitSelectComponent), + multi: true + } + ], + standalone: false }) export class CssUnitSelectComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html index 710d44774c..fbdb34748a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.html @@ -17,15 +17,20 @@ --> - {{ dateFormatDisplayValue(dateFormat) }} + @for (dateFormat of dateFormatList; track dateFormat) { + {{ dateFormatDisplayValue(dateFormat) }} + } - - + @if (dateFormatFormControl.value?.custom) { + + } + @if (dateFormatFormControl.value?.auto && !dateFormatFormControl.value?.custom) { + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts index 9545e6cb37..a710fd27d3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-select.component.ts @@ -35,7 +35,7 @@ import { } from '@shared/models/widget-settings.models'; import { TranslateService } from '@ngx-translate/core'; import { DatePipe } from '@angular/common'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { deepClone, mergeDeep } from '@core/utils'; import { @@ -48,21 +48,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-date-format-select', - templateUrl: './date-format-select.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DateFormatSelectComponent), - multi: true - } - ] + selector: 'tb-date-format-select', + templateUrl: './date-format-select.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateFormatSelectComponent), + multi: true + } + ], + standalone: false }) export class DateFormatSelectComponent implements OnInit, ControlValueAccessor { @ViewChild('customFormatButton', {static: false}) - customFormatButton: MatButton; + customFormatButton: MatIconButton; @Input() disabled: boolean; @@ -154,7 +155,7 @@ export class DateFormatSelectComponent implements OnInit, ControlValueAccessor { } } - openDateFormatSettingsPopup($event: Event, matButton: MatButton) { + openDateFormatSettingsPopup($event: Event, matButton: MatIconButton) { if ($event) { $event.stopPropagation(); } @@ -181,7 +182,7 @@ export class DateFormatSelectComponent implements OnInit, ControlValueAccessor { } } - openAutoFormatSettingsPopup($event: Event, matButton: MatButton) { + openAutoFormatSettingsPopup($event: Event, matButton: MatIconButton) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.ts index 45ffdcc580..b66d49c711 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/date-format-settings-panel.component.ts @@ -25,11 +25,12 @@ import { DatePipe } from '@angular/common'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-date-format-settings-panel', - templateUrl: './date-format-settings-panel.component.html', - providers: [], - styleUrls: ['./date-format-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-date-format-settings-panel', + templateUrl: './date-format-settings-panel.component.html', + providers: [], + styleUrls: ['./date-format-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DateFormatSettingsPanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.html index c7ece2becf..84aa13bccb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.html @@ -23,52 +23,59 @@ -
-
-
-
{{$index + 1}}
-
- - -
- - -
+ @if (propertiesFormArray().controls.length) { +
+ @for (propertyControl of propertiesFormArray().controls; track trackByProperty($index, propertyControl)) { +
+
+
{{$index + 1}}
+
+ + +
+ @if (!disabled) { + + } + @if (!disabled) { + + } +
+
+ }
-
-
- -
+ } @else { + {{ 'dynamic-form.property.no-items' | translate }} + } + @if (!disabled || !propertiesFormArray().controls.length) { +
+ +
+ }
- - {{ 'dynamic-form.property.no-items' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.ts index f5b71fb52c..f0b085dbae 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-array.component.ts @@ -31,22 +31,23 @@ import { defaultFormPropertyValue, FormProperty } from '@shared/models/dynamic-f import { CdkDragDrop } from '@angular/cdk/drag-drop'; @Component({ - selector: 'tb-dynamic-form-array', - templateUrl: './dynamic-form-array.component.html', - styleUrls: ['./dynamic-form-array.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormArrayComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormArrayComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-array', + templateUrl: './dynamic-form-array.component.html', + styleUrls: ['./dynamic-form-array.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormArrayComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormArrayComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormArrayComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.html index 885fa20ee4..062f300d6b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.html @@ -22,72 +22,83 @@
dynamic-form.property.name
dynamic-form.property.type
- - - -
-
-
-
- - -
+ @if (!disabled && importExport) { + + } + @if (!disabled && importExport) { -
+ } + @if (!disabled) { + + }
- -
-
- + @if (propertiesFormArray().controls.length) { +
+ @for (propertyControl of propertiesFormArray().controls; track trackByProperty($index, propertyControl)) { +
+ + +
+ @if (!disabled) { + + } +
+
+ } +
+ } @else { + {{ 'dynamic-form.property.no-properties' | translate }} + } + @if (errorText) { + + }
+ @if (!disabled) { +
+ +
+ }
- - {{ 'dynamic-form.property.no-properties' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.ts index bddbda4401..d994a65901 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-properties.component.ts @@ -53,22 +53,23 @@ import { DialogService } from '@core/services/dialog.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-dynamic-form-properties', - templateUrl: './dynamic-form-properties.component.html', - styleUrls: ['./dynamic-form-properties.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormPropertiesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormPropertiesComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-properties', + templateUrl: './dynamic-form-properties.component.html', + styleUrls: ['./dynamic-form-properties.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormPropertiesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormPropertiesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormPropertiesComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html index df90a75dba..9a26856883 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.html @@ -46,42 +46,50 @@
dynamic-form.property.type
- - {{ formPropertyTypeTranslations.get(type) | translate }} - + @for (type of formPropertyTypes; track type) { + + {{ formPropertyTypeTranslations.get(type) | translate }} + + }
-
-
dynamic-form.property.array-item
-
-
dynamic-form.property.item-type
- - - - {{ formPropertyTypeTranslations.get(type) | translate }} - - - -
-
-
dynamic-form.property.item-name
- - - + @if (isArray) { +
+
dynamic-form.property.array-item
+
+
dynamic-form.property.item-type
+ + + @for (type of arrayItemFormPropertyTypes; track type) { + + {{ formPropertyTypeTranslations.get(type) | translate }} + + } + + +
+
+
dynamic-form.property.item-name
+ + + +
+ +
+ } + @if (!isArray) { -
- - - - -
- - {{ 'dynamic-form.property.value-required' | translate }} - -
+ } + @if (![FormPropertyType.fieldset, FormPropertyType.htmlSection, FormPropertyType.array].includes(propertyFormGroup.get('type').value)) { +
+ + {{ 'dynamic-form.property.value-required' | translate }} + +
+ }
@@ -90,7 +98,7 @@ - + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value)) {
dynamic-form.property.sub-label
-
-
-
dynamic-form.property.input-field-suffix
- - - -
-
-
dynamic-form.property.disable-on-property
- - - {{ 'dynamic-form.property.disable-on-property-none' | translate }} - - {{ prop }} - - - -
+ } + @if ([FormPropertyType.number, FormPropertyType.text, FormPropertyType.password].includes(propertyItemType)) { +
+
dynamic-form.property.input-field-suffix
+ + + +
+ } + @if (propertyFormGroup.get('type').value !== FormPropertyType.htmlSection) { +
+
dynamic-form.property.disable-on-property
+ + + {{ 'dynamic-form.property.disable-on-property-none' | translate }} + @for (prop of booleanPropertyIds; track prop) { + + {{ prop }} + + } + + +
+ }
- - - - + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value)) { + + + } + @if (isPropertyTypeAllowedForRow(propertyFormGroup.get('type').value) && FormPropertyType.switch !== propertyFormGroup.get('type').value) { + + + }
@@ -164,154 +182,174 @@ (click)="cancel()"> {{ (disabled ? 'action.close' : 'action.cancel') | translate }} - + @if (!disabled) { + + }
-
-
dynamic-form.property.textarea-rows
- - - -
-
-
dynamic-form.property.number-settings
-
-
dynamic-form.property.min
- - - - -
dynamic-form.property.max
- - - - -
dynamic-form.property.step
- - +
dynamic-form.property.textarea-rows
+ +
-
-
- - - - {{ 'dynamic-form.property.properties' | translate }} - - - - - - - -
-
- - - - {{ 'dynamic-form.property.html-section-settings' | translate }} - - - -
- - -
- - -
-
-
- + } + @if (propertyItemType === FormPropertyType.number) { +
+
dynamic-form.property.number-settings
+
+
dynamic-form.property.min
+ + + + +
dynamic-form.property.max
+ + + + +
dynamic-form.property.step
+ + + +
+
+ } + @if (propertyItemType === FormPropertyType.fieldset) {
- {{ (propertyFormGroup.get('type').value === FormPropertyType.select ? 'dynamic-form.property.select-options' : 'dynamic-form.property.radio-button-options') | translate }} + {{ 'dynamic-form.property.properties' | translate }} -
-
dynamic-form.property.buttons-direction
- - {{ 'dynamic-form.property.direction-column' | translate }} - {{ 'dynamic-form.property.direction-row' | translate }} - -
-
- - {{ 'dynamic-form.property.enable-multiple-select' | translate }} - -
-
- - {{ 'dynamic-form.property.allow-empty-select-option' | translate }} - + + + + +
+ } + @if (propertyItemType === FormPropertyType.htmlSection) { +
+ + + + {{ 'dynamic-form.property.html-section-settings' | translate }} + + + +
+ +
-
-
dynamic-form.property.selected-options-limit
-
-
dynamic-form.property.min
- - - - -
dynamic-form.property.max
- - - + + + + +
+ } + @if ([FormPropertyType.select, FormPropertyType.radios].includes(propertyItemType)) { +
+ + + + {{ (propertyFormGroup.get('type').value === FormPropertyType.select ? 'dynamic-form.property.select-options' : 'dynamic-form.property.radio-button-options') | translate }} + + + + @if (propertyItemType === FormPropertyType.radios) { +
+
dynamic-form.property.buttons-direction
+ + {{ 'dynamic-form.property.direction-column' | translate }} + {{ 'dynamic-form.property.direction-row' | translate }} +
-
+ } + @if (propertyItemType === FormPropertyType.select) { +
+ + {{ 'dynamic-form.property.enable-multiple-select' | translate }} + +
+ } + @if (propertyItemType === FormPropertyType.select && !propertyFormGroup.get('multiple').value) { +
+ + {{ 'dynamic-form.property.allow-empty-select-option' | translate }} + +
+ } + @if (propertyItemType === FormPropertyType.select && propertyFormGroup.get('multiple').value) { +
+
dynamic-form.property.selected-options-limit
+
+
dynamic-form.property.min
+ + + + +
dynamic-form.property.max
+ + + +
+
+ }
- -
-
dynamic-form.property.help-id
- - - -
- + } + @if ([FormPropertyType.javascript, FormPropertyType.markdown].includes(propertyItemType)) { +
+
dynamic-form.property.help-id
+ + + +
+ } + @if (propertyItemType === FormPropertyType.datetime) {
dynamic-form.property.datetime-type
{{ 'dynamic-form.property.datetime-type-date' | translate }} + value="date">{{ 'dynamic-form.property.datetime-type-date' | translate }} {{ 'dynamic-form.property.datetime-type-time' | translate }} + value="time">{{ 'dynamic-form.property.datetime-type-time' | translate }} {{ 'dynamic-form.property.datetime-type-datetime' | translate }} + value="datetime">{{ 'dynamic-form.property.datetime-type-datetime' | translate }}
@@ -319,165 +357,221 @@ {{ 'dynamic-form.property.enable-clear-button' | translate }}
-
-
- - dynamic-form.property.default-value - - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
dynamic-form.property.default-value
- - -
{{ item.label | customTranslate }}
-
-
-
-
- - {{ 'dynamic-form.property.support-unit-conversion' | translate }} - -
-
-
dynamic-form.property.default-value
- - - - - - - - - - - - - - - - - - - - - {{ item.label | customTranslate }} - - - - - {{ item.label | customTranslate }} - - - - - - - - -
+ } + @if (propertyItemType === FormPropertyType.textarea) { +
+ + dynamic-form.property.default-value + + +
+ } + @if (propertyItemType === FormPropertyType.javascript) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.json) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.html) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.css) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.markdown) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.image && !isArray) { +
+ + +
+ } + @if (propertyItemType === FormPropertyType.radios) { +
+
dynamic-form.property.default-value
+ + @for (item of propertyFormGroup.get('items').value; track item) { + +
{{ item.label | customTranslate }}
+
+ } +
+
+ } + @if (propertyItemType === FormPropertyType.units) { +
+ + {{ 'dynamic-form.property.support-unit-conversion' | translate }} + +
+ } + @if (isPropertyTypeAllowedForRow(propertyItemType)) { +
+
dynamic-form.property.default-value
+ @if ([FormPropertyType.text, FormPropertyType.password].includes(propertyItemType)) { + + + + } + @if (propertyItemType === FormPropertyType.color) { + + + } + @if (propertyItemType === FormPropertyType.color_settings) { + + + } + @if (propertyItemType === FormPropertyType.number) { + + + + } + @if (propertyItemType === FormPropertyType.font) { + + + } + @if (propertyItemType === FormPropertyType.units) { + + } + @if (propertyItemType === FormPropertyType.switch) { + + } + @if (propertyItemType === FormPropertyType.icon) { + + + } + @if (propertyItemType === FormPropertyType.select) { + + @if (!propertyFormGroup.get('multiple').value) { + + @if (propertyFormGroup.get('allowEmptyOption').value) { + + } + @for (item of propertyFormGroup.get('items').value; track item) { + + {{ item.label | customTranslate }} + + } + + } + @if (propertyFormGroup.get('multiple').value) { + + @for (item of propertyFormGroup.get('items').value; track item) { + + {{ item.label | customTranslate }} + + } + + } + + } + @if (propertyItemType === FormPropertyType.datetime) { + @if (propertyFormGroup.get('dateTimeType').value === 'date') { + + } + @if (propertyFormGroup.get('dateTimeType').value === 'time') { + + } + @if (propertyFormGroup.get('dateTimeType').value === 'datetime') { + + } + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts index dfcdf49192..e59a08037f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-panel.component.ts @@ -34,10 +34,11 @@ import { isUndefinedOrNull } from '@core/utils'; import { StringItemsOption } from '@shared/components/string-items-list.component'; @Component({ - selector: 'tb-dynamic-form-property-panel', - templateUrl: './dynamic-form-property-panel.component.html', - styleUrls: ['./dynamic-form-property-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-property-panel', + templateUrl: './dynamic-form-property-panel.component.html', + styleUrls: ['./dynamic-form-property-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormPropertyPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.html index eae448d2a5..1d14969e74 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.html @@ -24,9 +24,11 @@ - - {{ formPropertyTypeTranslations.get(type) | translate }} - + @for (type of formPropertyTypes; track type) { + + {{ formPropertyTypeTranslations.get(type) | translate }} + + }
@@ -38,13 +40,15 @@ matTooltipPosition="above"> settings - + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.ts index 7692be80e0..fd67997e63 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-property-row.component.ts @@ -41,7 +41,7 @@ import { Validators } from '@angular/forms'; import { deepClone } from '@core/utils'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { defaultPropertyValue, @@ -59,22 +59,23 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-dynamic-form-property-row', - templateUrl: './dynamic-form-property-row.component.html', - styleUrls: ['./dynamic-form-property-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormPropertyRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormPropertyRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-property-row', + templateUrl: './dynamic-form-property-row.component.html', + styleUrls: ['./dynamic-form-property-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormPropertyRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormPropertyRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormPropertyRowComponent implements ControlValueAccessor, OnInit, Validator { @@ -82,7 +83,7 @@ export class DynamicFormPropertyRowComponent implements ControlValueAccessor, On idInput: ElementRef; @ViewChild('editButton') - editButton: MatButton; + editButton: MatIconButton; formPropertyTypes = formPropertyTypes; formPropertyTypeTranslations = formPropertyTypeTranslations; @@ -160,7 +161,7 @@ export class DynamicFormPropertyRowComponent implements ControlValueAccessor, On this.cd.markForCheck(); } - editProperty($event: Event, matButton: MatButton, add = false, editCanceled = () => {}) { + editProperty($event: Event, matButton: MatIconButton, add = false, editCanceled = () => {}) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.html index a15c256745..bcff604829 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.html @@ -25,13 +25,15 @@
- + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.ts index 6d414a18f9..d92cc58489 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-item-row.component.ts @@ -46,22 +46,23 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; export const selectItemValid = (item: FormSelectItem): boolean => isDefinedAndNotNull(item.value) && !!item.label; @Component({ - selector: 'tb-dynamic-form-select-item-row', - templateUrl: './dynamic-form-select-item-row.component.html', - styleUrls: ['./dynamic-form-select-item-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-select-item-row', + templateUrl: './dynamic-form-select-item-row.component.html', + styleUrls: ['./dynamic-form-select-item-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormSelectItemRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormSelectItemRowComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.html index cfb179fe4d..f0096cff5c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.html @@ -22,41 +22,49 @@
dynamic-form.property.label
-
-
- - -
- -
+ @if (selectItemsFormArray().controls.length) { +
+ @for (selectItemControl of selectItemsFormArray().controls; track trackBySelectItem($index, selectItemControl)) { +
+ + +
+ @if (!disabled) { + + } +
+
+ }
-
- -
-
- + } @else { + {{ 'dynamic-form.property.no-options' | translate }} + } + @if (errorText) { + + }
+ @if (!disabled) { +
+ +
+ }
- - {{ 'dynamic-form.property.no-options' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts index 9d664d5796..24d49bc608 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form-select-items.component.ts @@ -45,27 +45,27 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-dynamic-form-select-items', - templateUrl: './dynamic-form-select-items.component.html', - styleUrls: ['./dynamic-form-select-items.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormSelectItemsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormSelectItemsComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-dynamic-form-select-items', + templateUrl: './dynamic-form-select-items.component.html', + styleUrls: ['./dynamic-form-select-items.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormSelectItemsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormSelectItemsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DynamicFormSelectItemsComponent implements ControlValueAccessor, OnInit, Validator { @HostBinding('style.display') styleDisplay = 'flex'; - @HostBinding('style.overflow') styleOverflow = 'hidden'; @ViewChildren(DynamicFormSelectItemRowComponent) selectItemRows: QueryList; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html index cb1cb71750..c8014dabef 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.html @@ -18,66 +18,78 @@
-
{{ title }}
+ @if (title) { +
{{ title }}
+ } - - -
- - - -
{{ propertyGroup.title | customTranslate }}
-
-
- - - -
-
- + @for (propertyGroup of propertyGroups; track propertyGroup) { + @if (propertyGroup.visible) { + @if (propertyGroup.title) { +
+ + + +
{{ propertyGroup.title | customTranslate }}
+
+
+ + + +
+
+ } + @if (!propertyGroup.title) { -
-
-
+ } + } + }
- - - - - - - - - - - - - -
- + @if (propertyContainer.visible) { + @if (propertyContainer.type === FormPropertyContainerType.field) { + + + } + @if (propertyContainer.type === FormPropertyContainerType.row) { + + + } + @if (propertyContainer.type === FormPropertyContainerType.fieldset) { + - -
- - -
- -
-
+ title="{{ propertyContainer.label | customTranslate }}" + stroked + [properties]="propertyContainer.properties"> + + } + @if (propertyContainer.type === FormPropertyContainerType.array) { + @if (propertyContainer.arrayItemProperty.type === FormPropertyType.image) { +
+ + +
+ } + @if (propertyContainer.arrayItemProperty.type !== FormPropertyType.image) { + + + } + } + @if (propertyContainer.type === FormPropertyContainerType.htmlSection) { +
+ } + }
-
+ }
@@ -89,185 +101,247 @@
- -
- {{ propertyRow.label | customTranslate }} + @if (propertyRow.switch && propertyRow.switch.visible) { + +
+ {{ propertyRow.label | customTranslate }} +
+
+ } + @if (!propertyRow.switch) { +
+
+ {{ propertyRow.label | customTranslate }} +
- -
-
- {{ propertyRow.label | customTranslate }} + } + @if (propertyRow.properties.length) { +
+ @for (property of propertyRow.properties; track property) { + @if (property.visible) { + @if (property.subLabel) { +
{{ property.subLabel | customTranslate }}
+ } + @if (isInputFieldPropertyType(property.type)) { + + + } + @if (property.type === FormPropertyType.color) { + + + } + @if (property.type === FormPropertyType.color_settings) { + + + } + @if (property.type === FormPropertyType.font) { + + + } + @if (property.type === FormPropertyType.units) { + + } + @if (property.type === FormPropertyType.icon) { + + + } + @if (property.divider) { + + } + } + }
-
-
- - -
{{ property.subLabel | customTranslate }}
- - - - - - - - - - - - - - -
-
-
+ }
- - {{ property.name | customTranslate }} - - - - {{ property.name | customTranslate }} - -
{{ property.fieldSuffix | customTranslate }}
-
- - {{ property.name | customTranslate }} - -
{{ property.fieldSuffix | customTranslate }}
-
- - {{ property.name | customTranslate }} - + @if (singleField) { + {{ property.name | customTranslate }} + } + + + } + @if ([FormPropertyType.text, FormPropertyType.password].includes(property.type)) { + + @if (singleField) { + {{ property.name | customTranslate }} + } + + @if (property.fieldSuffix) { +
{{ property.fieldSuffix | customTranslate }}
+ } +
+ } + @if (property.type === FormPropertyType.number) { + + @if (singleField) { + {{ property.name | customTranslate }} + } + + @if (property.fieldSuffix) { +
{{ property.fieldSuffix | customTranslate }}
+ } +
+ } + @if (property.type === FormPropertyType.select) { + + @if (singleField) { + {{ property.name | customTranslate }} + } + + @if (!property.multiple && property.allowEmptyOption) { + + } + @for (item of property.items; track item) { + + {{ item.label | customTranslate }} + + } + + + } + @if (property.type === FormPropertyType.datetime) { + + + } + @if (property.type === FormPropertyType.javascript) { +
+ + +
+ } + @if (property.type === FormPropertyType.json) { +
+ + +
+ } + @if (property.type === FormPropertyType.html) { +
+ - -
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
{{ property.name | customTranslate }}
- - -
{{ item.label | customTranslate }}
-
-
-
+
+
+ } + @if (property.type === FormPropertyType.css) { +
+ + +
+ } + @if (property.type === FormPropertyType.markdown) { +
+ + +
+ } + @if (property.type === FormPropertyType.image) { +
+ + +
+ } + @if (property.type === FormPropertyType.radios) { +
+
{{ property.name | customTranslate }}
+ + @for (item of property.items; track item) { + +
{{ item.label | customTranslate }}
+
+ } +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.ts index 7441bf470f..5fa33a422b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/dynamic-form/dynamic-form.component.ts @@ -55,21 +55,22 @@ import { ContentType } from '@shared/models/constants'; import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-dynamic-form', - templateUrl: './dynamic-form.component.html', - styleUrls: ['./dynamic-form.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DynamicFormComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DynamicFormComponent), - multi: true - } - ] + selector: 'tb-dynamic-form', + templateUrl: './dynamic-form.component.html', + styleUrls: ['./dynamic-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DynamicFormComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DynamicFormComponent), + multi: true + } + ], + standalone: false }) export class DynamicFormComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html index 329a6aaf97..c11e528911 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.html @@ -24,26 +24,31 @@ [formControl]="entityAliasFormControl" (focusin)="onEntityAliasFocus()" [matAutocomplete]="entityAliasAutocomplete"> - - warning - - + @if (required && entityAliasFormControl.hasError('required') + && entityAliasFormControl.touched) { + + warning + + } + @if (entityAliasFormControl.value && !entityAliasFormControl.disabled) { + + } - - - + @for (entityAlias of filteredEntityAliases | async; track entityAlias) { + + + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts index f774a32657..95f137ce39 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/entity-alias-input.component.ts @@ -39,17 +39,18 @@ import { Observable, of } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-entity-alias-input', - templateUrl: './entity-alias-input.component.html', - styleUrls: ['./entity-alias-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityAliasInputComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-entity-alias-input', + templateUrl: './entity-alias-input.component.html', + styleUrls: ['./entity-alias-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAliasInputComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EntityAliasInputComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html index 9ca50c55f0..326832d34e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.html @@ -21,7 +21,9 @@ class="mat-block" [appearance]="inlineField ? 'outline' : appearance" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ 'filter.filter' | translate }} + @if (showLabel && !inlineField) { + {{ 'filter.filter' | translate }} + }
- - - warning - + @if (selectFilterFormGroup.get('filter').value && !disabled) { + + } + @if (inlineField && !modelValue && tbRequired + && selectFilterFormGroup.get('filter').touched) { + + warning + + }
- + @if (!inlineField && !selectFilterFormGroup.get('filter').value && !disabled) { + + } - - - - -
-
- filter.no-filters-found -
- + @for (filter of filteredFilters | async; track filter) { + + + + } + @if (!(filteredFilters | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ filter.no-filters-found +
+ } @else { + + {{ 'filter.no-filter-matching' | translate : {filter: (searchText | truncate: true: 6: '...')} }} + + } - {{ 'filter.no-filter-matching' | translate : {filter: (searchText | truncate: true: 6: '...')} }} + filter.create-new-filter - - - filter.create-new-filter - -
-
+
+
+ }
- - {{ 'filter.filter-required' | translate }} - + @if (!inlineField && !modelValue && tbRequired) { + + {{ 'filter.filter-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts index 80e1ce105b..c57ce2a231 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/filter/filter-select.component.ts @@ -37,18 +37,19 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-filter-select', - templateUrl: './filter-select.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FilterSelectComponent), - multi: true - }, - { - provide: ErrorStateMatcher, - useExisting: FilterSelectComponent - }] + selector: 'tb-filter-select', + templateUrl: './filter-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FilterSelectComponent), + multi: true + }, + { + provide: ErrorStateMatcher, + useExisting: FilterSelectComponent + }], + standalone: false }) export class FilterSelectComponent implements ControlValueAccessor, OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html index f0bf2aa5cf..b795dfe25b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/font-settings-panel.component.html @@ -19,13 +19,17 @@
widgets.widget-font.font-settings
widgets.widget-font.size
-
- - - - -
-
widgets.widget-font.auto
+ @if (!autoScale) { +
+ + + + +
+ } + @if (autoScale) { +
widgets.widget-font.auto
+ }
widgets.widget-font.font-family
@@ -33,19 +37,23 @@ - + @if (fontFormGroup.get('family').value) { + + } - - - + @for (family of filteredFontFamilies | async; track family) { + + + + }
@@ -55,9 +63,11 @@ - - {{ fontWeightTranslationsMap.has(weight) ? (fontWeightTranslationsMap.get(weight) | translate) : weight }} - + @for (weight of fontWeightsList; track weight) { + + {{ fontWeightTranslationsMap.has(weight) ? (fontWeightTranslationsMap.get(weight) | translate) : weight }} + + }
@@ -67,33 +77,45 @@ - - {{ fontStyleTranslationsMap.get(style) | translate }} - + @for (style of fontStylesList; track style) { + + {{ fontStyleTranslationsMap.get(style) | translate }} + + }
-
-
widgets.widget-font.line-height
- - - -
widgets.widget-font.auto
-
+ @if (!disabledLineHeight) { +
+
widgets.widget-font.line-height
+ @if (!autoScale) { + + + + } + @if (autoScale) { +
widgets.widget-font.auto
+ } +
+ }
widgets.widget-font.preview
- - + @if (clearButton) { + + } + @if (clearButton) { + + } + +
+ + } + + + } + +
+
+
widgets.color.gradient-end
+
+
+
widgets.color.end-value
+ + + +
+ + formControlName="end"> -
- - +
- - - -
-
-
widgets.color.gradient-end
-
-
-
widgets.color.end-value
- - - + } + @case (true) { +
+
+
+ widgets.color.gradient-start + widgets.color.gradient-start-min
- +
+ + + formControlName="color"> - -
-
-
- - -
-
-
- widgets.color.gradient-start - widgets.color.gradient-start-min -
-
- - - - +
-
- -
-
-
-
-
-
- widgets.color.gradient-color - widgets.color.gradient-color-min -
-
- - - - + @if (advancedGradientListFormGroups.length) { +
+
+ @for (gradientFormGroup of advancedGradientListFormGroups; track trackByGradient($index, gradientFormGroup); let isLast = $last) { +
+
+
+
+ widgets.color.gradient-color + widgets.color.gradient-color-min +
+
+ + + + +
+
+ + +
-
- - + }
-
-
- - -
-
-
- widgets.color.gradient-end - widgets.color.gradient-end-min -
-
- - - - + } + +
+
+
+ widgets.color.gradient-end + widgets.color.gradient-end-min +
+
+ + + + +
-
- + } + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts index 633ae1b89d..fa97267843 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/gradient.component.ts @@ -37,16 +37,17 @@ import { DataKeysCallbacks } from '@home/components/widget/lib/settings/common/k import { Datasource } from '@shared/models/widget.models'; @Component({ - selector: 'tb-gradient', - templateUrl: './gradient.component.html', - styleUrls: ['color-settings-panel.component.scss', 'gradient.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GradientComponent), - multi: true - } - ] + selector: 'tb-gradient', + templateUrl: './gradient.component.html', + styleUrls: ['color-settings-panel.component.scss', 'gradient.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GradientComponent), + multi: true + } + ], + standalone: false }) export class GradientComponent implements OnInit, ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html new file mode 100644 index 0000000000..a72f28e70e --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.html @@ -0,0 +1,128 @@ + + +
+
+ + {{ 'widgets.html-container.type-plain' | translate }} + {{ 'widgets.html-container.type-angular' | translate }} + +
+
+
+ +
+
+ + + +
{{ 'widgets.html-container.resources' | translate }}
+
+
+ @if (resourcesFormArray.length) { + @for (resourceControl of resourcesControls; track resourceControl; let i = $index) { +
+ + + @if (htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR) { + + {{ 'widget.resource-is-extension' | translate }} + + } + +
+ } + } @else { + widgets.html-container.no-resources + } +
+ +
+
+
+ + + + + + + + + @if (!fullscreen) { + + + + } +
+
+ @if (fullscreen) { + + } +
+
+
+
+
+ + + + + + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss new file mode 100644 index 0000000000..c2db82139d --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.scss @@ -0,0 +1,128 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.tb-html-container-settings { + height: 100%; +} + +.tb-html-container-settings .tb-html-container-settings-panel, .tb-html-container-settings-panel { + position: relative; + background: #fff; + .mat-mdc-tab-body-wrapper { + position: relative; + top: 0; + flex: 1; + } + .tb-action-expand-button { + position: absolute; + top: 4px; + right: 0; + z-index: 2; + } + .gutter { + display: none; + background-color: #eee; + background-repeat: no-repeat; + background-position: 50%; + &.gutter-horizontal { + cursor: col-resize; + background-image: url("../../../../../../../../../assets/split.js/grips/vertical.png"); + } + } + .tb-js-func { + &:not(.tb-fullscreen) { + &.tb-hide-brackets { + padding-bottom: 0; + } + } + } + .tb-html { + position: relative; + &:not(.tb-fullscreen) { + padding-bottom: 0; + } + .tb-html-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-html-content-panel { + border-top: none; + height: 100%; + } + } + .tb-css { + position: relative; + &:not(.tb-fullscreen) { + .tb-css-content-panel { + margin: 0; + } + } + .tb-css-toolbar { + position: absolute; + top: 0; + right: 8px; + z-index: 8; + .tb-title { + display: none; + } + } + .tb-css-content-panel { + border-top: none; + height: 100%; + } + } + &.tb-fullscreen { + padding: 8px; + gap: 8px; + .tb-action-expand-button { + position: relative; + top: 0; + right: 0; + } + .gutter { + display: block; + } + .tb-content { + border: 1px solid #c0c0c0; + .tb-html { + .tb-html-content-panel { + border: none; + } + } + .tb-css { + .tb-css-content-panel { + border: none; + } + } + .tb-js-func { + padding-top: 8px; + .tb-js-func-toolbar { + padding: 0 5px; + } + .tb-js-func-panel { + border-left: none; + border-right: none; + border-bottom: none; + } + } + } + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts new file mode 100644 index 0000000000..121362ef57 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/html/html-container-settings.component.ts @@ -0,0 +1,237 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { + AfterViewInit, + Component, + DestroyRef, + ElementRef, + forwardRef, + HostBinding, + Input, + OnInit, + ViewChild, + ViewEncapsulation +} from '@angular/core'; +import { WidgetResource } from '@shared/models/widget.models'; +import { + ControlValueAccessor, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + UntypedFormArray, + UntypedFormBuilder, + UntypedFormControl, + UntypedFormGroup, + Validator, + Validators +} from '@angular/forms'; +import { + AngularContainerFunctionEditorCompleter, + HTMLContainerFunctionEditorCompleter, + HtmlContainerWidgetSettings, + HtmlContainerWidgetType +} from '@home/components/widget/lib/html/html-container-widget.models'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { isJSResource } from '@shared/models/resource.models'; +import { WidgetService } from '@core/http/widget.service'; + +@Component({ + selector: 'tb-html-container-settings', + templateUrl: './html-container-settings.component.html', + styleUrls: ['./html-container-settings.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HtmlContainerSettingsComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class HtmlContainerSettingsComponent implements OnInit, AfterViewInit, ControlValueAccessor, Validator { + + HtmlContainerWidgetType = HtmlContainerWidgetType; + + functionScopeVariables = this.widgetService.getWidgetScopeVariables(); + + get containerFunctionEditorCompleter() { + return this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.ANGULAR + ? AngularContainerFunctionEditorCompleter + : HTMLContainerFunctionEditorCompleter; + } + + @HostBinding('class') + hostClass = 'tb-html-container-settings'; + + @ViewChild('leftPanel', { read: ElementRef }) + leftPanelElmRef!: ElementRef; + + @ViewChild('rightPanel', { read: ElementRef }) + rightPanelElmRef!: ElementRef; + + @Input() + disabled: boolean; + + fullscreen = false; + + tabsAnimationDuration = '500ms'; + + htmlContainerSettingsForm: UntypedFormGroup; + + private modelValue: HtmlContainerWidgetSettings; + + constructor(private fb: UntypedFormBuilder, + private widgetService: WidgetService, + private destroyRef: DestroyRef) { + } + + get resourcesFormArray(): UntypedFormArray { + return this.htmlContainerSettingsForm.get('resources') as UntypedFormArray; + } + + get resourcesControls(): UntypedFormGroup[] { + return this.resourcesFormArray.controls as UntypedFormGroup[]; + } + + ngOnInit(): void { + this.htmlContainerSettingsForm = this.fb.group({ + type: [null, []], + html: [null, []], + css: [null, []], + js: [null, []], + resources: this.fb.array([]) + }); + this.htmlContainerSettingsForm.get('type').valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => this.updateResources()); + this.htmlContainerSettingsForm.valueChanges.pipe( + takeUntilDestroyed(this.destroyRef) + ).subscribe(() => { + this.updateModel(); + }); + } + + ngAfterViewInit(): void { + if (this.leftPanelElmRef && this.rightPanelElmRef) { + this.initSplitLayout(this.leftPanelElmRef.nativeElement, + this.rightPanelElmRef.nativeElement); + } + } + + private initSplitLayout(leftPanel: any, rightPanel: any) { + Split([leftPanel, rightPanel], { + sizes: [50, 50], + gutterSize: 8, + cursor: 'col-resize' + }); + } + + registerOnChange(fn: any): void { + this.propagateChange = fn; + } + + registerOnTouched(_fn: any): void { + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + if (isDisabled) { + this.htmlContainerSettingsForm.disable({emitEvent: false}); + } else { + this.htmlContainerSettingsForm.enable({emitEvent: false}); + } + } + + writeValue(value: HtmlContainerWidgetSettings): void { + this.modelValue = value; + this.htmlContainerSettingsForm.get('type').patchValue(value.type, {emitEvent: false}); + this.htmlContainerSettingsForm.get('html').patchValue(value.html, {emitEvent: false}); + this.htmlContainerSettingsForm.get('css').patchValue(value.css, {emitEvent: false}); + this.htmlContainerSettingsForm.get('js').patchValue(value.js, {emitEvent: false}); + this.resourcesFormArray.clear({emitEvent: false}); + value.resources.forEach(r => { + this.resourcesFormArray.push(this.buildResourceFormGroup(r), {emitEvent: false}); + }); + } + + validate(_c: UntypedFormControl) { + return this.htmlContainerSettingsForm.valid ? null : { + htmlContainerSettings: { + valid: false, + } + }; + } + + addResource() { + const newResource: WidgetResource = { + url: '', + isModule: false + }; + this.resourcesFormArray.push(this.buildResourceFormGroup(newResource)); + } + + removeResource(index: number) { + this.resourcesFormArray.removeAt(index); + } + + toggleFullScreen(): void { + this.fullscreen = !this.fullscreen; + this.tabsAnimationDuration = '0ms'; + setTimeout(() => { + this.tabsAnimationDuration = '500ms'; + }); + } + + private propagateChange = (_v: any) => { }; + + private updateModel() { + this.modelValue = this.htmlContainerSettingsForm.value; + this.propagateChange(this.modelValue); + } + + private updateResources() { + if (this.htmlContainerSettingsForm.get('type').value === HtmlContainerWidgetType.PLAIN) { + const resources: WidgetResource[] = this.resourcesFormArray.value; + const filtered = resources.filter(r => !isJSResource(r.url)); + let updated = filtered.length !== resources.length; + filtered.forEach((r) => { + if (r.isModule) { + r.isModule = false; + updated = true; + } + }); + if (updated) { + this.resourcesFormArray.clear(); + filtered.forEach(r => { + this.resourcesFormArray.push(this.buildResourceFormGroup(r)); + }); + } + } + } + + private buildResourceFormGroup(resource: WidgetResource): UntypedFormGroup { + return this.fb.group({ + url: [resource.url, [Validators.required]], + isModule: [resource.isModule] + }); + } +} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.html index b8cbb8ba82..93a1ed6adc 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.html @@ -28,15 +28,17 @@ - -
-
-
{{ option.name }}
-
- + @for (option of options; track option) { + +
+
+
{{ option.name }}
+
+ +
-
- + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.scss index 62915cc390..de1901aa4a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.scss @@ -44,7 +44,7 @@ display: none; } } - > .mat-expansion-panel-content { + .mat-expansion-panel-content { > .mat-expansion-panel-body { padding: 0 16px 16px !important; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts index dca656ac46..8021f9d2dd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/image-cards-select.component.ts @@ -52,7 +52,8 @@ export interface ImageCardsColumns { { // eslint-disable-next-line @angular-eslint/directive-selector selector: 'tb-image-cards-select-option', - } + standalone: false +} ) export class ImageCardsSelectOptionDirective { @@ -70,17 +71,18 @@ export class ImageCardsSelectOptionDirective { } @Component({ - selector: 'tb-image-cards-select', - templateUrl: './image-cards-select.component.html', - styleUrls: ['./image-cards-select.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageCardsSelectComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-image-cards-select', + templateUrl: './image-cards-select.component.html', + styleUrls: ['./image-cards-select.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageCardsSelectComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ImageCardsSelectComponent implements ControlValueAccessor, OnInit, OnChanges, AfterContentInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.html index 485f6d0af4..f89fde663a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.html @@ -16,39 +16,43 @@ --> -
- - {{ 'widgets.status-widget.label' | translate }} - -
- - - + @if (layout !== StatusWidgetLayout.icon) { +
+ + {{ 'widgets.status-widget.label' | translate }} + +
+ + + +
-
-
- - {{ 'widgets.status-widget.status' | translate }} - -
- - - - - + } + @if (layout !== StatusWidgetLayout.icon) { +
+ + {{ 'widgets.status-widget.status' | translate }} + +
+ + + + + +
-
+ }
{{ 'widgets.status-widget.icon' | translate }} @@ -74,13 +78,17 @@ formControlName="primaryColor">
- -
-
widgets.status-widget.secondary
- - -
+ @if (layout !== StatusWidgetLayout.icon) { + + } + @if (layout !== StatusWidgetLayout.icon) { +
+
widgets.status-widget.secondary
+ + +
+ }
widgets.status-widget.background
@@ -100,13 +108,17 @@ formControlName="primaryColorDisabled">
- -
-
widgets.status-widget.secondary
- - -
+ @if (layout !== StatusWidgetLayout.icon) { + + } + @if (layout !== StatusWidgetLayout.icon) { +
+
widgets.status-widget.secondary
+ + +
+ }
widgets.status-widget.background
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.ts index 5e8f7e2ce6..b3caf03e89 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/indicator/status-widget-state-settings.component.ts @@ -25,16 +25,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { widgetTitleAutocompleteValues } from '@app/shared/public-api'; @Component({ - selector: 'tb-status-widget-state-settings', - templateUrl: './status-widget-state-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => StatusWidgetStateSettingsComponent), - multi: true - } - ] + selector: 'tb-status-widget-state-settings', + templateUrl: './status-widget-state-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StatusWidgetStateSettingsComponent), + multi: true + } + ], + standalone: false }) export class StatusWidgetStateSettingsComponent implements OnInit, OnChanges, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.html index 5b91e07224..802adb5211 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.html @@ -19,40 +19,45 @@

{{ 'datakey.configuration' | translate }}

- - + @if (hasAdvanced) { + + + }
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
+ [dataKeyConfigMode]="dataKeyConfigMode" + [dataKeySettingsForm]="data.dataKeySettingsForm" + [dataKeySettingsDirective]="data.dataKeySettingsDirective" + [deviceId]="data.deviceId" + [entityAliasId]="data.entityAliasId" + [dashboard]="data.dashboard" + [aliasController]="data.aliasController" + [widget]="data.widget" + [widgetType]="data.widgetType" + [showPostProcessing]="data.showPostProcessing" + [callbacks]="data.callbacks" + [hideDataKeyName]="data.hideDataKeyName" + [hideDataKeyLabel]="data.hideDataKeyLabel" + [hideDataKeyColor]="data.hideDataKeyColor" + [hideDataKeyUnits]="data.hideDataKeyUnits" + [hideDataKeyDecimals]="data.hideDataKeyDecimals" + [hideDataKeyAggregation]="data.hideDataKeyAggregation" + [supportsUnitConversion]="data.supportsUnitConversion" + formControlName="dataKey">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.ts index 72329d208e..ba5725de08 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config-dialog.component.ts @@ -61,10 +61,11 @@ export interface DataKeyConfigDialogData { } @Component({ - selector: 'tb-data-key-config-dialog', - templateUrl: './data-key-config-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: DataKeyConfigDialogComponent}], - styleUrls: ['./data-key-config-dialog.component.scss'] + selector: 'tb-data-key-config-dialog', + templateUrl: './data-key-config-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: DataKeyConfigDialogComponent }], + styleUrls: ['./data-key-config-dialog.component.scss'], + standalone: false }) export class DataKeyConfigDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html index 85acdab5cf..c8bec5a971 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html @@ -15,185 +15,218 @@ limitations under the License. --> -
-
-
-
datakey.general
- - {{ 'entity.key' | translate }} - - - - - - - - - - datakey.label - - - -
-
widget-config.units-short
- - -
-
-
widget-config.decimals-short
- - - -
-
-
-
{{ 'datakey.color' | translate }}
- - -
-
- datakey.data-generation-func -
- - -
- -
-
-
{{ 'datakey.aggregation' | translate }}
- - - - {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + close + + } + + @for (key of filteredKeys | async; track key) { + + - - -
-
- {{ dataKeyFormGroup.get('aggregationType').value ? (dataKeyAggregationTypeHintTranslations.get(aggregationTypes[dataKeyFormGroup.get('aggregationType').value]) | translate) : '' }} -
- {{ 'datakey.aggregation-type-hint-common' | translate }} -
-
-
-
-
-
-
datakey.delta-calculation
-
- - - - - {{ 'datakey.enable-delta-calculation' | translate }} - - {{ 'datakey.enable-delta-calculation-hint' | translate }} - - - - + } + + + } + @if (!hideDataKeyLabel) { + + datakey.label + + + } + @if (modelValue.type !== dataKeyTypes.alarm) { + @if (!hideDataKeyUnits) {
-
{{ 'widgets.chart.time-for-comparison' | translate }}
- - - - {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} - - - {{ 'widgets.chart.time-for-comparison-days' | translate }} - - - {{ 'widgets.chart.time-for-comparison-weeks' | translate }} - - - {{ 'widgets.chart.time-for-comparison-months' | translate }} - - - {{ 'widgets.chart.time-for-comparison-years' | translate }} - - - {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} - - - +
widget-config.units-short
+ +
-
-
{{ 'widgets.chart.custom-interval-value' | translate }}
- - + } + @if (!hideDataKeyDecimals) { +
+
widget-config.decimals-short
+ +
-
-
{{ 'datakey.delta-calculation-result' | translate }}
+ } + } + @if (!hideDataKeyColor) { +
+
{{ 'datakey.color' | translate }}
+ + +
+ } + @if (modelValue.type === dataKeyTypes.function) { +
+ datakey.data-generation-func +
+ + +
+ } + @if (!hideDataKeyAggregation && widgetType === widgetTypes.latest && modelValue.type === dataKeyTypes.timeseries) { +
+
+
{{ 'datakey.aggregation' | translate }}
- - - {{ comparisonResultTypeTranslations.get(comparisonResultTypes[comparisonResultType]) | translate }} - + + @for (aggregation of aggregations; track aggregation) { + + {{ aggregationTypesTranslations.get(aggregationTypes[aggregation]) | translate }} + + }
- - - +
+ {{ dataKeyFormGroup.get('aggregationType').value ? (dataKeyAggregationTypeHintTranslations.get(aggregationTypes[dataKeyFormGroup.get('aggregationType').value]) | translate) : '' }} + @if (dataKeyFormGroup.get('aggregationType').value !== aggregationTypes.NONE) { +
+ {{ 'datakey.aggregation-type-hint-common' | translate }} +
+ } +
+
+ }
-
-
- - - - - {{ 'datakey.use-data-post-processing-func' | translate }} - - - - - - - - - + @if (dataKeyFormGroup.get('aggregationType').value && dataKeyFormGroup.get('aggregationType').value !== aggregationTypes.NONE) { +
+
datakey.delta-calculation
+
+ + + + + {{ 'datakey.enable-delta-calculation' | translate }} + + {{ 'datakey.enable-delta-calculation-hint' | translate }} + + + + @if (dataKeyFormGroup.get('comparisonEnabled').value) { +
+
{{ 'widgets.chart.time-for-comparison' | translate }}
+ + + + {{ 'widgets.chart.time-for-comparison-previous-interval' | translate }} + + + {{ 'widgets.chart.time-for-comparison-days' | translate }} + + + {{ 'widgets.chart.time-for-comparison-weeks' | translate }} + + + {{ 'widgets.chart.time-for-comparison-months' | translate }} + + + {{ 'widgets.chart.time-for-comparison-years' | translate }} + + + {{ 'widgets.chart.time-for-comparison-custom-interval' | translate }} + + + +
+ @if (dataKeyFormGroup.get('timeForComparison').value === 'customInterval') { +
+
{{ 'widgets.chart.custom-interval-value' | translate }}
+ + + +
+ } +
+
{{ 'datakey.delta-calculation-result' | translate }}
+ + + @for (comparisonResultType of comparisonResults; track comparisonResultType) { + + {{ comparisonResultTypeTranslations.get(comparisonResultTypes[comparisonResultType]) | translate }} + + } + + +
+ } +
+
+
+
+ } + @if ((modelValue.type === dataKeyTypes.timeseries || modelValue.type === dataKeyTypes.attribute || modelValue.type === dataKeyTypes.count) && showPostProcessing) { +
+ + + + + {{ 'datakey.use-data-post-processing-func' | translate }} + + + + + @if (dataKeyFormGroup.get('usePostProcessing').value) { + + + } + @if (dataKeyFormGroup.get('usePostProcessing').value) { + + } + + +
+ }
-
- +} @else {
-
+} diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.ts index ac6596576c..87e25f2614 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.ts @@ -62,21 +62,22 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-data-key-config', - templateUrl: './data-key-config.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeyConfigComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DataKeyConfigComponent), - multi: true, - } - ] + selector: 'tb-data-key-config', + templateUrl: './data-key-config.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeyConfigComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataKeyConfigComponent), + multi: true, + } + ], + standalone: false }) export class DataKeyConfigComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts index 1c1fa01956..28a6714d23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-input.component.ts @@ -55,17 +55,18 @@ import { IAliasController } from '@core/api/widget-api.models'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-data-key-input', - templateUrl: './data-key-input.component.html', - styleUrls: ['./data-key-input.component.scss', './data-keys.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeyInputComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-data-key-input', + templateUrl: './data-key-input.component.html', + styleUrls: ['./data-key-input.component.scss', './data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeyInputComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DataKeyInputComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss index ecfd9c6ee2..32c703bb23 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.scss @@ -126,13 +126,13 @@ } .tb-dragging-chip-image-fill { background-color: rgba(0,0,0,0.3); - border-radius: var(--mdc-chip-container-shape-radius, 16px 16px 16px 16px); + border-radius: var(--mat-chip-container-shape-radius, 16px 16px 16px 16px); display: none; pointer-events: none; } .tb-dragging-chip-image { - background-color: var(--mdc-chip-elevated-container-color, transparent); - border-radius: var(--mdc-chip-container-shape-radius, 16px 16px 16px 16px); + background-color: var(--mat-chip-elevated-container-color, transparent); + border-radius: var(--mat-chip-container-shape-radius, 16px 16px 16px 16px); overflow: hidden; height: 32px; line-height: 20px; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts index 44fb225c96..525abd50a7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-keys.component.ts @@ -64,7 +64,7 @@ import { import { deepClone, guid, isDefinedAndNotNull, isObject, isUndefined } from '@core/utils'; import { Dashboard } from '@shared/models/dashboard.models'; import { AggregationType } from '@shared/models/time/time.models'; -import { DndDropEvent } from 'ngx-drag-drop/lib/dnd-dropzone.directive'; +import { DndDropEvent } from 'ngx-drag-drop'; import { moveItemInArray } from '@angular/cdk/drag-drop'; import { coerceBoolean } from '@shared/decorators/coercion'; import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component'; @@ -75,26 +75,27 @@ import { FormProperty } from '@shared/models/dynamic-form.models'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-data-keys', - templateUrl: './data-keys.component.html', - styleUrls: ['./data-keys.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeysComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DataKeysComponent), - multi: true, - }, - { - provide: ErrorStateMatcher, - useExisting: DataKeysComponent - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-data-keys', + templateUrl: './data-keys.component.html', + styleUrls: ['./data-keys.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeysComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataKeysComponent), + multi: true, + }, + { + provide: ErrorStateMatcher, + useExisting: DataKeysComponent + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DataKeysComponent implements ControlValueAccessor, OnInit, OnChanges, ErrorStateMatcher, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html index 545c944ec7..f1e494ff21 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.html @@ -16,26 +16,32 @@ --> -
-
{{ 'legend.direction' | translate }}
- - - - {{ legendDirectionTranslations.get(legendDirection[direction]) | translate }} - - - -
+ @if (!hideDirection) { +
+
{{ 'legend.direction' | translate }}
+ + + @for (direction of legendDirections; track direction) { + + {{ legendDirectionTranslations.get(legendDirection[direction]) | translate }} + + } + + +
+ }
{{ 'legend.position' | translate }}
- - {{ legendPositionTranslations.get(legendPosition[pos]) | translate }} - + {{ legendPositionTranslations.get(legendPosition[pos]) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts index 0f946cd479..99e7f21e19 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/legend-config.component.ts @@ -29,16 +29,17 @@ import { coerceBoolean } from '@shared/decorators/coercion'; // @dynamic @Component({ - selector: 'tb-legend-config', - templateUrl: './legend-config.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LegendConfigComponent), - multi: true - } - ] + selector: 'tb-legend-config', + templateUrl: './legend-config.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LegendConfigComponent), + multi: true + } + ], + standalone: false }) export class LegendConfigComponent implements OnInit, OnDestroy, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-source-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-source-row.component.html index b79c6ec5a0..7989ee86e8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-source-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-source-row.component.html @@ -19,34 +19,39 @@
- - {{ datasourceTypesTranslations.get(type) | translate }} - + @for (type of datasourceTypes; track type) { + + {{ datasourceTypesTranslations.get(type) | translate }} + + } - - - - - - - + @if (dataSourceFormGroup.get('dsType').value === DatasourceType.function) { + + + + } + @if (dataSourceFormGroup.get('dsType').value === DatasourceType.device) { + + + } + @if (dataSourceFormGroup.get('dsType').value === DatasourceType.entity) { + + + }
AdditionalMapDataSourceRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-additional-map-data-source-row', + templateUrl: './additional-map-data-source-row.component.html', + styleUrls: ['./additional-map-data-source-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdditionalMapDataSourceRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class AdditionalMapDataSourceRowComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.html index c409ea0d8c..932f00f12e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.html @@ -22,15 +22,21 @@
widgets.maps.data-layer.data-keys
-
-
- - + @if (dataSourcesFormArray().controls.length) { +
+ @for (dataSourceControl of dataSourcesFormArray().controls; track trackByDataSource($index, dataSourceControl)) { +
+ + +
+ }
-
+ } @else { + {{ 'widgets.maps.data-layer.no-datasources' | translate }} + }
- - {{ 'widgets.maps.data-layer.no-datasources' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.ts index 7e96dcf847..eb8fef90d0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/additional-map-data-sources.component.ts @@ -37,22 +37,23 @@ import { import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ - selector: 'tb-additional-map-data-sources', - templateUrl: './additional-map-data-sources.component.html', - styleUrls: ['./additional-map-data-sources.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AdditionalMapDataSourcesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => AdditionalMapDataSourcesComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-additional-map-data-sources', + templateUrl: './additional-map-data-sources.component.html', + styleUrls: ['./additional-map-data-sources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AdditionalMapDataSourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => AdditionalMapDataSourcesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class AdditionalMapDataSourcesComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss index d66cb5e23d..6c0c156130 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.scss @@ -16,7 +16,7 @@ @import '../../../../../../../../../scss/constants'; .tb-data-layer-color-settings-panel { - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); --mat-form-field-trailing-icon-color: rgba(0,0,0,0.38); width: 700px; max-width: 90vw; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts index 7f8fe16279..102cfe97b8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings-panel.component.ts @@ -28,11 +28,12 @@ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ - selector: 'tb-data-layer-color-settings-panel', - templateUrl: './data-layer-color-settings-panel.component.html', - providers: [], - styleUrls: ['./data-layer-color-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-data-layer-color-settings-panel', + templateUrl: './data-layer-color-settings-panel.component.html', + providers: [], + styleUrls: ['./data-layer-color-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DataLayerColorSettingsPanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html index 360461b1ce..b4a8555f97 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.html @@ -21,10 +21,11 @@ [disabled]="disabled" #matButton (click)="openColorSettingsPopup($event, matButton)"> - mdi:function-variant + @if (modelValue?.type === DataLayerColorType.function) { + mdi:function-variant + } @else { +
+
+
+ } - -
-
-
-
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts index 17e3a15177..f3c095ba4e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-color-settings.component.ts @@ -27,16 +27,17 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { DatasourceType } from '@shared/models/widget.models'; @Component({ - selector: 'tb-data-layer-color-settings', - templateUrl: './data-layer-color-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataLayerColorSettingsComponent), - multi: true - } - ] + selector: 'tb-data-layer-color-settings', + templateUrl: './data-layer-color-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerColorSettingsComponent), + multi: true + } + ], + standalone: false }) export class DataLayerColorSettingsComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html index 4429957983..3941753d57 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.html @@ -33,30 +33,36 @@ - - - - - + @if (patternSettingsFormGroup.get('type').value === DataLayerPatternType.pattern) { + + + } + @if (patternSettingsFormGroup.get('type').value === DataLayerPatternType.function) { + + + } + @if (patternType === 'tooltip') {
widgets.maps.data-layer.tooltip-trigger
- - {{ dataLayerTooltipTriggerTranslationMap.get(trigger) | translate }} - + @for (trigger of dataLayerTooltipTriggers; track trigger) { + + {{ dataLayerTooltipTriggerTranslationMap.get(trigger) | translate }} + + }
@@ -65,30 +71,32 @@ {{ 'widgets.maps.data-layer.auto-close-tooltips' | translate }}
-
-
widgets.maps.data-layer.tooltip-offset
-
-
-
widgets.maps.data-layer.tooltip-offset-horizontal
- - - -
-
-
widgets.maps.data-layer.tooltip-offset-vertical
- - - + @if (hasTooltipOffset) { +
+
widgets.maps.data-layer.tooltip-offset
+
+
+
widgets.maps.data-layer.tooltip-offset-horizontal
+ + + +
+
+
widgets.maps.data-layer.tooltip-offset-vertical
+ + + +
-
+ } - + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts index 5a7c1afa0b..6bc2fb7489 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/data-layer-pattern-settings.component.ts @@ -37,21 +37,22 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ - selector: 'tb-data-layer-pattern-settings', - templateUrl: './data-layer-pattern-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataLayerPatternSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DataLayerPatternSettingsComponent), - multi: true - } - ] + selector: 'tb-data-layer-pattern-settings', + templateUrl: './data-layer-pattern-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DataLayerPatternSettingsComponent), + multi: true + } + ], + standalone: false }) export class DataLayerPatternSettingsComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html index 0d83a4e423..9d9f4377e8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.html @@ -25,11 +25,13 @@ {{ 'widgets.maps.image.image-source-entity-key' | translate }}
- - - + @if (ImageSourceType.image === imageMapSourceFormGroup.get('sourceType').value) { + + + } + @if (ImageSourceType.entityKey === imageMapSourceFormGroup.get('sourceType').value) {
widgets.maps.image.source-entity-alias
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts index ddd3b1842d..ed02417950 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/image-map-source-settings.component.ts @@ -32,21 +32,22 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @Component({ - selector: 'tb-image-map-source-settings', - templateUrl: './image-map-source-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageMapSourceSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ImageMapSourceSettingsComponent), - multi: true - } - ] + selector: 'tb-image-map-source-settings', + templateUrl: './image-map-source-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ImageMapSourceSettingsComponent), + multi: true + } + ], + standalone: false }) export class ImageMapSourceSettingsComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html index 63f59f169c..6cab6208f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-button-row.component.html @@ -18,16 +18,17 @@
- - warning - + @if (mapActionButton.hasError('invalidButtonConfig')) { + + warning + + } - + MapActionButtonRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapActionButtonRowComponent), - multi: true - }] + selector: 'tb-map-action-button-row', + templateUrl: 'map-action-button-row.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonRowComponent), + multi: true + }], + standalone: false }) export class MapActionButtonRowComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts index 76968e3e4d..2494d4b30a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-action-buttons-settings.component.ts @@ -31,18 +31,19 @@ import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-map-action-button-settings', - templateUrl: './map-action-buttons-settings.component.html', - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapActionButtonsSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapActionButtonsSettingsComponent), - multi: true - }] + selector: 'tb-map-action-button-settings', + templateUrl: './map-action-buttons-settings.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapActionButtonsSettingsComponent), + multi: true + }], + standalone: false }) export class MapActionButtonsSettingsComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html index 886c9fb681..8e484c8e3d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layer-dialog.component.html @@ -30,159 +30,172 @@
{{ 'widget-config.datasource' | translate }}
- {{ datasourceTypesTranslations.get(type) | translate }} + @for (type of datasourceTypes; track type) { + {{ datasourceTypesTranslations.get(type) | translate }} + }
- - datasource.label - - - - - - - - + @if (dataLayerFormGroup.get('dsType').value === DatasourceType.function) { + + datasource.label + + + } + @if (dataLayerFormGroup.get('dsType').value === DatasourceType.device) { + + + } + @if (dataLayerFormGroup.get('dsType').value === DatasourceType.entity) { + + + } + @if ([DatasourceType.device, DatasourceType.entity].includes(dataLayerFormGroup.get('dsType').value)) { + + + }
- - - - widgets.maps.data-layer.more-datasources - - - - - - - + @if (dataLayerFormGroup.get('dsType').value !== DatasourceType.function) { + + + + widgets.maps.data-layer.more-datasources + + + + + + + + }
{{ 'datakey.keys' | translate }}
-
+ @if (['trips', 'markers'].includes(dataLayerType)) { +
+ + + + +
+ } + @if (dataLayerType === 'polygons') { + (keyEdit)="editKey('polygonKey')" + formControlName="polygonKey"> + } + @if (dataLayerType === 'circles') { + (keyEdit)="editKey('circleKey')" + formControlName="circleKey"> -
- - - - - - + } + @if (dataLayerType === 'polylines') { + + + }
{{ 'widget-config.appearance' | translate }}
- + @if (['trips', 'markers'].includes(dataLayerType)) {
@@ -230,28 +243,34 @@ -
-
widgets.maps.data-layer.marker.shape
- -
-
-
widgets.maps.data-layer.marker.icon
- -
-
-
widgets.maps.data-layer.marker.image
- -
+ @if (dataLayerFormGroup.get('markerType').value === MarkerType.shape) { +
+
widgets.maps.data-layer.marker.shape
+ +
+ } + @if (dataLayerFormGroup.get('markerType').value === MarkerType.icon) { +
+
widgets.maps.data-layer.marker.icon
+ +
+ } + @if (dataLayerFormGroup.get('markerType').value === MarkerType.image) { +
+
widgets.maps.data-layer.marker.image
+ +
+ }
widgets.maps.data-layer.marker.marker-offset
@@ -267,18 +286,20 @@
-
- - {{ 'widgets.maps.data-layer.marker.rotate-marker' | translate }} - -
-
widgets.maps.data-layer.marker.offset-angle
- - -
deg
-
+ @if (dataLayerType === 'trips') { +
+ + {{ 'widgets.maps.data-layer.marker.rotate-marker' | translate }} + +
+
widgets.maps.data-layer.marker.offset-angle
+ + +
deg
+
+
-
+ } @if (dataLayerType === 'trips') { @@ -286,19 +307,21 @@
-
-
widgets.maps.data-layer.marker.position-conversion
- - -
- - + @if (mapType === MapType.image) { +
+
widgets.maps.data-layer.marker.position-conversion
+ + +
+ } + } + @if (dataLayerType === 'trips') {
@@ -322,7 +345,7 @@ [dsType]="dataLayerFormGroup.get('dsType').value" [dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value" - helpId="widget/lib/map/path_color_fn" formControlName="pathStrokeColor"> + helpId="widget/lib/map/path_color_fn" formControlName="pathStrokeColor">
@@ -341,9 +364,11 @@
- - {{ pathDecoratorSymbolTranslationMap.get(symbol) | translate }} - + @for (symbol of pathDecoratorSymbols; track symbol) { + + {{ pathDecoratorSymbolTranslationMap.get(symbol) | translate }} + + }
@@ -413,7 +438,7 @@ [dsType]="dataLayerFormGroup.get('dsType').value" [dsEntityAliasId]="dataLayerFormGroup.get('dsEntityAliasId').value" [dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value" - helpId="widget/lib/map/path_point_color_fn" formControlName="pointColor"> + helpId="widget/lib/map/path_point_color_fn" formControlName="pointColor">
-
- + } + @if (['polygons', 'circles'].includes(dataLayerType)) {
{{ 'widgets.maps.data-layer.shape.fill' | translate }}
@@ -438,33 +463,39 @@ {{ 'widgets.maps.data-layer.shape.fill-type-image' | translate }}
-
-
widgets.maps.data-layer.shape.color
- +
widgets.maps.data-layer.shape.color
+ -
-
-
widgets.maps.data-layer.shape.stripe-pattern
- - -
-
-
widgets.maps.data-layer.shape.image
- -
+
+ } + @if (dataLayerFormGroup.get('fillType').value === ShapeFillType.stripe) { +
+
widgets.maps.data-layer.shape.stripe-pattern
+ + +
+ } + @if (dataLayerFormGroup.get('fillType').value === ShapeFillType.image) { +
+
widgets.maps.data-layer.shape.image
+ +
+ }
- - + } + @if (['polygons', 'circles', 'polylines'].includes(dataLayerType)) {
widgets.maps.data-layer.shape.stroke
@@ -479,10 +510,10 @@ [dsDeviceId]="dataLayerFormGroup.get('dsDeviceId').value" helpId="{{ dataLayerType === 'polygons' ? 'widget/lib/map/polygon_stroke_color_fn' : (dataLayerType === 'circles' ? 'widget/lib/map/polygon_stroke_color_fn' : 'widget/lib/map/polyline_stroke_color_fn') }}" - formControlName="strokeColor"> + formControlName="strokeColor">
-
+ } @if (dataLayerType !== 'trips') { } @@ -500,39 +531,46 @@ formControlName="groups">
-
-
{{ dataLayerEditTitle | translate }}
-
-
widgets.maps.data-layer.edit-instruments
- - - {{ dataLayerEditActionTranslationMap.get(action) | translate }} - - -
-
-
widgets.maps.data-layer.persist-location-attribute-scope
- - - {{ telemetryTypeTranslationsShort.get(AttributeScope.SERVER_SCOPE) | translate }} - - - {{ telemetryTypeTranslationsShort.get(AttributeScope.SHARED_SCOPE) | translate }} - - -
-
- - - {{ 'widgets.maps.data-layer.enable-snapping' | translate }} - - + @if (dataLayerType !== 'trips') { +
+
{{ dataLayerEditTitle | translate }}
+
+
widgets.maps.data-layer.edit-instruments
+ + @for (action of dataLayerEditActions; track action) { + + {{ dataLayerEditActionTranslationMap.get(action) | translate }} + + } + +
+ @if (showEditAttributeScope) { +
+
widgets.maps.data-layer.persist-location-attribute-scope
+ + + {{ telemetryTypeTranslationsShort.get(AttributeScope.SERVER_SCOPE) | translate }} + + + {{ telemetryTypeTranslationsShort.get(AttributeScope.SHARED_SCOPE) | translate }} + + +
+ } +
+ + + {{ 'widgets.maps.data-layer.enable-snapping' | translate }} + + +
-
- - + } + @if (dataLayerType === 'markers') { + + + }
- - - - - - - - - - + [widgetType]="dataLayerType === 'trips' ? widgetType.timeseries : widgetType.latest" + [dataKeyType]="context.functionsOnly ? DataKeyType.function : null" + [dataKeyTypes]="dataLayerType === 'trips' ? [DataKeyType.timeseries] : [DataKeyType.attribute, DataKeyType.timeseries]" + [callbacks]="context.callbacks" + [generateKey]="context.generateDataKey" + (keyEdit)="editKey('xKey')" + formControlName="xKey"> + + } + @if (['trips', 'markers'].includes(dataLayerType)) { + + + } + @if (dataLayerType === 'polygons') { + + + } + @if (dataLayerType === 'circles') { + + + } + @if (dataLayerType === 'polylines') { + + + }
-
+ @if (dataLayersFormArray().controls.length) { +
+ @for (dataLayerControl of dataLayersFormArray().controls; track trackByDataLayer($index, dataLayerControl)) { +
+ + +
+ +
+
+ }
-
+ } @else { + {{ noDataLayersText | translate }} + }
- - {{ noDataLayersText | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts index b11d836cfa..b8e1b73115 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-layers.component.ts @@ -41,22 +41,23 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { CdkDragDrop } from '@angular/cdk/drag-drop'; @Component({ - selector: 'tb-map-data-layers', - templateUrl: './map-data-layers.component.html', - styleUrls: ['./map-data-layers.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapDataLayersComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapDataLayersComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-data-layers', + templateUrl: './map-data-layers.component.html', + styleUrls: ['./map-data-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapDataLayersComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html index fcec47bc2b..2a2b6c27a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-source-row.component.html @@ -19,29 +19,33 @@
- - {{ datasourceTypesTranslations.get(type) | translate }} - + @for (type of datasourceTypes; track type) { + + {{ datasourceTypesTranslations.get(type) | translate }} + + } - - - - + @if (dataSourceFormGroup.get('dsType').value === DatasourceType.device) { + + + } + @if (dataSourceFormGroup.get('dsType').value === DatasourceType.entity) { + + + }
MapDataSourceRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-data-source-row', + templateUrl: './map-data-source-row.component.html', + styleUrls: ['./map-data-source-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourceRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapDataSourceRowComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html index 0c8ae060ce..252c7b7f96 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.html @@ -22,15 +22,21 @@
widgets.maps.data-layer.filter
-
-
- - + @if (dataSourcesFormArray().controls.length) { +
+ @for (dataSourceControl of dataSourcesFormArray().controls; track trackByDataSource($index, dataSourceControl)) { +
+ + +
+ }
-
+ } @else { + {{ 'widgets.maps.data-layer.no-datasources' | translate }} + }
- - {{ 'widgets.maps.data-layer.no-datasources' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts index 4b62112cc1..71afc02dec 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-data-sources.component.ts @@ -37,22 +37,23 @@ import { import { MapSettingsContext } from '@home/components/widget/lib/settings/common/map/map-settings.component.models'; @Component({ - selector: 'tb-map-data-sources', - templateUrl: './map-data-sources.component.html', - styleUrls: ['./map-data-sources.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapDataSourcesComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapDataSourcesComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-data-sources', + templateUrl: './map-data-sources.component.html', + styleUrls: ['./map-data-sources.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapDataSourcesComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapDataSourcesComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html index d59fe2d283..69d1abeab2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html @@ -21,47 +21,79 @@ - - {{ mapProviderTranslationMap.get(provider) | translate }} - + @for (provider of mapProviders; track provider) { + + {{ mapProviderTranslationMap.get(provider) | translate }} + + } - - - - {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ googleMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ hereLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ tencentLayerTranslationMap.get(layerType) | translate }} - - - - - - + @if (MapProvider.openfreemap === layerFormGroup.get('provider').value) { + + + @for (styleType of openFreeMapStyleTypes; track styleType) { + + {{ openFreeMapStyleTranslationMap.get(styleType) | translate }} + + } + + + } + @if (MapProvider.openstreet === layerFormGroup.get('provider').value) { + + + @for (layerType of openStreetLayerTypes; track layerType) { + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.google === layerFormGroup.get('provider').value) { + + + @for (layerType of googleMapLayerTypes; track layerType) { + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.here === layerFormGroup.get('provider').value) { + + + @for (layerType of hereLayerTypes; track layerType) { + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.tencent === layerFormGroup.get('provider').value) { + + + @for (layerType of tencentLayerTypes; track layerType) { + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.custom === layerFormGroup.get('provider').value) { + + + + }
-
-
{{ (MapProvider.custom === layerFormGroup.get('provider').value ? 'widgets.maps.layer.provider.custom.tile-url' : 'widgets.maps.layer.layer') | translate }}
- - - - {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ googleMapLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ hereLayerTranslationMap.get(layerType) | translate }} - - - - - - - {{ tencentLayerTranslationMap.get(layerType) | translate }} - - - - - - -
-
-
widgets.maps.layer.credentials.credentials
+ @if (MapProvider.custom === layerFormGroup.get('provider').value) {
-
widgets.maps.layer.credentials.api-key
- - - + +
+ {{ 'widgets.maps.layer.provider.custom.vector-tiles' | translate }} +
+
+ } +
+
{{ (MapProvider.custom === layerFormGroup.get('provider').value ? (layerFormGroup.get('vectorTiles').value ? 'widgets.maps.layer.provider.custom.style-url' : 'widgets.maps.layer.provider.custom.tile-url') : 'widgets.maps.layer.layer') | translate }}
+ @if (MapProvider.openfreemap === layerFormGroup.get('provider').value) { + + + @for (styleType of openFreeMapStyleTypes; track styleType) { + + {{ openFreeMapStyleTranslationMap.get(styleType) | translate }} + + } + + + } + @if (MapProvider.openstreet === layerFormGroup.get('provider').value) { + + + @for (layerType of openStreetLayerTypes; track layerType) { + + {{ openStreetMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.google === layerFormGroup.get('provider').value) { + + + @for (layerType of googleMapLayerTypes; track layerType) { + + {{ googleMapLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.here === layerFormGroup.get('provider').value) { + + + @for (layerType of hereLayerTypes; track layerType) { + + {{ hereLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.tencent === layerFormGroup.get('provider').value) { + + + @for (layerType of tencentLayerTypes; track layerType) { + + {{ tencentLayerTranslationMap.get(layerType) | translate }} + + } + + + } + @if (MapProvider.custom === layerFormGroup.get('provider').value) { + + + + }
+ @if ([MapProvider.google, MapProvider.here].includes(layerFormGroup.get('provider').value)) { +
+
widgets.maps.layer.credentials.credentials
+
+
widgets.maps.layer.credentials.api-key
+ + + +
+
+ }
widgets.maps.layer.reference.reference-layer
{{ 'widgets.maps.layer.reference.no-layer' | translate }} - - {{ referenceLayerTypeTranslationMap.get(layer) | translate }} - + @for (layer of referenceLayerTypes; track layer) { + + {{ referenceLayerTypeTranslationMap.get(layer) | translate }} + + }
+ @if (MapProvider.custom === layerFormGroup.get('provider').value) { +
+
widgets.maps.layer.provider.custom.attribution
+ + + +
+ }
-
-
- -
- -
+ @if (layersFormArray().controls.length) { +
+ @for (layerControl of layersFormArray().controls; track trackByLayer($index, layerControl)) { +
+ +
+ +
+
+ }
-
+ } @else { + {{ 'widgets.maps.layer.no-layers' | translate }} + }
- - {{ 'widgets.maps.layer.no-layers' | translate }} - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts index feaa633b34..35dc5ff04a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.ts @@ -38,22 +38,23 @@ import { } from '@shared/models/widget/maps/map.models'; @Component({ - selector: 'tb-map-layers', - templateUrl: './map-layers.component.html', - styleUrls: ['./map-layers.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapLayersComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapLayersComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-layers', + templateUrl: './map-layers.component.html', + styleUrls: ['./map-layers.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapLayersComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapLayersComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html index 4f95435f35..943d166a0b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.html @@ -26,13 +26,17 @@ {{ 'widgets.maps.map-type.image' | translate }}
- - - - + @if (MapType.geoMap === mapSettingsFormGroup.get('mapType').value) { + + + } + @if (MapType.image === mapSettingsFormGroup.get('mapType').value) { + + + }
@@ -41,47 +45,50 @@
- {{ 'widgets.maps.overlays.trips' | translate }} + @if (trip) { + {{ 'widgets.maps.overlays.trips' | translate }} + } {{ 'widgets.maps.overlays.markers' | translate }} {{ 'widgets.maps.overlays.polygons' | translate }} {{ 'widgets.maps.overlays.circles' | translate }} - //todo translation - Polylines + {{ 'widgets.maps.overlays.polylines' | translate }}
- + @if (trip) { + + } + [mapType]="mapSettingsFormGroup.get('mapType').value"> + [mapType]="mapSettingsFormGroup.get('mapType').value"> + [mapType]="mapSettingsFormGroup.get('mapType').value"> + [mapType]="mapSettingsFormGroup.get('mapType').value">
{{ 'widgets.maps.data-layer.additional-datasources' | translate }}
+ [context]="context">
@@ -92,35 +99,45 @@
widgets.maps.control.position
- - {{ mapControlsPositionTranslationMap.get(position) | translate }} - + @for (position of mapControlPositions; track position) { + + {{ mapControlsPositionTranslationMap.get(position) | translate }} + + }
widgets.maps.control.zoom-actions
- - {{ mapZoomActionTranslationMap.get(action) | translate }} - + @for (action of mapZoomActions; track action) { + + {{ mapZoomActionTranslationMap.get(action) | translate }} + + }
widgets.maps.control.scale
- - {{ mapScaleTranslationMap.get(scale) | translate }} - + @for (scale of mapScales; track scale) { + + {{ mapScaleTranslationMap.get(scale) | translate }} + + }
-
- - {{ 'widgets.maps.control.switch-to-drag-mode-using-button' | translate }} - -
- + @if (showDragButtonModeButtonSettings) { +
+ + {{ 'widgets.maps.control.switch-to-drag-mode-using-button' | translate }} + +
+ } + @if (trip) { + + }
@@ -128,7 +145,7 @@
{{ 'widgets.maps.common.common-map-settings' | translate }}
- + @if (MapType.geoMap === mapSettingsFormGroup.get('mapType').value) {
{{ 'widgets.maps.common.fit-map-bounds' | translate }} @@ -148,7 +165,7 @@
-
+ }
widgets.maps.common.entities-limit
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts index 322fbdbde0..51b1fa271b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-settings.component.ts @@ -54,21 +54,22 @@ import { deepClone, mergeDeep } from '@core/utils'; import { MatDialog } from '@angular/material/dialog'; @Component({ - selector: 'tb-map-settings', - templateUrl: './map-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapSettingsComponent), - multi: true - } - ] + selector: 'tb-map-settings', + templateUrl: './map-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapSettingsComponent), + multi: true + } + ], + standalone: false }) export class MapSettingsComponent implements OnInit, ControlValueAccessor, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html index 73bdeff799..2c3d443378 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.html @@ -19,49 +19,57 @@
widgets.maps.data-layer.tooltip-tag-actions
- -
-
-
- - {{ action.name }} - + @for (action of actionsFormGroup.get('actions').value; track action) { + +
+
+
+ + {{ action.name }} + +
+ @if (!disabled) { + + } + @if (!disabled) { + + }
- - -
- + + } - + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts index 5c751ac950..9d2983de90 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-tooltip-tag-actions.component.ts @@ -32,22 +32,23 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { WidgetActionSettingsPanelComponent } from '@home/components/widget/lib/settings/common/action/widget-action-settings-panel.component'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TranslateService } from '@ngx-translate/core'; import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-map-tooltip-tag-actions-panel', - templateUrl: './map-tooltip-tag-actions.component.html', - styleUrls: ['./map-tooltip-tag-actions.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapTooltipTagActionsComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-map-tooltip-tag-actions-panel', + templateUrl: './map-tooltip-tag-actions.component.html', + styleUrls: ['./map-tooltip-tag-actions.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapTooltipTagActionsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MapTooltipTagActionsComponent implements ControlValueAccessor, OnInit { @@ -108,7 +109,7 @@ export class MapTooltipTagActionsComponent implements ControlValueAccessor, OnIn } } - addAction($event: Event, matButton: MatButton): void { + addAction($event: Event, matButton: MatIconButton): void { if ($event) { $event.stopPropagation(); } @@ -127,7 +128,7 @@ export class MapTooltipTagActionsComponent implements ControlValueAccessor, OnIn }); } - editAction($event: Event, matButton: MatButton, index: number): void { + editAction($event: Event, matButton: MatIconButton, index: number): void { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts index 8c7ff9371e..eac6b9e410 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-clustering-settings.component.ts @@ -31,21 +31,22 @@ import { MarkerClusteringSettings } from '@shared/models/widget/maps/map.models' import { merge } from 'rxjs'; @Component({ - selector: 'tb-marker-clustering-settings', - templateUrl: './marker-clustering-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkerClusteringSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MarkerClusteringSettingsComponent), - multi: true - } - ] + selector: 'tb-marker-clustering-settings', + templateUrl: './marker-clustering-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + } + ], + standalone: false }) export class MarkerClusteringSettingsComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html index 150789ffbd..c6b8d4eefd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.html @@ -18,10 +18,10 @@
widgets.maps.data-layer.marker.marker-icon
+ [popover]="popover" (iconSelected)="selectIcon($event)">
widgets.maps.data-layer.marker.marker-appearance
- + @for (containerInfo of iconContainers; track containerInfo) { @if (containerInfo.iconContainer === iconContainer) { } - + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts index f1671807d8..8094bc0a68 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-icon-shapes.component.ts @@ -42,11 +42,12 @@ interface MarkerIconContainerInfo { } @Component({ - selector: 'tb-marker-icon-shapes', - templateUrl: './marker-icon-shapes.component.html', - providers: [], - styleUrls: ['./marker-icon-shapes.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-marker-icon-shapes', + templateUrl: './marker-icon-shapes.component.html', + providers: [], + styleUrls: ['./marker-icon-shapes.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MarkerIconShapesComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts index eea2bc6700..67539b82f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings-panel.component.ts @@ -25,11 +25,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MarkerImageSettings, MarkerImageType } from '@shared/models/widget/maps/map.models'; @Component({ - selector: 'tb-marker-image-settings-panel', - templateUrl: './marker-image-settings-panel.component.html', - providers: [], - styleUrls: ['./marker-image-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-marker-image-settings-panel', + templateUrl: './marker-image-settings-panel.component.html', + providers: [], + styleUrls: ['./marker-image-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MarkerImageSettingsPanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html index 22d7a09f48..2a3d3fd812 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.html @@ -21,8 +21,9 @@ [disabled]="disabled" #matButton (click)="openImageSettingsPopup($event, matButton)"> - mdi:function-variant + @if (modelValue?.type === MarkerImageType.function) { + mdi:function-variant + } @else { + + } - - - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts index 0d84469410..989e9ef0c7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-image-settings.component.ts @@ -24,16 +24,17 @@ import { } from '@home/components/widget/lib/settings/common/map/marker-image-settings-panel.component'; @Component({ - selector: 'tb-marker-image-settings', - templateUrl: './marker-image-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkerImageSettingsComponent), - multi: true - } - ] + selector: 'tb-marker-image-settings', + templateUrl: './marker-image-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerImageSettingsComponent), + multi: true + } + ], + standalone: false }) export class MarkerImageSettingsComponent implements ControlValueAccessor { @@ -81,11 +82,12 @@ export class MarkerImageSettingsComponent implements ControlValueAccessor { renderer: this.renderer, componentType: MarkerImageSettingsPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: 'left', + preferredPlacement: 'leftOnly', context: { markerImageSettings: this.modelValue, }, - isModal: true + isModal: true, + overlayStyle: {padding: '10px'} }); markerImageSettingsPanelPopover.tbComponentRef.instance.popover = markerImageSettingsPanelPopover; markerImageSettingsPanelPopover.tbComponentRef.instance.markerImageSettingsApplied.subscribe((markerImageSettings) => { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html index 0c76ad3fe5..5961577162 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.html @@ -26,13 +26,17 @@ [disabled]="disabled" #matButton (click)="openShapePopup($event, matButton)"> - -
-
+ @if (markerType === MarkerType.shape) { + + } + @if (markerType === MarkerType.icon) { +
+
+ } + helpId="widget/lib/map/color_fn" formControlName="color">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts index 319b423b2d..e251f6f9a8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shape-settings.component.ts @@ -53,16 +53,17 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { DatasourceType } from '@shared/models/widget.models'; @Component({ - selector: 'tb-marker-shape-settings', - templateUrl: './marker-shape-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkerShapeSettingsComponent), - multi: true - } - ] + selector: 'tb-marker-shape-settings', + templateUrl: './marker-shape-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerShapeSettingsComponent), + multi: true + } + ], + standalone: false }) export class MarkerShapeSettingsComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html index e9611bf955..2e94accae9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.html @@ -18,21 +18,25 @@
widgets.maps.data-layer.marker.marker-shapes
- - - - + @for (shapeInfo of shapes; track shapeInfo) { + @if (shapeInfo.shape === shape) { + + } + @if (shapeInfo.shape !== shape) { + + } + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts index 5499c6e39c..23fadbf093 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/marker-shapes.component.ts @@ -37,11 +37,12 @@ interface MarkerShapeInfo { } @Component({ - selector: 'tb-marker-shapes', - templateUrl: './marker-shapes.component.html', - providers: [], - styleUrls: ['./marker-shapes.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-marker-shapes', + templateUrl: './marker-shapes.component.html', + providers: [], + styleUrls: ['./marker-shapes.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MarkerShapesComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts index 8988365644..dcea878241 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component.ts @@ -22,11 +22,12 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ShapeFillImageSettings, ShapeFillImageType } from '@shared/models/widget/maps/map.models'; @Component({ - selector: 'tb-shape-fill-image-settings-panel', - templateUrl: './shape-fill-image-settings-panel.component.html', - providers: [], - styleUrls: ['./shape-fill-image-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-shape-fill-image-settings-panel', + templateUrl: './shape-fill-image-settings-panel.component.html', + providers: [], + styleUrls: ['./shape-fill-image-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ShapeFillImageSettingsPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html index f890575008..e5157f09ea 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.html @@ -21,9 +21,10 @@ [disabled]="disabled" #matButton (click)="openImageSettingsPopup($event, matButton)"> - mdi:function-variant + @if (modelValue?.type === ShapeFillImageType.function) { + mdi:function-variant + } @else { + + } - - - diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts index 466525f46f..74f4ddd1eb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-image-settings.component.ts @@ -24,16 +24,17 @@ import { } from '@home/components/widget/lib/settings/common/map/shape-fill-image-settings-panel.component'; @Component({ - selector: 'tb-shape-fill-image-settings', - templateUrl: './shape-fill-image-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ShapeFillImageSettingsComponent), - multi: true - } - ] + selector: 'tb-shape-fill-image-settings', + templateUrl: './shape-fill-image-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ShapeFillImageSettingsComponent), + multi: true + } + ], + standalone: false }) export class ShapeFillImageSettingsComponent implements ControlValueAccessor { @@ -81,11 +82,12 @@ export class ShapeFillImageSettingsComponent implements ControlValueAccessor { renderer: this.renderer, componentType: ShapeFillImageSettingsPanelComponent, hostView: this.viewContainerRef, - preferredPlacement: 'left', + preferredPlacement: 'leftOnly', context: { shapeFillImageSettings: this.modelValue, }, - isModal: true + isModal: true, + overlayStyle: {padding: '10px'} }).tbComponentRef.instance.shapeFillImageSettingsApplied.subscribe((shapeFillImageSettings) => { this.modelValue = shapeFillImageSettings; this.propagateChange(this.modelValue); diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts index 60fab91c82..52ab290e6f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component.ts @@ -28,11 +28,12 @@ import { MapSettingsContext } from '@home/components/widget/lib/settings/common/ import { DatasourceType } from '@shared/models/widget.models'; @Component({ - selector: 'tb-shape-fill-stripe-settings-panel', - templateUrl: './shape-fill-stripe-settings-panel.component.html', - providers: [], - styleUrls: ['./shape-fill-stripe-settings-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-shape-fill-stripe-settings-panel', + templateUrl: './shape-fill-stripe-settings-panel.component.html', + providers: [], + styleUrls: ['./shape-fill-stripe-settings-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ShapeFillStripeSettingsPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts index 0b8d19b4a7..b1f65c541c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/shape-fill-stripe-settings.component.ts @@ -28,16 +28,17 @@ import { } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; @Component({ - selector: 'tb-shape-fill-stripe-settings', - templateUrl: './shape-fill-stripe-settings.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ShapeFillStripeSettingsComponent), - multi: true - } - ] + selector: 'tb-shape-fill-stripe-settings', + templateUrl: './shape-fill-stripe-settings.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ShapeFillStripeSettingsComponent), + multi: true + } + ], + standalone: false }) export class ShapeFillStripeSettingsComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html index 966cef8c3f..36b6ed86fb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/trip-timeline-settings.component.html @@ -38,9 +38,11 @@
widgets.maps.timeline.speed-options
- - {{ speed }} - + @for (speed of [1,5,10,15,25]; track speed) { + + {{ speed }} + + }
@@ -50,7 +52,7 @@ + formControlName="timestampFormat">
TripTimelineSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TripTimelineSettingsComponent), - multi: true - } - ] + selector: 'tb-trip-timeline-settings', + templateUrl: './trip-timeline-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripTimelineSettingsComponent), + multi: true + } + ], + standalone: false }) export class TripTimelineSettingsComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html index d0a1ef44c0..56ed28abd8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.html @@ -16,26 +16,30 @@ --> -
-
scada.behavior.behavior
- -
- - - -
{{ behaviorGroup.title | customTranslate }}
-
-
- - - -
-
- - - -
-
+ @if (behaviorGroups?.length) { +
+
scada.behavior.behavior
+ @for (behaviorGroup of behaviorGroups; track behaviorGroup) { + @if (behaviorGroup.title) { +
+ + + +
{{ behaviorGroup.title | customTranslate }}
+
+
+ + + +
+
+ } + @if (!behaviorGroup.title) { + + } + } +
+ } @@ -44,43 +48,49 @@
- + @for (behavior of behaviors; track behavior) { - + }
{{ behavior.name | customTranslate }}
- - - - - - + @if (behavior.type === ScadaSymbolBehaviorType.value) { + + + } + @if (behavior.type === ScadaSymbolBehaviorType.action) { + + + } + @if (behavior.type === ScadaSymbolBehaviorType.widgetAction) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts index 80d31cf678..90616286f5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/scada/scada-symbol-object-settings.component.ts @@ -56,21 +56,22 @@ import { map } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-scada-symbol-object-settings', - templateUrl: './scada-symbol-object-settings.component.html', - styleUrls: ['./scada-symbol-object-settings.component.scss', './../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), - multi: true - } - ] + selector: 'tb-scada-symbol-object-settings', + templateUrl: './scada-symbol-object-settings.component.html', + styleUrls: ['./scada-symbol-object-settings.component.scss', './../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolObjectSettingsComponent), + multi: true + } + ], + standalone: false }) export class ScadaSymbolObjectSettingsComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.html index c24ff79466..a2e07002a1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.html @@ -19,59 +19,67 @@
- - {{ valueSourceDataKeyTypeTranslation.get(type) | translate }} - + @for (type of valueSourceDataKeyTypes; track type) { + + {{ valueSourceDataKeyTypeTranslation.get(type) | translate }} + + } - - + @if (valueSourceFormGroup.get('type').value === valueSourceDataKeyType.entity) { + + + }
- - - - warning - - - - - - + @if (valueSourceFormGroup.get('type').value === valueSourceDataKeyType.constant) { + + + @if (valueSourceFormGroup.get('value').hasError('required') + && valueSourceFormGroup.get('value').touched) { + + warning + + } + + } + @if (valueSourceFormGroup.get('type').value === valueSourceDataKeyType.latestKey) { + + + } + @if (valueSourceFormGroup.get('type').value === valueSourceDataKeyType.entity) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts index 2bc5f55597..cabeb765c8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source-data-key.component.ts @@ -39,16 +39,17 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-value-source-data-key', - templateUrl: './value-source-data-key.component.html', - styleUrls: ['value-source-data-key.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ValueSourceDataKeyComponent), - multi: true - } - ] + selector: 'tb-value-source-data-key', + templateUrl: './value-source-data-key.component.html', + styleUrls: ['value-source-data-key.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ValueSourceDataKeyComponent), + multi: true + } + ], + standalone: false }) export class ValueSourceDataKeyComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html index b088489cc9..8721b060f7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.html @@ -40,16 +40,20 @@ #entityAliasInput formControlName="entityAlias" [matAutocomplete]="entityAliasAutocomplete"> - + @if (valueSourceFormGroup.get('entityAlias').value && !valueSourceFormGroup.get('entityAlias').disabled) { + + } - - - + @for (entityAlias of filteredEntityAliases | async; track entityAlias) { + + + + }
@@ -57,16 +61,20 @@ #keyInput formControlName="attribute" [matAutocomplete]="keyAutocomplete"> - + @if (valueSourceFormGroup.get('attribute').value && !valueSourceFormGroup.get('attribute').disabled) { + + } - - - + @for (key of filteredKeys | async; track key) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts index e006e54208..2b91efa474 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/value-source.component.ts @@ -37,16 +37,17 @@ export interface ValueSourceProperty { } @Component({ - selector: 'tb-value-source', - templateUrl: './value-source.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ValueSourceComponent), - multi: true - } - ] + selector: 'tb-value-source', + templateUrl: './value-source.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ValueSourceComponent), + multi: true + } + ], + standalone: false }) export class ValueSourceComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html index edba87c0db..4e1e5e460c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html @@ -15,19 +15,19 @@ limitations under the License. --> -
+
- + widgets.widget-font.font-family - + {{ sizeTitle }}
- + widgets.widget-font.font-style @@ -41,7 +41,7 @@ - + widgets.widget-font.font-weight @@ -71,16 +71,19 @@
- - + @if (hasShadowColor) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.ts index 980cfe5780..79633ac7f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.ts @@ -32,16 +32,17 @@ export interface WidgetFont { } @Component({ - selector: 'tb-widget-font', - templateUrl: './widget-font.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetFontComponent), - multi: true - } - ] + selector: 'tb-widget-font', + templateUrl: './widget-font.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetFontComponent), + multi: true + } + ], + standalone: false }) export class WidgetFontComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts index 2a9d05b57b..8d6e96332f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-settings-common.module.ts @@ -267,6 +267,9 @@ import { import { ShapeFillStripeSettingsPanelComponent } from '@home/components/widget/lib/settings/common/map/shape-fill-stripe-settings-panel.component'; +import { + HtmlContainerSettingsComponent +} from '@home/components/widget/lib/settings/common/html/html-container-settings.component'; import { AxisScaleRowComponent } from './axis-scale-row.component'; @NgModule({ @@ -374,6 +377,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component'; DataKeyConfigDialogComponent, DataKeyConfigComponent, WidgetSettingsComponent, + HtmlContainerSettingsComponent, AxisScaleRowComponent ], imports: [ @@ -456,6 +460,7 @@ import { AxisScaleRowComponent } from './axis-scale-row.component'; DataKeyConfigDialogComponent, DataKeyConfigComponent, WidgetSettingsComponent, + HtmlContainerSettingsComponent, AxisScaleRowComponent ], providers: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html index 65f2044a7b..e3b048772e 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.html @@ -15,11 +15,15 @@ limitations under the License. --> -
+
-
{{definedDirectiveError}}
- - + @if (definedDirectiveError) { +
{{definedDirectiveError}}
+ } + @if (useDynamicForm()) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts index 7b07fe47e0..d779a634ac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget/widget-settings.component.ts @@ -47,19 +47,20 @@ import { WidgetConfigCallbacks } from '@home/components/widget/config/widget-con import { FormProperty } from '@shared/models/dynamic-form.models'; @Component({ - selector: 'tb-widget-settings', - templateUrl: './widget-settings.component.html', - styleUrls: ['./widget-settings.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => WidgetSettingsComponent), - multi: true - }] + selector: 'tb-widget-settings', + templateUrl: './widget-settings.component.html', + styleUrls: ['./widget-settings.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => WidgetSettingsComponent), + multi: true + }], + standalone: false }) export class WidgetSettingsComponent implements ControlValueAccessor, OnDestroy, OnChanges, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html index 5882b18996..764651379d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.html @@ -15,43 +15,52 @@ limitations under the License. --> - - {{ (keyType === dataKeyType.attribute - ? attributeLabel + [formGroup]="deviceKeyFormGroup"> + @if (!inlineField) { + {{ (keyType === dataKeyType.attribute + ? attributeLabel : timeseriesLabel) | translate }} + } - - - warning - + @if (deviceKeyFormGroup.get('key').value && !deviceKeyFormGroup.get('key').disabled) { + + } + @if (inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required') + && deviceKeyFormGroup.get('key').touched) { + + warning + + } - - - + @for (key of filteredKeys | async; track key) { + + + + } - - {{ requiredText | translate }} - + @if (!inlineField && requiredText && deviceKeyFormGroup.get('key').hasError('required')) { + + {{ requiredText | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts index 52110ad50a..d1c215a668 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/device-key-autocomplete.component.ts @@ -47,18 +47,20 @@ import { EntityFilter, singleEntityFilterFromDeviceId } from '@shared/models/que import { AliasFilterType } from '@shared/models/alias.models'; import { coerceBoolean } from '@shared/decorators/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-device-key-autocomplete', - templateUrl: './device-key-autocomplete.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceKeyAutocompleteComponent), - multi: true - } - ] + selector: 'tb-device-key-autocomplete', + templateUrl: './device-key-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceKeyAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class DeviceKeyAutocompleteComponent extends PageComponent implements OnInit, ControlValueAccessor, OnChanges { @@ -96,6 +98,9 @@ export class DeviceKeyAutocompleteComponent extends PageComponent implements OnI @coerceBoolean() inlineField: boolean; + @Input() + appearance: MatFormFieldAppearance; + dataKeyType = DataKeyType; private modelValue: string; diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts index 481097cf94..9c115025d7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/knob-control-widget-settings.component.ts @@ -24,9 +24,10 @@ import { ValueType } from '@shared/models/constants'; import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-knob-control-widget-settings', - templateUrl: './knob-control-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-knob-control-widget-settings', + templateUrl: './knob-control-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class KnobControlWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html index b4f79384ce..aa5b32162c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.html @@ -18,12 +18,13 @@
widgets.rpc.common-settings - + widgets.rpc.led-title @@ -38,7 +39,7 @@ {{ 'widgets.rpc.perform-rpc-status-check' | translate }} - + widgets.rpc.retrieve-led-status-value-method @@ -53,13 +54,14 @@ [class.!hidden]="ledIndicatorWidgetSettingsForm.get('performCheckStatus').value" [aliasController]="aliasController" [targetDevice]="targetDevice" + appearance="outline" [keyType]="ledIndicatorWidgetSettingsForm.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" attributeLabel="widgets.rpc.led-status-value-attribute" timeseriesLabel="widgets.rpc.led-status-value-timeseries" required formControlName="valueAttribute"> - + widgets.rpc.check-status-method @@ -73,7 +75,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -92,7 +94,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts index 45cfbcf7ec..faaa56db65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/led-indicator-widget-settings.component.ts @@ -23,9 +23,10 @@ import { WidgetService } from '@core/http/widget.service'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; @Component({ - selector: 'tb-led-indicator-widget-settings', - templateUrl: './led-indicator-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-led-indicator-widget-settings', + templateUrl: './led-indicator-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class LedIndicatorWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html index 76929a5394..4765dabde3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.html @@ -55,34 +55,36 @@ - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + }
@@ -90,14 +92,16 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + }
- + widgets.table.default-sort-order @@ -105,18 +109,20 @@
widgets.persistent-table.columns-settings - + widgets.persistent-table.display-columns - - {{ displayColumnFromValue(column)?.name }} - cancel - + @for (column of persistentTableWidgetSettingsForm.get('displayColumns').value; track column) { + + {{ displayColumnFromValue(column)?.name }} + cancel + + } - - - - -
-
- widgets.persistent-table.no-columns-found -
- + @for (column of filteredDisplayColumns | async; track column) { + + + + } + @if ((filteredDisplayColumns | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(columnSearchText)) { +
+ widgets.persistent-table.no-columns-found +
+ } @else { {{ translate.get('widgets.persistent-table.no-columns-matching', {column: truncate.transform(columnSearchText, true, 6, '...')}) | async }} - -
-
+ } +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts index d89e46e7b1..170947af25 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/persistent-table-widget-settings.component.ts @@ -35,9 +35,10 @@ interface DisplayColumn { } @Component({ - selector: 'tb-persistent-table-widget-settings', - templateUrl: './persistent-table-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-persistent-table-widget-settings', + templateUrl: './persistent-table-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class PersistentTableWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html index 15dd118737..9e6b354ab0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.common-settings - + widgets.rpc.switch-title diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts index 6f008e5a82..8c443e1462 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/round-switch-widget-settings.component.ts @@ -23,9 +23,10 @@ import { switchRpcDefaultSettings } from '@home/components/widget/lib/settings/c import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-round-switch-widget-settings', - templateUrl: './round-switch-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-round-switch-widget-settings', + templateUrl: './round-switch-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class RoundSwitchWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html index c6a9618641..9a988b763f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.html @@ -26,12 +26,14 @@
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.ts index 6edd8f191b..730bf1a0b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-button-style.component.ts @@ -29,16 +29,17 @@ export interface RpcButtonStyle { } @Component({ - selector: 'tb-rpc-button-style', - templateUrl: './rpc-button-style.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RpcButtonStyleComponent), - multi: true - } - ] + selector: 'tb-rpc-button-style', + templateUrl: './rpc-button-style.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RpcButtonStyleComponent), + multi: true + } + ], + standalone: false }) export class RpcButtonStyleComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html index 608dd81aed..00eef97a77 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.ts index 157689a4ad..141ac5a6de 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-shell-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-rpc-shell-widget-settings', - templateUrl: './rpc-shell-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-rpc-shell-widget-settings', + templateUrl: './rpc-shell-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class RpcShellWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html index 15fcefcf9a..9fd436701a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -37,7 +37,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.ts index 77e60522fe..31a5a0d81b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/rpc-terminal-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-rpc-terminal-widget-settings', - templateUrl: './rpc-terminal-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-rpc-terminal-widget-settings', + templateUrl: './rpc-terminal-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class RpcTerminalWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html index 72bc8d8219..2a22fdd74b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.html @@ -18,11 +18,11 @@
widgets.rpc.common-settings - + widgets.rpc.widget-title - + widgets.rpc.button-label @@ -32,7 +32,7 @@ {{ 'widgets.rpc.is-one-way-command' | translate }} - + widgets.rpc.rpc-method @@ -42,7 +42,7 @@ validateOnChange formControlName="methodParams"> - + widgets.rpc.request-timeout @@ -64,7 +64,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts index b8af3eaf0d..4319b823c2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/send-rpc-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { ContentType } from '@shared/models/constants'; @Component({ - selector: 'tb-send-rpc-widget-settings', - templateUrl: './send-rpc-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-send-rpc-widget-settings', + templateUrl: './send-rpc-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class SendRpcWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html index 330433141e..e34b9ae2a0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.html @@ -29,7 +29,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.rpc-state.turn-on
@@ -38,7 +38,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="onUpdateState"> + formControlName="onUpdateState">
widgets.rpc-state.turn-off
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="offUpdateState"> + formControlName="offUpdateState">
widgets.rpc-state.disabled-state
@@ -58,7 +58,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -69,12 +69,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> - - {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.single-switch.layout' | translate }}" formControlName="layout"> + @for (layout of singleSwitchLayouts; track layout) { + + {{ singleSwitchLayoutTranslationMap.get(layout) | translate }} + + }
@@ -87,15 +89,15 @@
+ appearance="outline" + brackets="curly" + subscriptSizing="dynamic" + predefinedValuesButton="true" + [disabled]="!singleSwitchWidgetSettingsForm.get('showLabel').value" + [predefinedValues]="predefinedValues" + placeholderText="{{ 'widget-config.set' | translate }}" + formControlName="label" + > diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts index d841317283..73124033e5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/single-switch-widget-settings.component.ts @@ -28,9 +28,10 @@ import { import { ValueType } from '@shared/models/constants'; @Component({ - selector: 'tb-single-switch-widget-settings', - templateUrl: './single-switch-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-single-switch-widget-settings', + templateUrl: './single-switch-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class SingleSwitchWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html index 51af51985d..4527111874 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slide-toggle-widget-settings.component.html @@ -18,8 +18,8 @@
widgets.rpc.common-settings - + formControlName="initialState">
widgets.slider.on-value-change
@@ -36,7 +36,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="valueChange"> + formControlName="valueChange">
widgets.rpc-state.disabled-state
@@ -47,7 +47,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -58,12 +58,14 @@ 'lt-sm': 1, 'lt-md': 2 }}" - label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> - - {{ sliderLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.slider.layout' | translate }}" formControlName="layout"> + @for (layout of sliderLayouts; track layout) { + + {{ sliderLayoutTranslationMap.get(layout) | translate }} + + }
@@ -84,25 +86,27 @@
widgets.slider.slider
-
- - {{ 'widgets.slider.value' | translate }} - -
- - - -
widget-config.decimals-suffix
-
- - - - + @if (sliderWidgetSettingsForm.get('layout').value !== sliderLayout.simplified) { +
+ + {{ 'widgets.slider.value' | translate }} + +
+ + + +
widget-config.decimals-suffix
+
+ + + + +
-
+ }
{{ 'widgets.slider.range' | translate }}
@@ -180,43 +184,47 @@
-
-
- {{ 'widgets.slider.left-icon' | translate }} -
-
- - - - - - - - -
-
-
-
- {{ 'widgets.slider.right-icon' | translate }} + @if (sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.left-icon' | translate }} +
+
+ + + + + + + + +
-
- - - - - - - - + } + @if (sliderWidgetSettingsForm.get('layout').value === sliderLayout.extended) { +
+
+ {{ 'widgets.slider.right-icon' | translate }} +
+
+ + + + + + + + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts index 53a9d483a7..f3899d7c66 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.ts @@ -31,9 +31,10 @@ import { formatValue } from '@core/utils'; import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; @Component({ - selector: 'tb-slider-widget-settings', - templateUrl: './slider-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-slider-widget-settings', + templateUrl: './slider-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class SliderWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html index 145459382d..19ab4f24f8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.html @@ -18,7 +18,7 @@
widgets.rpc.common-settings - + widgets.rpc.switch-title diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts index d51c9f8d8e..c9ea433d93 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-control-widget-settings.component.ts @@ -23,9 +23,10 @@ import { switchRpcDefaultSettings } from '@home/components/widget/lib/settings/c import { deepClone } from '@core/utils'; @Component({ - selector: 'tb-switch-control-widget-settings', - templateUrl: './switch-control-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-switch-control-widget-settings', + templateUrl: './switch-control-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class SwitchControlWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html index 3536341211..7ab592ef24 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.html @@ -23,7 +23,7 @@
widgets.rpc.retrieve-value-settings - + widgets.rpc.retrieve-value-method @@ -45,11 +45,12 @@ switchRpcSettingsFormGroup.get('retrieveValueMethod').value !== 'timeseries'" [aliasController]="aliasController" [targetDevice]="targetDevice" + appearance="outline" [keyType]="switchRpcSettingsFormGroup.get('retrieveValueMethod').value === 'attribute' ? dataKeyType.attribute : dataKeyType.timeseries" required formControlName="valueKey"> - + widgets.rpc.get-value-method @@ -63,7 +64,7 @@
widgets.rpc.update-value-settings - + widgets.rpc.set-value-method @@ -78,7 +79,7 @@
widgets.rpc.rpc-settings - + widgets.rpc.request-timeout @@ -97,7 +98,7 @@ - + widgets.rpc.persistent-polling-interval diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts index e58006d1cf..29f436c61d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/switch-rpc-settings.component.ts @@ -65,21 +65,22 @@ export const switchRpcDefaultSettings = (): SwitchRpcSettings => ({ }); @Component({ - selector: 'tb-switch-rpc-settings', - templateUrl: './switch-rpc-settings.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SwitchRpcSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SwitchRpcSettingsComponent), - multi: true - } - ] + selector: 'tb-switch-rpc-settings', + templateUrl: './switch-rpc-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SwitchRpcSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SwitchRpcSettingsComponent), + multi: true + } + ], + standalone: false }) export class SwitchRpcSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html index 192e5a7be7..e881157fa9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.html @@ -18,19 +18,20 @@
widgets.rpc.common-settings - - + widgets.rpc.button-label - + widgets.rpc.device-attribute-scope diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.ts index 765890a101..a76016b1e7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/update-device-attribute-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { ContentType } from '@shared/models/constants'; @Component({ - selector: 'tb-update-device-attribute-widget-settings', - templateUrl: './update-device-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-device-attribute-widget-settings', + templateUrl: './update-device-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateDeviceAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.html index bb81b8d5c2..0f30cc1434 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.html @@ -26,7 +26,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + formControlName="initialState">
widgets.value-stepper.left-button-click
@@ -36,7 +36,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="leftButtonClick"> + formControlName="leftButtonClick">
widgets.value-stepper.right-button-click
@@ -46,7 +46,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="rightButtonClick"> + formControlName="rightButtonClick">
widgets.button-state.disabled-state
@@ -57,7 +57,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="disabledState"> + formControlName="disabledState">
@@ -67,12 +67,14 @@ breakpoints: { 'lt-sm': 1 }}" - label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> - - {{ valueStepperTypeTranslationMap.get(type) | translate }} - + label="{{ 'widgets.button.layout' | translate }}" formControlName="type"> + @for (type of valueStepperTypes; track type) { + + {{ valueStepperTypeTranslationMap.get(type) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts index 72377dddbc..ac3921f3ab 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/value-stepper-widget-settings.component.ts @@ -33,9 +33,10 @@ import { getSourceTbUnitSymbol } from '@shared/models/unit.models'; type ButtonAppearanceType = 'left' | 'right'; @Component({ - selector: 'tb-value-stepper-widget-settings', - templateUrl: './value-stepper-widget-settings.component.html', - styleUrls: ['../widget-settings.scss'] + selector: 'tb-value-stepper-widget-settings', + templateUrl: './value-stepper-widget-settings.component.html', + styleUrls: ['../widget-settings.scss'], + standalone: false }) export class ValueStepperWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html index 3921e3655b..417ca8d436 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.html @@ -41,7 +41,7 @@ {{ 'widgets.date-range-navigator.picker-show-template' | translate }} - + widgets.date-range-navigator.first-day-of-week @@ -64,7 +64,7 @@ - + widgets.date-range-navigator.initial-interval @@ -108,7 +108,7 @@ - + widgets.date-range-navigator.initial-step-size diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.ts index ece9e5f9dc..183009aef7 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/date/date-range-navigator-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-date-range-navigator-widget-settings', - templateUrl: './date-range-navigator-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-date-range-navigator-widget-settings', + templateUrl: './date-range-navigator-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class DateRangeNavigatorWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-hierarchy-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-hierarchy-widget-settings.component.ts index a990e234bb..3acf41af24 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-hierarchy-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-hierarchy-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-entities-hierarchy-widget-settings', - templateUrl: './entities-hierarchy-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-entities-hierarchy-widget-settings', + templateUrl: './entities-hierarchy-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class EntitiesHierarchyWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts index 22483166f0..c1c4a5eab3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-key-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-entities-table-key-settings', - templateUrl: './entities-table-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-entities-table-key-settings', + templateUrl: './entities-table-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class EntitiesTableKeySettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html index eb36eafb06..8950de48f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.html @@ -20,14 +20,14 @@
widgets.table.table-header
{{ 'widgets.table.entities-table-title' | translate }}
-
@@ -104,34 +104,36 @@ - - warning - + class="tb-error"> + warning + + }
widgets.table.page-step-count
- - warning - + class="tb-error"> + warning + + }
@@ -139,9 +141,11 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts index e4238a13d2..68a560a956 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entities-table-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { buildPageStepSizeValues } from '@home/components/widget/lib/table-widget.models'; @Component({ - selector: 'tb-entities-table-widget-settings', - templateUrl: './entities-table-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-entities-table-widget-settings', + templateUrl: './entities-table-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class EntitiesTableWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entity-count-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entity-count-widget-settings.component.ts index e8bc5f9bad..8307aaff1d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entity-count-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/entity/entity-count-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { countDefaultSettings } from '@home/components/widget/lib/count/count-widget.models'; @Component({ - selector: 'tb-entity-count-widget-settings', - templateUrl: './entity-count-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-entity-count-widget-settings', + templateUrl: './entity-count-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class EntityCountWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts index 60035457d1..9b91652207 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-single-device-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-gateway-config-single-device-widget-settings', - templateUrl: './gateway-config-single-device-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-gateway-config-single-device-widget-settings', + templateUrl: './gateway-config-single-device-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class GatewayConfigSingleDeviceWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html index c525c67b56..3b35789e65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.html @@ -18,31 +18,32 @@
widgets.gateway.general-settings - - + widgets.gateway.default-archive-file-name - + widgets.gateway.device-type-for-new-gateway
widgets.gateway.messages-settings - + widgets.gateway.save-config-success-message - + widgets.gateway.device-name-exists-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.ts index d2d921db68..0eef25e44d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-config-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-gateway-config-widget-settings', - templateUrl: './gateway-config-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-gateway-config-widget-settings', + templateUrl: './gateway-config-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class GatewayConfigWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html index 1bebce0b7d..41e6a93d65 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gateway/gateway-events-widget-settings.component.html @@ -16,25 +16,27 @@ -->
- - + widgets.gateway.events-filter - - {{eventFilter}} - cancel - + @for (eventFilter of gatewayEventsWidgetSettingsForm.get('eventsReg').value; track eventFilter) { + + {{eventFilter}} + cancel + + } - - + @switch (gaugeType) { + @case ('radial') { - - + } + @case ('linear') { - - + } + }
widgets.gauge.ticks-settings
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts index ef7589de8c..58741c3350 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-linear-gauge-widget-settings.component.ts @@ -24,9 +24,10 @@ import { } from '@home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component'; @Component({ - selector: 'tb-analogue-linear-gauge-widget-settings', - templateUrl: './analogue-gauge-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-analogue-linear-gauge-widget-settings', + templateUrl: './analogue-gauge-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AnalogueLinearGaugeWidgetSettingsComponent extends AnalogueGaugeWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts index 891e248d67..6b60cf8ac0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/analogue-radial-gauge-widget-settings.component.ts @@ -24,9 +24,10 @@ import { } from '@home/components/widget/lib/settings/gauge/analogue-gauge-widget-settings.component'; @Component({ - selector: 'tb-analogue-radial-gauge-widget-settings', - templateUrl: './analogue-gauge-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-analogue-radial-gauge-widget-settings', + templateUrl: './analogue-gauge-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class AnalogueRadialGaugeWidgetSettingsComponent extends AnalogueGaugeWidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html index 29d9a65d17..3decf4f45f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/digital-gauge-widget-settings.component.html @@ -23,21 +23,25 @@ breakpoints: { 'lt-md': 2 }}" - label="{{ 'widgets.gauge.simple-gauge-type' | translate }}" - formControlName="gaugeType"> - - {{ digitalGaugeLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.gauge.simple-gauge-type' | translate }}" + formControlName="gaugeType"> + @for (layout of digitalGaugeLayouts; track layout) { + + {{ digitalGaugeLayoutTranslationMap.get(layout) | translate }} + + } -
-
{{ 'widgets.gauge.donut-start-angle' | translate }}
- - - -
+ @if (digitalGaugeWidgetSettingsForm.get('gaugeType').value === digitalGaugeType.donut) { +
+
{{ 'widgets.gauge.donut-start-angle' | translate }}
+ + + +
+ }
@@ -65,14 +69,15 @@
widgets.gauge.max-value-short
- - warning - + @if (digitalGaugeWidgetSettingsForm.get('maxValue').hasError('maxValue')) { + + warning + + }
@@ -227,8 +232,8 @@
widgets.gauge.tick-values
-
+ @for (tickValueControl of tickValuesFormArray().controls; track trackByTickValue($index, tickValueControl); let isLast = $last) { +
drag_indicator -
-
-
- widgets.gauge.no-tick-values +
+ }
+ @if (!tickValuesFormArray().controls.length) { +
+ widgets.gauge.no-tick-values +
+ }
+ @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts index dbc093a11f..82e8aacd1f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gauge/tick-value.component.ts @@ -27,16 +27,17 @@ import { Datasource } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-tick-value', - templateUrl: './tick-value.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TickValueComponent), - multi: true - } - ] + selector: 'tb-tick-value', + templateUrl: './tick-value.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TickValueComponent), + multi: true + } + ], + standalone: false }) export class TickValueComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html index 9a00b3bb4c..c995a68414 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-control-widget-settings.component.html @@ -21,6 +21,7 @@ @@ -28,19 +29,22 @@ widgets.gpio.gpio-switches
-
- - -
+ @for (gpioItemControl of typedSelectOptions; track trackByGpioItem($index, gpioItemControl); let isLast = $last) { +
+ + +
+ }
-
+ @if (!gpioListFormArray().controls.length) { +
widgets.gpio.no-gpio-switches -
+ class="tb-prompt flex items-center justify-center">widgets.gpio.no-gpio-switches +
+ }
-
-
-
+ @if (hasColor) { +
+
+
+ }
- + @if (!disabled) { + + }
@@ -46,32 +50,34 @@
- + widgets.gpio.pin - + widgets.gpio.label
- + widgets.gpio.row - + widgets.gpio.column
- - + @if (hasColor) { + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.ts index cae8737c3b..229fc61c6f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-item.component.ts @@ -55,16 +55,17 @@ export const gpioItemValidator = (hasColor: boolean): ValidatorFn => (control: A }; @Component({ - selector: 'tb-gpio-item', - templateUrl: './gpio-item.component.html', - styleUrls: ['./gpio-item.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GpioItemComponent), - multi: true - } - ] + selector: 'tb-gpio-item', + templateUrl: './gpio-item.component.html', + styleUrls: ['./gpio-item.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GpioItemComponent), + multi: true + } + ], + standalone: false }) export class GpioItemComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html index 1b7ff3d4e5..f002322bfe 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/gpio/gpio-panel-widget-settings.component.html @@ -28,20 +28,23 @@ widgets.gpio.gpio-leds
-
- - -
+ @for (gpioItemControl of typedSelectOptions; track trackByGpioItem($index, gpioItemControl); let isLast = $last) { +
+ + +
+ }
-
+ @if (!gpioListFormArray().controls.length) { +
widgets.gpio.no-gpio-leds -
+ class="tb-prompt flex items-center justify-center">widgets.gpio.no-gpio-leds +
+ }
+ @if (!disabled) { + + }
@@ -41,11 +45,11 @@
- + widgets.input-widgets.option-value - + widgets.input-widgets.option-label diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.ts index 63689be5c5..1734b25c46 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/datakey-select-option.component.ts @@ -45,16 +45,17 @@ export const dataKeySelectOptionValidator = (control: AbstractControl) => { }; @Component({ - selector: 'tb-datakey-select-option', - templateUrl: './datakey-select-option.component.html', - styleUrls: ['./datakey-select-option.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DataKeySelectOptionComponent), - multi: true - } - ] + selector: 'tb-datakey-select-option', + templateUrl: './datakey-select-option.component.html', + styleUrls: ['./datakey-select-option.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DataKeySelectOptionComponent), + multi: true + } + ], + standalone: false }) export class DataKeySelectOptionComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html index f11b16200f..53a508a038 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.html @@ -18,11 +18,11 @@
widgets.input-widgets.general-settings - + widgets.input-widgets.widget-title - + widgets.input-widgets.claim-button-label @@ -46,11 +46,11 @@
- + widgets.input-widgets.device-name-label - + widgets.input-widgets.secret-key-label @@ -60,23 +60,23 @@
widgets.input-widgets.messages-settings - + widgets.input-widgets.claim-device-success-message - + widgets.input-widgets.claim-device-not-found-message - + widgets.input-widgets.claim-device-failed-message - + widgets.input-widgets.claim-device-name-required-message - + widgets.input-widgets.claim-device-secret-key-required-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.ts index a74152df79..00993a3b27 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/device-claiming-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-device-claiming-widget-settings', - templateUrl: './device-claiming-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-device-claiming-widget-settings', + templateUrl: './device-claiming-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class DeviceClaimingWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts index 097d09ad9b..60245faff4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/photo-camera-input-widget-settings.component.ts @@ -21,9 +21,10 @@ import { AppState } from '@core/core.state'; import { Store } from '@ngrx/store'; @Component({ - selector: 'tb-photo-camera-input-widget-settings', - templateUrl: './photo-camera-input-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-photo-camera-input-widget-settings', + templateUrl: './photo-camera-input-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class PhotoCameraInputWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html index efa5b6cd3a..f23e000981 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.html @@ -18,23 +18,25 @@
widgets.input-widgets.general-settings -
{{ 'widgets.input-widgets.show-label' | translate }} - - widgets.input-widgets.label - - + @if (hasLabelValue) { + + widgets.input-widgets.label + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.ts index acfc9a8b59..f6d89abfd0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-attribute-general-settings.component.ts @@ -55,21 +55,22 @@ export function updateAttributeGeneralDefaultSettings(hasLabelValue = true): Upd } @Component({ - selector: 'tb-update-attribute-general-settings', - templateUrl: './update-attribute-general-settings.component.html', - styleUrls: ['./../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UpdateAttributeGeneralSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => UpdateAttributeGeneralSettingsComponent), - multi: true - } - ] + selector: 'tb-update-attribute-general-settings', + templateUrl: './update-attribute-general-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UpdateAttributeGeneralSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => UpdateAttributeGeneralSettingsComponent), + multi: true + } + ], + standalone: false }) export class UpdateAttributeGeneralSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.ts index 3b4b6e682b..b1c9065214 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-boolean-attribute-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-update-boolean-attribute-widget-settings', - templateUrl: './update-boolean-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-boolean-attribute-widget-settings', + templateUrl: './update-boolean-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateBooleanAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.ts index 0e18de2ee0..895e0affc0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-date-attribute-widget-settings.component.ts @@ -25,9 +25,10 @@ import { } from '@home/components/widget/lib/settings/input/update-attribute-general-settings.component'; @Component({ - selector: 'tb-update-date-attribute-widget-settings', - templateUrl: './update-date-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-date-attribute-widget-settings', + templateUrl: './update-date-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateDateAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.ts index 305db7e982..1323f552aa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-double-attribute-widget-settings.component.ts @@ -25,9 +25,10 @@ import { } from '@home/components/widget/lib/settings/input/update-attribute-general-settings.component'; @Component({ - selector: 'tb-update-double-attribute-widget-settings', - templateUrl: './update-double-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-double-attribute-widget-settings', + templateUrl: './update-double-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateDoubleAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html index 2c811a6ce1..195e95bb97 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-image-attribute-widget-settings.component.html @@ -18,9 +18,10 @@
widgets.input-widgets.general-settings -
widgets.input-widgets.general-settings -
- + widgets.input-widgets.latitude-key-name - + widgets.input-widgets.longitude-key-name @@ -53,16 +54,16 @@ {{ 'widgets.input-widgets.show-label' | translate }}
- + widgets.input-widgets.latitude-label - + widgets.input-widgets.longitude-label
- + widgets.input-widgets.input-fields-alignment @@ -81,7 +82,7 @@ {{ 'widgets.input-widgets.longitude-field-required' | translate }}
- + widgets.input-widgets.required-error-message diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.ts index 020bb13358..043b5e4373 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-location-attribute-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-update-location-attribute-widget-settings', - templateUrl: './update-location-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-location-attribute-widget-settings', + templateUrl: './update-location-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateLocationAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html index 6419028708..faadbd6581 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.html @@ -23,7 +23,7 @@ widgets.input-widgets.general-settings
- + widgets.input-widgets.datakey-type @@ -37,7 +37,7 @@ - + widgets.input-widgets.datakey-value-type @@ -83,7 +83,7 @@ {{ 'widgets.input-widgets.value-is-required' | translate }}
- + widgets.input-widgets.ability-to-edit-attribute @@ -97,42 +97,44 @@ - + widgets.input-widgets.disable-on-datakey-name
-
- - widgets.input-widgets.field-appearance - - - {{ 'widgets.input-widgets.appearance-fill' | translate }} - - - {{ 'widgets.input-widgets.appearance-outline' | translate }} - - - - - widgets.input-widgets.subscript-sizing - - - {{ 'widgets.input-widgets.subscript-sizing-fixed' | translate }} - - - {{ 'widgets.input-widgets.subscript-sizing-dynamic' | translate }} - - - -
+ @if (!['booleanSwitch', 'booleanCheckbox', 'radio'].includes(updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value)) { +
+ + widgets.input-widgets.field-appearance + + + {{ 'widgets.input-widgets.appearance-fill' | translate }} + + + {{ 'widgets.input-widgets.appearance-outline' | translate }} + + + + + widgets.input-widgets.subscript-sizing + + + {{ 'widgets.input-widgets.subscript-sizing-fixed' | translate }} + + + {{ 'widgets.input-widgets.subscript-sizing-dynamic' | translate }} + + + +
+ }
widgets.input-widgets.slide-toggle-settings - + widgets.input-widgets.slide-toggle-label-position @@ -152,20 +154,22 @@
-
- - -
-
-
- - {{ (updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value === 'select' ? 'widgets.input-widgets.no-select-options' : 'widgets.input-widgets.no-radio-options') | translate }} - + @for (selectOptionControl of typedSelectOptions; track trackBySelectOption($index, selectOptionControl); let isLast = $last) { +
+ + +
+ }
+ @if (!selectOptionsFormArray().controls.length) { +
+ + {{ (updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value === 'select' ? 'widgets.input-widgets.no-select-options' : 'widgets.input-widgets.no-radio-options') | translate }} + +
+ }
@@ -212,15 +217,15 @@ updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value !== 'double')" class="fields-group"> widgets.input-widgets.numeric-field-settings
- + widgets.input-widgets.step-interval - + widgets.input-widgets.min-value - + widgets.input-widgets.max-value @@ -228,29 +233,30 @@
widgets.input-widgets.error-messages - + widgets.input-widgets.required-error-message
- + class="flex flex-row gap-2"> + widgets.input-widgets.min-value-error-message - + widgets.input-widgets.max-value-error-message
+ updateMultipleAttributesKeySettingsForm.get('dataKeyValueType').value !== 'time'" class="mat-block flex-1" + appearance="outline"> widgets.input-widgets.invalid-date-error-message - + widgets.input-widgets.invalid-JSON-error-message @@ -258,16 +264,16 @@
widgets.input-widgets.dialog-editor-settings - + widgets.input-widgets.title
- + widgets.input-widgets.save-button-label - + widgets.input-widgets.cancel-button-label @@ -275,7 +281,7 @@
+ class="fields-group"> widgets.input-widgets.icon-settings {{ 'widgets.input-widgets.use-custom-icon' | translate }} @@ -283,11 +289,12 @@ + formControlName="icon" + appearance="outline"> + label="{{ 'widgets.input-widgets.input-cell-icon' | translate }}" + formControlName="customIcon">
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts index d29decfc04..0212ee4279 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-key-settings.component.ts @@ -30,9 +30,10 @@ import { } from '@home/components/widget/lib/multiple-input-widget.component'; @Component({ - selector: 'tb-update-multiple-attributes-key-settings', - templateUrl: './update-multiple-attributes-key-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-multiple-attributes-key-settings', + templateUrl: './update-multiple-attributes-key-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettingsComponent { @@ -255,6 +256,10 @@ export class UpdateMultipleAttributesKeySettingsComponent extends WidgetSettings return this.updateMultipleAttributesKeySettingsForm.get('selectOptions') as UntypedFormArray; } + get typedSelectOptions() { + return this.selectOptionsFormArray().controls as (AbstractControl & { new?: boolean })[]; + } + public trackBySelectOption(index: number, selectOptionControl: AbstractControl): any { return selectOptionControl; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html index b9def80dfd..4c0d9bf4f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.html @@ -18,10 +18,11 @@
widgets.input-widgets.general-settings -
- + widgets.input-widgets.save-button-label - + widgets.input-widgets.reset-button-label @@ -70,7 +71,7 @@ {{ 'widgets.input-widgets.show-group-title' | translate }} - + widgets.input-widgets.group-title @@ -79,7 +80,7 @@
widgets.input-widgets.layout
- + widgets.input-widgets.fields-alignment @@ -90,17 +91,17 @@ - + widgets.input-widgets.fields-in-row
- + widgets.input-widgets.row-gap - + widgets.input-widgets.column-gap diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.ts index aa31db2b0b..0a92babdfb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-multiple-attributes-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-update-multiple-attributes-widget-settings', - templateUrl: './update-multiple-attributes-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-multiple-attributes-widget-settings', + templateUrl: './update-multiple-attributes-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateMultipleAttributesWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.ts index ca134d8c73..0eb561a5f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/input/update-string-attribute-widget-settings.component.ts @@ -25,9 +25,10 @@ import { } from '@home/components/widget/lib/settings/input/update-attribute-general-settings.component'; @Component({ - selector: 'tb-update-string-attribute-widget-settings', - templateUrl: './update-string-attribute-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-update-string-attribute-widget-settings', + templateUrl: './update-string-attribute-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class UpdateStringAttributeWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html index f13de79d27..1a8fb6a328 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.html @@ -88,9 +88,11 @@ widgets.maps.show-tooltip-action - - {{showTooltipActionTranslations.get(action) | translate}} - + @for (action of showTooltipActions; track action) { + + {{showTooltipActionTranslations.get(action) | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts index 173901aa16..f229998584 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/circle-settings.component.ts @@ -39,21 +39,22 @@ import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-circle-settings', - templateUrl: './circle-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CircleSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CircleSettingsComponent), - multi: true - } - ] + selector: 'tb-circle-settings', + templateUrl: './circle-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CircleSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CircleSettingsComponent), + multi: true + } + ], + standalone: false }) export class CircleSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts index 8c49152772..8702cf28f3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/common-map-settings.component.ts @@ -34,21 +34,22 @@ import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-common-map-settings', - templateUrl: './common-map-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CommonMapSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CommonMapSettingsComponent), - multi: true - } - ] + selector: 'tb-common-map-settings', + templateUrl: './common-map-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CommonMapSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CommonMapSettingsComponent), + multi: true + } + ], + standalone: false }) export class CommonMapSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html index 247a92f7ba..b0d8c742f4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.html @@ -23,17 +23,21 @@ (focusin)="onFocus()" formControlName="key" [matAutocomplete]="keyAutocomplete"> - + @if (keyFormGroup.get('key').value && !keyFormGroup.get('key').disabled) { + + } - - - + @for (key of filteredKeys | async; track key) { + + + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts index c0c1ef98d4..7f085e920f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/datasources-key-autocomplete.component.ts @@ -28,16 +28,17 @@ import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-datasources-key-autocomplete', - templateUrl: './datasources-key-autocomplete.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatasourcesKeyAutocompleteComponent), - multi: true - } - ] + selector: 'tb-datasources-key-autocomplete', + templateUrl: './datasources-key-autocomplete.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatasourcesKeyAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class DatasourcesKeyAutocompleteComponent extends PageComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html index 7ffaff50c4..49b0e479c3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.html @@ -23,9 +23,11 @@ widgets.maps.default-map-type - - {{googleMapTypeTranslations.get(type) | translate}} - + @for (type of googleMapTypes; track type) { + + {{googleMapTypeTranslations.get(type) | translate}} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts index b9d54db5a6..673f7fc573 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/google-map-provider-settings.component.ts @@ -37,21 +37,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-google-map-provider-settings', - templateUrl: './google-map-provider-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GoogleMapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => GoogleMapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-google-map-provider-settings', + templateUrl: './google-map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GoogleMapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => GoogleMapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class GoogleMapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html index 056be11115..6a052ac907 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.html @@ -19,9 +19,11 @@ widgets.maps.map-layer - - {{hereMapProviderTranslations.get(provider) | translate}} - + @for (provider of hereMapProviders; track provider) { + + {{hereMapProviderTranslations.get(provider) | translate}} + + }
@@ -30,22 +32,23 @@ {{ 'widgets.maps.here-use-new-version-api-3' | translate }} -
- - widgets.maps.here-app-id - - - - widgets.maps.here-app-code - - -
- + @if (!providerSettingsFormGroup.get('credentials.useV3').value) { +
+ + widgets.maps.here-app-id + + + + widgets.maps.here-app-code + + +
+ } @else { widgets.maps.here-api-key -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts index e201586a63..40140fb1ff 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/here-map-provider-settings.component.ts @@ -38,21 +38,22 @@ import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-here-map-provider-settings', - templateUrl: './here-map-provider-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => HereMapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => HereMapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-here-map-provider-settings', + templateUrl: './here-map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HereMapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HereMapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class HereMapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html index 6182b2b1fa..628d4853e5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.html @@ -17,7 +17,7 @@ -->
+ formControlName="mapImageUrl">
widgets.maps.image-map-background-from-entity-attribute @@ -30,18 +30,22 @@ (focusin)="onEntityAliasFocus()" formControlName="imageEntityAlias" [matAutocomplete]="entityAliasAutocomplete"> - + @if (providerSettingsFormGroup.get('imageEntityAlias').value && !providerSettingsFormGroup.get('imageEntityAlias').disabled) { + + } - - - + @for (entityAlias of filteredEntityAliases | async; track entityAlias) { + + + + } @@ -52,18 +56,22 @@ (focusin)="onKeyFocus()" formControlName="imageUrlAttribute" [matAutocomplete]="keyAutocomplete"> - + @if (providerSettingsFormGroup.get('imageUrlAttribute').value && !providerSettingsFormGroup.get('imageUrlAttribute').disabled) { + + } - - - + @for (key of filteredKeys | async; track key) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts index a5493468a0..338db7c24f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/image-map-provider-settings.component.ts @@ -38,21 +38,22 @@ import { EntityService } from '@core/http/entity.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-image-map-provider-settings', - templateUrl: './image-map-provider-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageMapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ImageMapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-image-map-provider-settings', + templateUrl: './image-map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageMapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ImageMapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class ImageMapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts index 398376f71a..9d127af2d8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-editor-settings.component.ts @@ -33,21 +33,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-map-editor-settings', - templateUrl: './map-editor-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapEditorSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapEditorSettingsComponent), - multi: true - } - ] + selector: 'tb-map-editor-settings', + templateUrl: './map-editor-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapEditorSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapEditorSettingsComponent), + multi: true + } + ], + standalone: false }) export class MapEditorSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html index b2d334a621..8566956dc0 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-provider-settings.component.html @@ -21,9 +21,11 @@ widgets.maps.map-provider - - {{mapProviderTranslations.get(provider) | translate}} - + @for (provider of mapProviders; track provider) { + + {{mapProviderTranslations.get(provider) | translate}} + + } MapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-map-provider-settings', + templateUrl: './map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class MapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html index 0b93c0f951..a63b4c6348 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.html @@ -37,15 +37,17 @@ [widget]="widget" formControlName="circleSettings"> - - - - + @if (!routeMap) { + + + } + @if (routeMap) { + + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts index eb31c33075..5e98827469 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-settings-legacy.component.ts @@ -53,21 +53,22 @@ import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-map-settings-legacy', - templateUrl: './map-settings-legacy.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MapSettingsLegacyComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MapSettingsLegacyComponent), - multi: true - } - ] + selector: 'tb-map-settings-legacy', + templateUrl: './map-settings-legacy.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MapSettingsLegacyComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MapSettingsLegacyComponent), + multi: true + } + ], + standalone: false }) export class MapSettingsLegacyComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts index 3262e6b1cb..4a804c8e78 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/map-widget-settings-legacy.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ - selector: 'tb-map-widget-settings-legacy', - templateUrl: './map-widget-settings-legacy.component.html', - styleUrls: ['./../../widget-settings.scss'] + selector: 'tb-map-widget-settings-legacy', + templateUrl: './map-widget-settings-legacy.component.html', + styleUrls: ['./../../widget-settings.scss'], + standalone: false }) export class MapWidgetSettingsLegacyComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts index c757d7805a..6615302230 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/marker-clustering-settings.component.ts @@ -34,21 +34,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-marker-clustering-settings', - templateUrl: './marker-clustering-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkerClusteringSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MarkerClusteringSettingsComponent), - multi: true - } - ] + selector: 'tb-marker-clustering-settings', + templateUrl: './marker-clustering-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MarkerClusteringSettingsComponent), + multi: true + } + ], + standalone: false }) export class MarkerClusteringSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html index b786ea700e..609cfea4b8 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.html @@ -90,9 +90,11 @@ widgets.maps.show-tooltip-action - - {{showTooltipActionTranslations.get(action) | translate}} - + @for (action of showTooltipActions; track action) { + + {{showTooltipActionTranslations.get(action) | translate}} + + } @@ -171,8 +173,8 @@ + label="{{ 'widgets.maps.custom-marker-image' | translate }}" + formControlName="markerImage"> diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.ts index d890494f54..65421d0f53 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/markers-settings.component.ts @@ -36,21 +36,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-markers-settings', - templateUrl: './markers-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkersSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MarkersSettingsComponent), - multi: true - } - ] + selector: 'tb-markers-settings', + templateUrl: './markers-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkersSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MarkersSettingsComponent), + multi: true + } + ], + standalone: false }) export class MarkersSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.html index a08823395d..8fb0b3c705 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.html @@ -19,9 +19,11 @@ widgets.maps.openstreet-provider - - {{openStreetMapProviderTranslations.get(provider) | translate}} - + @for (provider of mapProviders; track provider) { + + {{openStreetMapProviderTranslations.get(provider) | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.ts index ef650fc877..6a795edd84 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/openstreet-map-provider-settings.component.ts @@ -37,21 +37,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-openstreet-map-provider-settings', - templateUrl: './openstreet-map-provider-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OpenStreetMapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => OpenStreetMapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-openstreet-map-provider-settings', + templateUrl: './openstreet-map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OpenStreetMapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => OpenStreetMapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class OpenStreetMapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.html index 3d82204a26..03bb018528 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.html @@ -88,9 +88,11 @@ widgets.maps.show-tooltip-action - - {{showTooltipActionTranslations.get(action) | translate}} - + @for (action of showTooltipActions; track action) { + + {{showTooltipActionTranslations.get(action) | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts index 129d268908..9b0b6e0110 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/polygon-settings.component.ts @@ -39,21 +39,22 @@ import { Widget } from '@shared/models/widget.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-polygon-settings', - templateUrl: './polygon-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PolygonSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => PolygonSettingsComponent), - multi: true - } - ] + selector: 'tb-polygon-settings', + templateUrl: './polygon-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PolygonSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PolygonSettingsComponent), + multi: true + } + ], + standalone: false }) export class PolygonSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts index 4a3cd9a3f8..cfba6811f2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-settings.component.ts @@ -33,21 +33,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-route-map-settings', - templateUrl: './route-map-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RouteMapSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => RouteMapSettingsComponent), - multi: true - } - ] + selector: 'tb-route-map-settings', + templateUrl: './route-map-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RouteMapSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => RouteMapSettingsComponent), + multi: true + } + ], + standalone: false }) export class RouteMapSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts index 23bf193892..67e88aa7b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/route-map-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { defaultMapSettings } from 'src/app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ - selector: 'tb-route-map-widget-settings', - templateUrl: './route-map-widget-settings.component.html', - styleUrls: ['./../../widget-settings.scss'] + selector: 'tb-route-map-widget-settings', + templateUrl: './route-map-widget-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + standalone: false }) export class RouteMapWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html index 88c36ab010..ccde76edad 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.html @@ -23,9 +23,11 @@ widgets.maps.default-map-type - - {{tencentMapTypeTranslations.get(type) | translate}} - + @for (type of tencentMapTypes; track type) { + + {{tencentMapTypeTranslations.get(type) | translate}} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts index d3f81a7ff3..4e6e8284d1 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/tencent-map-provider-settings.component.ts @@ -37,21 +37,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-tencent-map-provider-settings', - templateUrl: './tencent-map-provider-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TencentMapProviderSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TencentMapProviderSettingsComponent), - multi: true - } - ] + selector: 'tb-tencent-map-provider-settings', + templateUrl: './tencent-map-provider-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TencentMapProviderSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TencentMapProviderSettingsComponent), + multi: true + } + ], + standalone: false }) export class TencentMapProviderSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts index 074cba4a5c..861188a6b9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-common-settings.component.ts @@ -35,21 +35,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-trip-animation-common-settings', - templateUrl: './trip-animation-common-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TripAnimationCommonSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TripAnimationCommonSettingsComponent), - multi: true - } - ] + selector: 'tb-trip-animation-common-settings', + templateUrl: './trip-animation-common-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripAnimationCommonSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripAnimationCommonSettingsComponent), + multi: true + } + ], + standalone: false }) export class TripAnimationCommonSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.ts index f7ecd02f03..8f31d24f2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-marker-settings.component.ts @@ -34,21 +34,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-trip-animation-marker-settings', - templateUrl: './trip-animation-marker-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TripAnimationMarkerSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TripAnimationMarkerSettingsComponent), - multi: true - } - ] + selector: 'tb-trip-animation-marker-settings', + templateUrl: './trip-animation-marker-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripAnimationMarkerSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripAnimationMarkerSettingsComponent), + multi: true + } + ], + standalone: false }) export class TripAnimationMarkerSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.html index 6034cbfc5c..57e4cfb873 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.html @@ -73,9 +73,11 @@ widgets.maps.decorator-symbol - - {{polylineDecoratorSymbolTranslations.get(symbol) | translate}} - + @for (symbol of polylineDecoratorSymbols; track symbol) { + + {{polylineDecoratorSymbolTranslations.get(symbol) | translate}} + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts index 16c75cee56..9674ac2820 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-path-settings.component.ts @@ -37,21 +37,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-trip-animation-path-settings', - templateUrl: './trip-animation-path-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TripAnimationPathSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TripAnimationPathSettingsComponent), - multi: true - } - ] + selector: 'tb-trip-animation-path-settings', + templateUrl: './trip-animation-path-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripAnimationPathSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripAnimationPathSettingsComponent), + multi: true + } + ], + standalone: false }) export class TripAnimationPathSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts index 5e372051ad..8aae459e18 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-point-settings.component.ts @@ -38,21 +38,22 @@ import { WidgetService } from '@core/http/widget.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-trip-animation-point-settings', - templateUrl: './trip-animation-point-settings.component.html', - styleUrls: ['./../../widget-settings.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TripAnimationPointSettingsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TripAnimationPointSettingsComponent), - multi: true - } - ] + selector: 'tb-trip-animation-point-settings', + templateUrl: './trip-animation-point-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TripAnimationPointSettingsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TripAnimationPointSettingsComponent), + multi: true + } + ], + standalone: false }) export class TripAnimationPointSettingsComponent extends PageComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts index 440fd5df2c..5d4e28d7bb 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/legacy/trip-animation-widget-settings.component.ts @@ -39,9 +39,10 @@ import { import { extractType } from '@core/utils'; @Component({ - selector: 'tb-trip-animation-widget-settings', - templateUrl: './trip-animation-widget-settings.component.html', - styleUrls: ['./../../widget-settings.scss'] + selector: 'tb-trip-animation-widget-settings', + templateUrl: './trip-animation-widget-settings.component.html', + styleUrls: ['./../../widget-settings.scss'], + standalone: false }) export class TripAnimationWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts index 296086b22f..3963f144fa 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/map/map-widget-settings.component.ts @@ -24,9 +24,10 @@ import { mapWidgetDefaultSettings } from '@home/components/widget/lib/maps/map-w import { WidgetConfigComponentData } from '@home/models/widget-component.models'; @Component({ - selector: 'tb-map-widget-settings', - templateUrl: './map-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-map-widget-settings', + templateUrl: './map-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class MapWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html index e5176cd05b..e7dab2597f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.html @@ -16,16 +16,17 @@ -->
- + widgets.navigation.title - + widgets.navigation.navigation-path diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.ts index 08ee515099..b3f6c2838f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-card-widget-settings.component.ts @@ -21,9 +21,10 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-navigation-card-widget-settings', - templateUrl: './navigation-card-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-navigation-card-widget-settings', + templateUrl: './navigation-card-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class NavigationCardWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html index d8545078c1..18284f79b5 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.html @@ -24,14 +24,16 @@ {{ 'widgets.navigation.filter-type-exclude' | translate }}
- + widgets.navigation.items - - {{ filterItem }} - cancel - + @for (filterItem of navigationCardsWidgetSettingsForm.get('filter').value; track filterItem) { + + {{ filterItem }} + cancel + + } - - - + @for (filterItem of filteredFilterItems | async; track filterItem) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.ts index 3ffe4d6c22..fc17f555d4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/navigation/navigation-cards-widget-settings.component.ts @@ -26,9 +26,10 @@ import { Observable, of, Subject } from 'rxjs'; import { map, mergeMap, share, startWith } from 'rxjs/operators'; @Component({ - selector: 'tb-navigation-cards-widget-settings', - templateUrl: './navigation-cards-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-navigation-cards-widget-settings', + templateUrl: './navigation-cards-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class NavigationCardsWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts index 361a305421..cc04e67f2f 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/scada/scada-symbol-widget-settings.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { scadaSymbolWidgetDefaultSettings } from '@home/components/widget/lib/scada/scada-symbol-widget.models'; @Component({ - selector: 'tb-scada-symbol-widget-settings', - templateUrl: './scada-symbol-widget-settings.component.html', - styleUrls: ['./../widget-settings.scss'] + selector: 'tb-scada-symbol-widget-settings', + templateUrl: './scada-symbol-widget-settings.component.html', + styleUrls: ['./../widget-settings.scss'], + standalone: false }) export class ScadaSymbolWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html index fa9c964654..230db9e857 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.html @@ -23,12 +23,14 @@ breakpoints: { 'lt-md': 2 }}" - label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> - - {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} - + label="{{ 'widgets.wind-speed-direction.layout' | translate }}" formControlName="layout"> + @for (layout of windSpeedDirectionLayouts; track layout) { + + {{ windSpeedDirectionLayoutTranslationMap.get(layout) | translate }} + + }
{{ (hasCenterValue ? 'widgets.wind-speed-direction.center-value' : 'widgets.wind-speed-direction.wind-direction') | translate }}
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts index 96ea317109..102dc7ade6 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/weather/wind-speed-direction-widget-settings.component.ts @@ -31,9 +31,10 @@ import { getDataKey } from '@shared/models/widget-settings.models'; import { getSourceTbUnitSymbol, TbUnit } from '@shared/models/unit.models'; @Component({ - selector: 'tb-wind-speed-direction-widget-settings', - templateUrl: './wind-speed-direction-widget-settings.component.html', - styleUrls: [] + selector: 'tb-wind-speed-direction-widget-settings', + templateUrl: './wind-speed-direction-widget-settings.component.html', + styleUrls: [], + standalone: false }) export class WindSpeedDirectionWidgetSettingsComponent extends WidgetSettingsComponent { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts index 3d8c564b67..387737a461 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.module.ts @@ -375,6 +375,9 @@ import { ValueStepperWidgetSettingsComponent } from '@home/components/widget/lib/settings/control/value-stepper-widget-settings.component'; import { MapWidgetSettingsComponent } from '@home/components/widget/lib/settings/map/map-widget-settings.component'; +import { + HtmlContainerWidgetSettingsComponent +} from '@home/components/widget/lib/settings/html/html-container-widget-settings.component'; import { ApiUsageWidgetSettingsComponent } from "@home/components/widget/lib/settings/cards/api-usage-widget-settings.component"; @@ -515,6 +518,7 @@ import { UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent, ApiUsageWidgetSettingsComponent, ApiUsageDataKeyRowComponent ], @@ -656,6 +660,7 @@ import { UnreadNotificationWidgetSettingsComponent, ScadaSymbolWidgetSettingsComponent, MapWidgetSettingsComponent, + HtmlContainerWidgetSettingsComponent, ApiUsageWidgetSettingsComponent ] }) diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss index 8579f2a872..4d1c34e131 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/widget-settings.scss @@ -108,7 +108,7 @@ align-items: center; } - > .mat-expansion-panel-content { + .mat-expansion-panel-content { > .mat-expansion-panel-body { padding: 0; } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 0826b0a5e7..d2b191adfd 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts @@ -433,7 +433,7 @@ export function noDataMessage(noDataDisplayMessage: string, defaultMessage: stri return translate.instant(defaultMessage); } -export function constructTableCssString(widgetConfig: WidgetConfig): string { +export function constructTableCssString(widgetConfig: WidgetConfig, isTabsEnabled = false): string { const origColor = widgetConfig.color || 'rgba(0, 0, 0, 0.87)'; const origBackgroundColor = widgetConfig.backgroundColor || 'rgb(255, 255, 255)'; const currentEntityColor = 'rgba(221, 221, 221, 0.65)'; @@ -451,26 +451,22 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { const mdDarkDisabled = defaultColor.setAlpha(0.26).toRgbString(); const mdDarkDisabled2 = defaultColor.setAlpha(0.38).toRgbString(); const mdDarkDivider = defaultColor.setAlpha(0.12).toRgbString(); - - const cssString = ` { + + let cssString = ` { --mat-toolbar-container-text-color: ${mdDark}; - --mat-tab-header-active-label-text-color: ${mdDark}; - --mat-tab-header-inactive-label-text-color: ${mdDark}; - --mat-tab-header-pagination-icon-color: ${mdDark}; - --mat-tab-header-pagination-disabled-icon-color: ${mdDarkDisabled2}; --mat-table-header-headline-color: ${mdDarkSecondary}; --mat-table-row-item-label-text-color: ${mdDark}; --mat-icon-color: ${mdDarkSecondary}; - --mdc-icon-button-disabled-icon-color: ${mdDarkDisabled}; + --mat-icon-button-disabled-icon-color: ${mdDarkDisabled}; --mat-divider-color: ${mdDarkDivider}; --mat-paginator-container-text-color: ${mdDarkSecondary}; - --mdc-icon-button-icon-color: ${mdDarkSecondary}; + --mat-icon-button-icon-color: ${mdDarkSecondary}; --mat-paginator-enabled-icon-color: ${mdDarkSecondary}; --mat-paginator-disabled-icon-color: ${mdDarkDisabled}; --mat-select-enabled-trigger-text-color: ${mdDarkSecondary}; --mat-select-disabled-trigger-text-color: ${mdDarkDisabled}; --mat-table-row-item-outline-color: ${mdDarkDivider}; - --mdc-checkbox-unselected-focus-icon-color: ${mdDarkSecondary}; + --mat-checkbox-unselected-focus-icon-color: ${mdDarkSecondary}; --tb-orig-background-color: ${origBackgroundColor}; --tb-current-entity-color: ${currentEntityColor}; @@ -481,7 +477,19 @@ export function constructTableCssString(widgetConfig: WidgetConfig): string { --tb-selected-sticky-color: ${selectedStickyColor}; } `; - return cssString; + if (isTabsEnabled) { + cssString += ` + .mat-mdc-tab-group { + --mat-tab-inactive-label-text-color: ${mdDarkSecondary}; + --mat-tab-pagination-icon-color: ${mdDark}; + --mat-tab-pagination-disabled-icon-color: ${mdDarkDisabled2}; + --mat-tab-inactive-ripple-color: ${mdDarkSecondary}; + --mat-tab-inactive-focus-label-text-color: ${mdDarkSecondary}; + --mat-tab-inactive-hover-label-text-color: ${mdDarkSecondary}; + } + ` + } + return cssString ; } export function getHeaderTitle(dataKey: DataKey, keySettings: TableWidgetDataKeySettings | undefined, utils: UtilsService) { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss index c75500651e..91c6dbb317 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss +++ b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.scss @@ -46,7 +46,7 @@ } .mat-mdc-tab-header-pagination-disabled .mat-mdc-tab-header-pagination-chevron { - border-color: var(--mat-tab-header-pagination-disabled-icon-color); + border-color: var(--mat-tab-pagination-disabled-icon-color); } } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html index 99a31331fd..a744ac54a2 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.html @@ -41,90 +41,108 @@ class="flex-1" mat-stretch-tabs="false" [(selectedIndex)]="sourceIndex" (selectedIndexChange)="onSourceIndexChanged()"> - - -
- - - {{ 'widgets.table.timestamp-column-name' | translate }} - - - - - {{ h.dataKey.label }} - - - - - - -
-
-
-
+ @for (source of sources; track trackBySourcesIndex(index, source); let index = $index) { + + @if (isActiveTab(index)) { +
+
+ @if (showTimestamp) { + + {{ 'widgets.table.timestamp-column-name' | translate }} + + - - - -
- - - - -
-
- - - - + } + } +
+ @if (row.hasActions) { +
+ - - -
-
-
- - - -
- {{ noDataDisplayMessageText }} -
- - -
-
+ + @for (actionDescriptor of row.actionCellButtons; track trackByActionCellDescriptionId($index, actionDescriptor)) { + @if (actionDescriptor.icon) { + + } + } + +
+ } + } + + + + + + {{ noDataDisplayMessageText }} +
+ @if (displayPagination) { + + } + @if (displayPagination) { + + } + } + + } diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts index a8414465a4..4ce5b9e130 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/timeseries-table-widget.component.ts @@ -158,9 +158,10 @@ interface TimeseriesTableSource { } @Component({ - selector: 'tb-timeseries-table-widget', - templateUrl: './timeseries-table-widget.component.html', - styleUrls: ['./timeseries-table-widget.component.scss', './table-widget.scss'] + selector: 'tb-timeseries-table-widget', + templateUrl: './timeseries-table-widget.component.html', + styleUrls: ['./timeseries-table-widget.component.scss', './table-widget.scss'], + standalone: false }) export class TimeseriesTableWidgetComponent extends PageComponent implements OnInit, AfterViewInit, OnDestroy { @@ -378,8 +379,7 @@ export class TimeseriesTableWidgetComponent extends PageComponent implements OnI this.noDataDisplayMessageText = noDataMessage(this.widgetConfig.noDataDisplayMessage, 'widget.no-data-found', this.utils, this.translate); - - let cssString = constructTableCssString(this.widgetConfig); + let cssString = constructTableCssString(this.widgetConfig, true); const origBackgroundColor = this.widgetConfig.backgroundColor || 'rgb(255, 255, 255)'; cssString += '.tb-table-widget mat-toolbar.mat-mdc-table-toolbar:not([color=primary]) {\n' + diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.html index a41bf2ed82..a5a7bb955b 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.html @@ -16,32 +16,40 @@ -->
-
-
-
-
- -
-
-
-
+ @if (settings.showLabel) { +
+ } +
+
+
+ @if (settings.showTooltip) { + + } +
+
+ @for (mainTooltip of mainTooltips; track mainTooltip) { +
+ }
- +
+ @if (historicalData) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts index b69456ee30..9a7ea52d17 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/trip-animation/trip-animation.component.ts @@ -59,10 +59,11 @@ interface DataMap { } @Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'trip-animation', - templateUrl: './trip-animation.component.html', - styleUrls: ['./trip-animation.component.scss'] + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'trip-animation', + templateUrl: './trip-animation.component.html', + styleUrls: ['./trip-animation.component.scss'], + standalone: false }) export class TripAnimationComponent implements OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts index 1c3570ad0f..dbc7bf889d 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/lib/weather/wind-speed-direction-widget.component.ts @@ -68,10 +68,11 @@ const ticksTextMap: {[angle: number]: string} = { }; @Component({ - selector: 'tb-wind-speed-direction-widget', - templateUrl: './wind-speed-direction-widget.component.html', - styleUrls: ['./wind-speed-direction-widget.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-wind-speed-direction-widget', + templateUrl: './wind-speed-direction-widget.component.html', + styleUrls: ['./wind-speed-direction-widget.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WindSpeedDirectionWidgetComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts index dc0161d0e3..dd81f1e853 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-components.module.ts @@ -94,6 +94,7 @@ import { SelectMapEntityPanelComponent } from '@home/components/widget/lib/maps/panels/select-map-entity-panel.component'; import { MapTimelinePanelComponent } from '@home/components/widget/lib/maps/panels/map-timeline-panel.component'; +import { HtmlContainerWidgetComponent } from '@home/components/widget/lib/html/html-container-widget.component'; import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-usage-widget.component"; @NgModule({ @@ -153,6 +154,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u SelectMapEntityPanelComponent, MapTimelinePanelComponent, MapWidgetComponent, + HtmlContainerWidgetComponent, ApiUsageWidgetComponent ], imports: [ @@ -217,6 +219,7 @@ import { ApiUsageWidgetComponent } from "@home/components/widget/lib/cards/api-u NotificationTypeFilterPanelComponent, ScadaSymbolWidgetComponent, MapWidgetComponent, + HtmlContainerWidgetComponent, ApiUsageWidgetComponent ], providers: [ diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 166c32a7fd..e194b48ae9 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html @@ -16,222 +16,240 @@ -->
-
- - -
- - -
-
-
-
- -
-
- + @if (!hideHeader) { +
+ @if (!hideToggleHeader && widgetConfigMode === widgetConfigModes.advanced) { + + + } +
+ + +
-
-
-
widget-config.card-title
- - {{ 'widget-config.display-title' | translate }} - -
-
widget-config.title
-
- @if (widgetType === widgetTypes.static || widgetType === widgetTypes.alarm) { - - + } + @if (widgetConfigMode === widgetConfigModes.advanced) { +
+ @if (displayData) { +
+ +
+ } +
+ +
+
+
+
widget-config.card-title
+ + {{ 'widget-config.display-title' | translate }} + +
+
widget-config.title
+
+ @if (widgetType === widgetTypes.static || widgetType === widgetTypes.alarm) { + + + + } @else { + + } + + + + +
+
+
+
widget-config.title-tooltip
+ + + +
+
+ + {{ 'widget-config.display-icon' | translate }} + +
+ + - } @else { - - } - - + + + + +
+
+ + + + widget-config.advanced-title-style + + + + + + +
+
+
widget-config.card-style
+
+
{{ 'widget-config.text-color' | translate }}
+ formControlName="color">
-
-
-
widget-config.title-tooltip
- - - -
-
- - {{ 'widget-config.display-icon' | translate }} - -
- - - - - +
+
{{ 'widget-config.background-color' | translate }}
+ formControlName="backgroundColor">
-
- - - - widget-config.advanced-title-style - - - - - - -
-
-
widget-config.card-style
-
-
{{ 'widget-config.text-color' | translate }}
- - -
-
-
{{ 'widget-config.background-color' | translate }}
- - -
-
-
{{ 'widget-config.padding' | translate }}
- - - -
-
-
{{ 'widget-config.margin' | translate }}
- - - -
-
-
{{ 'widget-config.border-radius' | translate }}
- - - -
- - {{ 'widget-config.drop-shadow' | translate }} - - - - - widget-config.advanced-widget-style - - - - - - - -
-
-
widget-config.card-buttons
- - {{ 'widget-config.enable-fullscreen' | translate }} - -
-
-
-
- - -
-
-
-
-
widget-config.resize-options
-
- - {{ 'widget-config.resizable' | translate }} +
+
{{ 'widget-config.padding' | translate }}
+ + + +
+
+
{{ 'widget-config.margin' | translate }}
+ + + +
+
+
{{ 'widget-config.border-radius' | translate }}
+ + + +
+ + {{ 'widget-config.drop-shadow' | translate }} + + + + widget-config.advanced-widget-style + + + + + + +
-
- - {{ 'widget-config.preserve-aspect-ratio' | translate }} +
+
widget-config.card-buttons
+ + {{ 'widget-config.enable-fullscreen' | translate }}
-
-
{{ (isDefaultBreakpoint ? 'widget-config.mobile' : 'widget-config.list-layout') | translate }}
-
- - {{ 'widget-config.mobile-hide' | translate }} - -
-
- - {{ 'widget-config.desktop-hide' | translate }} - -
-
-
widget-config.order
- - - -
-
-
widget-config.height
- - - +
+
+ +
+
+ @if (!showLayoutConfig || isDefaultBreakpoint) { +
+
widget-config.resize-options
+
+ + {{ 'widget-config.resizable' | translate }} + +
+
+ + {{ 'widget-config.preserve-aspect-ratio' | translate }} + +
+
+ } + @if (showLayoutConfig) { +
+
{{ (isDefaultBreakpoint ? 'widget-config.mobile' : 'widget-config.list-layout') | translate }}
+ @if (isDefaultBreakpoint) { +
+ + {{ 'widget-config.mobile-hide' | translate }} + +
+ } + @if (isDefaultBreakpoint) { +
+ + {{ 'widget-config.desktop-hide' | translate }} + +
+ } +
+
widget-config.order
+ + + +
+
+
widget-config.height
+ + + +
+
+ } +
-
- + } @else {
-
- -
{{basicModeDirectiveError}}
-
- + @if (hasBasicModeDirective) { +
+ + @if (basicModeDirectiveError) { +
{{basicModeDirectiveError}}
+ } +
+ } @else {
@@ -241,98 +259,122 @@
-
+ }
-
+ } - - - - -
-
-
alarm.filter
- + @if (displayTimewindowConfig) { + + + + + } + @if (widgetType === widgetTypes.alarm) { +
+
+
alarm.filter
+ +
+
- -
-
- - -
- - - - -
-
widget-config.alarm-source
- - -
-
-
widget-config.limits
-
-
widget-config.data-page-size
- - - + } + @if (widgetType !== widgetTypes.rpc && + widgetType !== widgetTypes.alarm && + modelValue?.isDataEnabled) { +
+ +
-
+ } + @if (widgetType === widgetTypes.rpc && + modelValue?.isDataEnabled) { + + + + + } + @if (widgetType === widgetTypes.alarm && modelValue?.isDataEnabled) { +
+
widget-config.alarm-source
+ + +
+ } + @if (displayLimits) { +
+
widget-config.limits
+
+
widget-config.data-page-size
+ + + +
+
+ } - - - - -
-
widget-config.data-settings
-
-
widget-config.units-by-default
- - -
-
-
widget-config.decimals-by-default
- - - + @if (!displayData && displayTimewindowConfig) { + + + + + } + @if (displayAppearanceDataSettings) { +
+
widget-config.data-settings
+ @if (displayUnitsConfig) { +
+
widget-config.units-by-default
+ + +
+ } + @if (displayUnitsConfig) { +
+
widget-config.decimals-by-default
+ + + +
+ } + @if (displayNoDataDisplayMessageConfig) { +
+
widget-config.no-data-display-message
+ + + +
+ }
-
-
widget-config.no-data-display-message
- - - + } + @if (displayAdvancedAppearance) { +
+ +
-
-
- - -
+ }
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss index 212bc0724a..af7e27f481 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.scss @@ -43,6 +43,7 @@ } .tb-widget-config-content { & > .mat-content { + height: 100%; padding-top: 8px; @media #{$mat-xs} { padding-left: 8px; diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts index 49652803a6..57b579d8e4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.ts @@ -93,21 +93,22 @@ import { findWidgetModelDefinition } from '@shared/models/widget/widget-model.de import Timeout = NodeJS.Timeout; @Component({ - selector: 'tb-widget-config', - templateUrl: './widget-config.component.html', - styleUrls: ['./widget-config.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetConfigComponent), - multi: true - }, - { - provide: NG_ASYNC_VALIDATORS, - useExisting: forwardRef(() => WidgetConfigComponent), - multi: true, - } - ] + selector: 'tb-widget-config', + templateUrl: './widget-config.component.html', + styleUrls: ['./widget-config.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetConfigComponent), + multi: true + }, + { + provide: NG_ASYNC_VALIDATORS, + useExisting: forwardRef(() => WidgetConfigComponent), + multi: true, + } + ], + standalone: false }) export class WidgetConfigComponent extends PageComponent implements OnInit, OnDestroy, ControlValueAccessor, AsyncValidator { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html index e182371f2b..69cf1de06a 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.html @@ -28,15 +28,17 @@ [class.tb-edit]="isEdit || isEditingWidget" [class.tb-hover]="hovered" [style]="widget.style"> -
- - - - - - -
+ @if (!!widgetComponent.widgetContext?.inited) { +
+ @if (!widgetComponent.widgetContext?.embedTitlePanel) { + + } + @if (!widgetComponent.widgetContext?.embedActionsPanel) { + + } +
+ }
-
-
- {{widget.titleIcon}} -
- {{widget.title$ | async}} -
- -
- - -
+ @if (widget.showWidgetTitlePanel) { +
+ @if (widget.showTitle) { +
+ @if (widget.showTitleIcon) { + {{widget.titleIcon}} + } +
+ {{widget.title$ | async}} +
+ +
+ } + @if (widget.hasTimewindow) { + + + } +
+ }
-
-
- @for (action of widget.customHeaderActions; track action.name; let last = $last) { - + @if (widget.showWidgetActions) { +
+
+ @for (action of widget.customHeaderActions; track action.name; let last = $last) { + + } +
+ @for (action of widget.widgetActions; track action) { + } +
- - - -
+ } -@switch (action.buttonType) { - @case (widgetHeaderActionButtonType.miniFab) { - - } - @case (widgetHeaderActionButtonType.basic) { - - } - @case (widgetHeaderActionButtonType.raised) { - - } - @case (widgetHeaderActionButtonType.stroked) { - - } - @case (widgetHeaderActionButtonType.flat) { - - } - @default { - + @switch (action.buttonType) { + @case (widgetHeaderActionButtonType.miniFab) { + + } + @case (widgetHeaderActionButtonType.basic) { + + } + @case (widgetHeaderActionButtonType.raised) { + + } + @case (widgetHeaderActionButtonType.stroked) { + + } + @case (widgetHeaderActionButtonType.flat) { + + } + @default { + + } } -} diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts index d8ff0cb1c1..4bdceedf7c 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-container.component.ts @@ -67,11 +67,12 @@ export class WidgetComponentAction { // @dynamic @Component({ - selector: 'tb-widget-container', - templateUrl: './widget-container.component.html', - styleUrls: ['./widget-container.component.scss'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-widget-container', + templateUrl: './widget-container.component.html', + styleUrls: ['./widget-container.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class WidgetContainerComponent extends PageComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy { @@ -394,45 +395,48 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O } @Component({ - template: ` + template: `
-
- {{ 'widget.reference' | translate }} - -
+ @if (container.widget.isReference) { +
+ {{ 'widget.reference' | translate }} + +
+ }
`, - styles: [], - encapsulation: ViewEncapsulation.None + styles: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EditWidgetActionsTooltipComponent implements AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget-preview.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget-preview.component.ts index ea2770d0e5..d940696078 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget-preview.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget-preview.component.ts @@ -24,9 +24,10 @@ import { deepClone } from '@core/utils'; import { Timewindow } from '@shared/models/time/time.models'; @Component({ - selector: 'tb-widget-preview', - templateUrl: './widget-preview.component.html', - styleUrls: ['./widget-preview.component.scss'] + selector: 'tb-widget-preview', + templateUrl: './widget-preview.component.html', + styleUrls: ['./widget-preview.component.scss'], + standalone: false }) export class WidgetPreviewComponent extends PageComponent implements OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.html b/ui-ngx/src/app/modules/home/components/widget/widget.component.html index b0dbb95937..c66578afac 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.html @@ -21,13 +21,17 @@
-
+@if (widgetErrorData) { +
-
- {{ noDataDisplayMessageText }} -
+} +@if (displayNoData) { +
+ {{ noDataDisplayMessageText }} +
+}
diff --git a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts index d3ebd60b7c..b04fad8caf 100644 --- a/ui-ngx/src/app/modules/home/components/widget/widget.component.ts +++ b/ui-ngx/src/app/modules/home/components/widget/widget.component.ts @@ -130,11 +130,12 @@ import { HttpClient } from '@angular/common/http'; import { addDiagnosticChain } from '@angular/compiler-cli/src/ngtsc/diagnostics'; @Component({ - selector: 'tb-widget', - templateUrl: './widget.component.html', - styleUrls: ['./widget.component.scss'], - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-widget', + templateUrl: './widget.component.html', + styleUrls: ['./widget.component.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class WidgetComponent extends PageComponent implements OnInit, OnChanges, OnDestroy { @@ -333,6 +334,7 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, if (customHeaderActions$.length) { forkJoin(customHeaderActions$).subscribe((customHeaderActions) => { this.widgetContext.customHeaderActions.push(...customHeaderActions); + this.dashboardWidget.updateParamsFromData(true); }); } @@ -377,23 +379,23 @@ export class WidgetComponent extends PageComponent implements OnInit, OnChanges, const buttonStyle = {}; switch (buttonType) { case WidgetHeaderActionButtonType.basic: - buttonStyle['--mdc-text-button-label-text-color'] = buttonColor; + buttonStyle['--mat-button-text-label-text-color'] = buttonColor; break; case WidgetHeaderActionButtonType.raised: - buttonStyle['--mdc-protected-button-label-text-color'] = buttonColor; - buttonStyle['--mdc-protected-button-container-color'] = backgroundColor; + buttonStyle['--mat-button-protected-label-text-color'] = buttonColor; + buttonStyle['--mat-button-protected-container-color'] = backgroundColor; break; case WidgetHeaderActionButtonType.stroked: - buttonStyle['--mdc-outlined-button-label-text-color'] = buttonColor; - buttonStyle['--mdc-outlined-button-outline-color'] = borderColor; + buttonStyle['--mat-button-outlined-label-text-color'] = buttonColor; + buttonStyle['--mat-button-outlined-outline-color'] = borderColor; break; case WidgetHeaderActionButtonType.flat: - buttonStyle['--mdc-filled-button-label-text-color'] = buttonColor; - buttonStyle['--mdc-filled-button-container-color'] = backgroundColor; + buttonStyle['--mat-button-filled-label-text-color'] = buttonColor; + buttonStyle['--mat-button-filled-container-color'] = backgroundColor; break; case WidgetHeaderActionButtonType.miniFab: buttonStyle['--mat-fab-small-foreground-color'] = buttonColor; - buttonStyle['--mdc-fab-small-container-color'] = backgroundColor; + buttonStyle['--mat-fab-small-container-color'] = backgroundColor; break; default: buttonStyle['--mat-icon-color'] = buttonColor; diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html index 0ad5814a81..01a83e5d11 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html @@ -25,8 +25,11 @@ close - - +@if (isLoading$ | async) { + +} @else { +
+}
- {{ 'device.wizard.device-details' | translate}} -
- + {{ 'device.wizard.device-details' | translate }} +
+ device.name - - {{ 'device.name-required' | translate }} - - - {{ 'device.name-max-length' | translate }} - + @if (deviceWizardFormGroup.get('name').hasError('required')) { + {{ 'device.name-required' | translate }} + } + @if (deviceWizardFormGroup.get('name').hasError('maxlength')) { + {{ 'device.name-max-length' | translate }} + } - + device.label - - {{ 'device.label-max-length' | translate }} - + @if (deviceWizardFormGroup.get('label').hasError('maxlength')) { + {{ 'device.label-max-length' | translate }} + } @@ -68,20 +72,22 @@ {{ 'device.is-gateway' | translate }} - - {{ 'device.overwrite-activity-time' | translate }} - + @if (deviceWizardFormGroup.get('gateway').value) { + + {{ 'device.overwrite-activity-time' | translate }} + + }
- + device.description - +
@@ -100,15 +106,18 @@
- + @if (selectedIndex > 0) { + + } - + @if (showNext) { + + }
diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss index 9f4c873f35..19883b75ff 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.scss @@ -74,6 +74,7 @@ max-height: 100%; overflow-y: auto; scrollbar-gutter: stable; + padding-top: 4px; } } } diff --git a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts index 6c991d492a..c88be6b550 100644 --- a/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.ts @@ -35,9 +35,10 @@ import { CustomerId } from '@shared/models/id/customer-id'; import { HttpErrorResponse } from '@angular/common/http'; @Component({ - selector: 'tb-device-wizard', - templateUrl: './device-wizard-dialog.component.html', - styleUrls: ['./device-wizard-dialog.component.scss'] + selector: 'tb-device-wizard', + templateUrl: './device-wizard-dialog.component.html', + styleUrls: ['./device-wizard-dialog.component.scss'], + standalone: false }) export class DeviceWizardDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html index 06f99fc498..c397f8b1a0 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToCustomerText | translate }} @@ -35,7 +38,7 @@ formControlName="entityIds" required [entityType]="entityType" - > + >
diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts index 31020d49a4..dd11ea16c8 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-customer-dialog.component.ts @@ -36,10 +36,11 @@ export interface AddEntitiesToCustomerDialogData { } @Component({ - selector: 'tb-add-entities-to-customer-dialog', - templateUrl: './add-entities-to-customer-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AddEntitiesToCustomerDialogComponent}], - styleUrls: [] + selector: 'tb-add-entities-to-customer-dialog', + templateUrl: './add-entities-to-customer-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AddEntitiesToCustomerDialogComponent }], + styleUrls: [], + standalone: false }) export class AddEntitiesToCustomerDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html index 6a4be4aeb4..4aed4a41b8 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html @@ -25,15 +25,19 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToEdgeText | translate }} diff --git a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.ts index 18abe0c30e..e2c4568c8e 100644 --- a/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.ts @@ -38,10 +38,11 @@ export interface AddEntitiesToEdgeDialogData { } @Component({ - selector: 'tb-add-entities-to-edge-dialog', - templateUrl: './add-entities-to-edge-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AddEntitiesToEdgeDialogComponent}], - styleUrls: [] + selector: 'tb-add-entities-to-edge-dialog', + templateUrl: './add-entities-to-edge-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AddEntitiesToEdgeDialogComponent }], + styleUrls: [], + standalone: false }) export class AddEntitiesToEdgeDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html index 4af3aa07a1..efa6ef3c1c 100644 --- a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.html @@ -25,14 +25,18 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToCustomerText | translate }}
diff --git a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts index 7bc7ad92a2..541312bf1a 100644 --- a/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/dialogs/assign-to-customer-dialog.component.ts @@ -36,10 +36,11 @@ export interface AssignToCustomerDialogData { } @Component({ - selector: 'tb-assign-to-customer-dialog', - templateUrl: './assign-to-customer-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AssignToCustomerDialogComponent}], - styleUrls: [] + selector: 'tb-assign-to-customer-dialog', + templateUrl: './assign-to-customer-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AssignToCustomerDialogComponent }], + styleUrls: [], + standalone: false }) export class AssignToCustomerDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html index 90fc24c15b..0b337f0bd9 100644 --- a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html +++ b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.html @@ -35,7 +35,7 @@ [entityId]="data.entityId" [functionTestButtonLabel]="data.functionTestButtonLabel ?? ''" [disableDebugEventAction]="data.debugActionDisabled" - (debugEventSelected)="this.onDebugEventSelected($event)" + (debugEventSelected)="onDebugEventSelected($event)" />
diff --git a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts index e82a0b987c..3700be3cd5 100644 --- a/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/dialogs/events-dialog.component.ts @@ -37,9 +37,10 @@ export interface EventsDialogData { } @Component({ - selector: 'tb-debug-dialog', - templateUrl: './events-dialog.component.html', - styleUrl: './events-dialog.component.scss' + selector: 'tb-debug-dialog', + templateUrl: './events-dialog.component.html', + styleUrl: './events-dialog.component.scss', + standalone: false }) export class EventsDialogComponent extends DialogComponent implements AfterViewInit{ diff --git a/ui-ngx/src/app/modules/home/home.component.html b/ui-ngx/src/app/modules/home/home.component.html index 690c1f344c..81d0ae2e6d 100644 --- a/ui-ngx/src/app/modules/home/home.component.html +++ b/ui-ngx/src/app/modules/home/home.component.html @@ -51,8 +51,8 @@ arrow_back + [class.!hidden]="displaySearchMode()" + class="mat-toolbar-tools flex-1">
@@ -66,18 +66,22 @@ mat-icon-button (click)="openSearch()"> search - + @if (fullscreenEnabled) { + + } - - + @if (!hideLoadingBar && (isLoading$ | async)) { + + + }
diff --git a/ui-ngx/src/app/modules/home/home.component.ts b/ui-ngx/src/app/modules/home/home.component.ts index 25edc77b33..6191280933 100644 --- a/ui-ngx/src/app/modules/home/home.component.ts +++ b/ui-ngx/src/app/modules/home/home.component.ts @@ -36,9 +36,10 @@ import { ActivatedRoute } from '@angular/router'; import { isDefined, isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-home', - templateUrl: './home.component.html', - styleUrls: ['./home.component.scss'] + selector: 'tb-home', + templateUrl: './home.component.html', + styleUrls: ['./home.component.scss'], + standalone: false }) export class HomeComponent extends PageComponent implements AfterViewInit, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.html b/ui-ngx/src/app/modules/home/menu/menu-link.component.html index 3d3823d12a..70ea95a05f 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.html @@ -16,6 +16,8 @@ --> - {{section.icon}} + @if (section.icon !== null) { + {{section.icon}} + } {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} diff --git a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts index 20b8957b15..95abc6623b 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-link.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-link.component.ts @@ -18,10 +18,11 @@ import { ChangeDetectionStrategy, Component, Input, OnInit } from '@angular/core import { MenuSection } from '@core/services/menu.models'; @Component({ - selector: 'tb-menu-link', - templateUrl: './menu-link.component.html', - styleUrls: ['./menu-link.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-menu-link', + templateUrl: './menu-link.component.html', + styleUrls: ['./menu-link.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class MenuLinkComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html index 31753751af..ce475363df 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.html @@ -16,13 +16,17 @@ --> - {{section.icon}} + @if (section.icon !== null) { + {{section.icon}} + } {{section.customTranslate ? (section.name | customTranslate) : (section.name | translate)}} + [class.tb-toggled]="section.opened">
    -
  • - -
  • + @for (page of section.pages; track trackBySectionPages($index, page)) { +
  • + +
  • + }
diff --git a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts index cd00627433..4415e9f580 100644 --- a/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts +++ b/ui-ngx/src/app/modules/home/menu/menu-toggle.component.ts @@ -22,10 +22,11 @@ import { AppState } from '@core/core.state'; import { ActionPreferencesUpdateOpenedMenuSection } from '@core/auth/auth.actions'; @Component({ - selector: 'tb-menu-toggle', - templateUrl: './menu-toggle.component.html', - styleUrls: ['./menu-toggle.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-menu-toggle', + templateUrl: './menu-toggle.component.html', + styleUrls: ['./menu-toggle.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class MenuToggleComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/menu/side-menu.component.html b/ui-ngx/src/app/modules/home/menu/side-menu.component.html index 0921a59f14..34304319ae 100644 --- a/ui-ngx/src/app/modules/home/menu/side-menu.component.html +++ b/ui-ngx/src/app/modules/home/menu/side-menu.component.html @@ -16,8 +16,16 @@ -->
    -
  • - - -
  • + @for (section of menuSections$ | async; track trackByMenuSection($index, section)) { +
  • + @switch (section.type === 'link') { + @case (true) { + + } + @case (false) { + + } + } +
  • + }
diff --git a/ui-ngx/src/app/modules/home/menu/side-menu.component.ts b/ui-ngx/src/app/modules/home/menu/side-menu.component.ts index 6194146781..f266cee1a8 100644 --- a/ui-ngx/src/app/modules/home/menu/side-menu.component.ts +++ b/ui-ngx/src/app/modules/home/menu/side-menu.component.ts @@ -19,10 +19,11 @@ import { MenuService } from '@core/services/menu.service'; import { MenuSection } from '@core/services/menu.models'; @Component({ - selector: 'tb-side-menu', - templateUrl: './side-menu.component.html', - styleUrls: ['./side-menu.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-side-menu', + templateUrl: './side-menu.component.html', + styleUrls: ['./side-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class SideMenuComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts index eaeade89ae..cc3112442f 100644 --- a/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts +++ b/ui-ngx/src/app/modules/home/models/datasource/attribute-datasource.ts @@ -115,9 +115,11 @@ export class AttributeDatasource implements DataSource { } isAllSelected(): Observable { - const numSelected = this.selection.selected.length; return this.attributesSubject.pipe( - map((attributes) => numSelected === attributes.length) + map((attributes) => { + const numSelected = this.selection.selected.length; + return attributes.length > 0 && numSelected === attributes.length + }) ); } diff --git a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 913e754fb7..f870150e32 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts @@ -51,6 +51,9 @@ export type HeaderCellStyleFunction> = (key: string) = export type CellStyleFunction> = (entity: T, key: string) => object; export type CopyCellContent> = (entity: T, key: string, length: number) => object; +export type EntityColumnsType = Array> & EntityLinkTableColumn> & EntityChipsEntityTableColumn>>>; +export type EntityColumnType = Partial> & EntityLinkTableColumn> & EntityChipsEntityTableColumn>>; + export enum CellActionDescriptorType { 'DEFAULT', 'COPY_BUTTON'} export interface CellActionDescriptor> { @@ -175,7 +178,7 @@ export class EntityTableConfig, P extends PageLink = P selectionEnabled = true; searchEnabled = true; addEnabled = true; - addAsTextButton = false; + addAsTextButton = true; entitiesDeleteEnabled = true; detailsPanelEnabled = true; hideDetailsTabsOnEdit = true; diff --git a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts index 02f120f26f..e913e2bd71 100644 --- a/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/entity/entity-table-component.models.ts @@ -29,6 +29,7 @@ import { CellActionDescriptor, EntityActionTableColumn, EntityColumn, + EntityColumnsType, EntityTableColumn, EntityTableConfig, GroupActionDescriptor, @@ -46,7 +47,7 @@ export interface IEntitiesTableComponent { groupActionDescriptors: Array>>; cellActionDescriptors: Array>>; actionColumns: Array>>; - entityColumns: Array>>; + entityColumns: EntityColumnsType; displayedColumns: string[]; headerCellStyleCache: Array; cellContentCache: Array; diff --git a/ui-ngx/src/app/modules/home/models/widget-component.models.ts b/ui-ngx/src/app/modules/home/models/widget-component.models.ts index 5685487b3f..a9d57a0d41 100644 --- a/ui-ngx/src/app/modules/home/models/widget-component.models.ts +++ b/ui-ngx/src/app/modules/home/models/widget-component.models.ts @@ -38,6 +38,7 @@ import { Timewindow, WidgetTimewindow } from '@shared/models/time/time.models'; import { IAliasController, IStateController, + IWidgetHttpUtils, IWidgetSubscription, IWidgetUtils, RpcApi, @@ -58,6 +59,13 @@ import { ViewContainerRef } from '@angular/core'; import { HttpClient, HttpErrorResponse } from '@angular/common/http'; +import { + createDefaultHttpOptions, + defaultHttpOptions, + defaultHttpOptionsFromConfig, + defaultHttpOptionsFromParams, + defaultHttpUploadOptions +} from '@core/http/http-utils'; import { RafService } from '@core/services/raf.service'; import { WidgetTypeId } from '@shared/models/id/widget-type-id'; import { TenantId } from '@shared/models/id/tenant-id'; @@ -68,7 +76,8 @@ import { formatValue, getEntityDetailsPageURL, hasDatasourceLabelsVariables, - isDefined + isDefined, + isDefinedAndNotNull } from '@core/utils'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -294,6 +303,14 @@ export class WidgetContext { getEntityDetailsPageURL }; + httpUtils: IWidgetHttpUtils = { + defaultHttpOptions, + defaultHttpOptionsFromConfig, + defaultHttpOptionsFromParams, + defaultHttpUploadOptions, + createDefaultHttpOptions + }; + $widgetElement: JQuery; $container: JQuery; $containerParent: JQuery; @@ -555,7 +572,9 @@ export class LabelVariablePattern { const entityInfo = this.ctx.defaultSubscription.getFirstEntityInfo(); label = createLabelFromSubscriptionEntityInfo(entityInfo, label); } else { - const datasource = this.ctx.defaultSubscription?.firstDatasource ?? (this.ctx as any).mapInstance?.getData()[0]; + const datasource = isDefinedAndNotNull(this.ctx.defaultSubscription) + ? this.ctx.defaultSubscription.firstDatasource ?? undefined + : (this.ctx as any).mapInstance?.getData()[0]; label = createLabelFromDatasource(datasource, label); } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.html index c18d59a5c8..9948f20cf7 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.html @@ -15,9 +15,10 @@ limitations under the License. --> - - - +@if (!(hasRepository$ | async)) { + + +} @else { - +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.ts index 950f1606c2..94134bb100 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/auto-commit-admin-settings.component.ts @@ -25,9 +25,10 @@ import { selectHasRepository } from '@core/auth/auth.selectors'; import { RepositorySettingsComponent } from '@home/components/vc/repository-settings.component'; @Component({ - selector: 'tb-auto-commit-admin-settings', - templateUrl: './auto-commit-admin-settings.component.html', - styleUrls: [] + selector: 'tb-auto-commit-admin-settings', + templateUrl: './auto-commit-admin-settings.component.html', + styleUrls: [], + standalone: false }) export class AutoCommitAdminSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html index 4b122194e8..0142c83a85 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html @@ -23,19 +23,24 @@
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + admin.base-url - - {{ 'admin.base-url-required' | translate }} - + @if (generalSettings.get('baseUrl').hasError('required')) { + + {{ 'admin.base-url-required' | translate }} + + }
@@ -63,9 +68,12 @@
admin.device-connectivity.device-connectivity
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -78,38 +86,42 @@
- + @if (protocol === 'http') { - - + } + @if (protocol === 'mqtt') { - - + } + @if (protocol === 'coap') { - + }
{{ 'admin.device-connectivity.' + protocol | translate }}
- + admin.device-connectivity.host - + admin.device-connectivity.port - - {{ 'admin.device-connectivity.port-pattern' | translate }} - - - {{ 'admin.device-connectivity.port-range' | translate }} - + @if (deviceConnectivitySettingsForm.get(protocol + '.port').hasError('pattern')) { + + {{ 'admin.device-connectivity.port-pattern' | translate }} + + } + @if (deviceConnectivitySettingsForm.get(protocol + '.port').hasError('min') || + deviceConnectivitySettingsForm.get(protocol + '.port').hasError('max')) { + + {{ 'admin.device-connectivity.port-range' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts index e4634370d3..72543b5e6b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.ts @@ -31,9 +31,10 @@ import { Subject } from 'rxjs'; import { takeUntil } from 'rxjs/operators'; @Component({ - selector: 'tb-general-settings', - templateUrl: './general-settings.component.html', - styleUrls: ['./general-settings.component.scss', './settings-card.scss'] + selector: 'tb-general-settings', + templateUrl: './general-settings.component.html', + styleUrls: ['./general-settings.component.scss', './settings-card.scss'], + standalone: false }) export class GeneralSettingsComponent extends PageComponent implements HasConfirmForm, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html index 9ebfd0369b..25c9adfea0 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html @@ -22,9 +22,13 @@ admin.home-settings - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
@@ -32,6 +36,7 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
- + admin.mail-from - - {{ 'admin.mail-from-required' | translate }} - + @if (mailSettings.get('mailFrom').hasError('required')) { + + {{ 'admin.mail-from-required' | translate }} + + } - + admin.oauth2.smtp-provider - - {{ templates.get(provider)?.name || 'Custom' }} - + @for (provider of templateProvider; track provider) { + + {{ templates.get(provider)?.name || 'Custom' }} + + } @@ -54,99 +62,123 @@ - + admin.smtp-protocol - - {{protocol.toUpperCase()}} - + @for (protocol of smtpProtocols; track protocol) { + + {{protocol.toUpperCase()}} + + }
- + admin.smtp-host - - {{ 'admin.smtp-host-required' | translate }} - + @if (mailSettings.get('smtpHost').hasError('required')) { + + {{ 'admin.smtp-host-required' | translate }} + + } - + admin.smtp-port {{smtpPortInput.value?.length || 0}}/5 - - {{ 'admin.smtp-port-required' | translate }} - - - {{ 'admin.smtp-port-invalid' | translate }} - + @if (mailSettings.get('smtpPort').hasError('required')) { + + {{ 'admin.smtp-port-required' | translate }} + + } + @if (mailSettings.get('smtpPort').hasError('pattern') || mailSettings.get('smtpPort').hasError('maxlength')) { + + {{ 'admin.smtp-port-invalid' | translate }} + + }
- + admin.timeout-msec {{timeoutInput.value?.length || 0}}/6 - - {{ 'admin.timeout-required' | translate }} - - - {{ 'admin.timeout-invalid' | translate }} - + @if (mailSettings.get('timeout').hasError('required')) { + + {{ 'admin.timeout-required' | translate }} + + } + @if (mailSettings.get('timeout').hasError('pattern') || mailSettings.get('timeout').hasError('maxlength')) { + + {{ 'admin.timeout-invalid' | translate }} + + } {{ 'admin.enable-tls' | translate }} - - admin.tls-version - - - {{ tlsVersion }} - - - + @if (mailSettings.get('enableTls').value) { + + admin.tls-version + + @for (tlsVersion of tlsVersions; track tlsVersion) { + + {{ tlsVersion }} + + } + + + } {{ 'admin.enable-proxy' | translate }} -
-
- - admin.proxy-host - - - {{ 'admin.proxy-host-required' | translate }} - - - - admin.proxy-port - - - {{ 'admin.proxy-port-required' | translate }} - - - {{ 'admin.proxy-port-range' | translate }} - - -
-
- - admin.proxy-user - - - - admin.proxy-password - - - + @if (mailSettings.get('enableProxy').value) { +
+
+ + admin.proxy-host + + @if (mailSettings.get('proxyHost').hasError('required')) { + + {{ 'admin.proxy-host-required' | translate }} + + } + + + admin.proxy-port + + @if (mailSettings.get('proxyPort').hasError('required')) { + + {{ 'admin.proxy-port-required' | translate }} + + } + @if (mailSettings.get('proxyPort').hasError('pattern') + || mailSettings.get('proxyPort').hasError('min') + || mailSettings.get('proxyPort').hasError('max')) { + + {{ 'admin.proxy-port-range' | translate }} + + } + +
+
+ + admin.proxy-user + + + + admin.proxy-password + + + +
-
+ }
admin.oauth2.authentication - + common.username @@ -156,161 +188,197 @@ {{ 'admin.oauth2.basic' | translate }} {{ 'admin.oauth2.oauth2' | translate }} -
-
-
+ @if (helpLink && mailSettings.get('enableOauth2').value) { +
+
+
+ }
-
- - {{ 'admin.change-password' | translate }} - - - common.password - - - -
-
-
- - admin.oauth2.client-id - - - {{ 'admin.oauth2.client-id-required' | translate }} - - - {{ 'admin.oauth2.client-id-max-length' | translate }} - - - - - admin.oauth2.client-secret - - - {{ 'admin.oauth2.client-secret-required' | translate }} - - - {{ 'admin.oauth2.client-secret-max-length' | translate }} - - -
- - - admin.oauth2.microsoft-tenant-id - - - {{ 'admin.oauth2.microsoft-tenant-id-required' | translate }} - - - - - - tenant-profile.advanced-settings - - - -
- - admin.oauth2.authorization-uri - - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} + @if (!mailSettings.get('enableOauth2').value) { +
+ @if (showChangePassword) { + + {{ 'admin.change-password' | translate }} + + } + @if (mailSettings.get('changePassword').value || !showChangePassword) { + + common.password + + + + } +
+ } + @if (mailSettings.get('enableOauth2').value) { +
+
+ + admin.oauth2.client-id + + @if (mailSettings.get('clientId').hasError('required')) { + + {{ 'admin.oauth2.client-id-required' | translate }} - - {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + @if (mailSettings.get('clientId').hasError('maxlen gth')) { + + {{ 'admin.oauth2.client-id-max-length' | translate }} - - - - admin.oauth2.token-uri - - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} + } + + + admin.oauth2.client-secret + + @if (mailSettings.get('clientSecret').hasError('required')) { + + {{ 'admin.oauth2.client-secret-required' | translate }} - - {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + @if (mailSettings.get('clientSecret').hasError('maxlength')) { + + {{ 'admin.oauth2.client-secret-max-length' | translate }} - -
- - - - - -
- admin.oauth2.redirect-uri -
-
-
- - admin.oauth2.protocol - - - {{ domainSchemaTranslations.get(protocol) | translate | uppercase }} - - + } + +
+ @if (mailSettings.get('providerId').value === mailServerOauth2Provider.OFFICE_365) { + + admin.oauth2.microsoft-tenant-id + + @if (mailSettings.get('providerTenantId').hasError('required')) { + + {{ 'admin.oauth2.microsoft-tenant-id-required' | translate }} + + } + + } + + + + tenant-profile.advanced-settings + + + +
+ + admin.oauth2.authorization-uri + + + @if (mailSettings.get('authUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + } + @if (mailSettings.get('authUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + } - - admin.domain-name - - - {{ 'admin.error-verification-url' | translate }} - - - {{ 'admin.domain-name-max-length' | translate }} + + admin.oauth2.token-uri + + + @if (mailSettings.get('tokenUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} + + } + @if (mailSettings.get('tokenUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + + } + +
+ + +
+
+
+ admin.oauth2.redirect-uri +
+
+
+ + admin.oauth2.protocol + + @for (protocol of protocols; track protocol) { + + {{ domainSchemaTranslations.get(protocol) | translate | uppercase }} + + } + + + + admin.domain-name + + @if (domainForm.get('name').hasError('pattern')) { + + {{ 'admin.error-verification-url' | translate }} + + } + @if (domainForm.get('name').hasError('maxlength')) { + + {{ 'admin.domain-name-max-length' | translate }} + + } + +
+ @if (domainForm.hasError('unique')) { + + {{ 'admin.domain-name-unique' | translate }} + } +
+
+ + admin.oauth2.redirect-uri-template + + +
- - {{ 'admin.domain-name-unique' | translate }} - -
- -
- - admin.oauth2.redirect-uri-template - - - -
+
+
+ } + @if (mailSettings.get('enableOauth2').value) { +
+
+ admin.oauth2.access-token-status + + {{ accessTokenStatus }} +
-
-
-
-
- admin.oauth2.access-token-status - - {{ accessTokenStatus }} - -
- -
+ +
+ }
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts index 26cbc1002d..ef2c44e8de 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-dialog.component.ts @@ -27,10 +27,11 @@ import { ClientComponent } from '@home/pages/admin/oauth2/clients/client.compone import { ErrorStateMatcher } from '@angular/material/core'; @Component({ - selector: 'tb-client-dialog', - templateUrl: './client-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: ClientDialogComponent}], - styleUrls: [] + selector: 'tb-client-dialog', + templateUrl: './client-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: ClientDialogComponent }], + styleUrls: [], + standalone: false }) export class ClientDialogComponent extends DialogComponent implements OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts index 3ec2eed2f0..b0d28a8a1e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client-table-header.component.ts @@ -22,9 +22,10 @@ import { OAuth2Client, OAuth2ClientInfo } from '@shared/models/oauth2.models'; import { PageLink } from '@shared/models/page/page-link'; @Component({ - selector: 'tb-client-table-header', - templateUrl: './client-table-header.component.html', - styleUrls: [] + selector: 'tb-client-table-header', + templateUrl: './client-table-header.component.html', + styleUrls: [], + standalone: false }) export class ClientTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html index 12f522de96..3e02fae438 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html @@ -17,57 +17,73 @@ -->
- + admin.oauth2.title - - {{ 'admin.oauth2.client-title-required' | translate }} - - - {{ 'admin.oauth2.client-title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'admin.oauth2.client-title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'admin.oauth2.client-title-max-length' | translate }} + + }
- + admin.oauth2.provider - - {{ provider }} - + @for (provider of templateProvider; track provider) { + + {{ provider }} + + }
- + admin.oauth2.allowed-platforms - - {{ platformTypeTranslations.get(platform) | translate }} - + @for (platform of platformTypes; track platform) { + + {{ platformTypeTranslations.get(platform) | translate }} + + }
- + admin.oauth2.client-id - - {{ 'admin.oauth2.client-id-required' | translate }} - - - {{ 'admin.oauth2.client-id-max-length' | translate }} - + @if (entityForm.get('clientId').hasError('required')) { + + {{ 'admin.oauth2.client-id-required' | translate }} + + } + @if (entityForm.get('clientId').hasError('maxlength')) { + + {{ 'admin.oauth2.client-id-max-length' | translate }} + + } - + admin.oauth2.client-secret - - {{ 'admin.oauth2.client-secret-required' | translate }} - - - {{ 'admin.oauth2.client-secret-max-length' | translate }} - + @if (entityForm.get('clientSecret').hasError('required')) { + + {{ 'admin.oauth2.client-secret-required' | translate }} + + } + @if (entityForm.get('clientSecret').hasError('maxlength')) { + + {{ 'admin.oauth2.client-secret-max-length' | translate }} + + }
@@ -85,237 +101,291 @@ {{ 'admin.oauth2.mapper' | translate }}
-
-
- - admin.oauth2.access-token-uri - - - {{ 'admin.oauth2.access-token-uri-required' | translate }} - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - - - admin.oauth2.authorization-uri - - - {{ 'admin.oauth2.authorization-uri-required' | translate }} - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - -
-
- - admin.oauth2.jwk-set-uri - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - - - admin.oauth2.user-info-uri - - - {{ 'admin.oauth2.uri-pattern-error' | translate }} - - -
-
- - admin.oauth2.client-authentication-method - - - {{ clientAuthenticationMethod | uppercase }} - - - -
-
- - admin.oauth2.login-button-label - - - {{ 'admin.oauth2.login-button-label-required' | translate }} - - -
-
admin.oauth2.login-button-icon
- - -
-
-
-
- - {{ 'admin.oauth2.allow-user-creation' | translate }} - -
-
- - {{ 'admin.oauth2.activate-user' | translate }} - -
-
- - -
-
-
- - admin.oauth2.user-name-attribute-name - - - {{ 'admin.oauth2.user-name-attribute-name-required' | translate }} - - -
-
-
- - admin.oauth2.type - - - {{ mapperType }} - - - -
-
-
- - admin.oauth2.email-attribute-key - - - {{ 'admin.oauth2.email-attribute-key-required' | translate }} + @if (generalSettingsMode) { +
+
+ + admin.oauth2.access-token-uri + + @if (entityForm.get('accessTokenUri').hasError('required')) { + + {{ 'admin.oauth2.access-token-uri-required' | translate }} - - {{ 'admin.oauth2.email-attribute-key-max-length' | translate }} + } + @if (entityForm.get('accessTokenUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - -
-
- - admin.oauth2.first-name-attribute-key - - - {{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }} + } + + + admin.oauth2.authorization-uri + + @if (entityForm.get('authorizationUri').hasError('required')) { + + {{ 'admin.oauth2.authorization-uri-required' | translate }} - - - admin.oauth2.last-name-attribute-key - - - {{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }} + } + @if (entityForm.get('authorizationUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - -
-
- - admin.oauth2.tenant-name-strategy - - - {{ tenantNameStrategy }} - - - - - admin.oauth2.tenant-name-pattern - - - {{ 'admin.oauth2.tenant-name-pattern-required' | translate }} + } + +
+
+ + admin.oauth2.jwk-set-uri + + @if (entityForm.get('jwkSetUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} - - {{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }} + } + + + admin.oauth2.user-info-uri + + @if (entityForm.get('userInfoUri').hasError('pattern')) { + + {{ 'admin.oauth2.uri-pattern-error' | translate }} + } + +
+
+ + admin.oauth2.client-authentication-method + + @for (clientAuthenticationMethod of clientAuthenticationMethods; track clientAuthenticationMethod) { + + {{ clientAuthenticationMethod | uppercase }} + + } + + +
+ @if (isCustomProvider()) { +
+ + admin.oauth2.login-button-label + + @if (entityForm.get('loginButtonLabel').hasError('required')) { + + {{ 'admin.oauth2.login-button-label-required' | translate }} + + } +
+
admin.oauth2.login-button-icon
+ + +
-
- - admin.oauth2.customer-name-pattern - - - {{ 'admin.oauth2.customer-name-pattern-max-length' | translate }} - - + } +
+
+ + {{ 'admin.oauth2.allow-user-creation' | translate }} +
-
- - admin.oauth2.default-dashboard-name - - - {{ 'admin.oauth2.default-dashboard-name-max-length' | translate }} - - - - {{ 'admin.oauth2.always-fullscreen' | translate}} +
+ + {{ 'admin.oauth2.activate-user' | translate }}
-
-
- - admin.oauth2.url - - - {{ 'admin.oauth2.url-required' | translate }} - - - {{ 'admin.oauth2.url-pattern' | translate }} - - - {{ 'admin.oauth2.url-max-length' | translate }} - - -
-
- - common.username - - - {{ 'admin.oauth2.username-max-length' | translate }} - - - - common.password - - - {{ 'admin.oauth2.password-max-length' | translate }} + + +
+ } + @if (!generalSettingsMode) { +
+
+ + admin.oauth2.user-name-attribute-name + + @if (entityForm.get('userNameAttributeName').hasError('required')) { + + {{ 'admin.oauth2.user-name-attribute-name-required' | translate }} + } + +
+
+
+ + admin.oauth2.type + + @for (mapperType of mapperTypes; track mapperType) { + + {{ mapperType }} + + } +
- - {{ 'admin.oauth2.send-token' | translate}} - + @if (entityForm.get('mapperConfig.type').value !== mapperType.CUSTOM && entityForm.get('mapperConfig.basic')) { +
+ @if (entityForm.get('mapperConfig.type').value !== mapperType.GITHUB) { +
+ + admin.oauth2.email-attribute-key + + @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('required')) { + + {{ 'admin.oauth2.email-attribute-key-required' | translate }} + + } + @if (entityForm.get('mapperConfig.basic.emailAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.email-attribute-key-max-length' | translate }} + + } + +
+ } +
+ + admin.oauth2.first-name-attribute-key + + @if (entityForm.get('mapperConfig.basic.firstNameAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.first-name-attribute-key-max-length' | translate }} + + } + + + admin.oauth2.last-name-attribute-key + + @if (entityForm.get('mapperConfig.basic.lastNameAttributeKey').hasError('maxlength')) { + + {{ 'admin.oauth2.last-name-attribute-key-max-length' | translate }} + + } + +
+
+ + admin.oauth2.tenant-name-strategy + + @for (tenantNameStrategy of tenantNameStrategies; track tenantNameStrategy) { + + {{ tenantNameStrategy }} + + } + + + + admin.oauth2.tenant-name-pattern + + @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('required')) { + + {{ 'admin.oauth2.tenant-name-pattern-required' | translate }} + + } + @if (entityForm.get('mapperConfig.basic.tenantNamePattern').hasError('maxlength')) { + + {{ 'admin.oauth2.tenant-name-pattern-max-length' | translate }} + + } + +
+
+ + admin.oauth2.customer-name-pattern + + @if (entityForm.get('mapperConfig.basic.customerNamePattern').hasError('maxlength')) { + + {{ 'admin.oauth2.customer-name-pattern-max-length' | translate }} + + } + +
+
+ + admin.oauth2.default-dashboard-name + + @if (entityForm.get('mapperConfig.basic.defaultDashboardName').hasError('maxlength')) { + + {{ 'admin.oauth2.default-dashboard-name-max-length' | translate }} + + } + + + {{ 'admin.oauth2.always-fullscreen' | translate}} + +
+
+ } + @if (entityForm.get('mapperConfig.type').value === mapperType.CUSTOM && entityForm.get('mapperConfig.custom')) { +
+
+ + admin.oauth2.url + + @if (entityForm.get('mapperConfig.custom.url').hasError('required')) { + + {{ 'admin.oauth2.url-required' | translate }} + + } + @if (entityForm.get('mapperConfig.custom.url').hasError('pattern')) { + + {{ 'admin.oauth2.url-pattern' | translate }} + + } + @if (entityForm.get('mapperConfig.custom.url').hasError('maxlength')) { + + {{ 'admin.oauth2.url-max-length' | translate }} + + } + +
+
+ + common.username + + @if (entityForm.get('mapperConfig.custom.username').hasError('maxlength')) { + + {{ 'admin.oauth2.username-max-length' | translate }} + + } + + + common.password + + @if (entityForm.get('mapperConfig.custom.password').hasError('maxlength')) { + + {{ 'admin.oauth2.password-max-length' | translate }} + + } + +
+ + {{ 'admin.oauth2.send-token' | translate}} + +
+ }
-
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts index 7298f339f2..151b56df6c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.ts @@ -44,9 +44,10 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-client', - templateUrl: './client.component.html', - styleUrls: ['./client.component.scss'] + selector: 'tb-client', + templateUrl: './client.component.html', + styleUrls: ['./client.component.scss'], + standalone: false }) export class ClientComponent extends EntityComponent implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts index 91e18ec8b3..ff94740791 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain-table-header.component.ts @@ -21,9 +21,10 @@ import { AppState } from '@core/core.state'; import { DomainInfo } from '@shared/models/oauth2.models'; @Component({ - selector: 'tb-domain-table-header', - templateUrl: './domain-table-header.component.html', - styleUrls: [] + selector: 'tb-domain-table-header', + templateUrl: './domain-table-header.component.html', + styleUrls: [], + standalone: false }) export class DomainTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html index 0816cb6f5a..dc0726e68d 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html @@ -17,22 +17,28 @@ -->
- + admin.oauth2.domain-name - - {{ 'admin.oauth2.domain-name-required' | translate }} - - - {{ 'admin.domain-name-max-length' | translate }} - - - {{ 'admin.error-verification-url' | translate }} - + @if (entityForm.get('name').hasError('required')) { + + {{ 'admin.oauth2.domain-name-required' | translate }} + + } + @if (entityForm.get('name').hasError('maxlength')) { + + {{ 'admin.domain-name-max-length' | translate }} + + } + @if (entityForm.get('name').hasError('pattern')) { + + {{ 'admin.error-verification-url' | translate }} + + }
- + admin.oauth2.redirect-url-template
-
- + admin.jwt.expiration-time - - {{ 'admin.jwt.expiration-time-required' | translate }} - - - {{ 'admin.jwt.expiration-time-max' | translate }} - - - {{ 'admin.jwt.expiration-time-min' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('required')) { + + {{ 'admin.jwt.expiration-time-required' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('max')) { + + {{ 'admin.jwt.expiration-time-max' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenExpirationTime').hasError('min')) { + + {{ 'admin.jwt.expiration-time-min' | translate }} + + } - + admin.jwt.refresh-expiration-time - - {{ 'admin.jwt.refresh-expiration-time-required' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-max' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-min' | translate }} - - - {{ 'admin.jwt.refresh-expiration-time-less-token' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('required')) { + + {{ 'admin.jwt.refresh-expiration-time-required' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('max')) { + + {{ 'admin.jwt.refresh-expiration-time-max' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('min')) { + + {{ 'admin.jwt.refresh-expiration-time-min' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('refreshTokenExpTime').hasError('lessToken')) { + + {{ 'admin.jwt.refresh-expiration-time-less-token' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss index 406cd966e0..93eff35f8c 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.scss @@ -22,14 +22,18 @@ margin: 0; } - .fields-group { - padding: 8px 16px 0; - margin: 10px 0; - border: 1px groove rgba(0, 0, 0, .25); - border-radius: 4px; + .mat-mdc-card.settings-card { + fieldset { + .fields-group { + padding: 8px 16px 0; + margin: 10px 0; + border: 1px groove rgba(0, 0, 0, .25); + border-radius: 4px; - legend { - color: rgba(0, 0, 0, .7); + legend { + color: rgba(0, 0, 0, .7); + } + } } } } diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts index 74c28f05ad..12375a68ac 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.ts @@ -40,9 +40,10 @@ import { Observable, of } from 'rxjs'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-security-settings', - templateUrl: './security-settings.component.html', - styleUrls: ['./security-settings.component.scss', './settings-card.scss'] + selector: 'tb-security-settings', + templateUrl: './security-settings.component.html', + styleUrls: ['./security-settings.component.scss', './settings-card.scss'], + standalone: false }) export class SecuritySettingsComponent extends PageComponent implements HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html index c36923dc84..96975f3f70 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html @@ -25,25 +25,33 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + admin.sms-message - - {{ 'admin.sms-message-required' | translate }} - - - {{ 'admin.sms-message-max-length' | translate }} - + @if (sendTestSmsFormGroup.get('message').hasError('required')) { + + {{ 'admin.sms-message-required' | translate }} + + } + @if (sendTestSmsFormGroup.get('message').hasError('maxLength')) { + + {{ 'admin.sms-message-max-length' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.ts index 73687aabb3..f13dd74daa 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.ts @@ -31,9 +31,10 @@ export interface SendTestSmsDialogData { } @Component({ - selector: 'tb-send-test-sms-dialog', - templateUrl: './send-test-sms-dialog.component.html', - styleUrls: [] + selector: 'tb-send-test-sms-dialog', + templateUrl: './send-test-sms-dialog.component.html', + styleUrls: [], + standalone: false }) export class SendTestSmsDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html index 2c38ad4ab8..a83292d2ea 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html @@ -15,35 +15,41 @@ limitations under the License. --> - - - - admin.sms-provider-settings - - -
-
- - -
- - - - -
- - -
- -
-
+@if (isSysAdmin()) { + + + + admin.sms-provider-settings + + +
+
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ } + +
+ + +
+ + +
+
+
+
+}
@@ -53,32 +59,40 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ } - + admin.slack-api-token - - - admin.mobile-settings - - - - - - - - + @if (isSysAdmin()) { + + + admin.mobile-settings + + + + + } + @if (isSysAdmin()) { + + + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts index 8b9d73c73f..44b5dd3b6e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/trendz-settings.component.ts @@ -26,9 +26,10 @@ import { AppState } from "@core/core.state"; import { ActionAuthUpdateTrendzSettings } from "@core/auth/auth.actions"; @Component({ - selector: 'tb-trendz-settings', - templateUrl: './trendz-settings.component.html', - styleUrls: ['./trendz-settings.component.scss', './settings-card.scss'] + selector: 'tb-trendz-settings', + templateUrl: './trendz-settings.component.html', + styleUrls: ['./trendz-settings.component.scss', './settings-card.scss'], + standalone: false }) export class TrendzSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html index 2da7097509..46495abd5e 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/two-factor-auth-settings.component.html @@ -24,9 +24,13 @@
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
@@ -47,42 +51,45 @@ admin.2fa.enforce-for - - {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} - + @for (type of notificationTargetConfigTypes; track type) { + + {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} + + } -
-
- - {{ 'tenant.tenant' | translate }} - {{ 'tenant-profile.tenant-profile' | translate }} - -
- - - - - - - - -
+ @if (twoFaFormGroup.get('enforcedUsersFilter.type').value === notificationTargetConfigType.TENANT_ADMINISTRATORS) { +
+
+ + {{ 'tenant.tenant' | translate }} + {{ 'tenant-profile.tenant-profile' | translate }} + +
+ @if (twoFaFormGroup.get('enforcedUsersFilter.filterByTenants').value) { + + + } @else { + + + } +
+ }
@@ -93,42 +100,48 @@ @if (!atListOneProvider) { } - -
- - - - - {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} - - - - - - - - - admin.2fa.issuer-name - - + @for (provider of providersForm.controls; track trackByElement(i, provider); let i = $index) { +
+ + + + + {{ twoFactorAuthProvidersData.get(provider.value.providerType).name | translate }} + + + + + @switch (provider.get('providerType').value) { + @case (twoFactorAuthProviderType.TOTP) { + + admin.2fa.issuer-name + + @if (provider.get('issuerName').hasError('required') || + provider.get('issuerName').hasError('pattern')) { + {{ "admin.2fa.issuer-name-required" | translate }} - - -
+ } + + } + @case (twoFactorAuthProviderType.SMS) { +
admin.2fa.verification-message-template - - {{ "admin.2fa.verification-message-template-required" | translate }} - - - {{ "admin.2fa.verification-message-template-pattern" | translate }} - + @if (provider.get('smsVerificationMessageTemplate').hasError('required')) { + + {{ "admin.2fa.verification-message-template-required" | translate }} + + } + @if (provider.get('smsVerificationMessageTemplate').hasError('pattern')) { + + {{ "admin.2fa.verification-message-template-pattern" | translate }} + + }
-
+ } + @case (twoFactorAuthProviderType.EMAIL) { +
-
+ } + @case (twoFactorAuthProviderType.BACKUP_CODE) { +
admin.2fa.number-of-codes - - {{ "admin.2fa.number-of-codes-required" | translate }} - - - {{ "admin.2fa.number-of-codes-pattern" | translate }} - + @if (provider.get('codesQuantity').hasError('required')) { + + {{ "admin.2fa.number-of-codes-required" | translate }} + + } + @if (provider.get('codesQuantity').hasError('min') || + provider.get('codesQuantity').hasError('pattern')) { + + {{ "admin.2fa.number-of-codes-pattern" | translate }} + + }
- - - -
- + } + } + + +
+ }
admin.2fa.verification-limitations
@@ -197,11 +219,13 @@ admin.2fa.max-verification-failures-before-user-lockout - - {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} - + || twoFaFormGroup.get('maxVerificationFailuresBeforeUserLockout').hasError('max')) { + + {{ 'admin.2fa.max-verification-failures-before-user-lockout-pattern' | translate }} + + }
@@ -219,13 +243,17 @@ admin.2fa.number-of-checking-attempts - - {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} - - - {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} - + @if (twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('required')) { + + {{ 'admin.2fa.number-of-checking-attempts-required' | translate }} + + } + @if (twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('pattern') + || twoFaFormGroup.get('verificationCodeCheckRateLimitNumber').hasError('min')) { + + {{ 'admin.2fa.number-of-checking-attempts-pattern' | translate }} + + } { diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts index b0606b9c5b..75dd645b40 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-routing.module.ts @@ -26,7 +26,7 @@ import { EntityDetailsPageComponent } from '@home/components/entity/entity-detai import { ConfirmOnExitGuard } from '@core/guards/confirm-on-exit.guard'; import { entityDetailsPageBreadcrumbLabelFunction } from '@home/pages/home-pages.models'; import { BreadCrumbConfig } from '@shared/components/breadcrumb'; -import { CalculatedFieldsService } from '@core/http/calculated-fields.service'; +import { AlarmRulesService } from '@core/http/alarm-rules.service'; import { TranslateService } from '@ngx-translate/core'; import { MatDialog } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; @@ -40,7 +40,7 @@ import { AlarmRulesTableConfig } from '@home/components/alarm-rules/alarm-rules- export const AlarmRulesTableConfigResolver: ResolveFn = (_route: ActivatedRouteSnapshot, _state: RouterStateSnapshot, - calculatedFieldsService = inject(CalculatedFieldsService), + alarmRulesService = inject(AlarmRulesService), translate = inject(TranslateService), dialog = inject(MatDialog), store = inject(Store), @@ -52,7 +52,7 @@ export const AlarmRulesTableConfigResolver: ResolveFn = router = inject(Router), ) => { return new AlarmRulesTableConfig( - calculatedFieldsService, + alarmRulesService, translate, dialog, datePipe, diff --git a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts index 003c1f3b71..c26624bebc 100644 --- a/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/alarm/alarm-rules-tabs.component.ts @@ -18,17 +18,18 @@ import { Component } from '@angular/core'; import { EntityTabsComponent } from '../../components/entity/entity-tabs.component'; import { CalculatedFieldEventBody, DebugEventType, EventType } from '@shared/models/event.models'; import type { - CalculatedFieldsTableConfig, - CalculatedFieldsTableEntity -} from '@home/components/calculated-fields/calculated-fields-table-config'; + AlarmRulesTableConfig, + AlarmRuleTableEntity +} from '@home/components/alarm-rules/alarm-rules-table-config'; import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; @Component({ - selector: 'tb-alarm-rules-tabs', - templateUrl: './alarm-rules-tabs.component.html', - styleUrls: [] + selector: 'tb-alarm-rules-tabs', + templateUrl: './alarm-rules-tabs.component.html', + styleUrls: [], + standalone: false }) -export class AlarmRulesTabsComponent extends EntityTabsComponent { +export class AlarmRulesTabsComponent extends EntityTabsComponent { readonly DebugEventType = DebugEventType; readonly EventType = EventType; @@ -42,8 +43,7 @@ export class AlarmRulesTabsComponent extends EntityTabsComponent { - }); + (this.entitiesTableConfig as AlarmRulesTableConfig).getTestScriptDialog(this.entity, JSON.parse(event.arguments)) + .subscribe((_expression) => { }); }; } diff --git a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts index 836e46d2fe..a7ce0082cd 100644 --- a/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset-profile/asset-profile-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { AssetProfile } from '@shared/models/asset.models'; @Component({ - selector: 'tb-asset-profile-tabs', - templateUrl: './asset-profile-tabs.component.html', - styleUrls: [] + selector: 'tb-asset-profile-tabs', + templateUrl: './asset-profile-tabs.component.html', + styleUrls: [], + standalone: false }) export class AssetProfileTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html index 69b1aa3abe..ce680334da 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-table-header.component.html @@ -16,6 +16,7 @@ --> { diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html index a50bd2a9a7..bf117d4849 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.html @@ -43,10 +43,6 @@ - - - diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts index dace342fab..a75f20dfca 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset-tabs.component.ts @@ -22,9 +22,10 @@ import { AssetInfo } from '@app/shared/models/asset.models'; import { EntityId } from "@shared/models/id/entity-id"; @Component({ - selector: 'tb-asset-tabs', - templateUrl: './asset-tabs.component.html', - styleUrls: [] + selector: 'tb-asset-tabs', + templateUrl: './asset-tabs.component.html', + styleUrls: [], + standalone: false }) export class AssetTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.component.html b/ui-ngx/src/app/modules/home/pages/asset/asset.component.html index 6230fcae0d..837342ec57 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset.component.html +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.component.html @@ -64,7 +64,7 @@
- asset.assignedToCustomer @@ -75,42 +75,45 @@ {{ 'asset.asset-public' | translate }}
-
- +
+ asset.name - - {{ 'asset.name-required' | translate }} - - - {{ 'asset.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'asset.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'asset.name-max-length' | translate }} + } - + asset.label - - {{ 'asset.label-max-length' | translate }} - + @if (entityForm.get('label').hasError('maxlength')) { + {{ 'asset.label-max-length' | translate }} + } - - + @if (isAdd) { + + + }
- + asset.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts b/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts index bd6d94b1e0..4f8e3b7c63 100644 --- a/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts +++ b/ui-ngx/src/app/modules/home/pages/asset/asset.component.ts @@ -27,9 +27,10 @@ import { AssetInfo } from '@app/shared/models/asset.models'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @Component({ - selector: 'tb-asset', - templateUrl: './asset.component.html', - styleUrls: ['./asset.component.scss'] + selector: 'tb-asset', + templateUrl: './asset.component.html', + styleUrls: ['./asset.component.scss'], + standalone: false }) export class AssetComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts index 2a4cd70c90..b9943eb41f 100644 --- a/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts +++ b/ui-ngx/src/app/modules/home/pages/audit-log/audit-log-routing.module.ts @@ -25,7 +25,7 @@ export const auditLogsRoutes: Routes = [ path: 'auditLogs', component: AuditLogTableComponent, data: { - auth: [Authority.TENANT_ADMIN], + auth: [Authority.TENANT_ADMIN, Authority.SYS_ADMIN], title: 'audit-log.audit-logs', breadcrumb: { menuId: MenuId.audit_log diff --git a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts index acaacef7a7..8e441a5088 100644 --- a/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/calculated-fields/calculated-fields-tabs.component.ts @@ -24,9 +24,10 @@ import type { import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; @Component({ - selector: 'tb-calculated-fields-tabs', - templateUrl: './calculated-fields-tabs.component.html', - styleUrls: [] + selector: 'tb-calculated-fields-tabs', + templateUrl: './calculated-fields-tabs.component.html', + styleUrls: [], + standalone: false }) export class CalculatedFieldsTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html index 1c0bb7322b..d6f3379ead 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.html @@ -39,13 +39,6 @@ - - - - diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts index 13afac05ac..0bf2f691a0 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/customer/customer-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { Customer } from '@shared/models/customer.model'; @Component({ - selector: 'tb-customer-tabs', - templateUrl: './customer-tabs.component.html', - styleUrls: [] + selector: 'tb-customer-tabs', + templateUrl: './customer-tabs.component.html', + styleUrls: [], + standalone: false }) export class CustomerTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/customer/customer.component.html b/ui-ngx/src/app/modules/home/pages/customer/customer.component.html index b96e574cef..cb771237d1 100644 --- a/ui-ngx/src/app/modules/home/pages/customer/customer.component.html +++ b/ui-ngx/src/app/modules/home/pages/customer/customer.component.html @@ -46,13 +46,14 @@ [class.!hidden]="isEdit"> {{'customer.manage-dashboards' | translate }} - + @if (edgesSupportEnabled()) { + + } - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- - + +
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts index d5ea2a9849..336043907f 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.ts @@ -30,9 +30,10 @@ export interface DashboardInfoDialogData { } @Component({ - selector: 'tb-import-dashboard-file-dialog', - templateUrl: './import-dashboard-file-dialog.component.html', - styleUrls: [] + selector: 'tb-import-dashboard-file-dialog', + templateUrl: './import-dashboard-file-dialog.component.html', + styleUrls: [], + standalone: false }) export class ImportDashboardFileDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html index 43f0f3dc6b..e21f996584 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/make-dashboard-public-dialog.component.html @@ -25,13 +25,14 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- - +
{{ publicLink }}
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ labelText | translate }} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts index eb25e7cba1..ab1da02eef 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/dashboard/manage-dashboard-customers-dialog.component.ts @@ -35,10 +35,11 @@ export interface ManageDashboardCustomersDialogData { } @Component({ - selector: 'tb-manage-dashboard-customers-dialog', - templateUrl: './manage-dashboard-customers-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: ManageDashboardCustomersDialogComponent}], - styleUrls: [] + selector: 'tb-manage-dashboard-customers-dialog', + templateUrl: './manage-dashboard-customers-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: ManageDashboardCustomersDialogComponent }], + styleUrls: [], + standalone: false }) export class ManageDashboardCustomersDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index 69dd15b60f..76349148fd 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html @@ -17,28 +17,36 @@ --> @if (entity) { -
- - device-profile.transport-type - - - {{deviceTransportTypeTranslations.get(type) | translate}} - - - - {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} - - - {{ 'device-profile.transport-type-required' | translate }} - - -
- - + @if (transportType.isActive) { +
+ + device-profile.transport-type + + @for (type of deviceTransportTypes; track type) { + + {{deviceTransportTypeTranslations.get(type) | translate}} + + } + + @if (detailsForm.get('transportType').value) { + + {{deviceTransportTypeHints.get(detailsForm.get('transportType').value) | translate}} + + } + @if (detailsForm.get('transportType').hasError('required')) { + + {{ 'device-profile.transport-type-required' | translate }} + + } + +
+ + +
-
+ } @if (authUser.authority === authorities.TENANT_ADMIN && !isEdit) { @@ -58,11 +66,13 @@
} @if (alarmRulesOldVersion || isEdit) { -
-
- + @if (alarmRules.isActive) { +
+
+ +
-
+ } } @else {
-
-
- - + @if (deviceProvisioning.isActive) { +
+
+ + +
-
+ } @if (!isEdit) { @@ -101,13 +113,15 @@ } } - -
-
- - +@if (false) { + +
+
+ + +
-
- + +} diff --git a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts index 1aadb36778..0560f88174 100644 --- a/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.ts @@ -27,9 +27,10 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-profile-tabs', - templateUrl: './device-profile-tabs.component.html', - styleUrls: [] + selector: 'tb-device-profile-tabs', + templateUrl: './device-profile-tabs.component.html', + styleUrls: [], + standalone: false }) export class DeviceProfileTabsComponent extends EntityTabsComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts index 0dce317071..15c5a7eaa6 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/coap-device-transport-configuration.component.ts @@ -29,14 +29,15 @@ import { takeUntil } from 'rxjs/operators'; import { isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-coap-device-transport-configuration', - templateUrl: './coap-device-transport-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CoapDeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-coap-device-transport-configuration', + templateUrl: './coap-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CoapDeviceTransportConfigurationComponent), + multi: true + }], + standalone: false }) export class CoapDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts index 4e4c4b51a1..ff39e03bb0 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-configuration.component.ts @@ -27,14 +27,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-default-device-configuration', - templateUrl: './default-device-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultDeviceConfigurationComponent), - multi: true - }] + selector: 'tb-default-device-configuration', + templateUrl: './default-device-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceConfigurationComponent), + multi: true + }], + standalone: false }) export class DefaultDeviceConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts index 1e196210fc..fe6238ab90 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/default-device-transport-configuration.component.ts @@ -27,14 +27,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-default-device-transport-configuration', - templateUrl: './default-device-transport-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DefaultDeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-default-device-transport-configuration', + templateUrl: './default-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DefaultDeviceTransportConfigurationComponent), + multi: true + }], + standalone: false }) export class DefaultDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html index eaa5d38f0b..64d28b2b9a 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.html @@ -16,12 +16,14 @@ -->
-
- - - - +
+ @switch (type) { + @case (deviceProfileType.DEFAULT) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts index 54432ccf11..aab6c76339 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-configuration.component.ts @@ -24,14 +24,15 @@ import { deepClone } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-configuration', - templateUrl: './device-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceConfigurationComponent), - multi: true - }] + selector: 'tb-device-configuration', + templateUrl: './device-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceConfigurationComponent), + multi: true + }], + standalone: false }) export class DeviceConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html index d5690a9cdc..cb9b8eed57 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.html @@ -17,27 +17,31 @@ -->
- - - -
device.device-configuration
-
-
- - -
- - - -
device.transport-configuration
-
-
- - -
+ @if (displayDeviceConfiguration) { + + + +
device.device-configuration
+
+
+ + +
+ } + @if (displayTransportConfiguration) { + + + +
device.transport-configuration
+
+
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts index 9acf174e4b..c6f03a84f1 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-data.component.ts @@ -36,21 +36,22 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-data', - templateUrl: './device-data.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceDataComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceDataComponent), - multi: true - }, - ] + selector: 'tb-device-data', + templateUrl: './device-data.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceDataComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceDataComponent), + multi: true + }, + ], + standalone: false }) export class DeviceDataComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html index b18974839e..41f2007ce9 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.html @@ -16,36 +16,38 @@ -->
-
- - - - - - - - - - - - - - - - - - - - +
+ @switch (transportType) { + @case (deviceTransportType.DEFAULT) { + + + } + @case (deviceTransportType.MQTT) { + + + } + @case (deviceTransportType.LWM2M) { + + + } + @case (deviceTransportType.COAP) { + + + } + @case (deviceTransportType.SNMP) { + + + } + }
diff --git a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts index ccc7f20c5e..96456ebe14 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/device-transport-configuration.component.ts @@ -33,20 +33,22 @@ import { deepClone } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device-transport-configuration', - templateUrl: './device-transport-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DeviceTransportConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-device-transport-configuration', + templateUrl: './device-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DeviceTransportConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DeviceTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class DeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts index 930b62b9f8..aa11e891d8 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/lwm2m-device-transport-configuration.component.ts @@ -29,14 +29,15 @@ import { Subject } from 'rxjs'; import { isDefinedAndNotNull } from '@core/utils'; @Component({ - selector: 'tb-lwm2m-device-transport-configuration', - templateUrl: './lwm2m-device-transport-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => Lwm2mDeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-lwm2m-device-transport-configuration', + templateUrl: './lwm2m-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => Lwm2mDeviceTransportConfigurationComponent), + multi: true + }], + standalone: false }) export class Lwm2mDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts index f409713ed3..414fe213bc 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/mqtt-device-transport-configuration.component.ts @@ -26,14 +26,15 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-mqtt-device-transport-configuration', - templateUrl: './mqtt-device-transport-configuration.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MqttDeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-mqtt-device-transport-configuration', + templateUrl: './mqtt-device-transport-configuration.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttDeviceTransportConfigurationComponent), + multi: true + }], + standalone: false }) export class MqttDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html index 631c28ad98..ec05b5ae77 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html @@ -17,117 +17,149 @@ -->
- + device-profile.snmp.host - - {{ 'device-profile.snmp.host-required' | translate }} - + @if (snmpDeviceTransportConfigurationFormGroup.get('host').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('host').hasError('pattern')) { + + {{ 'device-profile.snmp.host-required' | translate }} + + } - + device-profile.snmp.port - - {{ 'device-profile.snmp.port-required' | translate }} - - - {{ 'device-profile.snmp.port-format' | translate }} - + @if (snmpDeviceTransportConfigurationFormGroup.get('port').hasError('required')) { + + {{ 'device-profile.snmp.port-required' | translate }} + + } + @if (snmpDeviceTransportConfigurationFormGroup.get('port').hasError('pattern') || + snmpDeviceTransportConfigurationFormGroup.get('port').hasError('min')) { + + {{ 'device-profile.snmp.port-format' | translate }} + + }
- + device-profile.snmp.protocol-version - - {{ snmpDeviceProtocolVersion | lowercase }} - + @for (snmpDeviceProtocolVersion of snmpDeviceProtocolVersions; track snmpDeviceProtocolVersion) { + + {{ snmpDeviceProtocolVersion | lowercase }} + + } - - {{ 'device-profile.snmp.protocol-version-required' | translate }} - - -
- - device-profile.snmp.community - - - {{ 'device-profile.snmp.community-required' | translate }} + @if (snmpDeviceTransportConfigurationFormGroup.get('protocolVersion').hasError('required')) { + + {{ 'device-profile.snmp.protocol-version-required' | translate }} - -
-
-
- - device-profile.snmp.user-name - - - {{ 'device-profile.snmp.user-name-required' | translate }} - - - - device-profile.snmp.security-name - - - {{ 'device-profile.snmp.security-name-required' | translate }} - - -
-
- - device-profile.snmp.authentication-protocol - - - {{ snmpAuthenticationProtocolTranslation.get(snmpAuthenticationProtocol) }} - - - - {{ 'device-profile.snmp.authentication-protocol-required' | translate }} - - - - device-profile.snmp.authentication-passphrase - - - {{ 'device-profile.snmp.authentication-passphrase-required' | translate }} - - -
-
- - device-profile.snmp.privacy-protocol - - - {{ snmpPrivacyProtocolTranslation.get(snmpPrivacyProtocol) }} - - - - {{ 'device-profile.snmp.privacy-protocol-required' | translate }} - - - - device-profile.snmp.privacy-passphrase - - - {{ 'device-profile.snmp.privacy-passphrase-required' | translate }} - - -
-
- - device-profile.snmp.context-name - - - - device-profile.snmp.engine-id - + } + + @if (!isV3protocolVersion()) { +
+ + device-profile.snmp.community + + @if (snmpDeviceTransportConfigurationFormGroup.get('community').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('community').hasError('pattern')) { + + {{ 'device-profile.snmp.community-required' | translate }} + + } -
-
+
+ } + @if (isV3protocolVersion()) { +
+
+ + device-profile.snmp.user-name + + @if (snmpDeviceTransportConfigurationFormGroup.get('username').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('username').hasError('pattern')) { + + {{ 'device-profile.snmp.user-name-required' | translate }} + + } + + + device-profile.snmp.security-name + + @if (snmpDeviceTransportConfigurationFormGroup.get('securityName').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('securityName').hasError('pattern')) { + + {{ 'device-profile.snmp.security-name-required' | translate }} + + } + +
+
+ + device-profile.snmp.authentication-protocol + + @for (snmpAuthenticationProtocol of snmpAuthenticationProtocols; track snmpAuthenticationProtocol) { + + {{ snmpAuthenticationProtocolTranslation.get(snmpAuthenticationProtocol) }} + + } + + @if (snmpDeviceTransportConfigurationFormGroup.get('authenticationProtocol').hasError('required')) { + + {{ 'device-profile.snmp.authentication-protocol-required' | translate }} + + } + + + device-profile.snmp.authentication-passphrase + + @if (snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('authenticationPassphrase').hasError('pattern')) { + + {{ 'device-profile.snmp.authentication-passphrase-required' | translate }} + + } + +
+
+ + device-profile.snmp.privacy-protocol + + @for (snmpPrivacyProtocol of snmpPrivacyProtocols; track snmpPrivacyProtocol) { + + {{ snmpPrivacyProtocolTranslation.get(snmpPrivacyProtocol) }} + + } + + @if (snmpDeviceTransportConfigurationFormGroup.get('privacyProtocol').hasError('required')) { + + {{ 'device-profile.snmp.privacy-protocol-required' | translate }} + + } + + + device-profile.snmp.privacy-passphrase + + @if (snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').hasError('required') || + snmpDeviceTransportConfigurationFormGroup.get('privacyPassphrase').hasError('pattern')) { + + {{ 'device-profile.snmp.privacy-passphrase-required' | translate }} + + } + +
+
+ + device-profile.snmp.context-name + + + + device-profile.snmp.engine-id + + +
+
+ } diff --git a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts index 1c87b0d95d..c27b4eed9f 100644 --- a/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.ts @@ -42,19 +42,21 @@ import { isDefinedAndNotNull } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-snmp-device-transport-configuration', - templateUrl: './snmp-device-transport-configuration.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), - multi: true - }, { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), - multi: true - }] + selector: 'tb-snmp-device-transport-configuration', + templateUrl: './snmp-device-transport-configuration.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SnmpDeviceTransportConfigurationComponent), + multi: true + } + ], + standalone: false }) export class SnmpDeviceTransportConfigurationComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html b/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html index fd30184330..20ef481b4a 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.html @@ -18,7 +18,7 @@

{{ dialogTitle }}

- +
-
- - - {{ deviceTransportTypeTranslationMap.get(BasicTransportType.HTTP) | translate }} - - - {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.MQTT) | translate }} - - - {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.COAP) | translate }} - - - {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.SNMP) | translate }} - - - {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.LWM2M) | translate }} - - -
- - -
device.connectivity.use-following-instructions
- - - - - Windows - - -
-
-
device.connectivity.install-necessary-client-tools
-
device.connectivity.install-curl-windows
-
- - -
-
-
- - - - MacOS - - -
-
-
device.connectivity.install-necessary-client-tools
-
device.connectivity.install-curl-macos
-
- - -
-
-
- - - - Linux - - -
-
-
device.connectivity.install-necessary-client-tools
- -
- - -
-
-
-
-
- -
- - -
- + @if (loadedCommand) { +
+ + @if (allowTransportType.has(BasicTransportType.HTTP)) { + + {{ deviceTransportTypeTranslationMap.get(BasicTransportType.HTTP) | translate }} + + } + @if (allowTransportType.has(DeviceTransportType.MQTT)) { + + {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.MQTT) | translate }} + + } + @if (allowTransportType.has(DeviceTransportType.COAP)) { + + {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.COAP) | translate }} + + } + @if (allowTransportType.has(DeviceTransportType.SNMP)) { + + {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.SNMP) | translate }} + + } + @if (allowTransportType.has(DeviceTransportType.LWM2M)) { + + {{ deviceTransportTypeTranslationMap.get(DeviceTransportType.LWM2M) | translate }} + + } + +
+ @switch (transportProtocol.value) { + @case (BasicTransportType.HTTP) {
device.connectivity.use-following-instructions
- + @@ -146,18 +79,13 @@
device.connectivity.install-necessary-client-tools
-
- - -
+
device.connectivity.install-curl-windows
+ *ngTemplateOutlet="executeCommand; context: {cmd: { + noSecLabel: 'HTTP', noSec: commands.http.http, + secLabel: 'HTTPs', sec: commands.http.https, + doc: {text: '', href: ''}}}">
@@ -171,14 +99,13 @@
device.connectivity.install-necessary-client-tools
- +
device.connectivity.install-curl-macos
+ *ngTemplateOutlet="executeCommand; context: {cmd: { + noSecLabel: 'HTTP', noSec: commands.http.http, + secLabel: 'HTTPs', sec: commands.http.https, + doc: {text: '', href: ''}}}">
@@ -193,146 +120,235 @@
device.connectivity.install-necessary-client-tools
+ [data]='createMarkDownCommand("sudo apt-get install curl")'>
+ *ngTemplateOutlet="executeCommand; context: {cmd: { + noSecLabel: 'HTTP', noSec: commands.http.http, + secLabel: 'HTTPs', sec: commands.http.https, + doc: {text: '', href: ''}}}">
- - - - Docker - - -
- + } + @case (DeviceTransportType.MQTT) { + @if (commands.mqtt.sparkplug) { +
+ + +
+ } @else { +
device.connectivity.use-following-instructions
+ + + + + Windows + + +
+
+
device.connectivity.install-necessary-client-tools
+
+ + +
+
+ + +
+
+
+ + + + MacOS + + +
+
+
device.connectivity.install-necessary-client-tools
+ +
+ + +
+
+
+ + + + Linux + + +
+
+
device.connectivity.install-necessary-client-tools
+ +
+ + +
+
+
+ @if (!!commands.mqtt.docker) { + + + + Docker + + +
+ - -
+
+
+
+
+ } + + } + } + @case (DeviceTransportType.COAP) { +
device.connectivity.use-following-instructions
+ + + + + Linux - - - - - -
device.connectivity.use-following-instructions
- - - - - Linux - - -
-
-
device.connectivity.install-necessary-client-tools
-
+ +
+ @if (commands.coap.coaps !== 'Check documentation') { +
+
device.connectivity.install-necessary-client-tools
+
- -
-
- + +
+
+ } + - -
- - - - - - Docker - - -
- +
+
+
+ @if (!!commands.coap.docker) { + + + + Docker + + +
+ - -
-
-
- - - -
- +
+
+ + } + + } + @case (DeviceTransportType.SNMP) { +
+ - -
- - -
- +
+ } + @case (DeviceTransportType.LWM2M) { +
+ - -
-
- -
-
-
-
device.state
-
- {{ (status ? 'device.active' : 'device.inactive') | translate }} -
+ +
+ } + }
-
attribute.latest-telemetry
-
-
-
device.time
-
attribute.key
-
attribute.value
+
+
+
device.state
+
+ {{ (status ? 'device.active' : 'device.inactive') | translate }} +
-
-
-
{{ telemetry.lastUpdateTs | date: 'yyyy-MM-dd HH:mm:ss' }}
-
{{ telemetry.key }}
-
{{ telemetry.value }}
+
attribute.latest-telemetry
+
+
+
device.time
+
attribute.key
+
attribute.value
+ @if (latestTelemetry.length) { +
+ @for (telemetry of latestTelemetry; track telemetry) { +
+
{{ telemetry.lastUpdateTs | date: 'yyyy-MM-dd HH:mm:ss' }}
+
{{ telemetry.key }}
+
{{ telemetry.value }}
+
+ } +
+ } @else { +
+
+
attribute.no-latest-telemetry
+
+ }
+
+ } @else { +
+ + + {{ 'device.connectivity.loading-check-connectivity-command' | translate }} +
-
+ }
{{ 'action.dont-show-again' | translate}} + (click)="close()">{{ 'action.close' | translate }}
- -
- - - {{ 'device.connectivity.loading-check-connectivity-command' | translate }} - -
-
- -
-
-
attribute.no-latest-telemetry
-
-
@@ -344,24 +360,25 @@ {{ cmd.secLabel }}
- - + @switch (protocolType.value) { + @case ('noSec') { - - -
- -
- + [data]=createMarkDownCommand(cmd.noSec)> + } + @case ('sec') { + @if (cmd.sec !== 'Check documentation') { +
+ +
+ } @else { -
-
-
+ } + } + }
diff --git a/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.ts index 77bd019133..2385d1a933 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-check-connectivity-dialog.component.ts @@ -50,9 +50,10 @@ export interface DeviceCheckConnectivityDialogData { } @Component({ - selector: 'tb-device-check-connectivity-dialog', - templateUrl: './device-check-connectivity-dialog.component.html', - styleUrls: ['./device-check-connectivity-dialog.component.scss'] + selector: 'tb-device-check-connectivity-dialog', + templateUrl: './device-check-connectivity-dialog.component.html', + styleUrls: ['./device-check-connectivity-dialog.component.scss'], + standalone: false }) export class DeviceCheckConnectivityDialogComponent extends DialogComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html index b0f26e1ccf..5f679a5389 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.html @@ -24,25 +24,28 @@ close - - +@if ((isLoading$ | async)) { + + +}
-
-
- - -
-
- + @if (!loadingCredentials) { +
+
+ + +
+
+ } @else {
{{ 'device.loading-device-credentials' | translate }}
-
+ }
- + {{ 'action.save' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts index 470604499c..4eda9c96d8 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-credentials-dialog.component.ts @@ -36,10 +36,11 @@ export interface DeviceCredentialsDialogData { } @Component({ - selector: 'tb-device-credentials-dialog', - templateUrl: './device-credentials-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: DeviceCredentialsDialogComponent}], - styleUrls: ['./device-credentials-dialog.component.scss'] + selector: 'tb-device-credentials-dialog', + templateUrl: './device-credentials-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: DeviceCredentialsDialogComponent }], + styleUrls: ['./device-credentials-dialog.component.scss'], + standalone: false }) export class DeviceCredentialsDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts index a51d21f9b2..d65f21d312 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-table-header.component.ts @@ -23,9 +23,10 @@ import { EntityType } from '@shared/models/entity-type.models'; import { DeviceProfileId } from '../../../../shared/models/id/device-profile-id'; @Component({ - selector: 'tb-device-table-header', - templateUrl: './device-table-header.component.html', - styleUrls: [] + selector: 'tb-device-table-header', + templateUrl: './device-table-header.component.html', + styleUrls: [], + standalone: false }) export class DeviceTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts index 01e186612b..96c23db9d2 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device-tabs.component.ts @@ -22,9 +22,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { EntityId } from "@shared/models/id/entity-id"; @Component({ - selector: 'tb-device-tabs', - templateUrl: './device-tabs.component.html', - styleUrls: [] + selector: 'tb-device-tabs', + templateUrl: './device-tabs.component.html', + styleUrls: [], + standalone: false }) export class DeviceTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.html b/ui-ngx/src/app/modules/home/pages/device/device.component.html index 07606331cf..3da91d1e44 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device.component.html +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.html @@ -83,7 +83,7 @@
- device.assignedToCustomer @@ -94,37 +94,39 @@ {{ 'device.device-public' | translate }}
-
- +
+ device.name - - {{ 'device.name-required' | translate }} - - - {{ 'device.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'device.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'device.name-max-length' | translate }} + } - + device.label - - {{ 'device.label-max-length' | translate }} - + @if (entityForm.get('label').hasError('maxlength')) { + {{ 'device.label-max-length' | translate }} + } {{ 'device.is-gateway' | translate }} - - {{ 'device.overwrite-activity-time' | translate }} - + @if (entityForm.get('additionalInfo.gateway').value) { + + {{ 'device.overwrite-activity-time' | translate }} + + } - + device.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/device/device.component.ts b/ui-ngx/src/app/modules/home/pages/device/device.component.ts index c5e798c0ff..de4ec0ae27 100644 --- a/ui-ngx/src/app/modules/home/pages/device/device.component.ts +++ b/ui-ngx/src/app/modules/home/pages/device/device.component.ts @@ -39,9 +39,10 @@ import { distinctUntilChanged } from 'rxjs/operators'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-device', - templateUrl: './device.component.html', - styleUrls: ['./device.component.scss'] + selector: 'tb-device', + templateUrl: './device.component.html', + styleUrls: ['./device.component.scss'], + standalone: false }) export class DeviceComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.html index ed523cf98d..9376ff1b3e 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.html @@ -25,64 +25,65 @@
-
-
- - - - - Docker - - -
-
- + @if (loadedInstructions) { +
+
+ + + + + Docker + + +
+
+ +
-
- - - - - - Ubuntu - - -
-
- + + + + + + Ubuntu + + +
+
+ +
-
- - - - - - CentOS/RHEL - - -
-
- + + + + + + CentOS/RHEL + + +
+
+ +
-
- - - + + + +
+
+ } @else { +
+ + + {{ 'edge.loading-edge-instructions' | translate }} +
-
+ }
{{ 'action.dont-show-again' | translate}} + (click)="close()">{{ 'action.close' | translate }}
- -
- - - {{ 'edge.loading-edge-instructions' | translate }} - -
-
diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts index 41ed81b1f4..f907f11238 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-instructions-dialog.component.ts @@ -39,9 +39,10 @@ export interface EdgeInstructionsDialogData { } @Component({ - selector: 'tb-edge-installation-dialog', - templateUrl: './edge-instructions-dialog.component.html', - styleUrls: ['./edge-instructions-dialog.component.scss'] + selector: 'tb-edge-installation-dialog', + templateUrl: './edge-instructions-dialog.component.html', + styleUrls: ['./edge-instructions-dialog.component.scss'], + standalone: false }) export class EdgeInstructionsDialogComponent extends DialogComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts index 054b6626b4..c1111fa449 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-table-header.component.ts @@ -22,9 +22,10 @@ import { AppState } from '@core/core.state'; import { EdgeInfo } from '@shared/models/edge.models'; @Component({ - selector: 'tb-edge-table-header', - templateUrl: './edge-table-header.component.html', - styleUrls: [] + selector: 'tb-edge-table-header', + templateUrl: './edge-table-header.component.html', + styleUrls: [], + standalone: false }) export class EdgeTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html index b912757027..3f298aff3d 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.html @@ -15,42 +15,56 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + +} +@if (entity) { + + + + +} +@if (entity) { + + + +} +@if (entity) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} +@if (entity) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts index 48a19840c2..58bc88a8db 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge-tabs.component.ts @@ -21,9 +21,10 @@ import { EdgeInfo } from '@shared/models/edge.models'; import { EntityTabsComponent } from '@home/components/entity/entity-tabs.component'; @Component({ - selector: 'tb-edge-tabs', - templateUrl: './edge-tabs.component.html', - styleUrls: [] + selector: 'tb-edge-tabs', + templateUrl: './edge-tabs.component.html', + styleUrls: [], + standalone: false }) export class EdgeTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html index 83f95c71e2..25fe5e2c0c 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge.component.html +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.html @@ -114,30 +114,27 @@
-
- - - - - - -
+ @if (!upgradeAvailable) { + + } @else { + + }
- edge.assignedToCustomer @@ -148,57 +145,60 @@ {{ 'edge.edge-public' | translate }}
-
- +
+ edge.name - - {{ 'edge.name-required' | translate }} - - - {{ 'edge.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'edge.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'edge.name-max-length' | translate }} + }
- - edge.edge-key - - -
- - edge.edge-secret - - -
-
- +
+ edge.label - - {{ 'edge.label-max-length' | translate }} - + @if (entityForm.get('label').hasError('maxlength')) { + {{ 'edge.label-max-length' | translate }} + }
- + edge.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts b/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts index 396ba8b9b5..7a0d82798c 100644 --- a/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts +++ b/ui-ngx/src/app/modules/home/pages/edge/edge.component.ts @@ -29,9 +29,10 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.mod import {EdgeService} from "@core/http/edge.service"; @Component({ - selector: 'tb-edge', - templateUrl: './edge.component.html', - styleUrls: ['./edge.component.scss'] + selector: 'tb-edge', + templateUrl: './edge.component.html', + styleUrls: ['./edge.component.scss'], + standalone: false }) export class EdgeComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html index d2ca9dcfe9..c62c5ce8d4 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-table-header.component.html @@ -16,6 +16,7 @@ --> { diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html index b3e1ee2883..8368ffe49b 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.html @@ -15,43 +15,51 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + +} +@if (entity) { + + + + +} +@if (entity) { + + + +} +@if (entity) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts index 26d678c45b..577b4eb147 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { EntityViewInfo } from '@app/shared/models/entity-view.models'; @Component({ - selector: 'tb-entity-view-tabs', - templateUrl: './entity-view-tabs.component.html', - styleUrls: [] + selector: 'tb-entity-view-tabs', + templateUrl: './entity-view-tabs.component.html', + styleUrls: [], + standalone: false }) export class EntityViewTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html index 15b0b3da8b..06c1470ff6 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html @@ -64,7 +64,7 @@
- entity-view.assignedToCustomer @@ -75,29 +75,30 @@ {{ 'entity-view.entity-view-public' | translate }}
-
- +
+ entity-view.name - - {{ 'entity-view.name-required' | translate }} - - - {{ 'entity-view.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'entity-view.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'entity-view.name-max-length' | translate }} + } + [entityType]="entityType.ENTITY_VIEW">
- + + formControlName="entityId" + appearance="outline">
@@ -109,26 +110,26 @@
entity-view.attributes-propagation-hint
- + label="entity-view.client-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> - + label="entity-view.shared-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> - + label="entity-view.server-attributes" + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> @@ -138,12 +139,12 @@
entity-view.timeseries-data-hint
- + label="entity-view.timeseries" + [dataKeyType]="dataKeyType.timeseries" + appearance="outline">
@@ -152,16 +153,18 @@ dateText="{{ 'entity-view.start-ts' | translate }}" [maxDate]="maxStartTimeMs | async" formControlName="startTimeMs" + appearance="outline" >
- + entity-view.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts index 98e1a55695..65485f340c 100644 --- a/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts +++ b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.ts @@ -30,9 +30,10 @@ import { EntityId } from '@app/shared/models/id/entity-id'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @Component({ - selector: 'tb-entity-view', - templateUrl: './entity-view.component.html', - styleUrls: ['./entity-view.component.scss'] + selector: 'tb-entity-view', + templateUrl: './entity-view.component.html', + styleUrls: ['./entity-view.component.scss'], + standalone: false }) export class EntityViewComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html index d3269fae53..848430c0cf 100644 --- a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.html @@ -15,25 +15,30 @@ limitations under the License. --> - - +@if (homeDashboard) { + +} @else { - - - - {{section.name}} - - - - - - {{place.icon}} - {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} - - - - - - + @for (section of homeSections$| async; track section) { + + + + {{section.name}} + + + + @for (place of section.places; track place) { + + + {{place.icon}} + {{place.customTranslate ? (place.name | customTranslate) : (place.name | translate)}} + + + } + + + + + } - +} diff --git a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts index 67546086cb..f7b38179ab 100644 --- a/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts +++ b/ui-ngx/src/app/modules/home/pages/home-links/home-links.component.ts @@ -23,10 +23,11 @@ import { ActivatedRoute } from '@angular/router'; import { HomeDashboard } from '@shared/models/dashboard.models'; @Component({ - selector: 'tb-home-links', - templateUrl: './home-links.component.html', - styleUrls: ['./home-links.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-home-links', + templateUrl: './home-links.component.html', + styleUrls: ['./home-links.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class HomeLinksComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html index d984ac99c9..79bd13c8b1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.html @@ -26,9 +26,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts index 1ea70bc9f6..7a9f72b7aa 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-dialog.component.ts @@ -33,10 +33,11 @@ export interface MobileAppDialogData { } @Component({ - selector: 'tb-mobile-app-dialog', - templateUrl: './mobile-app-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: MobileAppDialogComponent}], - styleUrls: [] + selector: 'tb-mobile-app-dialog', + templateUrl: './mobile-app-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: MobileAppDialogComponent }], + styleUrls: [], + standalone: false }) export class MobileAppDialogComponent extends DialogComponent implements OnDestroy, AfterViewInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts index 13698ac4a5..274edf5933 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-config.resolver.ts @@ -57,7 +57,6 @@ export class MobileAppTableConfigResolver { ) { this.config.selectionEnabled = false; this.config.entityType = EntityType.MOBILE_APP; - this.config.addAsTextButton = true; this.config.entitiesDeleteEnabled = false; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.MOBILE_APP); diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.ts index 0711aa1116..d0b5f2f416 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app-table-header.component.ts @@ -21,9 +21,10 @@ import { AppState } from '@core/core.state'; import { MobileApp } from '@shared/models/mobile-app.models'; @Component({ - selector: 'tb-mobile-app-table-header', - templateUrl: './mobile-app-table-header.component.html', - styleUrls: ['./mobile-app-table-header.component.scss'] + selector: 'tb-mobile-app-table-header', + templateUrl: './mobile-app-table-header.component.html', + styleUrls: ['./mobile-app-table-header.component.scss'], + standalone: false }) export class MobileAppTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html index bf8bbacc83..0e88ff03a6 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.html @@ -28,30 +28,40 @@ icon="content_copy"> - - {{ 'mobile.mobile-package-required' | translate }} - - - {{ 'mobile.mobile-package-max-length' | translate }} - - - {{ 'mobile.mobile-package-pattern' | translate }} - + @if (entityForm.get('pkgName').hasError('required')) { + + {{ 'mobile.mobile-package-required' | translate }} + + } + @if (entityForm.get('pkgName').hasError('maxlength')) { + + {{ 'mobile.mobile-package-max-length' | translate }} + + } + @if (entityForm.get('pkgName').hasError('pattern')) { + + {{ 'mobile.mobile-package-pattern' | translate }} + + }
mobile.mobile-package-title - - {{ 'mobile.mobile-package-title-max-length' | translate }} - + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'mobile.mobile-package-title-max-length' | translate }} + + } mobile.platform-type - - {{ platformTypeTranslations.get(platformType) | translate }} - + @for (platformType of platformTypes; track platformType) { + + {{ platformTypeTranslations.get(platformType) | translate }} + + } @@ -59,14 +69,15 @@
- + @if (isEdit) { + + }
- - {{ 'mobile.application-secret-required' | translate }} - + @if (entityForm.get('appSecret').hasError('required')) { + + {{ 'mobile.application-secret-required' | translate }} + + }
mobile.status - - {{ mobileAppStatusTranslations.get(mobileAppStatus) | translate }} - + @for (mobileAppStatus of mobileAppStatuses; track mobileAppStatus) { + + {{ mobileAppStatusTranslations.get(mobileAppStatus) | translate }} + + }
@@ -96,9 +111,11 @@ mobile.min-version - - {{ 'mobile.invalid-version-pattern' | translate }} - + @if (entityForm.get('versionInfo.minVersion').hasError('pattern')) { + + {{ 'mobile.invalid-version-pattern' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts index abcb7beb5d..886cd5ab6e 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/mobile-app.component.ts @@ -26,14 +26,15 @@ import { EntityType } from '@shared/models/entity-type.models'; import { MobileApp, MobileAppStatus, mobileAppStatusTranslations } from '@shared/models/mobile-app.models'; import { PlatformType, platformTypeTranslations } from '@shared/models/oauth2.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { EditorPanelComponent } from '@home/pages/mobile/common/editor-panel.component'; @Component({ - selector: 'tb-mobile-app', - templateUrl: './mobile-app.component.html', - styleUrls: ['./mobile-app.component.scss'] + selector: 'tb-mobile-app', + templateUrl: './mobile-app.component.html', + styleUrls: ['./mobile-app.component.scss'], + standalone: false }) export class MobileAppComponent extends EntityComponent { @@ -108,7 +109,7 @@ export class MobileAppComponent extends EntityComponent { .addValidators(Validators.required); form.get('storeInfo.appId').addValidators(Validators.required); } else { - form.get('storeInfo.storeLink').clearValidators(); + form.get('storeInfo.storeLink').removeValidators(Validators.required); form.get('storeInfo.sha256CertFingerprints').removeValidators(Validators.required); form.get('storeInfo.appId').removeValidators(Validators.required); } @@ -152,7 +153,7 @@ export class MobileAppComponent extends EntityComponent { this.entityForm.get('appSecret').markAsDirty(); } - editReleaseNote($event: Event, matButton: MatButton, isLatest: boolean) { + editReleaseNote($event: Event, matButton: MatIconButton, isLatest: boolean) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.html index d9aa1ada3d..d02a692247 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.html @@ -23,9 +23,12 @@ close - - -
+@if (isLoading$ | async) { + + +} @else { +
+}
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.scss index c7f636367b..e2d3654c56 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.scss @@ -20,5 +20,5 @@ max-height: 100vh; display: grid; grid-template-rows: min-content 4px minmax(auto, 1fr) min-content; - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.ts index 39d0c5e266..1843192844 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/applications/remove-app-dialog.component.ts @@ -32,9 +32,10 @@ export interface MobileAppDeleteDialogData { } @Component({ - selector: 'tb-remove-app-dialog', - templateUrl: './remove-app-dialog.component.html', - styleUrls: ['./remove-app-dialog.component.scss'] + selector: 'tb-remove-app-dialog', + templateUrl: './remove-app-dialog.component.html', + styleUrls: ['./remove-app-dialog.component.scss'], + standalone: false }) export class RemoveAppDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts index e3644c117f..bff9631ce4 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/add-mobile-page-dialog.component.ts @@ -25,9 +25,10 @@ import { FormBuilder } from '@angular/forms'; import { deepTrim } from '@core/utils'; @Component({ - selector: 'tb-add-mobile-page-dialog', - templateUrl: './add-mobile-page-dialog.component.html', - styleUrls: ['./add-mobile-page-dialog.component.scss'] + selector: 'tb-add-mobile-page-dialog', + templateUrl: './add-mobile-page-dialog.component.html', + styleUrls: ['./add-mobile-page-dialog.component.scss'], + standalone: false }) export class AddMobilePageDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts index 045f1e3298..afbdd00885 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page-panel.component.ts @@ -20,10 +20,11 @@ import { CustomMobilePage } from '@shared/models/mobile-app.models'; import { TbPopoverComponent } from '@shared/components/popover.component'; @Component({ - selector: 'tb-custom-menu-item-panel', - templateUrl: './custom-mobile-page-panel.component.html', - styleUrls: ['./custom-mobile-page-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-custom-menu-item-panel', + templateUrl: './custom-mobile-page-panel.component.html', + styleUrls: ['./custom-mobile-page-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CustomMobilePagePanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html index 95b34f5866..e422703215 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.html @@ -30,41 +30,46 @@ mobile.page-name - - warning - - - warning - - - warning - + @if (customMobilePageForm.get('label').hasError('required') && customMobilePageForm.get('label').touched) { + + warning + + } + @if (customMobilePageForm.get('label').hasError('pattern') && customMobilePageForm.get('label').touched) { + + warning + + } + @if (customMobilePageForm.get('label').hasError('maxlength') && customMobilePageForm.get('label').touched) { + + warning + + }
mobile.page-type - - {{ mobilePageTypeTranslations.get(pageType) | translate }} - + @for (pageType of mobilePagesTypes; track pageType) { + + {{ mobilePageTypeTranslations.get(pageType) | translate }} + + } - + @if (customMobilePageForm.get('type').value === MobilePageType.DASHBOARD) { - - + } + @if (customMobilePageForm.get('type').value === MobilePageType.WEB_VIEW) { mobile.url - - {{ 'mobile.invalid-url-format' | translate }} - + @if (customMobilePageForm.get('url').hasError('pattern')) { + + {{ 'mobile.invalid-url-format' | translate }} + + } - - + } + @if (customMobilePageForm.get('type').value === MobilePageType.CUSTOM) { mobile.path - - {{ 'mobile.invalid-path-format' | translate }} - + @if (customMobilePageForm.get('path').hasError('pattern')) { + + {{ 'mobile.invalid-path-format' | translate }} + + } - + } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss index 5546fb629a..5d02045480 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.scss @@ -14,7 +14,7 @@ * limitations under the License. */ :host{ - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); .mobile-page-form { display: flex; diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts index 4c97990028..84652c3027 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/custom-mobile-page.component.ts @@ -37,21 +37,22 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @Component({ - selector: 'tb-mobile-page-item', - templateUrl: './custom-mobile-page.component.html', - styleUrls: ['./custom-mobile-page.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CustomMobilePageComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CustomMobilePageComponent), - multi: true - } - ] + selector: 'tb-mobile-page-item', + templateUrl: './custom-mobile-page.component.html', + styleUrls: ['./custom-mobile-page.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CustomMobilePageComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CustomMobilePageComponent), + multi: true + } + ], + standalone: false }) export class CustomMobilePageComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html index 14f54ae664..b33aa51ef1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.html @@ -18,16 +18,18 @@
mobile.edit-page
- + @if (!disabled) { + + }
@@ -43,22 +45,24 @@ mobile.page-name - - warning - - - warning - + @if (mobilePageFormGroup.get('label').hasError('pattern') && mobilePageFormGroup.get('label').touched) { + + warning + + } + @if (mobilePageFormGroup.get('label').hasError('maxlength') && mobilePageFormGroup.get('label').touched) { + + warning + + }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss index af103c84ac..2aa92b7fc9 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.scss @@ -20,7 +20,7 @@ display: flex; flex-direction: column; gap: 16px; - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); @media #{$mat-lt-md} { width: 90vw; } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts index de3d7a1a79..e4c1d3b288 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/default-mobile-page-panel.component.ts @@ -21,10 +21,11 @@ import { FormBuilder, Validators } from '@angular/forms'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-default-mobile-page-panel', - templateUrl: './default-mobile-page-panel.component.html', - styleUrls: ['./default-mobile-page-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-default-mobile-page-panel', + templateUrl: './default-mobile-page-panel.component.html', + styleUrls: ['./default-mobile-page-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class DefaultMobilePagePanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html index 46b3132100..90008ab846 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.html @@ -22,38 +22,38 @@ {{ 'mobile.show-hidden-pages' | translate }}
- - - - + @if (!readonly) { + + + + + }
-
-
-
- {{ 'mobile.custom-page' | translate }} + @for (menuItemControl of visibleMobilePagesControls(); track trackByMenuItem($index, menuItemControl);) { +
+
+ @if (isCustom(menuItemControl)) { +
+ {{ 'mobile.custom-page' | translate }} +
+ } + @if (dragEnabled) { + + } + +
- - - + @if (!$last && !readonly) { + + }
- -
+ }
- + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts index b61263c2d5..9506f68043 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-layout.component.ts @@ -44,21 +44,22 @@ import { MatDialog } from '@angular/material/dialog'; import { AddMobilePageDialogComponent } from '@home/pages/mobile/bundes/layout/add-mobile-page-dialog.component'; @Component({ - selector: 'tb-mobile-layout', - templateUrl: './mobile-layout.component.html', - styleUrls: ['./mobile-layout.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MobileLayoutComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MobileLayoutComponent), - multi: true - } - ], + selector: 'tb-mobile-layout', + templateUrl: './mobile-layout.component.html', + styleUrls: ['./mobile-layout.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MobileLayoutComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MobileLayoutComponent), + multi: true + } + ], + standalone: false }) export class MobileLayoutComponent implements ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html index 9824311079..4c5bcf74ed 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.html @@ -34,58 +34,67 @@
- - warning - - - warning - - - warning - - - - + @if (mobilePageRowForm.get('label').hasError('required') && mobilePageRowForm.get('label').touched) { + + warning + + } + @if (mobilePageRowForm.get('label').hasError('pattern') && mobilePageRowForm.get('label').touched) { + + warning + + } + @if (mobilePageRowForm.get('label').hasError('maxlength') && mobilePageRowForm.get('label').touched) { + + warning + + } + @if (isCustomMenuItem) { + + + + }
- - - + @if (!disabled) { + + @if (isDefaultMenuItem) { + + } + @if (isCustomMenuItem) { + + } + } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts index 516e007761..c9c1bee48b 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/layout/mobile-page-item-row.component.ts @@ -49,7 +49,7 @@ import { mobilePageTypeTranslations } from '@shared/models/mobile-app.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { deepClone } from '@core/utils'; import { TbPopoverService } from '@shared/components/popover.service'; import { CustomMobilePagePanelComponent } from '@home/pages/mobile/bundes/layout/custom-mobile-page-panel.component'; @@ -58,22 +58,23 @@ import { TranslateService } from '@ngx-translate/core'; import { DisplayPopoverConfig } from '@shared/components/popover.models'; @Component({ - selector: 'tb-mobile-menu-item-row', - templateUrl: './mobile-page-item-row.component.html', - styleUrls: ['./mobile-page-item-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MobilePageItemRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => MobilePageItemRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-mobile-menu-item-row', + templateUrl: './mobile-page-item-row.component.html', + styleUrls: ['./mobile-page-item-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MobilePageItemRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MobilePageItemRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MobilePageItemRowComponent implements ControlValueAccessor, OnInit, Validator { @@ -215,7 +216,7 @@ export class MobilePageItemRowComponent implements ControlValueAccessor, OnInit, this.pageRemoved.emit(); } - edit($event: Event, matButton: MatButton) { + edit($event: Event, matButton: MatIconButton) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts index 1f120de868..b03faa5b5a 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-app-configuration-dialog.component.ts @@ -33,9 +33,10 @@ export interface MobileAppConfigurationDialogData { } @Component({ - selector: 'tb-mobile-app-configuration-dialog', - templateUrl: './mobile-app-configuration-dialog.component.html', - styleUrls: ['./mobile-app-configuration-dialog.component.scss'] + selector: 'tb-mobile-app-configuration-dialog', + templateUrl: './mobile-app-configuration-dialog.component.html', + styleUrls: ['./mobile-app-configuration-dialog.component.scss'], + standalone: false }) export class MobileAppConfigurationDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html index 1def8334ad..738dd372b7 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html @@ -25,9 +25,11 @@ close - - -
+@if (isLoading$ | async) { + +} @else { +
+}
@@ -41,15 +43,21 @@ mobile.title - - {{ 'mobile.title-required' | translate }} - - - {{ 'mobile.title-cannot-contain-only-spaces' | translate }} - - - {{ 'mobile.title-max-length' | translate }} - + @if (bundlesForms.get('title').hasError('required')) { + + {{ 'mobile.title-required' | translate }} + + } + @if (bundlesForms.get('title').hasError('pattern')) { + + {{ 'mobile.title-cannot-contain-only-spaces' | translate }} + + } + @if (bundlesForms.get('title').hasError('maxlength')) { + + {{ 'mobile.title-max-length' | translate }} + + } mobile.description - + @@ -89,7 +97,7 @@ labelText="{{ 'admin.oauth2.clients' | translate }}" placeholderText="{{ 'admin.oauth2.add-client' | translate }}"> @@ -106,8 +114,10 @@
- + @if (selectedIndex > 0) { + + } - + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts index 49cad3a3ab..dbc1a5d9c8 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/common/editor-panel.component.ts @@ -20,10 +20,11 @@ import { TbPopoverComponent } from '@shared/components/popover.component'; import { EditorOptions } from 'tinymce'; @Component({ - selector: 'tb-release-notes-panel', - templateUrl: './editor-panel.component.html', - styleUrls: ['./editor-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-release-notes-panel', + templateUrl: './editor-panel.component.html', + styleUrls: ['./editor-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EditorPanelComponent implements OnInit { @@ -47,7 +48,7 @@ export class EditorPanelComponent implements OnInit { tinyMceOptions: Partial = { base_url: '/assets/tinymce', suffix: '.min', - plugins: ['link', 'table', 'image', 'imagetools', 'lists', 'fullscreen'], + plugins: ['link', 'table', 'image', 'lists', 'fullscreen'], menubar: 'edit insert view format', toolbar: ['fontfamily fontsize | bold italic underline strikethrough forecolor backcolor', 'alignleft aligncenter alignright alignjustify | bullist | link table image | fullscreen'], diff --git a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html index 76d088a20c..5a4cc4599a 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.html @@ -22,9 +22,12 @@
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -35,18 +38,20 @@ {{ 'admin.mobile-app.custom' | translate }}
-
-
{{ 'mobile.bundle' | translate }}
- - -
+ @if (!mobileAppSettingsForm.get('useDefaultApp').value) { +
+
{{ 'mobile.bundle' | translate }}
+ + +
+ }
{{ 'admin.mobile-app.android' | translate }} @@ -66,48 +71,57 @@ {{ 'admin.mobile-app.disabled' | translate }}
-
-
- - {{ 'admin.mobile-app.badges' | translate }} - - - - - {{ badgePosition.value | translate }} - - - + @if (mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { +
+
+ + {{ 'admin.mobile-app.badges' | translate }} + + + + @for (badgePosition of badgePositionTranslationsMap | keyvalue; track badgePosition) { + + {{ badgePosition.value | translate }} + + } + + +
-
-
-
- - {{ 'admin.mobile-app.label' | translate }} - - - - +
+ + {{ 'admin.mobile-app.label' | translate }} + + + + @if (!mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').untouched && + mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').hasError('required') || + mobileAppSettingsForm.get('qrCodeConfig.qrCodeLabel').hasError('maxlength')) { + - warning - - + class="tb-error"> + warning + + } + +
-
-
-
admin.mobile-app.preview
- - -
+ } + @if (mobileAppSettingsForm.get('qrCodeConfig.showOnHomePage').value) { +
+
admin.mobile-app.preview
+ + +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.ts b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.ts index 9c6b58a967..1745a8a0b1 100644 --- a/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/mobile/qr-code-widget/mobile-qr-code-widget-settings.component.ts @@ -27,9 +27,10 @@ import { EntityType } from '@shared/models/entity-type.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-mobile-qr-code-widget', - templateUrl: './mobile-qr-code-widget-settings.component.html', - styleUrls: ['mobile-qr-code-widget-settings.component.scss', '../../admin/settings-card.scss'] + selector: 'tb-mobile-qr-code-widget', + templateUrl: './mobile-qr-code-widget-settings.component.html', + styleUrls: ['mobile-qr-code-widget-settings.component.scss', '../../admin/settings-card.scss'], + standalone: false }) export class MobileQrCodeWidgetSettingsComponent extends PageComponent implements HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts index f1ce8808ef..2ab4382a2c 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-notification-dialog.component.ts @@ -27,9 +27,10 @@ export interface InboxNotificationDialogData { } @Component({ - selector: 'tb-inbox-notification-dialog', - templateUrl: './inbox-notification-dialog.component.html', - styleUrls: ['inbox-notification-dialog.component.scss'] + selector: 'tb-inbox-notification-dialog', + templateUrl: './inbox-notification-dialog.component.html', + styleUrls: ['inbox-notification-dialog.component.scss'], + standalone: false }) export class InboxNotificationDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts index 06519198c0..1ed605db97 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-config.resolver.ts @@ -37,9 +37,10 @@ import { InboxNotificationDialogComponent, InboxNotificationDialogData } from '@home/pages/notification/inbox/inbox-notification-dialog.component'; -import { Injectable } from '@angular/core'; +import { Injectable, SecurityContext } from '@angular/core'; import { ActivatedRouteSnapshot } from '@angular/router'; import { UtilsService } from '@core/services/utils.service'; +import { DomSanitizer } from '@angular/platform-browser'; @Injectable() export class InboxTableConfigResolver { @@ -50,7 +51,8 @@ export class InboxTableConfigResolver { private translate: TranslateService, private dialog: MatDialog, private datePipe: DatePipe, - private utilsService: UtilsService) { + private utilsService: UtilsService, + private sanitizer: DomSanitizer) { this.config.entityType = EntityType.NOTIFICATION; this.config.detailsPanelEnabled = false; @@ -95,14 +97,14 @@ export class InboxTableConfigResolver { new EntityTableColumn('type', 'notification.type', '10%', (notification) => this.translate.instant(NotificationTemplateTypeTranslateMap.get(notification.type).name)), new EntityTableColumn('subject', 'notification.subject', '30%', - (entity) => this.utilsService.customTranslation(entity.subject, entity.subject)), + (entity) => this.sanitizer.sanitize(SecurityContext.HTML, this.utilsService.customTranslation(entity.subject, entity.subject))), new EntityTableColumn('text', 'notification.message', '60%', - (entity) => this.utilsService.customTranslation(entity.text, entity.text)) + (entity) => this.sanitizer.sanitize(SecurityContext.HTML, this.utilsService.customTranslation(entity.text, entity.text))) ); } - resolve(route: ActivatedRouteSnapshot): EntityTableConfig { + resolve(_route: ActivatedRouteSnapshot): EntityTableConfig { return this.config; } @@ -128,7 +130,7 @@ export class InboxTableConfigResolver { }); } - private markAsRead($event, entity){ + private markAsRead($event: Event, entity){ if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts index f1b9bea7c7..7aade4fbad 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/inbox/inbox-table-header.component.ts @@ -21,9 +21,10 @@ import { AppState } from '@core/core.state'; import { Notification } from '@shared/models/notification.models'; @Component({ - selector: 'tb-inbox-table-header', - templateUrl: './inbox-table-header.component.html', - styleUrls: [] + selector: 'tb-inbox-table-header', + templateUrl: './inbox-table-header.component.html', + styleUrls: [], + standalone: false }) export class InboxTableHeaderComponent extends EntityTableHeaderComponent { diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html index 02e706264d..9d4a353683 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.html @@ -26,136 +26,155 @@ close - - -
+ @if (isLoading$ | async) { + + } @else { +
+ }
- + notification.name - - {{ 'notification.name-required' | translate }} - + @if (targetNotificationForm.get('name').hasError('required')) { + {{ 'notification.name-required' | translate }} + }
- -
-
{{ notificationTargetTypeTranslationMap.get(notificationTargetType) | translate }}
-
-
-
-
- - notification.recipient-type.user-filters - - - {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} - - - {{ notificationTargetConfigTypeInfoMap.get(targetNotificationForm.get('configuration.usersFilter.type').value).hint | translate }} - - - -
-
- - {{ 'tenant.tenant' | translate }} - {{ 'tenant-profile.tenant-profile' | translate }} - -
- - - - - - - - -
-
- - - - - - - - -
-
-
- - - - {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} + @for (notificationTargetType of notificationTargetTypes; track notificationTargetType) { + +
+
{{ notificationTargetTypeTranslationMap.get(notificationTargetType) | translate }}
+
-
- - -
-
-
- - {{ "notification.use-old-api" | translate }} - - - open_in_new - + } + + @if (targetNotificationForm.get('configuration.type').value === notificationTargetType.PLATFORM_USERS) { +
+ + notification.recipient-type.user-filters + + @for (type of notificationTargetConfigTypes; track type) { + + {{ notificationTargetConfigTypeInfoMap.get(type).name | translate }} + + } + + {{ notificationTargetConfigTypeInfoMap.get(targetNotificationForm.get('configuration.usersFilter.type').value).hint | translate }} + + @switch (targetNotificationForm.get('configuration.usersFilter.type').value) { + @case (notificationTargetConfigType.TENANT_ADMINISTRATORS) { + @if (isSysAdmin()) { +
+
+ + {{ 'tenant.tenant' | translate }} + {{ 'tenant-profile.tenant-profile' | translate }} + +
+ @if (targetNotificationForm.get('configuration.usersFilter.filterByTenants').value) { + + + } @else { + + + } +
+ } + } + @case (notificationTargetConfigType.USER_LIST) { + + + } + @case (notificationTargetConfigType.CUSTOMER_USERS) { + + + } + } +
+ } + @if (targetNotificationForm.get('configuration.type').value === notificationTargetType.SLACK) { +
+ + + @for (slackChanelType of slackChanelTypes; track slackChanelType) { + + {{ slackChanelTypesTranslateMap.get(slackChanelType) | translate }} + + } + + + +
+ } + @if (targetNotificationForm.get('configuration.type').value === notificationTargetType.MICROSOFT_TEAMS) { +
+
+ + {{ "notification.use-old-api" | translate }} + + + open_in_new + +
+ + {{ (targetNotificationForm.get('configuration.useOldApi').value ? + 'notification.webhook-url' : 'notification.workflow-url') | translate }} + + @if (targetNotificationForm.get('configuration.webhookUrl').hasError('required')) { + {{ (targetNotificationForm.get('configuration.useOldApi').value ? 'notification.webhook-url-required' : 'notification.workflow-url-required') | translate }} + } + + + notification.channel-name + + @if (targetNotificationForm.get('configuration.channelName').hasError('required')) { + {{ 'notification.channel-name-required' | translate }} + } +
- - {{ (targetNotificationForm.get('configuration.useOldApi').value ? - 'notification.webhook-url' : 'notification.workflow-url') | translate }} - - - {{ (targetNotificationForm.get('configuration.useOldApi').value ? 'notification.webhook-url-required' : 'notification.workflow-url-required') | translate }} - - - - notification.channel-name - - - {{ 'notification.channel-name-required' | translate }} - - -
- + } + notification.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts index 755d9d181d..deb518e93b 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-notification-dialog.component.ts @@ -46,9 +46,10 @@ export interface RecipientNotificationDialogData { } @Component({ - selector: 'tb-target-notification-dialog', - templateUrl: './recipient-notification-dialog.component.html', - styleUrls: ['recipient-notification-dialog.component.scss'] + selector: 'tb-target-notification-dialog', + templateUrl: './recipient-notification-dialog.component.html', + styleUrls: ['recipient-notification-dialog.component.scss'], + standalone: false }) export class RecipientNotificationDialogComponent extends DialogComponent implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts index c2fc08fb7c..8e729e94ef 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/recipient/recipient-table-config.resolver.ts @@ -48,7 +48,6 @@ export class RecipientTableConfigResolver { this.config.entityType = EntityType.NOTIFICATION_TARGET; this.config.detailsPanelEnabled = false; - this.config.addAsTextButton = true; this.config.rowPointer = true; this.config.entityTranslations = entityTypeTranslations.get(EntityType.NOTIFICATION_TARGET); diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html index 987e87fb0d..77bbf0e4a0 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalation-form.component.html @@ -17,25 +17,28 @@ -->
-
notification.first-recipient
- + @if (systemEscalation) { +
notification.first-recipient
+ } @else {
notification.after + [min]="60000" + [max]="604800000" + disabledAdvanced=""> notification.notify
-
+ } -
-
- notification.no-rule -
-
- -
+ @for (escalationControl of escalationsFormArray.controls; track $index; let isLast = $last) { +
+ + + @if ($index === 0) { + + } + @if (!($index === 0) && !disabled) { + + } +
+ } + @if (!escalationsFormArray.controls.length) { +
+ notification.no-rule +
+ } + @if (!disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts index 36d2913621..37aa7393f8 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/escalations.component.ts @@ -35,21 +35,22 @@ import { takeUntil } from 'rxjs/operators'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-escalations-component', - templateUrl: './escalations.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EscalationsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => EscalationsComponent), - multi: true, - } - ] + selector: 'tb-escalations-component', + templateUrl: './escalations.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EscalationsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EscalationsComponent), + multi: true, + } + ], + standalone: false }) export class EscalationsComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html index cc6e24e952..cb2643d893 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/rule/rule-notification-dialog.component.html @@ -25,9 +25,11 @@ close - - -
+@if (isLoading$ | async) { + +} @else { +
+}
@@ -37,70 +39,76 @@ {{ 'notification.basic-settings' | translate }} - + notification.rule-name - - {{ 'notification.rule-name-required' | translate }} - + @if (ruleNotificationForm.get('name').hasError('required')) { + {{ 'notification.rule-name-required' | translate }} + } {{ 'notification.rule-enable' | translate }} - + notification.trigger.trigger - - {{ triggerTypeTranslationMap.get(trigger) | translate }} - + @for (trigger of triggerTypes; track trigger) { + + {{ triggerTypeTranslationMap.get(trigger) | translate }} + + } - - {{ 'notification.trigger.trigger-required' | translate }} - + @if (ruleNotificationForm.get('triggerType').hasError('required')) { + {{ 'notification.trigger.trigger-required' | translate }} + } -
- - - -
- + @if (ruleNotificationForm.get('triggerType').value !== triggerType.ALARM) { +
+ + + +
+ } @else {
notification.escalation-chain
notification.recipients
-
+ }
notification.stop-escalation-alarm-status-become
- + alarm.alarm-status-list - - {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} - + @for (searchStatus of alarmSearchStatuses; track searchStatus) { + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + }
@@ -108,559 +116,608 @@ - - {{ 'notification.alarm-trigger-settings' | translate }} -
-
-
- notification.filter - - - - - alarm.alarm-severity-list - - - {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} - + @if (ruleNotificationForm.get('triggerType').value === triggerType.ALARM) { + + {{ 'notification.alarm-trigger-settings' | translate }} + +
+
+ notification.filter + + + + alarm.alarm-severity-list + + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + } + + +
+ + notification.notify-on + + @for (alarmAction of alarmActions; track alarmAction) { + + {{ alarmActionTranslationMap.get(alarmAction) | translate }} + + } + @if (alarmTemplateForm.get('triggerConfig.notifyOn').hasError('required')) { + {{ 'notification.notify-on-required' | translate }} + } -
- - notification.notify-on - - - {{ alarmActionTranslationMap.get(alarmAction) | translate }} - - - - {{ 'notification.notify-on-required' | translate }} - - -
-
-
-
- - notification.description - - -
-
-
- - - {{ 'notification.device-activity-trigger-settings' | translate }} -
-
-
- - {{ 'device.device' | translate }} - {{ 'device-profile.device-profile' | translate }} - -
- - - - - - - - - - notification.notify-on - - - {{ deviceEventTranslationMap.get(deviceEvent) | translate }} - - - - {{ 'notification.notify-on-required' | translate }} - - -
-
-
-
- - notification.description - - -
-
-
- - - {{ 'notification.entity-action-trigger-settings' | translate }} -
-
- notification.filter - - -
- notification.status - {{ 'notification.created' | translate }} - {{ 'notification.updated' | translate }} - {{ 'notification.deleted' | translate }}
-
-
-
-
- - notification.description - - -
-
-
- - - {{ 'notification.alarm-comment-trigger-settings' | translate }} -
-
-
- notification.filter - - - - - alarm.alarm-severity-list - - - {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} - - - - - alarm.alarm-status-list - - - {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} - - + +
+
+ + notification.description + -
-
- - {{ 'notification.notify-only-user-comments' | translate }} - - - {{ 'notification.notify-on-comment-update' | translate }} -
-
- -
-
- - notification.description - - -
-
-
- - {{ 'notification.alarm-assignment-trigger-settings' | translate }} -
-
-
- notification.filter - - + + + } - - alarm.alarm-severity-list - - - {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} - + @if (ruleNotificationForm.get('triggerType').value === triggerType.DEVICE_ACTIVITY) { + + {{ 'notification.device-activity-trigger-settings' | translate }} +
+
+
+ + {{ 'device.device' | translate }} + {{ 'device-profile.device-profile' | translate }} + +
+ @if (deviceInactivityTemplateForm.get('triggerConfig.filterByDevice').value) { + + + } @else { + + + } + + notification.notify-on + + @for (deviceEvent of deviceEvents; track deviceEvent) { + + {{ deviceEventTranslationMap.get(deviceEvent) | translate }} + + } + @if (deviceInactivityTemplateForm.get('triggerConfig.notifyOn').hasError('required')) { + {{ 'notification.notify-on-required' | translate }} + } - - alarm.alarm-status-list - - - {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} - - +
+
+
+
+ + notification.description + +
+
+
+ } + + @if (ruleNotificationForm.get('triggerType').value === triggerType.ENTITY_ACTION) { + + {{ 'notification.entity-action-trigger-settings' | translate }} +
+
+ notification.filter + + +
+ notification.status + {{ 'notification.created' | translate }} + {{ 'notification.updated' | translate }} + {{ 'notification.deleted' | translate }} +
- - notification.notify-on - - - {{ alarmAssignmentActionTranslationMap.get(alarmAssignmentAction) | translate }} - - - - {{ 'notification.notify-on-required' | translate }} - - -
- -
-
- - notification.description - - -
-
-
- - {{ 'notification.rule-engine-events-trigger-settings' | translate }} -
-
-
- notification.rule-engine-filter - - - - rulechain.rulechain-events - - - {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} - - + +
+
+ + notification.description + - - {{ 'notification.only-rule-chain-lifecycle-failures' | translate }} - -
-
- notification.rule-node-filter - - {{ 'notification.track-rule-node-events' | translate }} - -
- - rulenode.rule-node-events - - - {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} - +
+ + + } + + @if (ruleNotificationForm.get('triggerType').value === triggerType.ALARM_COMMENT) { + + {{ 'notification.alarm-comment-trigger-settings' | translate }} +
+
+
+ notification.filter + + + + alarm.alarm-severity-list + + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + } - - {{ 'notification.only-rule-node-lifecycle-failures' | translate }} + + alarm.alarm-status-list + + @for (searchStatus of alarmSearchStatuses; track searchStatus) { + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + } + + +
+
+ + {{ 'notification.notify-only-user-comments' | translate }} + + + {{ 'notification.notify-on-comment-update' | translate }}
-
-
- -
-
- - notification.description - - -
-
-
+
+ +
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.edge-trigger-settings' | translate }} -
-
-
- notification.filter - - - + @if (ruleNotificationForm.get('triggerType').value === triggerType.ALARM_ASSIGNMENT) { + + {{ 'notification.alarm-assignment-trigger-settings' | translate }} + +
+
+ notification.filter + + + + alarm.alarm-severity-list + + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverity) | translate }} + + } + + + + alarm.alarm-status-list + + @for (searchStatus of alarmSearchStatuses; track searchStatus) { + + {{ alarmSearchStatusTranslationMap.get(searchStatus) | translate }} + + } + + +
+ notification.notify-on - - - {{ edgeConnectionEventTranslationMap.get(edgeEvent) | translate }} - + + @for (alarmAssignmentAction of alarmAssignmentActions; track alarmAssignmentAction) { + + {{ alarmAssignmentActionTranslationMap.get(alarmAssignmentAction) | translate }} + + } + @if (alarmAssignmentTemplateForm.get('triggerConfig.notifyOn').hasError('required')) { + {{ 'notification.notify-on-required' | translate }} + } -
-
-
-
-
- - notification.description - - -
-
-
+ + +
+
+ + notification.description + + +
+
+ + } - - {{ 'notification.edge-trigger-settings' | translate }} -
-
-
- notification.filter - - -
-
-
-
-
- - notification.description - - -
-
-
+ @if (ruleNotificationForm.get('triggerType').value === triggerType.RULE_ENGINE_COMPONENT_LIFECYCLE_EVENT) { + + {{ 'notification.rule-engine-events-trigger-settings' | translate }} +
+
+
+ notification.rule-engine-filter + + + + rulechain.rulechain-events + + @for (componentLifecycleEvent of componentLifecycleEvents; track componentLifecycleEvent) { + + {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} + + } + + + + {{ 'notification.only-rule-chain-lifecycle-failures' | translate }} + +
+
+ notification.rule-node-filter + + {{ 'notification.track-rule-node-events' | translate }} + + @if (ruleEngineEventsTemplateForm.get('triggerConfig.trackRuleNodeEvents').value) { +
+ + rulenode.rule-node-events + + @for (componentLifecycleEvent of componentLifecycleEvents; track componentLifecycleEvent) { + + {{ componentLifecycleEventTranslationMap.get(componentLifecycleEvent) | translate }} + + } + + + + {{ 'notification.only-rule-node-lifecycle-failures' | translate }} + +
+ } +
+
+
+
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.entities-limit-trigger-settings' | translate }} -
-
- notification.filter - - -
- notification.threshold -
- - - - - - % + @if (ruleNotificationForm.get('triggerType').value === triggerType.EDGE_CONNECTION) { + + {{ 'notification.edge-trigger-settings' | translate }} + +
+
+ notification.filter + + + + notification.notify-on + + @for (edgeEvent of edgeConnectionEvents; track edgeEvent) { + + {{ edgeConnectionEventTranslationMap.get(edgeEvent) | translate }} + + } + +
+
+ +
+
+ + notification.description + + +
+
+
+ } + + @if (ruleNotificationForm.get('triggerType').value === triggerType.EDGE_COMMUNICATION_FAILURE) { + + {{ 'notification.edge-trigger-settings' | translate }} +
+
+
+ notification.filter + + +
+
+
+
+
+ + notification.description + + +
+
+
+ } + + @if (ruleNotificationForm.get('triggerType').value === triggerType.ENTITIES_LIMIT) { + + {{ 'notification.entities-limit-trigger-settings' | translate }} +
+
+ notification.filter + + +
+ notification.threshold +
+ + + + + + % + +
-
-
- -
-
- - notification.description - - -
-
-
+ + +
+
+ + notification.description + + +
+
+ + } - - {{ 'notification.api-usage-trigger-settings' | translate }} -
-
- - api-usage.api-features - - - {{ apiFeatureTranslationMap.get(apiFeature) | translate }} - - - {{ 'notification.api-feature-hint' | translate }} - - - notification.notify-on - - - {{ apiUsageStateValueTranslationMap.get(apiUsageStateValue) | translate }} - - - - {{ 'notification.notify-on-required' | translate }} - - -
-
-
-
- - notification.description - - -
-
-
+ @if (ruleNotificationForm.get('triggerType').value === triggerType.API_USAGE_LIMIT) { + + {{ 'notification.api-usage-trigger-settings' | translate }} +
+
+ + api-usage.api-features + + @for (apiFeature of apiFeatures; track apiFeature) { + + {{ apiFeatureTranslationMap.get(apiFeature) | translate }} + + } + + {{ 'notification.api-feature-hint' | translate }} + + + notification.notify-on + + @for (apiUsageStateValue of apiUsageStateValues; track apiUsageStateValue) { + + {{ apiUsageStateValueTranslationMap.get(apiUsageStateValue) | translate }} + + } + + @if (apiUsageLimitTemplateForm.get('triggerConfig.notifyOn').hasError('required')) { + {{ 'notification.notify-on-required' | translate }} + } + +
+
+
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.new-platform-version-trigger-settings' | translate }} -
-
- - notification.description - - -
-
-
+ @if (ruleNotificationForm.get('triggerType').value === triggerType.NEW_PLATFORM_VERSION) { + + {{ 'notification.new-platform-version-trigger-settings' | translate }} +
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.rate-limits-trigger-settings' | translate }} -
-
- - -
-
-
-
- - notification.description - - -
-
-
+ @if (ruleNotificationForm.get('triggerType').value === triggerType.RATE_LIMITS) { + + {{ 'notification.rate-limits-trigger-settings' | translate }} +
+
+ + +
+
+
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.task-processing-failure-trigger-settings' | translate }} -
-
- - notification.description - - -
-
-
+ @if (ruleNotificationForm.get('triggerType').value === triggerType.TASK_PROCESSING_FAILURE) { + + {{ 'notification.task-processing-failure-trigger-settings' | translate }} +
+
+ + notification.description + + +
+
+
+ } - - {{ 'notification.resources-shortage-trigger-settings' | translate }} -
-
- notification.filter -
- notification.cpu-threshold -
- - - - - - % - + @if (ruleNotificationForm.get('triggerType').value === triggerType.RESOURCES_SHORTAGE) { + + {{ 'notification.resources-shortage-trigger-settings' | translate }} + +
+ notification.filter +
+ notification.cpu-threshold +
+ + + + + + % + +
-
-
- notification.ram-threshold -
- - - - - - % - +
+ notification.ram-threshold +
+ + + + + + % + +
-
-
- notification.storage-threshold -
- - - - - - % - +
+ notification.storage-threshold +
+ + + + + + % + +
-
-
-
-
-
- - notification.description - - -
-
-
+ + +
+
+ + notification.description + + +
+
+ + } +
- + @if (selectedIndex > 0) { + + }
-
-
{{ NotificationDeliveryMethodInfoMap.get(errorStat.key).name | translate }}
- - - - - -
- {{ error.key }} - {{ error.value }}
- -
+ @for (errorStat of errorStatEntries; track errorStat; let isLast = $last) { +
+
{{ NotificationDeliveryMethodInfoMap.get(errorStat.key).name | translate }}
+ + @for (error of (errorStat.value | keyvalue); track error) { + + + + + } +
+ {{ error.key }} + {{ error.value }}
+ @if (!isLast) { + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts index c20b49bc16..1dfc3b44ca 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-error-dialog.component.ts @@ -31,13 +31,15 @@ export interface NotificationRequestErrorDialogData { } @Component({ - selector: 'tb-notification-send-error-dialog', - templateUrl: './sent-error-dialog.component.html', - styleUrls: ['sent-error-dialog.component.scss'] + selector: 'tb-notification-send-error-dialog', + templateUrl: './sent-error-dialog.component.html', + styleUrls: ['sent-error-dialog.component.scss'], + standalone: false }) export class SentErrorDialogComponent extends DialogComponent { errorStats: { [key in NotificationDeliveryMethod]: {[errorKey in string]: string}}; + errorStatEntries: Array<{ key: NotificationDeliveryMethod; value: { [k: string]: string } }>; NotificationDeliveryMethodInfoMap = NotificationDeliveryMethodInfoMap; @@ -48,6 +50,7 @@ export class SentErrorDialogComponent extends DialogComponent).map(([key, value]) => ({ key, value })); } cancel(): void { diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html index c2963e7c6b..1a0be821be 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.html @@ -25,8 +25,10 @@ close - - +@if (isLoading$ | async) { + + +}
{{ 'notification.use-template' | translate }}
-
- - - -
- - -
-
-
-
- -
notification.at-least-one-should-be-selected
+
+ @if (notificationRequestForm.get('useTemplate').value) { + + + + } @else { + +
+
+
+
+ +
notification.at-least-one-should-be-selected
+
+ @if (showRefresh) { + + }
- -
-
- - - - {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} - - - chevron_right - - - -
- - {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} - -
-
-
+
+ @for (deliveryMethods of notificationDeliveryMethods; track deliveryMethods) { + @if (isInteractDeliveryMethod(deliveryMethods)) { + + + {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} + + @if (isInteractDeliveryMethod(deliveryMethods)) { + + chevron_right + + } + + } @else { +
+ + {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} + +
+ } + } +
-
-
+ } +
+ @if (notificationRequestForm.get('targets').value?.length > 1) { +
+ @for (detail of (preview.recipientsCountByTarget | keyvalue); track detail.key) { +
+ {{ detail.value }}{{ detail.key }} +
+ } +
} - - - + + + @for (recipientTitle of preview.recipientsPreview; track recipientTitle) { + + {{ recipientTitle }} + + } + + + @if (preview.processedTemplates.WEB?.enabled) { +
+
+ mdi:bell-badge +
notification.delivery-method.web-preview
+
+
+ +
+
+ } + @if (preview.processedTemplates.MOBILE_APP?.enabled) { +
+
+ mdi:cellphone-text +
notification.delivery-method.mobile-app-preview
+
+
+
{{ preview.processedTemplates.MOBILE_APP.subject }}
+
{{ preview.processedTemplates.MOBILE_APP.body }}
+
+
+ } + @if (preview.processedTemplates.SMS?.enabled) { +
+
+ mdi:message-processing +
notification.delivery-method.sms-preview
+
+
+ {{ preview.processedTemplates.SMS.body }} +
+
+ } + @if (preview.processedTemplates.EMAIL?.enabled) { +
+
+ mdi:email +
notification.delivery-method.email-preview
+
+
+
{{ preview.processedTemplates.EMAIL.subject }}
+ + +
+
+ } + @if (preview.processedTemplates.SLACK?.enabled) { +
+
+ mdi:slack +
notification.delivery-method.slack-preview
+
+
+ {{ preview.processedTemplates.SLACK.body }} +
+
+ } + @if (preview.processedTemplates.MICROSOFT_TEAMS?.enabled) { +
+
+ mdi:microsoft-teams +
notification.delivery-method.microsoft-teams-preview
+
+
+
{{ preview.processedTemplates.MICROSOFT_TEAMS.subject }}
+ {{ preview.processedTemplates.MICROSOFT_TEAMS.body }} + @if (preview.processedTemplates.MICROSOFT_TEAMS.button?.enabled) { + + } +
+
+ } + + }
- + @if (selectedIndex > 0) { + + } + (click)="nextStep()">{{ nextStepLabel() | translate }}
diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss index e365022df4..774f4eb672 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.component.scss @@ -52,10 +52,10 @@ } &.tb-error { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); &.tb-required::after { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); } } } @@ -218,6 +218,7 @@ width: 100%;; overflow-y: auto; scrollbar-gutter: stable; + padding-top: 4px; @media #{$mat-gt-sm} { min-width: 500px; } diff --git a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts index 70a972066f..eec70df210 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/sent/sent-notification-dialog.componet.ts @@ -54,9 +54,10 @@ export interface RequestNotificationDialogData { } @Component({ - selector: 'tb-sent-notification-dialog', - templateUrl: './sent-notification-dialog.component.html', - styleUrls: ['./sent-notification-dialog.component.scss'] + selector: 'tb-sent-notification-dialog', + templateUrl: './sent-notification-dialog.component.html', + styleUrls: ['./sent-notification-dialog.component.scss'], + standalone: false }) export class SentNotificationDialogComponent extends TemplateConfiguration implements OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.html b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.html index 856cf091a6..d4dda48d4c 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.html @@ -29,13 +29,15 @@ -
-
- + @for (deliveryMethod of deliveryMethods; track deliveryMethod) { +
+
+ +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts index 187ae39d3d..071ccbeeb5 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-setting-form.component.ts @@ -25,16 +25,17 @@ import { } from '@shared/models/notification.models'; @Component({ - selector: 'tb-notification-setting-form', - templateUrl: './notification-setting-form.component.html', - styleUrls: ['./notification-setting-form.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => NotificationSettingFormComponent), - multi: true - } - ] + selector: 'tb-notification-setting-form', + templateUrl: './notification-setting-form.component.html', + styleUrls: ['./notification-setting-form.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationSettingFormComponent), + multi: true + } + ], + standalone: false }) export class NotificationSettingFormComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html index 608a11539e..73d0209640 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.html @@ -25,13 +25,17 @@
+ (click)="resetSettings()">{{ 'notification.settings.reset-all' | translate }}
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
@@ -46,25 +50,31 @@ notification.settings.type -
-
- - {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} - + @for (deliveryMethods of notificationDeliveryMethods; track deliveryMethods) { +
+
+ + {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} + +
-
+ }
-
- - - -
+ @for (settingsControl of notificationSettingsFormArray.controls; track settingsControl; let i = $index) { +
+ + + @if (!$last) { + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts index 3abddf49af..4adc4d4c6c 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/settings/notification-settings.component.ts @@ -32,9 +32,10 @@ import { NotificationService } from '@core/http/notification.service'; import { DialogService } from '@core/services/dialog.service'; @Component({ - selector: 'tb-notification-settings', - templateUrl: './notification-settings.component.html', - styleUrls: ['./notification-settings.component.scss'] + selector: 'tb-notification-settings', + templateUrl: './notification-settings.component.html', + styleUrls: ['./notification-settings.component.scss'], + standalone: false }) export class NotificationSettingsComponent extends PageComponent implements OnInit, HasConfirmForm { diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html index 77ec27dd9b..63ada301e1 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.html @@ -29,58 +29,63 @@
- - notification.button-text - - - {{ 'notification.button-text-required' | translate }} - - - {{ 'notification.button-text-max-length' | translate : + @if (!hideButtonText) { + + notification.button-text + + @if (actionButtonConfigForm.get('text').hasError('required')) { + {{ 'notification.button-text-required' | translate }} + } + @if (actionButtonConfigForm.get('text').hasError('maxlength')) { + {{ 'notification.button-text-max-length' | translate : {length: actionButtonConfigForm.get('text').getError('maxlength').requiredLength} - }} - - + }} + } + + }
- + notification.action-type - - {{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }} - + @for (actionButtonLinkType of actionButtonLinkTypes; track actionButtonLinkType) { + + {{ actionButtonLinkTypeTranslateMap.get(actionButtonLinkType) | translate }} + + } - @if(actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK) { - + @if (actionButtonConfigForm.get('linkType').value === actionButtonLinkType.LINK) { + notification.link - - {{ 'notification.link-required' | translate }} - - - {{ 'notification.link-max-length' | translate : - {length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength} - }} - + @if (actionButtonConfigForm.get('link').hasError('required')) { + {{ 'notification.link-required' | translate }} + } + @if (actionButtonConfigForm.get('link').hasError('maxlength')) { + {{ 'notification.link-max-length' | translate : + {length: actionButtonConfigForm.get('link').getError('maxlength').requiredLength} + }} + } } @else { - + - + }
- - {{ 'notification.set-entity-from-notification' | translate }} - + @if (actionButtonConfigForm.get('linkType').value === actionButtonLinkType.DASHBOARD) { + + {{ 'notification.set-entity-from-notification' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts index cf828ce68f..5b3975a1a0 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-action-button-configuration.component.ts @@ -32,20 +32,21 @@ import { isDefinedAndNotNull } from '@core/utils'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-notification-action-button-configuration', - templateUrl: './notification-action-button-configuration.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), - multi: true, - } - ] + selector: 'tb-notification-action-button-configuration', + templateUrl: './notification-action-button-configuration.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NotificationActionButtonConfigurationComponent), + multi: true, + } + ], + standalone: false }) export class NotificationActionButtonConfigurationComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html index 9bbe1eaa5c..cb7ed554cb 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.html @@ -18,7 +18,7 @@
notification.customize-messages
- {{ 'notification.input-fields-support-templatization' | translate}} + {{ 'notification.input-fields-support-templatization' | translate }}
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }} - - - - - notification.subject - - - {{ 'notification.subject-required' | translate }} - - - {{'notification.subject-max-length' | translate : - {length: templateConfigurationForm.get('WEB.subject').getError('maxlength').requiredLength} - }} - - - - notification.message - - - {{ 'notification.message-required' | translate }} - - - {{ 'notification.message-max-length' | translate : - {length: templateConfigurationForm.get('WEB.body').getError('maxlength').requiredLength} - }} - - -
-
- - {{ 'icon.icon' | translate }} - -
- - - - + @if (templateConfigurationForm.get('WEB.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.WEB).name | translate }} + + + + + notification.subject + + @if (templateConfigurationForm.get('WEB.subject').hasError('required')) { + {{ 'notification.subject-required' | translate }} + } + @if (templateConfigurationForm.get('WEB.subject').hasError('maxlength')) { + {{ 'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('WEB.subject').getError('maxlength').requiredLength} + }} + } + + + notification.message + + @if (templateConfigurationForm.get('WEB.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + @if (templateConfigurationForm.get('WEB.body').hasError('maxlength')) { + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('WEB.body').getError('maxlength').requiredLength} + }} + } + +
+
+ + {{ 'icon.icon' | translate }} + +
+ + + +
-
- - -
-
-
-
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }} - - - - - notification.subject - - - {{ 'notification.subject-required' | translate }} - - - {{ 'notification.subject-max-length' | translate : - {length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength} - }} - - - - notification.message - - - {{ 'notification.message-required' | translate }} - - - {{ 'notification.message-max-length' | translate : - {length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength} - }} - - -
-
- - {{ 'icon.icon' | translate }} - -
- - - - + + +
+ + +
+ } + @if (templateConfigurationForm.get('MOBILE_APP.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MOBILE_APP).name | translate }} + + + + + notification.subject + + @if (templateConfigurationForm.get('MOBILE_APP.subject').hasError('required')) { + {{ 'notification.subject-required' | translate }} + } + @if (templateConfigurationForm.get('MOBILE_APP.subject').hasError('maxlength')) { + {{ 'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('MOBILE_APP.subject').getError('maxlength').requiredLength} + }} + } + + + notification.message + + @if (templateConfigurationForm.get('MOBILE_APP.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + @if (templateConfigurationForm.get('MOBILE_APP.body').hasError('maxlength')) { + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('MOBILE_APP.body').getError('maxlength').requiredLength} + }} + } + +
+
+ + {{ 'icon.icon' | translate }} + +
+ + + +
+ +
- - - -
-
-
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }} - - - - - notification.message - - - - {{ 'notification.message-required' | translate }} - - - {{ 'notification.message-max-length' | translate : - {length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength} - }} - - - - -
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }} - - - - - notification.subject - - - {{ 'notification.subject-required' | translate }} - - - {{'notification.subject-max-length' | translate : - {length: templateConfigurationForm.get('EMAIL.subject').getError('maxlength').requiredLength} - }} - - - notification.message - - - - {{ 'notification.message-required' | translate }} - - - -
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }} - - - - - notification.message - - - {{ 'notification.message-required' | translate }} - - - - -
-
- - - - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }} - {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }} - - - - - notification.subject - - - - notification.message - - - {{ 'notification.message-required' | translate }} - - -
-
-
notification.theme-color
- + + +
+ } + @if (templateConfigurationForm.get('SMS.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SMS).name | translate }} + + + + + notification.message + + + @if (templateConfigurationForm.get('SMS.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + @if (templateConfigurationForm.get('SMS.body').hasError('maxlength')) { + {{ 'notification.message-max-length' | translate : + {length: templateConfigurationForm.get('SMS.body').getError('maxlength').requiredLength} + }} + } + + + +
+ } + @if (templateConfigurationForm.get('EMAIL.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.EMAIL).name | translate }} + + + + + notification.subject + + @if (templateConfigurationForm.get('EMAIL.subject').hasError('required')) { + {{ 'notification.subject-required' | translate }} + } + @if (templateConfigurationForm.get('EMAIL.subject').hasError('maxlength')) { + {{ 'notification.subject-max-length' | translate : + {length: templateConfigurationForm.get('EMAIL.subject').getError('maxlength').requiredLength} + }} + } + + notification.message + + + @if ((interacted || templateConfigurationForm.get('EMAIL.body').touched) && templateConfigurationForm.get('EMAIL.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + + +
+ } + @if (templateConfigurationForm.get('SLACK.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.SLACK).name | translate }} + + + + + notification.message + + @if (templateConfigurationForm.get('SLACK.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + + + +
+ } + @if (templateConfigurationForm.get('MICROSOFT_TEAMS.enabled').value) { +
+ + + + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).icon }} + {{ NotificationDeliveryMethodInfoMap.get(NotificationDeliveryMethod.MICROSOFT_TEAMS).name | translate }} + + + + + notification.subject + + + + notification.message + + @if (templateConfigurationForm.get('MICROSOFT_TEAMS.body').hasError('required')) { + {{ 'notification.message-required' | translate }} + } + +
+
+
notification.theme-color
+ +
+ +
- - - -
-
-
+
+ + + } diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss index 7b651fab21..de9c0a06a7 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.scss @@ -19,7 +19,7 @@ top: 0; z-index: 5; padding-bottom: 12px; - background-color: var(--mdc-dialog-container-color, white); + background-color: var(--mat-dialog-container-color, white); .tb-hint-available-params { color: rgba(0, 0, 0, 0.54); @@ -54,10 +54,10 @@ } &.tb-error { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); &.tb-required::after { - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); } } } @@ -65,7 +65,7 @@ :host ::ng-deep { .tb-form-panel .mat-expansion-panel.tb-settings { - & > .mat-expansion-panel-content > .mat-expansion-panel-body { + .mat-expansion-panel-content > .mat-expansion-panel-body { gap: 0; } } diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts index d3a606b8b9..4a4f11d40b 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts +++ b/ui-ngx/src/app/modules/home/pages/notification/template/configuration/notification-template-configuration.component.ts @@ -40,21 +40,22 @@ import { TranslateService } from '@ngx-translate/core'; import { EditorOptions } from 'tinymce'; @Component({ - selector: 'tb-template-configuration', - templateUrl: './notification-template-configuration.component.html', - styleUrls: ['./notification-template-configuration.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), - multi: true, - } - ] + selector: 'tb-template-configuration', + templateUrl: './notification-template-configuration.component.html', + styleUrls: ['./notification-template-configuration.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => NotificationTemplateConfigurationComponent), + multi: true, + } + ], + standalone: false }) export class NotificationTemplateConfigurationComponent implements OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html index 7298fb6ed9..dbe49a6238 100644 --- a/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/notification/template/template-notification-dialog.component.html @@ -25,8 +25,11 @@ close - - +@if (isLoading$ | async) { + +} @else { +
+}
{{ 'notification.setup' | translate }}
- + notification.name - - {{ 'notification.name-required' | translate }} - - - - notification.type - - - {{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }} - - + @if (templateNotificationForm.get('name').hasError('required')) { + {{ 'notification.name-required' | translate }} + } + @if (!hideSelectType) { + + notification.type + + @for (notificationType of notificationTypes; track notificationType) { + + {{ notificationTemplateTypeTranslateMap.get(notificationType).name | translate }} + + } + + + }
notification.at-least-one-should-be-selected
-
- - {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} - -
+ @for (deliveryMethods of notificationDeliveryMethods; track deliveryMethods) { +
+ + {{ notificationDeliveryMethodInfoMap.get(deliveryMethods).name | translate }} + +
+ }
@@ -86,8 +93,10 @@
- + @if (selectedIndex > 0) { + + }
- - -
+ @if (isLoading$ | async) { + + + } + @if (!(isLoading$ | async)) { +
+ }
- + user.email - - {{ 'user.email-required' | translate }} - - - {{ 'user.invalid-email-format' | translate }} - + @if (profile.get('email').hasError('required')) { + + {{ 'user.email-required' | translate }} + + } + @if (profile.get('email').hasError('email')) { + + {{ 'user.invalid-email-format' | translate }} + + } - + user.first-name - + user.last-name - + language.language @@ -68,7 +77,7 @@ } - + unit.unit-system @@ -78,20 +87,23 @@ } -
- - - {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} - -
+ @if (!isSysAdmin()) { +
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+ }
+ +
+ + +
+
+ + +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss new file mode 100644 index 0000000000..fa37306f7b --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-note-dialog.component.scss @@ -0,0 +1,30 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: grid; + width: 650px; + height: 100%; + max-width: 100%; + max-height: 100vh; + grid-template-rows: min-content minmax(auto, 1fr) min-content; +} + +:host-context(.tb-fullscreen-dialog .mat-mdc-dialog-container) { + .mat-mdc-dialog-content { + padding: 8px; + } +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html index 8156c1690d..41e72a14cf 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/add-rule-node-dialog.component.html @@ -26,8 +26,10 @@ close - - + @if (isLoading$ | async) { + + + }
close - - + @if (isLoading$ | async) { + + + }
close - - + @if (isLoading$ | async) { + + + }
- + rulechain.name - - {{ 'rulechain.name-required' | translate }} - - - {{ 'rulechain.name-max-length' | translate }} - + @if (createNestedRuleChainFormGroup.get('name').hasError('required')) { + + {{ 'rulechain.name-required' | translate }} + + } + @if (createNestedRuleChainFormGroup.get('name').hasError('maxlength')) { + + {{ 'rulechain.name-max-length' | translate }} + + }
- + rulechain.description diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html index 65aabc17aa..f6e244a81b 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.html @@ -15,16 +15,19 @@ limitations under the License. --> - + rulenode.link-labels - - {{label.name}} - close - + @for (label of labels; track label) { + + {{label.name}} + @if (!disabled) { + close + } + + } - - - - -
-
- rulenode.no-link-labels-found + @for (label of filteredLabels | async; track label) { + + + + } + @if ((filteredLabels | async)?.length === 0) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ rulenode.no-link-labels-found +
+ } @else { + + {{ translate.get('rulenode.no-link-label-matching', + {label: truncate.transform(searchText, true, 6, '...')}) | async }} + + } + @if (allowCustom) { + + rulenode.create-new-link-label + + }
- - - {{ translate.get('rulenode.no-link-label-matching', - {label: truncate.transform(searchText, true, 6, '...')}) | async }} - - - - rulenode.create-new-link-label - -
- + + } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts index 0aa2d86e6f..6745796cbb 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/link-labels.component.ts @@ -29,14 +29,15 @@ import { catchError, map, mergeMap, share, startWith } from 'rxjs/operators'; import { RuleChainService } from '@core/http/rule-chain.service'; @Component({ - selector: 'tb-link-labels', - templateUrl: './link-labels.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => LinkLabelsComponent), - multi: true - }] + selector: 'tb-link-labels', + templateUrl: './link-labels.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => LinkLabelsComponent), + multi: true + }], + standalone: false }) export class LinkLabelsComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html index 25d2fd315d..f1108b17ea 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.html @@ -17,12 +17,16 @@ -->
-
{{definedDirectiveError}}
- - + @if (definedDirectiveError) { +
{{definedDirectiveError}}
+ } + @if (!useDefinedDirective()) { + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss index 3c5fb83105..0d342442a9 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.scss @@ -29,7 +29,7 @@ .tb-error { letter-spacing: 0.25px; - color: var(--mdc-theme-error, #f44336); + color: var(--mat-theme-error, #f44336); } .tb-required::after { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts index e1d8f11bf5..013e4841f3 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-config.component.ts @@ -47,15 +47,16 @@ import { deepClone } from '@core/utils'; import { RuleChainType } from '@shared/models/rule-chain.models'; @Component({ - selector: 'tb-rule-node-config', - templateUrl: './rule-node-config.component.html', - styleUrls: ['./rule-node-config.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RuleNodeConfigComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None + selector: 'tb-rule-node-config', + templateUrl: './rule-node-config.component.html', + styleUrls: ['./rule-node-config.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleNodeConfigComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class RuleNodeConfigComponent implements ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html index 6afce790b6..a83c1f8b17 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html @@ -15,43 +15,48 @@ limitations under the License. --> -
- -
+@if (!isAdd && ruleNode.component?.configurationDescriptor?.nodeDefinition?.ruleChainNode) { +
+ +
+}
- + rulenode.name - - {{ 'rulenode.name-required' | translate }} - - - {{ 'rulenode.name-max-length' | translate }} - + @if (ruleNodeFormGroup.get('name').hasError('required') || ruleNodeFormGroup.get('name').hasError('pattern')) { + + {{ 'rulenode.name-required' | translate }} + + } + @if (ruleNodeFormGroup.get('name').hasError('maxlength')) { + + {{ 'rulenode.name-max-length' | translate }} + + }
- + [entityType]="entityType.RULE_NODE"/> + @if (isSingleton()) { + + }
- - + @if (isAddQueue()) { + + + }
- + rulenode.rule-node-description - +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts index 91af3024b6..b5cbfaf5a8 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.ts @@ -40,9 +40,10 @@ import { takeUntil } from 'rxjs/operators'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; @Component({ - selector: 'tb-rule-node', - templateUrl: './rule-node-details.component.html', - styleUrls: ['./rule-node-details.component.scss'] + selector: 'tb-rule-node', + templateUrl: './rule-node-details.component.html', + styleUrls: ['./rule-node-details.component.scss'], + standalone: false }) export class RuleNodeDetailsComponent extends PageComponent implements OnInit, OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts index 14cd0a8190..e8ac36e3fb 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-link.component.ts @@ -23,14 +23,15 @@ import { TranslateService } from '@ngx-translate/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-rule-node-link', - templateUrl: './rule-node-link.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RuleNodeLinkComponent), - multi: true - }] + selector: 'tb-rule-node-link', + templateUrl: './rule-node-link.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleNodeLinkComponent), + multi: true + }], + standalone: false }) export class RuleNodeLinkComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html new file mode 100644 index 0000000000..015c2a38d4 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.html @@ -0,0 +1,59 @@ + + + + +
+
{{ 'rulechain.note-background-color' | translate }}
+ + +
+
+ + + rule-node-config.advanced-settings + +
+
{{ 'rulechain.note-border' | translate }}
+
+ + + px + + + +
+
+
+ + {{ 'rulechain.note-apply-default-markdown-style' | translate }} + +
+ + +
+
+ diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss similarity index 80% rename from ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss rename to ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss index 7b620f8c2f..6d89be41ad 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-dialog.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss @@ -13,16 +13,12 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -:host { - .tb-editable-switch { - padding-left: 10px; - - .editable-switch { - margin: 0; - } - - .editable-label { - margin: 5px 0; +:host ::ng-deep { + tb-markdown-editor { + .markdown-content.tb-edit-mode { + &:not(.tb-fullscreen) { + padding-bottom: 0; + } } } -} +} \ No newline at end of file diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts new file mode 100644 index 0000000000..a5a6bb1622 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.ts @@ -0,0 +1,106 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, DestroyRef, inject, Input, OnChanges, OnInit, SimpleChanges, ViewChild } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatExpansionPanel } from '@angular/material/expansion'; +import { FormBuilder, Validators } from '@angular/forms'; +import { + FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE as DEFAULT_APPLY_MARKDOWN_STYLE, + FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR as DEFAULT_BACKGROUND_COLOR, + FC_RULE_NOTE_DEFAULT_BORDER_WIDTH as DEFAULT_BORDER_WIDTH, + FcRuleNote +} from '@shared/models/rule-node.models'; +import { isNotEmptyStr } from '@core/utils'; +import tinycolor from 'tinycolor2'; + +@Component({ + selector: 'tb-rule-note-editor', + templateUrl: './rule-note-editor.component.html', + styleUrls: ['./rule-note-editor.component.scss'], + standalone: false +}) +export class RuleNoteEditorComponent implements OnChanges, OnInit { + + @Input() + note: FcRuleNote; + + @ViewChild('advancedPanel') advancedPanel: MatExpansionPanel; + + private fb = inject(FormBuilder); + private destroyRef = inject(DestroyRef); + + noteForm = this.fb.group({ + content: ['', Validators.maxLength(65536)], + backgroundColor: [DEFAULT_BACKGROUND_COLOR], + borderColor: [tinycolor(DEFAULT_BACKGROUND_COLOR).darken(20).toString()], + borderWidth: [DEFAULT_BORDER_WIDTH, [Validators.min(0)]], + applyDefaultMarkdownStyle: [DEFAULT_APPLY_MARKDOWN_STYLE], + markdownCss: ['', Validators.maxLength(65536)] + }); + + ngOnInit(): void { + this.noteForm.get('backgroundColor').valueChanges + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe(color => { + const borderControl = this.noteForm.get('borderColor'); + if (borderControl.pristine) { + borderControl.setValue(this.borderColorFrom(color), { emitEvent: false }); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.note) { + this.updatedForm(); + } + } + + private borderColorFrom(bg: string): string { + const color = tinycolor(bg || DEFAULT_BACKGROUND_COLOR); + return color.isDark() ? color.lighten(20).toString() : color.darken(20).toString(); + } + + private updatedForm(): void { + const bg = this.note?.backgroundColor || DEFAULT_BACKGROUND_COLOR; + this.noteForm.setValue({ + content: this.note?.content || '', + backgroundColor: bg, + borderColor: this.note?.borderColor || this.borderColorFrom(bg), + borderWidth: this.note?.borderWidth ?? DEFAULT_BORDER_WIDTH, + applyDefaultMarkdownStyle: this.note?.applyDefaultMarkdownStyle ?? DEFAULT_APPLY_MARKDOWN_STYLE, + markdownCss: this.note?.markdownCss || '' + }); + const shouldExpand = this.hasNonDefaultAdvancedSettings(bg); + setTimeout(() => { + if (shouldExpand) { + this.advancedPanel?.open(); + } else { + this.advancedPanel?.close(); + } + this.noteForm.markAsPristine(); + }, 0); + } + + private hasNonDefaultAdvancedSettings(bg: string): boolean { + const note = this.note; + if (!note) return false; + return (note.borderColor != null && note.borderColor !== this.borderColorFrom(bg)) + || (note.borderWidth != null && note.borderWidth !== DEFAULT_BORDER_WIDTH) + || note.applyDefaultMarkdownStyle === false + || isNotEmptyStr(note.markdownCss); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html index 9d855647fa..e506e8d87c 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.html @@ -28,14 +28,15 @@ opened position="start"> - - + @if (!isImport) { + + + }
@@ -59,37 +60,40 @@
- - - - {{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).icon }} -
{{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).name }}
-
-
- - -
+ @for (ruleNodeType of ruleNodeTypesLibraryArray; track ruleNodeType) { + + + + {{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).icon }} +
{{ ruleNodeTypeDescriptorsMap.get(ruleNodeType).name }}
+
+
+ + +
+ }
- -
-
-
- - - - - - - - - - -
-
-
{{editingRuleNode.component.name}}
-
 
-
-
 
-
+
+
+
+ + + + + + @if (editingRuleNode.ruleNodeId) { + + + + + } + +
+
+
{{editingRuleNode.component.name}}
+
 
+
+
 
+
+
-
- - - - -
-
-
- - -
+ + + + } + @if (editingRuleNodeLink) { + +
+
+
+ + +
+ } + @if (editingNote) { + + + + + } - + @if (!isImport) { + + + } -
+ @for (menuItem of contextInfo.menuItems; track menuItem) { +
+ @if (menuItem.divider) { + + } + @if (!menuItem.divider) { + + } +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss index 4152ca767a..b38fe007e8 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.scss @@ -54,6 +54,15 @@ z-index: 2; } + button.mdc-fab.add-note-button { + position: absolute; + top: 10px; + right: 110px; + opacity: .85; + margin: 0 6px; + z-index: 2; + } + .tb-library-node-btn { width: 20px; height: 90px; @@ -188,6 +197,20 @@ margin: -3px; border: solid 3px #f00; } + &.fc-edit { + margin: -2px; + border: solid 2px rgba(0, 0, 0, 0.35); + } + } + } + + .fc-note { + border-radius: 8px; + &.fc-selected:not(.fc-edit) { + box-shadow: 0 0 0 3px #f00; + } + &.fc-selected.fc-edit { + box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.35); } } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts index dfa196f2db..7ac3a00b80 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.component.ts @@ -51,7 +51,6 @@ import { ErrorStateMatcher } from '@angular/material/core'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { MatExpansionPanel } from '@angular/material/expansion'; import { DialogService } from '@core/services/dialog.service'; -import { AuthService } from '@core/auth/auth.service'; import { ActivatedRoute, Router } from '@angular/router'; import { inputNodeComponent, @@ -63,9 +62,12 @@ import { } from '@shared/models/rule-chain.models'; import { FcItemInfo, FlowchartConstants, NgxFlowchartComponent, UserCallbacks } from 'ngx-flowchart'; import { + FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE, + FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR, FcRuleEdge, FcRuleNode, FcRuleNodeType, + FcRuleNote, getRuleNodeHelpLink, LinkLabel, outputNodeClazz, @@ -81,14 +83,15 @@ import { RuleChainService } from '@core/http/rule-chain.service'; import { NEVER, Observable, of, ReplaySubject, skip, startWith, Subject, throwError } from 'rxjs'; import { catchError, debounceTime, distinctUntilChanged, mergeMap, takeUntil, tap } from 'rxjs/operators'; import { ISearchableComponent } from '../../models/searchable-component.models'; -import { deepClone, isDefinedAndNotNull } from '@core/utils'; +import { deepClone, guid, isDefinedAndNotNull } from '@core/utils'; import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component'; import { RuleNodeLinkComponent } from './rule-node-link.component'; +import { RuleNoteEditorComponent } from './rule-note-editor.component'; import { DialogComponent } from '@shared/components/dialog.component'; import { MatMenuTrigger } from '@angular/material/menu'; import { ItemBufferService, RuleNodeConnection } from '@core/services/item-buffer.service'; import { Hotkey } from 'angular2-hotkeys'; -import { DebugEventType, DebugRuleNodeEventBody, EventType } from '@shared/models/event.models'; +import { DebugEventType, DebugRuleNodeEventBody } from '@shared/models/event.models'; import { MatMiniFabButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { VersionControlComponent } from '@home/components/vc/version-control.component'; @@ -97,16 +100,17 @@ import { MatDrawer } from '@angular/material/sidenav'; import { HttpStatusCode } from '@angular/common/http'; import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; import { EntityDebugSettings } from '@shared/models/entity.models'; -import Timeout = NodeJS.Timeout; import { DomSanitizer } from '@angular/platform-browser'; import { AdditionalDebugActionConfig } from '@home/components/entity/debug/entity-debug-settings.model'; import { EventsDialogComponent } from '@home/dialogs/events-dialog.component'; +import Timeout = NodeJS.Timeout; @Component({ - selector: 'tb-rulechain-page', - templateUrl: './rulechain-page.component.html', - styleUrls: ['./rulechain-page.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-rulechain-page', + templateUrl: './rulechain-page.component.html', + styleUrls: ['./rulechain-page.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class RuleChainPageComponent extends PageComponent implements AfterViewInit, OnInit, OnDestroy, HasDirtyFlag, ISearchableComponent, AfterViewChecked { @@ -131,8 +135,6 @@ export class RuleChainPageComponent extends PageComponent @ViewChild('drawer') drawer: MatDrawer; - eventTypes = EventType; - debugEventTypes = DebugEventType; ruleChainMenuPosition = { x: '0px', y: '0px' }; @@ -162,11 +164,16 @@ export class RuleChainPageComponent extends PageComponent @ViewChild('tbRuleNode') ruleNodeComponent: RuleNodeDetailsComponent; @ViewChild('tbRuleNodeLink') ruleNodeLinkComponent: RuleNodeLinkComponent; + @ViewChild('tbRuleNote') ruleNoteComponent: RuleNoteEditorComponent; editingRuleNodeLink: FcRuleEdge = null; isEditingRuleNodeLink = false; editingRuleNodeLinkIndex = -1; + editingNote: FcRuleNote = null; + isEditingNote = false; + editingNoteIndex = -1; + hotKeys: Hotkey[] = []; enableHotKeys = true; @@ -179,22 +186,23 @@ export class RuleChainPageComponent extends PageComponent ruleChainModel: FcRuleNodeModel = { nodes: [], - edges: [] + edges: [], + notes: [] }; selectedObjects = []; editCallbacks: UserCallbacks = { - edgeDoubleClick: (event, edge) => { + edgeDoubleClick: (_event, edge) => { this.openLinkDetails(edge); }, - edgeEdit: (event, edge) => { + edgeEdit: (_event, edge) => { this.openLinkDetails(edge); }, nodeCallbacks: { - doubleClick: (event, node: FcRuleNode) => { + doubleClick: (_event, node: FcRuleNode) => { this.openNodeDetails(node); }, - nodeEdit: (event, node: FcRuleNode) => { + nodeEdit: (_event, node: FcRuleNode) => { this.openNodeDetails(node); }, mouseEnter: this.displayNodeDescriptionTooltip.bind(this), @@ -203,7 +211,7 @@ export class RuleChainPageComponent extends PageComponent }, isValidEdge: (source, destination) => source.type === FlowchartConstants.rightConnectorType && destination.type === FlowchartConstants.leftConnectorType, - createEdge: (event, edge: FcRuleEdge) => { + createEdge: (_event, edge: FcRuleEdge) => { const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source) as FcRuleNode; if (sourceNode.component.type === RuleNodeType.INPUT) { const found = this.ruleChainModel.edges.find(theEdge => theEdge.source === (this.inputConnectorId + '')); @@ -237,8 +245,19 @@ export class RuleChainPageComponent extends PageComponent } } }, - dropNode: (event, node: FcRuleNode) => { + dropNode: (_event, node: FcRuleNode) => { this.addRuleNode(node); + }, + noteCallbacks: { + noteEdit: (_event, note) => { + this.openNoteDetails(note); + }, + doubleClick: (_event, note) => { + this.openNoteDetails(note); + } + }, + noteRemoved: (_note) => { + this.isDirty = true; } }; @@ -266,11 +285,9 @@ export class RuleChainPageComponent extends PageComponent private tooltipTimeout: Timeout; - constructor(protected store: Store, - private route: ActivatedRoute, + constructor(private route: ActivatedRoute, private router: Router, private ruleChainService: RuleChainService, - private authService: AuthService, private translate: TranslateService, private itembuffer: ItemBufferService, private popoverService: TbPopoverService, @@ -281,7 +298,7 @@ export class RuleChainPageComponent extends PageComponent public dialog: MatDialog, public dialogService: DialogService, public fb: FormBuilder) { - super(store); + super(); this.route.data.pipe( takeUntil(this.destroy$) ).subscribe( @@ -362,12 +379,15 @@ export class RuleChainPageComponent extends PageComponent this.selectedObjects = []; this.ruleChainModel.nodes = []; this.ruleChainModel.edges = []; + this.ruleChainModel.notes = []; this.ruleNodeTypesModel = {}; if (this.ruleChainCanvas) { this.ruleChainCanvas.adjustCanvasSize(true); } this.isEditingRuleNode = false; this.isEditingRuleNodeLink = false; + this.isEditingNote = false; + this.editingNote = null; this.updateRuleNodesHighlight(); } @@ -388,7 +408,7 @@ export class RuleChainPageComponent extends PageComponent new Hotkey(['ctrl+c', 'meta+c'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); - this.copyRuleNodes(); + this.copyRuleChainObjects(); return false; } return true; @@ -399,8 +419,8 @@ export class RuleChainPageComponent extends PageComponent new Hotkey(['ctrl+v', 'meta+v'], (event: KeyboardEvent) => { if (this.enableHotKeys) { event.preventDefault(); - if (this.itembuffer.hasRuleNodes()) { - this.pasteRuleNodes(); + if (this.itembuffer.hasRuleChainObjects()) { + this.pasteRuleChainObjects(); } return false; } @@ -464,6 +484,17 @@ export class RuleChainPageComponent extends PageComponent }, ['INPUT', 'SELECT', 'TEXTAREA'], this.translate.instant('rulenode.create-nested-rulechain')) ); + this.hotKeys.push( + new Hotkey('alt+n', (event: KeyboardEvent) => { + if (this.enableHotKeys) { + event.preventDefault(); + this.addNote(); + return false; + } + return true; + }, ['INPUT', 'SELECT', 'TEXTAREA'], + this.translate.instant('rulechain.add-note')) + ); } } @@ -522,7 +553,7 @@ export class RuleChainPageComponent extends PageComponent }); if (this.expansionPanels) { for (let i = 0; i < ruleNodeTypesLibrary.length; i++) { - const panel = this.expansionPanels.find((item, index) => index === i); + const panel = this.expansionPanels.find((_item, index) => index === i); if (panel) { const type = ruleNodeTypesLibrary[i]; if (!this.ruleNodeTypesModel[type].model.nodes.length) { @@ -656,6 +687,7 @@ export class RuleChainPageComponent extends PageComponent } }); } + this.ruleChainModel.notes = deepClone(this.ruleChainMetaData.notes) || []; if (this.ruleChainCanvas) { this.ruleChainCanvas.adjustCanvasSize(true); } @@ -687,10 +719,12 @@ export class RuleChainPageComponent extends PageComponent } private prepareContextMenu(item: FcItemInfo): RuleChainMenuContextInfo { - if (this.objectsSelected() || (!item.node && !item.edge)) { + if (this.objectsSelected() || (!item.node && !item.edge && !item.note)) { return this.prepareRuleChainContextMenu(); } else if (item.node) { return this.prepareRuleNodeContextMenu(item.node); + } else if (item.note) { + return this.prepareNoteContextMenu(item.note); } else if (item.edge) { return this.prepareEdgeContextMenu(item.edge); } @@ -704,11 +738,12 @@ export class RuleChainPageComponent extends PageComponent subtitle: this.translate.instant('rulechain.rulechain'), menuItems: [] }; - if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length) { + if (this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length || + this.ruleChainCanvas.modelService.notes.getSelectedNotes().length) { contextInfo.menuItems.push( { action: () => { - this.copyRuleNodes(); + this.copyRuleChainObjects(); }, enabled: true, value: 'rulenode.copy-selected', @@ -720,14 +755,25 @@ export class RuleChainPageComponent extends PageComponent contextInfo.menuItems.push( { action: ($event) => { - this.pasteRuleNodes($event); + this.pasteRuleChainObjects($event); }, - enabled: this.itembuffer.hasRuleNodes(), + enabled: this.itembuffer.hasRuleChainObjects(), value: 'action.paste', icon: 'content_paste', shortcut: 'M-V' } ); + contextInfo.menuItems.push( + { + action: ($event) => { + this.addNote($event); + }, + enabled: true, + value: 'rulechain.add-note', + icon: 'sticky_note_2', + shortcut: 'A-N' + } + ); contextInfo.menuItems.push( { divider: true @@ -906,7 +952,7 @@ export class RuleChainPageComponent extends PageComponent toIndexSet.add(toIndex); } }); - const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index)); + const noInputNodes = selectedNodes.filter((_node, index) => !toIndexSet.has(index)); return noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled).length <= 1; } return false; @@ -924,11 +970,13 @@ export class RuleChainPageComponent extends PageComponent } }).afterClosed().subscribe((ruleChain) => { if (ruleChain) { + const selectedNotes: FcRuleNote[] = this.ruleChainCanvas.modelService.notes.getSelectedNotes(); this.ruleChainCanvas.modelService.deselectAll(); const ruleChainMetaData: RuleChainMetaData = { ruleChainId: ruleChain.id, nodes: [], - connections: [] + connections: [], + notes: deepClone(selectedNotes) }; let outputEdges: FcRuleEdge[] = []; let minX: number = null; @@ -992,7 +1040,7 @@ export class RuleChainPageComponent extends PageComponent } } }); - const noInputNodes = selectedNodes.filter((node, index) => !toIndexSet.has(index)); + const noInputNodes = selectedNodes.filter((_node, index) => !toIndexSet.has(index)); const possibleInputNodes = noInputNodes.filter((node: FcRuleNode) => node.component.configurationDescriptor.nodeDefinition.inEnabled); let inputEdges: FcRuleEdge[] = []; @@ -1038,6 +1086,10 @@ export class RuleChainPageComponent extends PageComponent node.additionalInfo.layoutX -= deltaX; node.additionalInfo.layoutY -= deltaY; }); + ruleChainMetaData.notes?.forEach((note) => { + note.x -= deltaX; + note.y -= deltaY; + }); this.ruleChainService.saveRuleChainMetadata(ruleChainMetaData).subscribe(() => { const component = this.ruleChainService.getRuleNodeComponentByClazz(this.ruleChainType, ruleChainNodeClazz); const descriptor = ruleNodeTypeDescriptors.get(component.type); @@ -1089,6 +1141,9 @@ export class RuleChainPageComponent extends PageComponent selectedNodes.forEach((node) => { this.ruleChainCanvas.modelService.nodes.delete(node); }); + selectedNotes.forEach((note) => { + this.ruleChainCanvas.modelService.notes.delete(note); + }); this.onModelChanged(); this.updateRuleNodesHighlight(); }); @@ -1145,12 +1200,15 @@ export class RuleChainPageComponent extends PageComponent } private copyNode(node: FcRuleNode) { - this.itembuffer.copyRuleNodes([node], []); + this.itembuffer.copyRuleChainObjects([node], [], []); } - private copyRuleNodes() { + private copyRuleChainObjects() { const nodes: FcRuleNode[] = this.ruleChainCanvas.modelService.nodes.getSelectedNodes(); const edges: FcRuleEdge[] = this.ruleChainCanvas.modelService.edges.getSelectedEdges(); + const notes: FcRuleNote[] = (this.ruleChainModel.notes || []).filter( + note => this.ruleChainCanvas.modelService.notes.isSelected(note) + ); const connections: RuleNodeConnection[] = []; edges.forEach((edge) => { const sourceNode = this.ruleChainCanvas.modelService.nodes.getNodeByConnectorId(edge.source); @@ -1169,10 +1227,10 @@ export class RuleChainPageComponent extends PageComponent connections.push(connection); } }); - this.itembuffer.copyRuleNodes(nodes, connections); + this.itembuffer.copyRuleChainObjects(nodes, connections, notes); } - private pasteRuleNodes(event?: MouseEvent) { + private pasteRuleChainObjects(event?: MouseEvent) { const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement); let x: number; let y: number; @@ -1187,11 +1245,11 @@ export class RuleChainPageComponent extends PageComponent x = scrollLeft + scrollParent.width() / 2; y = scrollTop + scrollParent.height() / 2; } - const ruleNodes = this.itembuffer.pasteRuleNodes(x, y); - if (ruleNodes) { + const ruleChainObjects = this.itembuffer.pasteRuleChainObjects(x, y); + if (ruleChainObjects) { this.ruleChainCanvas.modelService.deselectAll(); const nodes: FcRuleNode[] = []; - ruleNodes.nodes.forEach((node) => { + ruleChainObjects.nodes.forEach((node) => { node.id = 'rule-chain-node-' + this.nextNodeID++; const component = node.component; if (component.configurationDescriptor.nodeDefinition.inEnabled) { @@ -1214,7 +1272,7 @@ export class RuleChainPageComponent extends PageComponent this.ruleChainModel.nodes.push(node); this.ruleChainCanvas.modelService.nodes.select(node); }); - ruleNodes.connections.forEach((connection) => { + ruleChainObjects.connections.forEach((connection) => { const sourceNode = nodes[connection.fromIndex]; const destNode = nodes[connection.toIndex]; if ( (connection.isInputSource || sourceNode) && destNode ) { @@ -1250,15 +1308,142 @@ export class RuleChainPageComponent extends PageComponent } } }); + if (ruleChainObjects.notes?.length) { + if (!this.ruleChainModel.notes) { + this.ruleChainModel.notes = []; + } + ruleChainObjects.notes.forEach((note) => { + note.id = guid(); + this.ruleChainModel.notes.push(note); + this.ruleChainCanvas.modelService.notes.select(note); + }); + } this.updateRuleNodesHighlight(); this.validate(); this.onModelChanged(); } } + addNote(event?: MouseEvent): void { + this.dialog.open>(AddNoteDialogComponent, { + disableClose: true, + panelClass: ['tb-dialog', 'tb-fullscreen-dialog'], + data: { + id: '', x: 0, y: 0, width: 200, height: 120, + content: '', + backgroundColor: FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR, + applyDefaultMarkdownStyle: FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE, + markdownCss: '' + } + }).afterClosed().subscribe((noteData) => { + if (noteData !== undefined) { + const canvas = $(this.ruleChainCanvas.modelService.canvasHtmlElement); + let x: number; + let y: number; + if (event) { + const offset = canvas.offset(); + x = Math.round(event.clientX - offset.left); + y = Math.round(event.clientY - offset.top); + } else { + const scrollParent = canvas.parent(); + x = scrollParent.scrollLeft() + scrollParent.width() / 2; + y = scrollParent.scrollTop() + scrollParent.height() / 2; + } + const note: FcRuleNote = { + id: guid(), + x, + y, + width: 200, + height: 120, + ...noteData + }; + if (!this.ruleChainModel.notes) { + this.ruleChainModel.notes = []; + } + this.ruleChainModel.notes.push(note); + this.isDirty = true; + } + }); + } + + private prepareNoteContextMenu(note: FcRuleNote): RuleChainMenuContextInfo { + const contextInfo: RuleChainMenuContextInfo = { + headerClass: 'tb-rulechain-header', + icon: 'sticky_note_2', + title: this.translate.instant('rulechain.note'), + menuItems: [] + }; + if (!note.readonly) { + contextInfo.menuItems.push( + { + action: () => { + this.openNoteDetails(note); + }, + enabled: true, + value: 'action.edit', + icon: 'edit' + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.itembuffer.copyRuleChainObjects([], [], [note]); + }, + enabled: true, + value: 'action.copy', + icon: 'content_copy' + } + ); + contextInfo.menuItems.push( + { + action: () => { + this.ruleChainCanvas.modelService.notes.delete(note); + }, + enabled: true, + value: 'action.delete', + icon: 'clear', + shortcut: 'Del' + } + ); + } + return contextInfo; + } + + private openNoteDetails(note: FcRuleNote): void { + this.enableHotKeys = false; + this.updateErrorTooltips(true); + this.isEditingRuleNode = false; + this.editingRuleNode = null; + this.isEditingRuleNodeLink = false; + this.editingRuleNodeLink = null; + this.isEditingNote = true; + this.editingNoteIndex = this.ruleChainModel.notes.indexOf(note); + this.editingNote = deepClone(note); + } + + saveNote(): void { + this.ruleNoteComponent.noteForm.markAsPristine(); + Object.assign(this.editingNote, this.ruleNoteComponent.noteForm.value); + this.ruleChainModel.notes[this.editingNoteIndex] = this.editingNote; + this.editingNote = deepClone(this.editingNote); + this.onModelChanged(); + } + + onRevertNoteEdit(): void { + this.ruleNoteComponent.noteForm.markAsPristine(); + const note = this.ruleChainModel.notes[this.editingNoteIndex]; + this.editingNote = deepClone(note); + } + + onEditNoteClosed(): void { + this.editingNote = null; + this.isEditingNote = false; + } + onDetailsDrawerClosed() { this.onEditRuleNodeClosed(); this.onEditRuleNodeLinkClosed(); + this.onEditNoteClosed(); this.enableHotKeys = true; this.updateErrorTooltips(false); } @@ -1418,7 +1603,8 @@ export class RuleChainPageComponent extends PageComponent objectsSelected(): boolean { return this.ruleChainCanvas.modelService.nodes.getSelectedNodes().length > 0 || - this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0; + this.ruleChainCanvas.modelService.edges.getSelectedEdges().length > 0 || + this.ruleChainCanvas.modelService.notes.getSelectedNotes().length > 0; } deleteSelected() { @@ -1476,6 +1662,7 @@ export class RuleChainPageComponent extends PageComponent ruleChainId: this.ruleChain.id, nodes: [], connections: [], + notes: deepClone(this.ruleChainModel.notes) || [], version: ruleChain.version }; const nodes: FcRuleNode[] = []; @@ -1534,6 +1721,9 @@ export class RuleChainPageComponent extends PageComponent .subscribe((savedRuleChainMetaData) => { this.ruleChain.version = savedRuleChainMetaData.version; this.ruleChainMetaData = savedRuleChainMetaData; + if (!this.ruleChainMetaData.notes) { + this.ruleChainMetaData.notes = deepClone(this.ruleChainModel.notes) || []; + } if (this.isImport) { this.isDirtyValue = false; this.isImport = false; @@ -1788,13 +1978,14 @@ export interface AddRuleNodeLinkDialogData { } @Component({ - selector: 'tb-add-rule-node-link-dialog', - templateUrl: './add-rule-node-link-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: AddRuleNodeLinkDialogComponent}], - styleUrls: ['./add-rule-node-link-dialog.component.scss'] + selector: 'tb-add-rule-node-link-dialog', + templateUrl: './add-rule-node-link-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: AddRuleNodeLinkDialogComponent }], + styleUrls: ['./add-rule-node-link-dialog.component.scss'], + standalone: false }) export class AddRuleNodeLinkDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { ruleNodeLinkFormGroup: UntypedFormGroup; @@ -1824,9 +2015,6 @@ export class AddRuleNodeLinkDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { @ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent; @@ -1880,9 +2069,6 @@ export class AddRuleNodeDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { @@ -1976,3 +2163,30 @@ export class CreateNestedRuleChainDialogComponent extends DialogComponent> { + + @ViewChild('tbRuleNote') ruleNoteComponent: RuleNoteEditorComponent; + + constructor(protected store: Store, + protected router: Router, + @Inject(MAT_DIALOG_DATA) public data: FcRuleNote, + public dialogRef: MatDialogRef>) { + super(store, router, dialogRef); + } + + cancel(): void { + this.dialogRef.close(undefined); + } + + save(): void { + if (this.ruleNoteComponent.noteForm.valid) { + this.dialogRef.close(this.ruleNoteComponent.noteForm.value); + } + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts index 84fdecafcb..b796f16db4 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.models.ts @@ -40,6 +40,6 @@ export interface RuleChainMenuContextInfo { icon: string; iconUrl?: string; title: string; - subtitle: string; + subtitle?: string; menuItems: RuleChainMenuItem[]; } diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts index 388b23d56c..473d022b08 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-page.module.ts @@ -27,23 +27,27 @@ import { AddRuleNodeDialogComponent, AddRuleNodeLinkDialogComponent, CreateNestedRuleChainDialogComponent, + AddNoteDialogComponent, RuleChainPageComponent } from '@home/pages/rulechain/rulechain-page.component'; import { RuleNodeDetailsComponent } from '@home/pages/rulechain/rule-node-details.component'; import { RuleNodeConfigComponent } from '@home/pages/rulechain/rule-node-config.component'; import { LinkLabelsComponent } from '@home/pages/rulechain/link-labels.component'; import { RuleNodeLinkComponent } from '@home/pages/rulechain/rule-node-link.component'; +import { RuleNoteEditorComponent } from '@home/pages/rulechain/rule-note-editor.component'; @NgModule({ declarations: [ RuleChainPageComponent, RuleNodeDetailsComponent, + RuleNoteEditorComponent, LinkLabelsComponent, RuleNodeLinkComponent, RuleNodeConfigComponent, AddRuleNodeLinkDialogComponent, AddRuleNodeDialogComponent, - CreateNestedRuleChainDialogComponent + CreateNestedRuleChainDialogComponent, + AddNoteDialogComponent ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html index cd93367fc0..ecf2433b31 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.html @@ -15,46 +15,60 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - - - - - - - +@if (entity) { + + + + +} +@if (entity) { + + + + +} +@if (entity) { + + + +} +@if (entity) { + + + +} +@if (entity) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts index 0ec3c4df52..8e26eed483 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { RuleChain } from '@shared/models/rule-chain.models'; @Component({ - selector: 'tb-rulechain-tabs', - templateUrl: './rulechain-tabs.component.html', - styleUrls: [] + selector: 'tb-rulechain-tabs', + templateUrl: './rulechain-tabs.component.html', + styleUrls: [], + standalone: false }) export class RuleChainTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html index 403cbbb953..fbc7e9f286 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.html @@ -77,24 +77,24 @@
-
- +
+ rulechain.name - - {{ 'rulechain.name-required' | translate }} - - - {{ 'rulechain.name-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + {{ 'rulechain.name-required' | translate }} + } + @if (entityForm.get('name').hasError('maxlength')) { + {{ 'rulechain.name-max-length' | translate }} + } {{ 'rulechain.debug-mode' | translate }}
- + rulechain.description - +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts index df55eeff90..5769009066 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.component.ts @@ -25,9 +25,10 @@ import { RuleChain } from '@shared/models/rule-chain.models'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @Component({ - selector: 'tb-rulechain', - templateUrl: './rulechain.component.html', - styleUrls: ['./rulechain.component.scss'] + selector: 'tb-rulechain', + templateUrl: './rulechain.component.html', + styleUrls: ['./rulechain.component.scss'], + standalone: false }) export class RuleChainComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts index c1f2e94a40..641a820de2 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulechain.module.ts @@ -22,13 +22,15 @@ import { RuleChainRoutingModule } from '@modules/home/pages/rulechain/rulechain- import { HomeComponentsModule } from '@modules/home/components/home-components.module'; import { RuleChainTabsComponent } from '@home/pages/rulechain/rulechain-tabs.component'; import { RuleNodeComponent } from '@home/pages/rulechain/rulenode.component'; -import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart'; +import { RuleNoteComponent } from '@home/pages/rulechain/rulenote.component'; +import { FC_NODE_COMPONENT_CONFIG, FC_NOTE_COMPONENT_CONFIG } from 'ngx-flowchart'; @NgModule({ declarations: [ RuleChainComponent, RuleChainTabsComponent, RuleNodeComponent, + RuleNoteComponent, ], providers: [ { @@ -37,6 +39,12 @@ import { FC_NODE_COMPONENT_CONFIG } from 'ngx-flowchart'; nodeComponentType: RuleNodeComponent } }, + { + provide: FC_NOTE_COMPONENT_CONFIG, + useValue: { + noteComponentType: RuleNoteComponent + } + }, ], imports: [ CommonModule, diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html index e406168acb..c125d1ab1b 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.html @@ -16,52 +16,66 @@ -->
+ (dblclick)="userNodeCallbacks.doubleClick($event, node)" + (mousedown)="userNodeCallbacks.mouseDown($event, node)" + (mouseenter)="userNodeCallbacks.mouseEnter($event, node)" + (mouseleave)="userNodeCallbacks.mouseLeave($event, node)">
- {{node.icon}} - + @if (!iconUrl) { + {{node.icon}} + } + @if (iconUrl) { + + }
{{ node.component.name }} - {{ node.name }} + @if (node.name) { + {{ node.name }} + }
-
-
-
+ @for (connector of modelservice.nodes.getConnectorsByType(node, flowchartConstants.leftConnectorType); track connector) { +
+
+
+ }
-
-
-
+ @for (connector of modelservice.nodes.getConnectorsByType(node, flowchartConstants.rightConnectorType); track connector) { +
+
+
+ }
-
-
- -
-
- × -
-
-
-
- -
-
+ @if (modelservice.isEditable() && !node.readonly) { +
+
+ +
+
+ × +
+
+ } + @if (node.component?.configurationDescriptor?.nodeDefinition?.ruleChainNode && modelservice.isEditable()) { +
+
+ +
+
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts index 87e2d0a31b..04c293d241 100644 --- a/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenode.component.ts @@ -23,10 +23,11 @@ import { RuleChainType } from '@app/shared/models/rule-chain.models'; import { TranslateService } from '@ngx-translate/core'; @Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'rule-node', - templateUrl: './rulenode.component.html', - styleUrls: ['./rulenode.component.scss'] + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'rule-node', + templateUrl: './rulenode.component.html', + styleUrls: ['./rulenode.component.scss'], + standalone: false }) export class RuleNodeComponent extends FcNodeComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html new file mode 100644 index 0000000000..e99ab9f965 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.html @@ -0,0 +1,44 @@ + +@if (modelservice.isEditable() && !note.readonly && edit) { +
+ +
+
+ × +
+} +
+ @if (note?.content) { + + + } +
diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss new file mode 100644 index 0000000000..18de130565 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.scss @@ -0,0 +1,65 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: block; + width: 100%; + height: 100%; + box-sizing: border-box; + + .fc-nodeedit, + .fc-nodedelete { + display: block; + position: absolute; + border: solid 2px #eee; + border-radius: 50%; + font-weight: 600; + line-height: 20px; + height: 20px; + padding-top: 2px; + width: 22px; + background: #494949; + color: #fff; + text-align: center; + vertical-align: bottom; + cursor: pointer; + z-index: 10; + } + + .fc-nodeedit { + top: -24px; + right: 16px; + font-size: 15px; + } + + .fc-nodedelete { + top: -24px; + right: -13px; + font-size: 18px; + } + + .tb-rule-note-bg { + position: relative; + width: 100%; + height: 100%; + background-color: #FFF9C4; + border: 1px solid #E6D600; + border-radius: 8px; + box-sizing: border-box; + padding: 8px; + overflow: auto; + } +} diff --git a/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts new file mode 100644 index 0000000000..5624f4d271 --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rulenote.component.ts @@ -0,0 +1,88 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, OnChanges, OnInit, SimpleChanges } from '@angular/core'; +import { FcNoteComponent } from 'ngx-flowchart'; +import cssjs from '@core/css/css'; +import { hashCode, isNotEmptyStr } from '@core/utils'; +import { + FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE, + FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR, + FcRuleNote +} from '@shared/models/rule-node.models'; + +@Component({ + selector: 'tb-rule-note', + templateUrl: './rulenote.component.html', + styleUrls: ['./rulenote.component.scss'], + standalone: false +}) +export class RuleNoteComponent extends FcNoteComponent implements OnInit, OnChanges { + + readonly defaultBackgroundColor = FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR; + + private static readonly HEADING_STYLE_OVERRIDE = '.tb-markdown-view h1 { font-size: 3rem; }'; + private static readonly PADDING_STYLE_OVERRIDE = '.tb-markdown-view p, .tb-markdown-view h1, .tb-markdown-view h2, .tb-markdown-view h3, .tb-markdown-view h4, .tb-markdown-view h5, .tb-markdown-view h6 {padding-left: 0 !important;}'; + + applyDefault = FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE; + additionalStyles: string[]; + noteClass: string; + note: FcRuleNote; + + ngOnInit(): void { + super.ngOnInit(); + this.applyDefault = this.note?.applyDefaultMarkdownStyle ?? FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE; + this.processCss(); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.note) { + this.applyDefault = this.note?.applyDefaultMarkdownStyle ?? FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE; + this.processCss(); + } + } + + private processCss(): void { + let cssString = this.note?.markdownCss; + const styles: string[] = []; + if (this.applyDefault) { + styles.push(RuleNoteComponent.HEADING_STYLE_OVERRIDE); + styles.push(RuleNoteComponent.PADDING_STYLE_OVERRIDE); + } + if (isNotEmptyStr(cssString)) { + const cssParser = new cssjs(); + this.noteClass = 'rule-note-' + hashCode(cssString); + cssParser.cssPreviewNamespace = this.noteClass; + cssParser.testMode = false; + const cssObjects = cssParser.applyNamespacing(cssString); + cssString = cssParser.getCSSForEditor(cssObjects); + styles.push(cssString); + } else { + this.noteClass = undefined; + } + this.additionalStyles = styles.length ? styles : undefined; + } + + noteEdit(event: MouseEvent): void { + event.stopPropagation(); + this.userNoteCallbacks?.noteEdit?.(event, this.note); + } + + noteDelete(event: MouseEvent): void { + event.stopPropagation(); + this.modelservice.notes.delete(this.note); + } +} diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html index 3275e3e898..81eef74152 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.html @@ -46,31 +46,39 @@
scada.behavior.type
- - {{ scadaSymbolBehaviorTypeTranslations.get(type) | translate }} - + @for (type of scadaSymbolBehaviorTypes; track type) { + + {{ scadaSymbolBehaviorTypeTranslations.get(type) | translate }} + + }
-
-
scada.behavior.value-type
- - - -
- - {{ valueTypesMap.get(behaviorFormGroup.get('valueType').value).name | translate }} -
-
- - - {{ valueTypesMap.get(valueType).name | translate }} - -
-
-
- - + @if ([ScadaSymbolBehaviorType.value, ScadaSymbolBehaviorType.action].includes(behaviorFormGroup.get('type').value)) { +
+
scada.behavior.value-type
+ + + @if (behaviorFormGroup.get('valueType').value) { + +
+ + {{ valueTypesMap.get(behaviorFormGroup.get('valueType').value).name | translate }} +
+
+ } + @for (valueType of valueTypes; track valueType) { + + + {{ valueTypesMap.get(valueType).name | translate }} + + } +
+
+
+ } + @if (behaviorFormGroup.get('type').value === ScadaSymbolBehaviorType.value) { + @if (behaviorFormGroup.get('valueType').value === ValueType.BOOLEAN) {
scada.behavior.true-label
@@ -89,7 +97,7 @@
-
+ }
scada.behavior.default-settings
-
- + } + @if (behaviorFormGroup.get('type').value === ScadaSymbolBehaviorType.action) {
scada.behavior.default-settings
-
- + } + @if (behaviorFormGroup.get('type').value === ScadaSymbolBehaviorType.widgetAction) {
scada.behavior.default-settings
-
+ }
- + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts index c488258b6d..793c1307db 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-panel.component.ts @@ -35,10 +35,11 @@ import { WidgetActionCallbacks } from '@home/components/widget/action/manage-wid import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-scada-symbol-behavior-panel', - templateUrl: './scada-symbol-behavior-panel.component.html', - styleUrls: ['./scada-symbol-behavior-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-behavior-panel', + templateUrl: './scada-symbol-behavior-panel.component.html', + styleUrls: ['./scada-symbol-behavior-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolBehaviorPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html index 195d2de0fd..819bb8de99 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.html @@ -24,9 +24,11 @@ - - {{ scadaSymbolBehaviorTypeTranslations.get(type) | translate }} - + @for (type of scadaSymbolBehaviorTypes; track type) { + + {{ scadaSymbolBehaviorTypeTranslations.get(type) | translate }} + + }
@@ -38,13 +40,15 @@ matTooltipPosition="above"> settings - + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts index 2d53986923..a7116b8e8a 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behavior-row.component.ts @@ -47,7 +47,7 @@ import { scadaSymbolBehaviorTypeTranslations, updateBehaviorDefaultSettings } from '@home/components/widget/lib/scada/scada-symbol.models'; import { deepClone, isUndefinedOrNull } from '@core/utils'; -import { MatButton } from '@angular/material/button'; +import { MatIconButton } from '@angular/material/button'; import { TbPopoverService } from '@shared/components/popover.service'; import { ScadaSymbolBehaviorPanelComponent @@ -91,22 +91,23 @@ export const behaviorValid = (behavior: ScadaSymbolBehavior): boolean => { }; @Component({ - selector: 'tb-scada-symbol-metadata-behavior-row', - templateUrl: './scada-symbol-behavior-row.component.html', - styleUrls: ['./scada-symbol-behavior-row.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata-behavior-row', + templateUrl: './scada-symbol-behavior-row.component.html', + styleUrls: ['./scada-symbol-behavior-row.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorRowComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, OnInit, Validator { @@ -114,7 +115,7 @@ export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, On idInput: ElementRef; @ViewChild('editButton') - editButton: MatButton; + editButton: MatIconButton; scadaSymbolBehaviorTypes = scadaSymbolBehaviorTypes; scadaSymbolBehaviorTypeTranslations = scadaSymbolBehaviorTypeTranslations; @@ -195,7 +196,7 @@ export class ScadaSymbolBehaviorRowComponent implements ControlValueAccessor, On this.cd.markForCheck(); } - editBehavior($event: Event, matButton: MatButton, add = false, editCanceled = () => {}) { + editBehavior($event: Event, matButton: MatIconButton, add = false, editCanceled = () => {}) { if ($event) { $event.stopPropagation(); } diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html index c65ea45ebb..f85a37dee9 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.html @@ -23,43 +23,51 @@
scada.behavior.type
-
-
- - -
- -
+ @if (behaviorsFormArray().controls.length) { +
+ @for (behaviorControl of behaviorsFormArray().controls; track trackByBehavior($index, behaviorControl)) { +
+ + +
+ @if (!disabled) { + + } +
+
+ }
-
- -
-
- + } @else { + {{ 'scada.behavior.no-behaviors' | translate }} + } + @if (errorText) { + + }
+ @if (!disabled) { +
+ +
+ } - - {{ 'scada.behavior.no-behaviors' | translate }} - diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts index 9775ad9a63..bb24f86a19 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-behaviors.component.ts @@ -55,22 +55,23 @@ import { WidgetActionCallbacks } from '@home/components/widget/action/manage-wid import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-scada-symbol-metadata-behaviors', - templateUrl: './scada-symbol-behaviors.component.html', - styleUrls: ['./scada-symbol-behaviors.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata-behaviors', + templateUrl: './scada-symbol-behaviors.component.html', + styleUrls: ['./scada-symbol-behaviors.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolBehaviorsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolBehaviorsComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html index a7fbbfce2e..e7782cebb9 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.html @@ -19,22 +19,22 @@ diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts index 3f9cdddd7c..2e1a7e4d29 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component.ts @@ -36,10 +36,11 @@ import { import { JsFuncComponent } from '@shared/components/js-func.component'; @Component({ - selector: 'tb-scada-symbol-metadata-tag-function-panel', - templateUrl: './scada-symbol-metadata-tag-function-panel.component.html', - styleUrls: ['./scada-symbol-metadata-tag-function-panel.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata-tag-function-panel', + templateUrl: './scada-symbol-metadata-tag-function-panel.component.html', + styleUrls: ['./scada-symbol-metadata-tag-function-panel.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolMetadataTagFunctionPanelComponent implements OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html index 643e161e37..b3a64d6c0e 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.html @@ -26,23 +26,27 @@
- + @if (!disabled || tagFormGroup.get('stateRenderFunction').value) { + + }
- + @if (!disabled || tagFormGroup.get('clickAction').value) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts index 352d3823da..a1237d87dc 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag.component.ts @@ -43,22 +43,23 @@ import { } from '@home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tag-function-panel.component'; @Component({ - selector: 'tb-scada-symbol-metadata-tag', - templateUrl: './scada-symbol-metadata-tag.component.html', - styleUrls: ['./scada-symbol-metadata-tag.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata-tag', + templateUrl: './scada-symbol-metadata-tag.component.html', + styleUrls: ['./scada-symbol-metadata-tag.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataTagComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolMetadataTagComponent implements ControlValueAccessor, OnInit, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html index 225596a062..38d1532b0f 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.html @@ -22,18 +22,23 @@
scada.state-render-function
scada.tag.on-click-action
-
-
- - - + @if (tagsFormArray().controls.length) { +
+ @for (tagControl of tagsFormArray().controls; track trackByTag($index, tagControl)) { +
+ + + @if (!$last) { + + } +
+ }
-
+ } @else { + {{ 'scada.tag.no-tags' | translate }} + }
- - {{ 'scada.tag.no-tags' | translate }} - diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts index d4286b762c..f2556d3cf0 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata-tags.component.ts @@ -49,22 +49,23 @@ const tagIsEmpty = (tag: ScadaSymbolTag): boolean => !tag.stateRenderFunction && !tag.actions?.click?.actionFunction; @Component({ - selector: 'tb-scada-symbol-metadata-tags', - templateUrl: './scada-symbol-metadata-tags.component.html', - styleUrls: ['./scada-symbol-metadata-tags.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata-tags', + templateUrl: './scada-symbol-metadata-tags.component.html', + styleUrls: ['./scada-symbol-metadata-tags.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataTagsComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolMetadataTagsComponent implements ControlValueAccessor, OnInit, Validator, OnChanges { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html index 3d981e3611..30dbe4f734 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.html @@ -37,8 +37,10 @@
scada.description
+ rows="1">
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts index 9dd2afba52..a893100791 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/metadata-components/scada-symbol-metadata.component.ts @@ -58,22 +58,23 @@ import { WidgetActionCallbacks } from '@home/components/widget/action/manage-wid import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-scada-symbol-metadata', - templateUrl: './scada-symbol-metadata.component.html', - styleUrls: ['./scada-symbol-metadata.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolMetadataComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => ScadaSymbolMetadataComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-metadata', + templateUrl: './scada-symbol-metadata.component.html', + styleUrls: ['./scada-symbol-metadata.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => ScadaSymbolMetadataComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolMetadataComponent extends PageComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html index bba6f4319e..9dfcd7bd24 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.html @@ -43,14 +43,16 @@ remove
-
- -
+ @if (displayShowHidden) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts index 6ba0866d89..16380ea570 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-editor.component.ts @@ -50,10 +50,11 @@ export interface ScadaSymbolEditorData { type editorModeType = 'svg' | 'xml'; @Component({ - selector: 'tb-scada-symbol-editor', - templateUrl: './scada-symbol-editor.component.html', - styleUrls: ['./scada-symbol-editor.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol-editor', + templateUrl: './scada-symbol-editor.component.html', + styleUrls: ['./scada-symbol-editor.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolEditorComponent implements OnInit, OnDestroy, AfterViewInit, OnChanges { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts index e8558c5206..f4065f8f93 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts @@ -57,15 +57,16 @@ abstract class ScadaSymbolPanelComponent implements AfterViewInit { } @Component({ - template: `
+ template: `
{{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }}
`, - styleUrls: ['./scada-symbol-tooltip.component.scss'], - encapsulation: ViewEncapsulation.None + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { @@ -83,7 +84,7 @@ class ScadaSymbolAddTagPanelComponent extends ScadaSymbolPanelComponent { } @Component({ - template: `
+ template: `
{{ (isAdd ? 'scada.tag.enter-tag' : 'scada.tag.update-tag' ) | translate }}: close
`, - styleUrls: ['./scada-symbol-tooltip.component.scss'], - encapsulation: ViewEncapsulation.None + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implements AfterViewInit, OnDestroy { @@ -181,33 +183,40 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem } @Component({ - template: `
- {{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }}: - {{ symbolElement?.tag }} - - - -
`, - styleUrls: ['./scada-symbol-tooltip.component.scss'], - encapsulation: ViewEncapsulation.None + template: `
+ {{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }}: + {{ symbolElement?.tag }} + @if (!symbolElement?.readonly) { + + } + @if (displayTagSettings) { + + } + @if (!symbolElement?.readonly) { + + } +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { @@ -305,7 +314,7 @@ class ScadaSymbolTagPanelComponent extends ScadaSymbolPanelComponent implements } @Component({ - template: `
+ template: `
`, - styleUrls: ['./scada-symbol-tooltip.component.scss'], - encapsulation: ViewEncapsulation.None + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) class ScadaSymbolRemoveTagConfirmComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { @@ -355,40 +365,53 @@ class ScadaSymbolRemoveTagConfirmComponent extends ScadaSymbolPanelComponent imp } @Component({ - template: `
-
scada.state-render-function
- - -
scada.tag.on-click-action
- - -
`, - styleUrls: ['./scada-symbol-tooltip.component.scss'], - encapsulation: ViewEncapsulation.None + template: `
+ @if (!symbolElement?.readonly || hasStateRenderFunction) { +
scada.state-render-function
+ } + @if (hasStateRenderFunction) { + + } + @if (!hasStateRenderFunction && !symbolElement?.readonly) { + + } + @if (!symbolElement?.readonly || hasClickAction) { +
scada.tag.on-click-action
+ } + @if (hasClickAction) { + + } + @if (!hasClickAction && !symbolElement?.readonly) { + + } +
`, + styleUrls: ['./scada-symbol-tooltip.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) class ScadaSymbolTagSettingsComponent extends ScadaSymbolPanelComponent implements OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html index 5b8abc406e..06d8ff153b 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.html @@ -37,46 +37,48 @@ {{ 'scada.create-widget' | translate }}
-
-
- - - - -
-
-
-
-
scada.symbol.symbol
- - + @if (previewMode) { +
+
+ + + + +
+
+
+
+
scada.symbol.symbol
+ + +
+ +
- -
-
+ }
visibility {{ 'scada.preview' | translate }} -
- - -
+ @if (!readonly) { +
+ + +
+ }
- - - + @if (!previewMode) { + + } + @if (previewMode) { + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts index 2c04591850..f2a358fde7 100644 --- a/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts +++ b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol.component.ts @@ -82,10 +82,11 @@ import { WidgetService } from '@core/http/widget.service'; import { ActionNotificationShow } from '@core/notification/notification.actions'; @Component({ - selector: 'tb-scada-symbol', - templateUrl: './scada-symbol.component.html', - styleUrls: ['./scada-symbol.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scada-symbol', + templateUrl: './scada-symbol.component.html', + styleUrls: ['./scada-symbol.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScadaSymbolComponent extends PageComponent implements OnInit, OnDestroy, HasDirtyFlag, ScadaSymbolEditObjectCallbacks { diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html index 3822fea0e1..bb0eace707 100644 --- a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/backup-code-auth-dialog.component.html @@ -24,15 +24,20 @@ close - - -
+@if (isLoading$ | async) { + + +} @else { +
+}

security.2fa.dialog.backup-code-description

-
- {{ code }} -
+ @for (code of backupCode?.codes; track code) { +
+ {{ code }} +
+ }
- - -
+@if (isLoading$ | async) { + + +} @else { +
+}
@@ -37,16 +40,20 @@

security.2fa.dialog.email-step-description

- + - - {{ 'user.email-required' | translate }} - - - {{ 'user.invalid-email-format' | translate }} - + @if (emailConfigForm.get('email').hasError('required')) { + + {{ 'user.email-required' | translate }} + + } + @if (emailConfigForm.get('email').hasError('email')) { + + {{ 'user.invalid-email-format' | translate }} + + } - - -
+@if (isLoading$ | async) { + + +} @else { +
+}
@@ -39,6 +42,7 @@
@@ -58,15 +62,17 @@ {{ 'security.2fa.dialog.verification-step-description' | translate : {address: smsConfigForm.get('phone').value} }}

- + - - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} - + @if (smsVerificationForm.get('verificationCode').invalid) { + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + }
- - -
+@if (isLoading$ | async) { + + +} @else { +
+}
@@ -66,15 +69,17 @@

security.2fa.dialog.enter-verification-code

- + - - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} - + @if (totpConfigForm.get('verificationCode').invalid) { + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts index bb4fc921cb..fe394393e7 100644 --- a/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/authentication-dialog/totp-auth-dialog.component.ts @@ -31,9 +31,10 @@ import { MatStepper } from '@angular/material/stepper'; import { unwrapModule } from '@core/utils'; @Component({ - selector: 'tb-totp-auth-dialog', - templateUrl: './totp-auth-dialog.component.html', - styleUrls: ['./authentication-dialog.component.scss'] + selector: 'tb-totp-auth-dialog', + templateUrl: './totp-auth-dialog.component.html', + styleUrls: ['./authentication-dialog.component.scss'], + standalone: false }) export class TotpAuthDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index 4fca803187..548160ec32 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.html +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.html @@ -50,45 +50,57 @@

profile.change-password

- + profile.current-password - - {{ 'security.password-requirement.incorrect-password-try-again' | translate }} - + @if (changePassword.get('currentPassword').hasError('differencePassword')) { + + {{ 'security.password-requirement.incorrect-password-try-again' | translate }} + + } - + login.new-password - - {{ 'security.password-requirement.password-not-meet-requirements' | translate }} - - - {{ changePassword.get('newPassword').getError('alreadyUsed') }} - - - {{ 'security.password-requirement.password-should-difference' | translate }} - - - {{ 'security.password-requirement.password-should-not-contain-spaces' | translate }} - + @if (changePassword.get('newPassword').errors + && !changePassword.get('newPassword').hasError('alreadyUsed') + && !changePassword.get('newPassword').hasError('hasWhitespaces') + && !changePassword.get('newPassword').hasError('passwordSameAsOld')) { + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + } + @if (changePassword.get('newPassword').hasError('alreadyUsed')) { + + {{ changePassword.get('newPassword').getError('alreadyUsed') }} + + } + @if (changePassword.get('newPassword').hasError('passwordSameAsOld')) { + + {{ 'security.password-requirement.password-should-difference' | translate }} + + } + @if (changePassword.get('newPassword').hasError('hasWhitespaces')) { + + {{ 'security.password-requirement.password-should-not-contain-spaces' | translate }} + + }
- + login.new-password-again - - {{ 'security.password-requirement.new-passwords-not-match' | translate }} - + @if (changePassword.get('newPassword2').hasError('passwordsNotMatch')) { + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + }
@@ -97,47 +109,61 @@
-
-

security.password-requirement.password-requirements

-

security.password-requirement.at-least

-

- - {{ changePassword.get('newPassword').hasError('notUpperCase') ? 'mdi:circle-small' : 'mdi:check' }} - - {{ 'security.password-requirement.uppercase-letter' | translate : {count: passwordPolicy.minimumUppercaseLetters} }} -

-

- - {{ changePassword.get('newPassword').hasError('notLowerCase') ? 'mdi:circle-small' : 'mdi:check' }} - - {{ 'security.password-requirement.lowercase-letter' | translate : {count: passwordPolicy.minimumLowercaseLetters} }} -

-

- - {{ changePassword.get('newPassword').hasError('notNumeric') ? 'mdi:circle-small' : 'mdi:check' }} - - {{ 'security.password-requirement.digit' | translate : {count: passwordPolicy.minimumDigits} }} -

-

- - {{ changePassword.get('newPassword').hasError('notSpecial') ? 'mdi:circle-small' : 'mdi:check' }} - - {{ 'security.password-requirement.special-character' | translate : {count: passwordPolicy.minimumSpecialCharacters} }} -

-

- - {{ changePassword.get('newPassword').hasError('minLength') ? 'mdi:circle-small' : 'mdi:check' }} - - {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.minimumLength} }} -

-
-

security.password-requirement.at-most

-

- - {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.maximumLength} }} -

+ @if (passwordPolicy) { +
+

security.password-requirement.password-requirements

+

security.password-requirement.at-least

+ @if (passwordPolicy.minimumUppercaseLetters > 0) { +

+ + {{ changePassword.get('newPassword').hasError('notUpperCase') ? 'mdi:circle-small' : 'mdi:check' }} + + {{ 'security.password-requirement.uppercase-letter' | translate : {count: passwordPolicy.minimumUppercaseLetters} }} +

+ } + @if (passwordPolicy.minimumLowercaseLetters > 0) { +

+ + {{ changePassword.get('newPassword').hasError('notLowerCase') ? 'mdi:circle-small' : 'mdi:check' }} + + {{ 'security.password-requirement.lowercase-letter' | translate : {count: passwordPolicy.minimumLowercaseLetters} }} +

+ } + @if (passwordPolicy.minimumDigits > 0) { +

+ + {{ changePassword.get('newPassword').hasError('notNumeric') ? 'mdi:circle-small' : 'mdi:check' }} + + {{ 'security.password-requirement.digit' | translate : {count: passwordPolicy.minimumDigits} }} +

+ } + @if (passwordPolicy.minimumSpecialCharacters > 0) { +

+ + {{ changePassword.get('newPassword').hasError('notSpecial') ? 'mdi:circle-small' : 'mdi:check' }} + + {{ 'security.password-requirement.special-character' | translate : {count: passwordPolicy.minimumSpecialCharacters} }} +

+ } + @if (passwordPolicy.minimumLength > 0) { +

+ + {{ changePassword.get('newPassword').hasError('minLength') ? 'mdi:circle-small' : 'mdi:check' }} + + {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.minimumLength} }} +

+ } + @if (passwordPolicy.maximumLength > 0) { +
+

security.password-requirement.at-most

+

+ + {{ 'security.password-requirement.character' | translate : {count: passwordPolicy.maximumLength} }} +

+
+ }
-
+ }
- - - admin.2fa.2fa - - -
security.2fa.2fa-description
-
-

security.2fa.authenticate-with

-
- -
-

{{ providersData.get(provider).name | translate }}

-
-
- {{ providersData.get(provider).description | translate }} + @if (allowTwoFactorProviders.length) { + + + admin.2fa.2fa + + +
security.2fa.2fa-description
+
+

security.2fa.authenticate-with

+ + @for (provider of allowTwoFactorProviders; track trackByProvider($index, provider)) { +
+

{{ providersData.get(provider).name | translate }}

+
+ @if (!twoFactorAuth.get(provider).value) { +
+ {{ providersData.get(provider).description | translate }} +
+ } @else { +
+ {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} +
+ } + +
- -
- {{ providersData.get(provider).activatedHint | translate: providerDataInfo(provider) }} -
-
- - + @if (twoFactorAuth.get(provider).value && provider !== twoFactorAuthProviderType.BACKUP_CODE && !activeSingleProvider) { + + security.2fa.main-2fa-method + + } + @if (twoFactorAuth.get(provider).value && provider === twoFactorAuthProviderType.BACKUP_CODE) { + + }
- - security.2fa.main-2fa-method - - -
- - - - + @if (!$last) { + + } + } + + + }
diff --git a/ui-ngx/src/app/modules/home/pages/security/security.component.ts b/ui-ngx/src/app/modules/home/pages/security/security.component.ts index 25eb42e673..60a8c54e0d 100644 --- a/ui-ngx/src/app/modules/home/pages/security/security.component.ts +++ b/ui-ngx/src/app/modules/home/pages/security/security.component.ts @@ -59,9 +59,10 @@ import { import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/models/password.models'; @Component({ - selector: 'tb-security', - templateUrl: './security.component.html', - styleUrls: ['./security.component.scss'] + selector: 'tb-security', + templateUrl: './security.component.html', + styleUrls: ['./security.component.scss'], + standalone: false }) export class SecurityComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html index dcad41d86a..d8e4ab8bda 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html @@ -31,4 +31,10 @@ [entityName]="entity.name"> + @if (!isEdit) { + + + + } } diff --git a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts index e034ad9b7f..0eb66ec279 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { TenantProfile } from '@shared/models/tenant.model'; @Component({ - selector: 'tb-tenant-profile-tabs', - templateUrl: './tenant-profile-tabs.component.html', - styleUrls: [] + selector: 'tb-tenant-profile-tabs', + templateUrl: './tenant-profile-tabs.component.html', + styleUrls: [], + standalone: false }) export class TenantProfileTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts index 39d31a5367..8e0738b848 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { TenantInfo } from '@shared/models/tenant.model'; @Component({ - selector: 'tb-tenant-tabs', - templateUrl: './tenant-tabs.component.html', - styleUrls: [] + selector: 'tb-tenant-tabs', + templateUrl: './tenant-tabs.component.html', + styleUrls: [], + standalone: false }) export class TenantTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html index 83d4e47d85..8e7b639c38 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.html @@ -48,17 +48,22 @@
- + tenant.title - - {{ 'tenant.title-required' | translate }} - - - {{ 'tenant.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'tenant.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'tenant.title-max-length' | translate }} + + }
- + @if (entity?.id) { +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
+
+ } + + tenant.description - + -
-
- - - {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} - -
-
-
diff --git a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts index 9ce2817e00..bfac5322f4 100644 --- a/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts +++ b/ui-ngx/src/app/modules/home/pages/tenant/tenant.component.ts @@ -27,9 +27,10 @@ import { isDefinedAndNotNull } from '@core/utils'; import { CountryData } from '@shared/models/country.models'; @Component({ - selector: 'tb-tenant', - templateUrl: './tenant.component.html', - styleUrls: ['./tenant.component.scss'] + selector: 'tb-tenant', + templateUrl: './tenant.component.html', + styleUrls: ['./tenant.component.scss'], + standalone: false }) export class TenantComponent extends ContactBasedComponent { diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html index 1ac1ff6cf2..c8e1b97f5f 100644 --- a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts index ec869655c7..ead57fc0bf 100644 --- a/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/activation-link-dialog.component.ts @@ -30,8 +30,9 @@ export interface ActivationLinkDialogData { } @Component({ - selector: 'tb-activation-link-dialog', - templateUrl: './activation-link-dialog.component.html' + selector: 'tb-activation-link-dialog', + templateUrl: './activation-link-dialog.component.html', + standalone: false }) export class ActivationLinkDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html index 9dee500758..60ae3ad1c2 100644 --- a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.html @@ -26,17 +26,22 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
user.activation-method - - {{ activationMethodTranslations.get(activationMethodEnum[activationMethod]) | translate }} - + @for (activationMethod of activationMethods; track activationMethod) { + + {{ activationMethodTranslations.get(activationMethodEnum[activationMethod]) | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts index ed39e2b326..c8b7aabdac 100644 --- a/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/add-user-dialog.component.ts @@ -40,9 +40,10 @@ export interface AddUserDialogData { } @Component({ - selector: 'tb-add-user-dialog', - templateUrl: './add-user-dialog.component.html', - styleUrls: ['./add-user-dialog.component.scss'] + selector: 'tb-add-user-dialog', + templateUrl: './add-user-dialog.component.html', + styleUrls: ['./add-user-dialog.component.scss'], + standalone: false }) export class AddUserDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html index 97ece30b0c..1046a3799e 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.html @@ -15,32 +15,42 @@ limitations under the License. --> - - - - - - - - - - - - - - - - - +@if (entity) { + + + + +} +@if (entity) { + + + + +} +@if (entity) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} +@if (entity) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts index 0180d79f79..298af6f0de 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/user-tabs.component.ts @@ -21,9 +21,10 @@ import { EntityTabsComponent } from '../../components/entity/entity-tabs.compone import { User } from '@app/shared/models/user.model'; @Component({ - selector: 'tb-user-tabs', - templateUrl: './user-tabs.component.html', - styleUrls: [] + selector: 'tb-user-tabs', + templateUrl: './user-tabs.component.html', + styleUrls: [], + standalone: false }) export class UserTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.html b/ui-ngx/src/app/modules/home/pages/user/user.component.html index 00a0944043..789d2244b6 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user.component.html +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.html @@ -46,13 +46,14 @@ [class.!hidden]="isEdit || isUserActivated()"> {{'user.resend-activation' | translate }} - + @if (loginAsUserEnabled$ | async) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/user/user.component.ts b/ui-ngx/src/app/modules/home/pages/user/user.component.ts index bea553fae0..3a60b33208 100644 --- a/ui-ngx/src/app/modules/home/pages/user/user.component.ts +++ b/ui-ngx/src/app/modules/home/pages/user/user.component.ts @@ -31,9 +31,10 @@ import { environment as env } from '@env/environment'; import { UnitSystems } from '@shared/models/unit.models'; @Component({ - selector: 'tb-user', - templateUrl: './user.component.html', - styleUrls: ['./user.component.scss'] + selector: 'tb-user', + templateUrl: './user.component.html', + styleUrls: ['./user.component.scss'], + standalone: false }) export class UserComponent extends EntityComponent{ diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html index 18752ef82a..1b316cdc81 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.html @@ -25,20 +25,26 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ dialogTitle }} - + widget.title - - {{ 'widget.title-required' | translate }} - + @if (saveWidgetTypeAsFormGroup.get('title').hasError('required')) { + + {{ 'widget.title-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts index 8c6e8fe9d4..c0522ba993 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/save-widget-type-as-dialog.component.ts @@ -36,9 +36,10 @@ export interface SaveWidgetTypeAsDialogData { } @Component({ - selector: 'tb-save-widget-type-as-dialog', - templateUrl: './save-widget-type-as-dialog.component.html', - styleUrls: [] + selector: 'tb-save-widget-type-as-dialog', + templateUrl: './save-widget-type-as-dialog.component.html', + styleUrls: [], + standalone: false }) export class SaveWidgetTypeAsDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html index ba70e617eb..60ecf4ea6d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.html @@ -26,20 +26,25 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + @for (type of allWidgetTypes; track type) { + + }
diff --git a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts index c2c7195003..aec29b4ecd 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/select-widget-type-dialog.component.ts @@ -23,9 +23,10 @@ import { Router } from '@angular/router'; import { widgetType, widgetTypesData } from '@shared/models/widget.models'; @Component({ - selector: 'tb-select-widget-type-dialog', - templateUrl: './select-widget-type-dialog.component.html', - styleUrls: ['./select-widget-type-dialog.component.scss'] + selector: 'tb-select-widget-type-dialog', + templateUrl: './select-widget-type-dialog.component.html', + styleUrls: ['./select-widget-type-dialog.component.scss'], + standalone: false }) export class SelectWidgetTypeDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html index c9c3f634ae..685f0f3336 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-editor.component.html @@ -27,9 +27,11 @@ - - {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} - + @for (type of allWidgetTypes; track type) { + + {{ widgetTypesDataMap.get(widgetTypes[type]).name | translate }} + + } @@ -48,15 +50,17 @@ undo action.undo - + @if (!isReadOnly) { + + } - + @if (!isReadOnly) { + + } -
+ @for (resource of widget.resources; track resource; let i = $index) { +
+ + + + {{ 'widget.resource-is-extension' | translate }} + + +
+ } + @if (selectWidgetTypeFormGroup.get('widgetType').value && !disabled) { + + } - -
- {{ widgetType.name }} -
-
-
{{ 'widget.deprecated' | translate }}
+ @for (widgetType of filteredWidgetTypes | async; track widgetType) { + +
+ {{ widgetType.name }} +
+
+ @if (widgetType.deprecated) { +
{{ 'widget.deprecated' | translate }}
+ } +
-
- - -
-
- {{ 'widget.no-widgets-text' | translate }} + + } + @if (!(filteredWidgetTypes | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'widget.no-widgets-text' | translate }} +
+ } @else { + + {{ translate.get('widget.no-widgets-matching', {entity: searchText}) | async }} + + }
- - - {{ translate.get('widget.no-widgets-matching', {entity: searchText}) | async }} - - -
- + + } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts index e309e21d1d..04fd5af1cb 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.ts @@ -37,15 +37,16 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { WidgetService } from '@core/http/widget.service'; @Component({ - selector: 'tb-widget-type-autocomplete', - templateUrl: './widget-type-autocomplete.component.html', - styleUrls: ['./widget-type-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetTypeAutocompleteComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None + selector: 'tb-widget-type-autocomplete', + templateUrl: './widget-type-autocomplete.component.html', + styleUrls: ['./widget-type-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetTypeAutocompleteComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html index 77957a8008..ec789ed6d2 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.html @@ -15,9 +15,11 @@ limitations under the License. --> - - - +@if (isTenantWidgetType() && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts index acee3159d2..0168bfb639 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-tabs.component.ts @@ -22,9 +22,10 @@ import { NULL_UUID } from '@shared/models/id/has-uuid'; import { WidgetTypeDetails } from '@shared/models/widget.models'; @Component({ - selector: 'tb-widget-type-tabs', - templateUrl: './widget-type-tabs.component.html', - styleUrls: [] + selector: 'tb-widget-type-tabs', + templateUrl: './widget-type-tabs.component.html', + styleUrls: [], + standalone: false }) export class WidgetTypeTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html index 871b9b37ef..b3f4d8024f 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.html @@ -44,27 +44,27 @@
- + widget.title - - {{ 'widget.title-required' | translate }} - - - {{ 'widget.title-max-length' | translate }} - + @if (entityForm.get('name').hasError('required')) { + + {{ 'widget.title-required' | translate }} + + } + @if (entityForm.get('name').hasError('maxlength')) { + + {{ 'widget.title-max-length' | translate }} + + } - - widget.description - - {{descriptionInput.value?.length || 0}}/1024 -
@@ -75,6 +75,11 @@ {{ 'widget.deprecated' | translate }}
+ + widget.description + + {{descriptionInput.value?.length || 0}}/1024 +
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts index b765fadb1d..c6911e287e 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type.component.ts @@ -24,9 +24,10 @@ import { EntityTableConfig } from '@home/models/entity/entities-table-config.mod import { WidgetTypeDetails } from '@shared/models/widget.models'; @Component({ - selector: 'tb-widget-type', - templateUrl: './widget-type.component.html', - styleUrls: [] + selector: 'tb-widget-type', + templateUrl: './widget-type.component.html', + styleUrls: [], + standalone: false }) export class WidgetTypeComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html index c5c8ed259e..c3a965b752 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
implements ErrorStateMatcher { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html index c798bcd9d5..d0b3e8936d 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.html @@ -15,9 +15,11 @@ limitations under the License. --> - - - +@if (isTenantWidgetsBundle() && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts index 5a684bc031..6e569ff701 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-tabs.component.ts @@ -22,9 +22,10 @@ import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { NULL_UUID } from '@shared/models/id/has-uuid'; @Component({ - selector: 'tb-widgets-bundle-tabs', - templateUrl: './widgets-bundle-tabs.component.html', - styleUrls: [] + selector: 'tb-widgets-bundle-tabs', + templateUrl: './widgets-bundle-tabs.component.html', + styleUrls: [], + standalone: false }) export class WidgetsBundleTabsComponent extends EntityTabsComponent { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html index 39308311ab..e56c3370ae 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.html @@ -27,13 +27,14 @@ {{ widgetsBundle.title }}: {{ 'widget.widgets' | translate }} - + @if (!isReadOnly) { + + }
- - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
-
-
- {{ widget.name }} -
-
{{ widget.name }}
-
{{ 'widget.deprecated' | translate }}
+ @for (widget of widgets; track trackByWidget($index, widget); let isLast = $last) { +
+
+ {{ widget.name }} +
+
{{ widget.name }}
+ @if (widget.deprecated) { +
{{ 'widget.deprecated' | translate }}
+ } +
+ @if (!editMode) { + + } + @if (!editMode) { + + } + @if (editMode && !addMode) { + + } + @if (editMode) { + + }
- - - - -
+ }
-
- add -
-
-
+ @if (editMode && !addMode && widgets?.length) { +
add -
widget.add-existing-widget
-
-
-
-
widget.no-widgets
-
-
- - -
- + } + @if (editMode && !addMode && !widgets?.length) { +
+
+ add +
widget.add-existing-widget
+
-
+ } + @if (isReadOnly && !widgets?.length) { +
+
+
widget.no-widgets
+
+ } + @if (addMode) { +
+ + +
+ +
+
+ } - - - + @if (editMode && (widgets?.length || isDirty)) { + + } + @if (editMode && (widgets?.length || isDirty)) { + + } + @if (!isReadOnly && !editMode) { + + } diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts index 7eb3afddc6..50cf6b9ef7 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle-widgets.component.ts @@ -36,9 +36,10 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; type WidgetTypeBundle = WithOptional; @Component({ - selector: 'tb-widgets-bundle-widget', - templateUrl: './widgets-bundle-widgets.component.html', - styleUrls: ['./widgets-bundle-widgets.component.scss'] + selector: 'tb-widgets-bundle-widget', + templateUrl: './widgets-bundle-widgets.component.html', + styleUrls: ['./widgets-bundle-widgets.component.scss'], + standalone: false }) export class WidgetsBundleWidgetsComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html index 6546118554..7f607c1730 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html @@ -15,61 +15,67 @@ limitations under the License. --> -
- - - - -
+@if (!standalone) { +
+ + + + +
+}
- + widgets-bundle.title - - {{ 'widgets-bundle.title-required' | translate }} - - - {{ 'widgets-bundle.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'widgets-bundle.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'widgets-bundle.title-max-length' | translate }} + + } - - widgets-bundle.description - - {{descriptionInput.value?.length || 0}}/1024 - {{ 'widgets-bundle.scada' | translate }} - + widgets-bundle.order + + widgets-bundle.description + + {{descriptionInput.value?.length || 0}}/1024 +
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts index 1ccabc8050..bbfe6cb90e 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts +++ b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.ts @@ -23,9 +23,10 @@ import { WidgetsBundle } from '@shared/models/widgets-bundle.model'; import { EntityTableConfig } from '@home/models/entity/entities-table-config.models'; @Component({ - selector: 'tb-widgets-bundle', - templateUrl: './widgets-bundle.component.html', - styleUrls: ['./widgets-bundle.component.scss'] + selector: 'tb-widgets-bundle', + templateUrl: './widgets-bundle.component.html', + styleUrls: ['./widgets-bundle.component.scss'], + standalone: false }) export class WidgetsBundleComponent extends EntityComponent { diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html index 703c710add..a73d8b3a25 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.html @@ -21,9 +21,13 @@ login.create-password
- - - + @if (isLoading || (isLoading$ | async)) { + + + } + @if (!isLoading && !(isLoading$ | async)) { + + }
@@ -39,18 +43,22 @@ formControlName="newPassword"/> lock - - {{ 'security.password-requirement.password-not-meet-requirements' | translate }} - + @if (createPassword.get('newPassword').errors) { + + {{ 'security.password-requirement.password-not-meet-requirements' | translate }} + + } login.password-again lock - - {{ 'security.password-requirement.new-passwords-not-match' | translate }} - + @if (createPassword.get('newPassword2').hasError('passwordsNotMatch')) { + + {{ 'security.password-requirement.new-passwords-not-match' | translate }} + + }
+ [passwordControl]="createPassword.get('newPassword')" + [passwordPolicy]="passwordPolicy" + [trigger]="passwordTrigger"> diff --git a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts index 03103ae6c3..c72f1751a2 100644 --- a/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/create-password.component.ts @@ -24,9 +24,10 @@ import { finalize } from 'rxjs/operators'; import { PageComponent } from '@shared/components/page.component'; @Component({ - selector: 'tb-create-password', - templateUrl: './create-password.component.html', - styleUrls: ['./password.component.scss'] + selector: 'tb-create-password', + templateUrl: './create-password.component.html', + styleUrls: ['./password.component.scss'], + standalone: false }) export class CreatePasswordComponent extends PageComponent { diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html index 495d6fa0cb..7126e6b3a2 100644 --- a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.html @@ -76,7 +76,7 @@ tooltipPosition="above" icon="content_copy" [style]="{'font-size': '24px', color: 'rgba(255,255,255,.8)'}" - > + >
@@ -120,6 +120,7 @@ label="{{ 'login.phone-input.phone-input-label' | translate }}" hint="login.phone-input.phone-input-hint" requiredErrorText="{{ 'login.phone-input.phone-input-required' | translate }}" + appearance="outline" validationErrorText="{{ 'login.phone-input.phone-input-validation' | translate }}" formControlName="phone" [floatLabel]="'auto'"> @@ -167,12 +168,16 @@ type="email" required autocomplete="email" placeholder="{{ 'login.email-label' | translate }}" /> - - {{ 'login.email-required' | translate }} - - - {{ 'login.invalid-email-format' | translate }} - + @if (emailConfigForm.get('email').hasError('required')) { + + {{ 'login.email-required' | translate }} + + } + @if (emailConfigForm.get('email').hasError('email')) { + + {{ 'login.invalid-email-format' | translate }} + + }
@@ -267,9 +272,9 @@ autocomplete="one-time-code" placeholder="{{ 'login.verification-code' | translate }}"> @if (configForm.get('verificationCode').getError('required') || - configForm.get('verificationCode').getError('minlength') || - configForm.get('verificationCode').getError('maxlength') || - configForm.get('verificationCode').getError('pattern')) { + configForm.get('verificationCode').getError('minlength') || + configForm.get('verificationCode').getError('maxlength') || + configForm.get('verificationCode').getError('pattern')) { {{ 'login.verification-code-invalid' | translate }} } @if (configForm.get('verificationCode').getError('incorrectCode')) { diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss index bd37554922..46b613b1f1 100644 --- a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.scss @@ -23,7 +23,7 @@ .tb-two-factor-auth-login-content { background-color: #eee; - --mdc-elevated-card-container-elevation: none; + --mat-card-elevated-container-elevation: none; .progress-bar { z-index: 10; diff --git a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts index c5e02c19da..364ce7ffac 100644 --- a/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/force-two-factor-auth-login.component.ts @@ -61,9 +61,10 @@ enum BackupCodeState { } @Component({ - selector: 'tb-force-two-factor-auth-login', - templateUrl: './force-two-factor-auth-login.component.html', - styleUrls: ['./force-two-factor-auth-login.component.scss'] + selector: 'tb-force-two-factor-auth-login', + templateUrl: './force-two-factor-auth-login.component.html', + styleUrls: ['./force-two-factor-auth-login.component.scss'], + standalone: false }) export class ForceTwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss index 3186eefbd5..7ec064c7ad 100644 --- a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.scss @@ -20,7 +20,7 @@ flex: 1 1 0; .tb-expired-link-content { background-color: #eee; - --mdc-elevated-card-container-elevation: none; + --mat-card-elevated-container-elevation: none; .tb-expired-link-card { letter-spacing: 0.15px; line-height: 24px; diff --git a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts index 5bc00c565e..1e4f2ec68d 100644 --- a/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/link-expired.component.ts @@ -18,9 +18,10 @@ import { Component } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; @Component({ - selector: 'tb-link-expired', - templateUrl: './link-expired.component.html', - styleUrls: ['./link-expired.component.scss'] + selector: 'tb-link-expired', + templateUrl: './link-expired.component.html', + styleUrls: ['./link-expired.component.scss'], + standalone: false }) export class LinkExpiredComponent { diff --git a/ui-ngx/src/app/modules/login/pages/login/login.component.html b/ui-ngx/src/app/modules/login/pages/login/login.component.html index 39fd731fea..14f968d11e 100644 --- a/ui-ngx/src/app/modules/login/pages/login/login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/login.component.html @@ -22,9 +22,13 @@
- - - + @if (isLoading || (isLoading$ | async)) { + + + } + @if (!isLoading && !(isLoading$ | async)) { + + }
@if(oauth2Clients?.length) { @@ -60,9 +64,11 @@ login.username email - - {{ 'login.invalid-email-format' | translate }} - + @if (loginFormGroup.get('username').invalid) { + + {{ 'login.invalid-email-format' | translate }} + + } common.password @@ -71,7 +77,7 @@ lock + class="tb-action-button" type="submit">{{ 'login.sign-in' | translate }}
+ [passwordControl]="resetPassword.get('newPassword')" + [passwordPolicy]="passwordPolicy" + [trigger]="passwordTrigger"> diff --git a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts index 23e775f085..fb6a679ab0 100644 --- a/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/reset-password.component.ts @@ -23,9 +23,10 @@ import { passwordsMatchValidator, passwordStrengthValidator } from '@shared/mode import { finalize } from 'rxjs/operators'; @Component({ - selector: 'tb-reset-password', - templateUrl: './reset-password.component.html', - styleUrls: ['./password.component.scss'] + selector: 'tb-reset-password', + templateUrl: './reset-password.component.html', + styleUrls: ['./password.component.scss'], + standalone: false }) export class ResetPasswordComponent { diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html index f1a58933fc..eb894f322e 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.html @@ -27,70 +27,85 @@ -
-

login.select-way-to-verify

- - - -
- -
-
-

{{ providerDescription }}

-
- - - - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} - - - {{ 'security.2fa.dialog.verification-code-incorrect' | translate }} - - - {{ 'security.2fa.dialog.verification-code-many-request' | translate }} - - -
- - - -
-
-
- {{ 'login.resend-code-wait' | translate : {time: countDownTime} }} -
- + } +
+ } + @if (selectedProvider) { + +
+
+

{{ providerDescription }}

+
+ + + @if (verificationForm.get('verificationCode').getError('required') || + verificationForm.get('verificationCode').getError('minlength') || + verificationForm.get('verificationCode').getError('maxlength') || + verificationForm.get('verificationCode').getError('pattern')) { + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + } + @if (verificationForm.get('verificationCode').getError('incorrectCode')) { + + {{ 'security.2fa.dialog.verification-code-incorrect' | translate }} + + } + @if (verificationForm.get('verificationCode').getError('tooManyRequest')) { + + {{ 'security.2fa.dialog.verification-code-many-request' | translate }} + + } +
- + +
+ @if (showResendAction) { +
+ @if (countDownTime) { +
+ {{ 'login.resend-code-wait' | translate : {time: countDownTime} }} +
+ } + +
+ } + @if (allowProviders.length > 1) { + + } +
-
-
- + + + }
diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss index c215525ec5..1f5faee280 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.scss @@ -21,7 +21,7 @@ .tb-two-factor-auth-login-content { background-color: #eee; - --mdc-elevated-card-container-elevation: none; + --mat-card-elevated-container-elevation: none; .tb-two-factor-auth-login-card { padding: 48px 48px 48px 16px; diff --git a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts index b4b2e896ac..856374fe27 100644 --- a/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts +++ b/ui-ngx/src/app/modules/login/pages/login/two-factor-auth-login.component.ts @@ -32,9 +32,10 @@ import { isEqual } from '@core/utils'; import { ActionNotificationShow } from '@core/notification/notification.actions'; @Component({ - selector: 'tb-two-factor-auth-login', - templateUrl: './two-factor-auth-login.component.html', - styleUrls: ['./two-factor-auth-login.component.scss'] + selector: 'tb-two-factor-auth-login', + templateUrl: './two-factor-auth-login.component.html', + styleUrls: ['./two-factor-auth-login.component.scss'], + standalone: false }) export class TwoFactorAuthLoginComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/breadcrumb.component.ts b/ui-ngx/src/app/shared/components/breadcrumb.component.ts index 858bd20370..75603fc9cb 100644 --- a/ui-ngx/src/app/shared/components/breadcrumb.component.ts +++ b/ui-ngx/src/app/shared/components/breadcrumb.component.ts @@ -28,10 +28,11 @@ import { MenuSection, menuSectionMap } from '@core/services/menu.models'; import { MenuService } from '@core/services/menu.service'; @Component({ - selector: 'tb-breadcrumb', - templateUrl: './breadcrumb.component.html', - styleUrls: ['./breadcrumb.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-breadcrumb', + templateUrl: './breadcrumb.component.html', + styleUrls: ['./breadcrumb.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class BreadcrumbComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.html b/ui-ngx/src/app/shared/components/button/copy-button.component.html index d664b4d097..c72cfdcda3 100644 --- a/ui-ngx/src/app/shared/components/button/copy-button.component.html +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.html @@ -23,10 +23,11 @@ [matTooltip]="matTooltipText" [matTooltipPosition]="matTooltipPosition" (click)="copy($event)"> - - {{ icon || mdiIcon }} - - + @if (!copied) { + + {{ icon || mdiIcon }} + + } @else { done - + } diff --git a/ui-ngx/src/app/shared/components/button/copy-button.component.ts b/ui-ngx/src/app/shared/components/button/copy-button.component.ts index 6f2573ccbd..9f5617a4da 100644 --- a/ui-ngx/src/app/shared/components/button/copy-button.component.ts +++ b/ui-ngx/src/app/shared/components/button/copy-button.component.ts @@ -22,9 +22,10 @@ import { ThemePalette } from '@angular/material/core'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-copy-button', - styleUrls: ['copy-button.component.scss'], - templateUrl: './copy-button.component.html' + selector: 'tb-copy-button', + styleUrls: ['copy-button.component.scss'], + templateUrl: './copy-button.component.html', + standalone: false }) export class CopyButtonComponent { diff --git a/ui-ngx/src/app/shared/components/button/toggle-password.component.html b/ui-ngx/src/app/shared/components/button/toggle-password.component.html index 07e91101ab..e284af02c4 100644 --- a/ui-ngx/src/app/shared/components/button/toggle-password.component.html +++ b/ui-ngx/src/app/shared/components/button/toggle-password.component.html @@ -15,6 +15,8 @@ limitations under the License. --> - +@if (!hideToggle) { + +} diff --git a/ui-ngx/src/app/shared/components/button/toggle-password.component.ts b/ui-ngx/src/app/shared/components/button/toggle-password.component.ts index 28ba243e1e..b3a1ca1e70 100644 --- a/ui-ngx/src/app/shared/components/button/toggle-password.component.ts +++ b/ui-ngx/src/app/shared/components/button/toggle-password.component.ts @@ -17,9 +17,10 @@ import { AfterViewInit, Component, ElementRef } from '@angular/core'; @Component({ - selector: 'tb-toggle-password', - templateUrl: 'toggle-password.component.html', - styleUrls: [], + selector: 'tb-toggle-password', + templateUrl: 'toggle-password.component.html', + styleUrls: [], + standalone: false }) export class TogglePasswordComponent implements AfterViewInit { showPassword = false; diff --git a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html index fb0c8bfab9..3684b29e5b 100644 --- a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html +++ b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.html @@ -27,16 +27,28 @@ [class.tb-disabled-state]="disabled" [class.tb-disabled]="disabled"> -
- {{ appearance.leftAppearance.icon }} - {{ leftLabel$ | async }} -
+ @if (appearance.leftAppearance.showIcon || appearance.leftAppearance.showLabel) { +
+ @if (appearance.leftAppearance.showIcon) { + {{ appearance.leftAppearance.icon }} + } + @if (appearance.leftAppearance.showLabel) { + {{ leftLabel$ | async }} + } +
+ }
-
- {{ appearance.rightAppearance.icon }} - {{ rightLabel$ | async }} -
+ @if (appearance.rightAppearance.showIcon || appearance.rightAppearance.showLabel) { +
+ @if (appearance.rightAppearance.showIcon) { + {{ appearance.rightAppearance.icon }} + } + @if (appearance.rightAppearance.showLabel) { + {{ rightLabel$ | async }} + } +
+ }
diff --git a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts index 62d3d9ff0c..488afa9a81 100644 --- a/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts +++ b/ui-ngx/src/app/shared/components/button/widget-button-toggle.component.ts @@ -46,9 +46,10 @@ const initialButtonHeight = 60; const horizontalLayoutPadding = 10; @Component({ - selector: 'tb-widget-button-toggle', - templateUrl: './widget-button-toggle.component.html', - styleUrls: ['./widget-button-toggle.component.scss'] + selector: 'tb-widget-button-toggle', + templateUrl: './widget-button-toggle.component.html', + styleUrls: ['./widget-button-toggle.component.scss'], + standalone: false }) export class WidgetButtonToggleComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges { diff --git a/ui-ngx/src/app/shared/components/button/widget-button.component.html b/ui-ngx/src/app/shared/components/button/widget-button.component.html index 2ff5d5e07f..16cf5fd557 100644 --- a/ui-ngx/src/app/shared/components/button/widget-button.component.html +++ b/ui-ngx/src/app/shared/components/button/widget-button.component.html @@ -33,8 +33,14 @@ [class.tb-disabled-state]="disabled" [style.border-radius]="computedBorderRadius" [style.pointer-events]="disableEvents ? 'none' : ''"> -
- {{ appearance.icon }} - {{ label$ | async }} -
+ @if (appearance.showIcon || appearance.showLabel) { +
+ @if (appearance.showIcon) { + {{ appearance.icon }} + } + @if (appearance.showLabel) { + {{ label$ | async }} + } +
+ } diff --git a/ui-ngx/src/app/shared/components/button/widget-button.component.ts b/ui-ngx/src/app/shared/components/button/widget-button.component.ts index 213b5c2a47..7c4ef584d5 100644 --- a/ui-ngx/src/app/shared/components/button/widget-button.component.ts +++ b/ui-ngx/src/app/shared/components/button/widget-button.component.ts @@ -45,10 +45,11 @@ const horizontalLayoutPadding = 24; const verticalLayoutPadding = 16; @Component({ - selector: 'tb-widget-button', - templateUrl: './widget-button.component.html', - styleUrls: ['./widget-button.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-widget-button', + templateUrl: './widget-button.component.html', + styleUrls: ['./widget-button.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetButtonComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges { diff --git a/ui-ngx/src/app/shared/components/cheatsheet.component.ts b/ui-ngx/src/app/shared/components/cheatsheet.component.ts index 93ae5c87b1..41fa0ce39f 100644 --- a/ui-ngx/src/app/shared/components/cheatsheet.component.ts +++ b/ui-ngx/src/app/shared/components/cheatsheet.component.ts @@ -20,8 +20,8 @@ import { MousetrapInstance } from 'mousetrap'; import Mousetrap from 'mousetrap'; @Component({ - selector : 'tb-hotkeys-cheatsheet', - styles : [` + selector: 'tb-hotkeys-cheatsheet', + styles: [` .tb-hotkeys-container { display: table !important; position: fixed; @@ -114,18 +114,25 @@ import Mousetrap from 'mousetrap'; font-size: 1.2em; } } `], - template : ``, + template: ``, + standalone: false }) export class TbCheatSheetComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/circular-progress.directive.ts b/ui-ngx/src/app/shared/components/circular-progress.directive.ts index e0c73dc6ce..36bd7e22c0 100644 --- a/ui-ngx/src/app/shared/components/circular-progress.directive.ts +++ b/ui-ngx/src/app/shared/components/circular-progress.directive.ts @@ -18,8 +18,9 @@ import { ComponentRef, Directive, ElementRef, Input, ViewContainerRef } from '@a import { MatProgressSpinner } from '@angular/material/progress-spinner'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[tb-circular-progress]' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tb-circular-progress]', + standalone: false }) export class CircularProgressDirective { diff --git a/ui-ngx/src/app/shared/components/color-input.component.html b/ui-ngx/src/app/shared/components/color-input.component.html index 92c360e8c7..95c16c2662 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.html +++ b/ui-ngx/src/app/shared/components/color-input.component.html @@ -15,26 +15,37 @@ limitations under the License. --> - - - {{icon}} - {{label}} - -
-
-
- - - - {{ requiredText }} - -
- +@if (!asBoxInput) { + + @if (icon || label) { + + @if (icon) { + {{icon}} + } + @if (label) { + {{label}} + } + + } +
+
+
+ + @if (colorClearButton && !disabled) { + + } + @if (colorFormGroup.get('color').hasError('required')) { + + {{ requiredText }} + + } +
+} @else {
- +} diff --git a/ui-ngx/src/app/shared/components/color-input.component.ts b/ui-ngx/src/app/shared/components/color-input.component.ts index e3621045fb..77e5668dfc 100644 --- a/ui-ngx/src/app/shared/components/color-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-input.component.ts @@ -42,18 +42,20 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { TbPopoverService } from '@shared/components/popover.service'; import { ColorPickerPanelComponent } from '@shared/components/color-picker/color-picker-panel.component'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-color-input', - templateUrl: './color-input.component.html', - styleUrls: ['./color-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ColorInputComponent), - multi: true - } - ] + selector: 'tb-color-input', + templateUrl: './color-input.component.html', + styleUrls: ['./color-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorInputComponent), + multi: true + } + ], + standalone: false }) export class ColorInputComponent extends PageComponent implements OnInit, ControlValueAccessor { @@ -82,6 +84,9 @@ export class ColorInputComponent extends PageComponent implements OnInit, Contro @coerceBoolean() noBorder = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/color-picker/color-input.base.scss b/ui-ngx/src/app/shared/components/color-picker/color-input.base.scss new file mode 100644 index 0000000000..bae2e433aa --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/color-input.base.scss @@ -0,0 +1,28 @@ +/** + * Copyright © 2016-2026 The Thingsboard Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +:host { + .color-input-container { + display: flex; + gap: 4px; + align-items: center; + } + .color-input { + max-width: 72px; + margin-bottom: 4px; + } +} + + diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.html b/ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.html index 0c58364c17..088c48bd15 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.html +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker-panel.component.html @@ -18,22 +18,26 @@
- + @if (colorClearButton) { + + }
- + @if (colorCancelButton) { + + }
diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss index ed161a5b9f..799bcdf00c 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.scss @@ -83,7 +83,7 @@ height: 56px; display: flex; align-items: center; - gap: 20px; + gap: 8px; .presentation-select { font-size: 14px; @@ -104,7 +104,7 @@ display: flex; flex-direction: row; flex-wrap: wrap; - justify-content: space-between; + justify-content: center; gap: 8px; @media #{$mat-xs} { flex-direction: column; diff --git a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts index ef4bb638d7..78bc599104 100644 --- a/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/color-picker.component.ts @@ -35,16 +35,17 @@ const colorPresetsHex = '#A1ADB1', '#F9A19B', '#FFD190', '#FFF59D', '#C5E1A4', '#A5D7A7', '#80CBC3', '#81C4E9', '#B39CDB', '#CD93D7', '#F48FB1', '#BC91A4']; @Component({ - selector: `tb-color-picker`, - templateUrl: `./color-picker.component.html`, - styleUrls: [`./color-picker.component.scss`], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ColorPickerComponent), - multi: true - } - ] + selector: `tb-color-picker`, + templateUrl: `./color-picker.component.html`, + styleUrls: [`./color-picker.component.scss`], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ColorPickerComponent), + multi: true + } + ], + standalone: false }) export class ColorPickerComponent implements ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss index 2f7a404c0d..73a6aff9f6 100644 --- a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss +++ b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.scss @@ -19,11 +19,11 @@ gap: 8px; } .hex-input { - max-width: 190px; + max-width: 220px; } .alpha-input { - min-width: 60px; - max-width: 60px; + min-width: 72px; + max-width: 72px; } ::ng-deep { diff --git a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts index a182e38fa8..84bcd97769 100644 --- a/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts +++ b/ui-ngx/src/app/shared/components/color-picker/hex-input.component.ts @@ -18,10 +18,11 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from import { Color } from '@iplab/ngx-color-picker'; @Component({ - selector: `tb-hex-input`, - templateUrl: `./hex-input.component.html`, - styleUrls: ['./hex-input.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: `tb-hex-input`, + templateUrl: `./hex-input.component.html`, + styleUrls: ['./hex-input.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class HexInputComponent { diff --git a/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.html b/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.html new file mode 100644 index 0000000000..cf8e25eb51 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.html @@ -0,0 +1,54 @@ + +
+
+ + + + @if (labelVisible) { + H + } +
+
+ + + {{suffixValue}} + + @if (labelVisible) { + S + } +
+
+ + + {{suffixValue}} + + @if (labelVisible) { + L + } +
+
+ + + {{suffixValue}} + + @if (labelVisible) { + A + } +
+
diff --git a/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.ts new file mode 100644 index 0000000000..e1cc27e8fa --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/hsla-input.component.ts @@ -0,0 +1,72 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Color } from '@iplab/ngx-color-picker'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +type Channel = 'H' | 'S' | 'L'; + +@Component({ + selector: 'tb-hsla-input', + templateUrl: './hsla-input.component.html', + styleUrl: './color-input.base.scss', + standalone: false +}) +export class HslaInputComponent { + + @Input() + public color: Color; + + @Output() + public colorChange = new EventEmitter(false); + + @Input() + @coerceBoolean() + public labelVisible = false; + + @Input() + public suffixValue = '%'; + + public get value(): ReturnType { + return this.color.getHsla(); + } + + public get alphaValue(): number { + return this.color ? Math.round(this.color.getHsla().getAlpha() * 100) : 0; + } + + public onAlphaInputChange(inputValue: number): void { + if (!this.color) return; + const hsla = this.color.getHsla(); + const alpha = +inputValue / 100; + if (hsla.alpha !== alpha) { + const newColor = new Color().setHsla(hsla.getHue(), hsla.getSaturation(), hsla.getLightness(), alpha); + this.colorChange.emit(newColor); + } + } + + public onInputChange(newValue: number, channel: Channel): void { + if (!this.color) return; + const hsla = this.value; + const hue = channel === 'H' ? +newValue : hsla.getHue(); + const saturation = channel === 'S' ? +newValue : hsla.getSaturation(); + const lightness = channel === 'L' ? +newValue : hsla.getLightness(); + if (hue === hsla.getHue() && saturation === hsla.getSaturation() && lightness === hsla.getLightness()) return; + const newColor = new Color().setHsla(hue, saturation, lightness, hsla.getAlpha()); + this.colorChange.emit(newColor); + } +} diff --git a/ui-ngx/src/app/shared/components/color-picker/input-change.directive.ts b/ui-ngx/src/app/shared/components/color-picker/input-change.directive.ts new file mode 100644 index 0000000000..55604df28e --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/input-change.directive.ts @@ -0,0 +1,46 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Directive, EventEmitter, HostBinding, HostListener, Input, numberAttribute, Output } from '@angular/core'; + +@Directive({ + selector: '[inputChange]', + standalone: false +}) +export class InputChangeDirective { + + @Input({transform: numberAttribute}) + @HostBinding('attr.min') + min = 0; + + @Input({transform: numberAttribute}) + @HostBinding('attr.max') + max = 255; + + @Output() + public inputChange = new EventEmitter(); + + @HostListener('input', ['$event']) + public inputChanges(event: any): void { + const element = event.target as HTMLInputElement || event.srcElement as HTMLInputElement; + const value = element.value; + + const numeric = parseFloat(value); + if (!isNaN(numeric) && numeric >= this.min && numeric <= this.max) { + this.inputChange.emit(numeric); + } + } +} diff --git a/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.html b/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.html new file mode 100644 index 0000000000..3cb856f147 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.html @@ -0,0 +1,52 @@ + +
+
+ + + + @if (labelVisible) { + R + } +
+
+ + + + @if (labelVisible) { + G + } +
+
+ + + + @if (labelVisible) { + B + } +
+
+ + + {{suffixValue}} + + @if (labelVisible) { + A + } +
+
diff --git a/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.ts b/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.ts new file mode 100644 index 0000000000..0aea5af264 --- /dev/null +++ b/ui-ngx/src/app/shared/components/color-picker/rgba-input.component.ts @@ -0,0 +1,71 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { Color } from '@iplab/ngx-color-picker'; +import { coerceBoolean } from '@shared/decorators/coercion'; + +type Channel = 'R' | 'G' | 'B'; + +@Component({ + selector: 'tb-rgba-input', + templateUrl: './rgba-input.component.html', + styleUrl: './color-input.base.scss', + standalone: false +}) +export class RgbaInputComponent { + + @Input() + public color: Color; + + @Output() + public colorChange = new EventEmitter(false); + + @Input() + @coerceBoolean() + public labelVisible = false; + + @Input() + public suffixValue = '%'; + + public get value(): ReturnType { + return this.color.getRgba(); + } + + public get alphaValue(): string { + return this.color ? Math.round(this.color.getRgba().getAlpha() * 100).toString() : ''; + } + + public onAlphaInputChange(inputValue: number): void { + if (!this.color) return; + const color = this.color.getRgba(); + const alpha = +inputValue / 100; + if (color.getAlpha() !== alpha) { + const newColor = new Color().setRgba(color.getRed(), color.getGreen(), color.getBlue(), alpha).toRgbaString(); + this.colorChange.emit(new Color(newColor)); + } + } + + onInputChange(newValue: number, channel: Channel) { + if (!this.color) return; + const rgba = this.value; + const red = channel === 'R' ? newValue : rgba.getRed(); + const green = channel === 'G' ? newValue : rgba.getGreen(); + const blue = channel === 'B' ? newValue : rgba.getBlue(); + if (red === rgba.getRed() && green === rgba.getGreen() && blue === rgba.getBlue()) return; + this.colorChange.emit(new Color().setRgba(red, green, blue, rgba.alpha)); + } +} diff --git a/ui-ngx/src/app/shared/components/contact.component.html b/ui-ngx/src/app/shared/components/contact.component.html index 2ae047d443..283ba88061 100644 --- a/ui-ngx/src/app/shared/components/contact.component.html +++ b/ui-ngx/src/app/shared/components/contact.component.html @@ -17,54 +17,56 @@ -->
- + contact.city - - {{ 'contact.city-max-length' | translate }} - + @if (parentForm.get('city').hasError('maxlength')) { + {{ 'contact.city-max-length' | translate }} + } - + contact.state - - {{ 'contact.state-max-length' | translate }} - + @if (parentForm.get('state').hasError('maxlength')) { + {{ 'contact.state-max-length' | translate }} + } - + contact.postal-code - - {{ 'contact.postal-code-invalid' | translate }} - + @if (parentForm.get('zip').hasError('pattern')) { + {{ 'contact.postal-code-invalid' | translate }} + }
- + contact.address - + contact.address2 - + contact.email - - {{ 'user.invalid-email-format' | translate }} - + @if (parentForm.get('email').hasError('email')) { + {{ 'user.invalid-email-format' | translate }} + }
diff --git a/ui-ngx/src/app/shared/components/contact.component.ts b/ui-ngx/src/app/shared/components/contact.component.ts index a05342cfd2..f6d191e9d3 100644 --- a/ui-ngx/src/app/shared/components/contact.component.ts +++ b/ui-ngx/src/app/shared/components/contact.component.ts @@ -18,8 +18,9 @@ import { Component, Input } from '@angular/core'; import { UntypedFormGroup } from '@angular/forms'; @Component({ - selector: 'tb-contact', - templateUrl: './contact.component.html' + selector: 'tb-contact', + templateUrl: './contact.component.html', + standalone: false }) export class ContactComponent { diff --git a/ui-ngx/src/app/shared/components/country-autocomplete.component.html b/ui-ngx/src/app/shared/components/country-autocomplete.component.html index befb160df6..1e3ffecac5 100644 --- a/ui-ngx/src/app/shared/components/country-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/country-autocomplete.component.html @@ -29,38 +29,51 @@ (focusin)="onFocus()" (blur)="checkInputAndAutoSelect()" [matAutocomplete]="countryAutocomplete"> - + @if (countryFormGroup.get('country').value && !disabled) { + + } - - {{country.flag}} - - - -
-
- contact.no-country-found -
- - + @for (country of filteredCountries | async; track country) { + + {{country.flag}} + + + } + @if (!(filteredCountries | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ contact.no-country-found +
+ } @else { + {{ 'contact.no-country-matching' | translate: - {country: (searchText | truncate: true: 6: '...')} }} - - -
-
+ {country: (searchText | truncate: true: 6: '...')} }} +
+ } +
+
+ }
- {{ autocompleteHint }} - - {{ requiredText }} - - - {{ objectRequiredText }} - + @if (autocompleteHint) { + {{ autocompleteHint }} + } + @if (countryFormGroup.get('country').hasError('required')) { + + {{ requiredText }} + + } + @if (countryFormGroup.get('country').hasError('objectRequired')) { + + {{ objectRequiredText }} + + } diff --git a/ui-ngx/src/app/shared/components/country-autocomplete.component.ts b/ui-ngx/src/app/shared/components/country-autocomplete.component.ts index 728d8afcbf..6fa7e71d99 100644 --- a/ui-ngx/src/app/shared/components/country-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/country-autocomplete.component.ts @@ -50,22 +50,23 @@ interface CountrySearchData extends Country { } @Component({ - selector: 'tb-country-autocomplete', - templateUrl: 'country-autocomplete.component.html', - styleUrls: ['./country-autocomplete.component.scss'], - providers: [ - CountryData, - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => CountryAutocompleteComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CountryAutocompleteComponent), - multi: true - } - ] + selector: 'tb-country-autocomplete', + templateUrl: 'country-autocomplete.component.html', + styleUrls: ['./country-autocomplete.component.scss'], + providers: [ + CountryData, + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CountryAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CountryAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class CountryAutocompleteComponent implements OnInit, OnChanges, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/css.component.html b/ui-ngx/src/app/shared/components/css.component.html index 9729b97e06..b0050c974f 100644 --- a/ui-ngx/src/app/shared/components/css.component.html +++ b/ui-ngx/src/app/shared/components/css.component.html @@ -21,9 +21,11 @@
- + @if (!disabled) { + + }
CssComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => CssComponent), - multi: true, - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-css', + templateUrl: './css.component.html', + styleUrls: ['./css.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => CssComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => CssComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class CssComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html index 751665e57f..b3bcad6e3b 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.html @@ -18,11 +18,13 @@ - {{ label }} + class="mat-block" + [floatLabel]="floatLabel" + [appearance]="inlineField ? 'outline' : appearance" + [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> + @if (!inlineField && label) { + {{ label }} + } - - {{ displayDashboardFn(selectDashboardFormGroup.get('dashboard').value) | customTranslate }} - - - - warning - + @if (useDashboardLink && selectDashboardFormGroup.get('dashboard').value && disabled) { + + {{ displayDashboardFn(selectDashboardFormGroup.get('dashboard').value) | customTranslate }} + + } + @if (selectDashboardFormGroup.get('dashboard').value && !disabled) { + + } + @if (inlineField && requiredText && selectDashboardFormGroup.get('dashboard').hasError('required') + && selectDashboardFormGroup.get('dashboard').touched) { + + warning + + } - - - - -
-
- {{ 'dashboard.no-dashboards-text' | translate }} + @for (dashboard of filteredDashboards | async; track dashboard) { + + + + } + @if (!(filteredDashboards | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'dashboard.no-dashboards-text' | translate }} +
+ } @else { + + {{ translate.get('dashboard.no-dashboards-matching', {entity: searchText}) | async }} + + }
- - - {{ translate.get('dashboard.no-dashboards-matching', {entity: searchText}) | async }} - - -
- + + } - - - - - - + @if (!inlineField) { + + + + } + @if (!inlineField) { + + + + } diff --git a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts index ff9c1a728a..74d0a228ad 100644 --- a/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-autocomplete.component.ts @@ -42,14 +42,15 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; @Component({ - selector: 'tb-dashboard-autocomplete', - templateUrl: './dashboard-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DashboardAutocompleteComponent), - multi: true - }] + selector: 'tb-dashboard-autocomplete', + templateUrl: './dashboard-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DashboardAutocompleteComponent), + multi: true + }], + standalone: false }) export class DashboardAutocompleteComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html index b26352b817..2342210dba 100644 --- a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.html @@ -20,9 +20,11 @@ {{'dashboard.select-dashboard' | translate}} - - {{dashboard.title}} - + @for (dashboard of dashboards$ | async; track dashboard) { + + {{dashboard.title}} + + }
diff --git a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts index 1a1bb5e0f7..a30a010401 100644 --- a/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-select-panel.component.ts @@ -27,9 +27,10 @@ export interface DashboardSelectPanelData { } @Component({ - selector: 'tb-dashboard-select-panel', - templateUrl: './dashboard-select-panel.component.html', - styleUrls: ['./dashboard-select-panel.component.scss'] + selector: 'tb-dashboard-select-panel', + templateUrl: './dashboard-select-panel.component.html', + styleUrls: ['./dashboard-select-panel.component.scss'], + standalone: false }) export class DashboardSelectPanelComponent { diff --git a/ui-ngx/src/app/shared/components/dashboard-select.component.html b/ui-ngx/src/app/shared/components/dashboard-select.component.html index f3b278cd59..b617afe9d1 100644 --- a/ui-ngx/src/app/shared/components/dashboard-select.component.html +++ b/ui-ngx/src/app/shared/components/dashboard-select.component.html @@ -20,9 +20,11 @@ [disabled]="disabled" [(ngModel)]="dashboardId" (ngModelChange)="dashboardIdChanged()"> - - {{dashboard.title}} - + @for (dashboard of dashboards$ | async; track dashboard) { + + {{dashboard.title}} + + }
+ @if (selectDashboardStateFormGroup.get('dashboardStateId').value) { + + } - - - + @for (state of filteredStatesDashboard$ | async; track state) { + + + + } - - {{ 'widget-action.target-dashboard-state-required' | translate }} - + @if (selectDashboardStateFormGroup.get('dashboardStateId').hasError('required')) { + + {{ 'widget-action.target-dashboard-state-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts b/ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts index c980b7791b..2d5404244c 100644 --- a/ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/dashboard-state-autocomplete.component.ts @@ -24,18 +24,19 @@ import { Store } from '@ngrx/store'; import { AppState } from '@app/core/core.state'; import { TranslateService } from '@ngx-translate/core'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { FloatLabelType } from '@angular/material/form-field'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; import { DashboardUtilsService } from '@core/services/dashboard-utils.service'; @Component({ - selector: 'tb-dashboard-state-autocomplete', - templateUrl: './dashboard-state-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DashboardStateAutocompleteComponent), - multi: true - }] + selector: 'tb-dashboard-state-autocomplete', + templateUrl: './dashboard-state-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DashboardStateAutocompleteComponent), + multi: true + }], + standalone: false }) export class DashboardStateAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -50,6 +51,9 @@ export class DashboardStateAutocompleteComponent implements ControlValueAccessor @Input() label: string = this.translate.instant('widget-action.target-dashboard-state'); + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() placeholder: string; diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html index 0778e2ecd6..d2e07083f6 100644 --- a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.html @@ -16,12 +16,11 @@ -->

{{data.title}}

- +@if (data.textMode) {
-
- +} @else {
-
+}
diff --git a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts index a9f9f6162e..620baa4f43 100644 --- a/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/alert-dialog.component.ts @@ -29,9 +29,10 @@ export interface AlertDialogData { } @Component({ - selector: 'tb-alert-dialog', - templateUrl: './alert-dialog.component.html', - styleUrls: ['./alert-dialog.component.scss'] + selector: 'tb-alert-dialog', + templateUrl: './alert-dialog.component.html', + styleUrls: ['./alert-dialog.component.scss'], + standalone: false }) export class AlertDialogComponent extends DialogComponent{ constructor(protected store: Store, diff --git a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts index 43c9ab84c5..1ac8a4af24 100644 --- a/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/color-picker-dialog.component.ts @@ -32,9 +32,10 @@ export interface ColorPickerDialogResult { } @Component({ - selector: 'tb-color-picker-dialog', - templateUrl: './color-picker-dialog.component.html', - styleUrls: ['./color-picker-dialog.component.scss'] + selector: 'tb-color-picker-dialog', + templateUrl: './color-picker-dialog.component.html', + styleUrls: ['./color-picker-dialog.component.scss'], + standalone: false }) export class ColorPickerDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts index 617f12ea04..b2f1ed3fe9 100644 --- a/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/confirm-dialog.component.ts @@ -30,9 +30,10 @@ export interface ConfirmDialogData { // @dynamic @Component({ - selector: 'tb-confirm-dialog', - templateUrl: './confirm-dialog.component.html', - styleUrls: ['./confirm-dialog.component.scss'] + selector: 'tb-confirm-dialog', + templateUrl: './confirm-dialog.component.html', + styleUrls: ['./confirm-dialog.component.scss'], + standalone: false }) export class ConfirmDialogComponent extends DialogComponent{ constructor(protected store: Store, diff --git a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts index c1defae012..50d847609f 100644 --- a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts +++ b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts @@ -14,34 +14,48 @@ /// limitations under the License. /// -import { OverlayModule } from '@angular/cdk/overlay'; -import { NgModule } from '@angular/core'; -import { DEFAULT_DIALOG_CONFIG, DialogConfig, DialogModule } from '@angular/cdk/dialog'; +import { Overlay, OverlayContainer, OverlayModule } from '@angular/cdk/overlay'; +import { inject, Injector, NgModule } from '@angular/core'; +import { DEFAULT_DIALOG_CONFIG, Dialog, DialogConfig, DialogModule } from '@angular/cdk/dialog'; import { MatDialogModule } from '@angular/material/dialog'; import { DynamicDialog, DynamicMatDialog } from './dynamic-dialog'; import { DynamicOverlay } from './dynamic-overlay'; -import { DynamicOverlayContainer } from './dynamic-overlay-container'; +import { DynamicOverlayContainer, PARENT_OVERLAY_CONTAINER } from './dynamic-overlay-container'; export const DYNAMIC_MAT_DIALOG_PROVIDERS = [ - DynamicOverlayContainer, - DynamicOverlay, - DynamicDialog, - DynamicMatDialog, { - provide: DEFAULT_DIALOG_CONFIG, - useValue: { - ...new DialogConfig() + provide: DynamicMatDialog, + useFactory: () => { + const parentInjector = inject(Injector); + const parentOverlayContainer = parentInjector.get(OverlayContainer); + + const customInjector = Injector.create({ + providers: [ + { provide: PARENT_OVERLAY_CONTAINER, useValue: parentOverlayContainer }, + DynamicOverlayContainer, + { provide: OverlayContainer, useExisting: DynamicOverlayContainer }, + DynamicOverlay, + { provide: Overlay, useExisting: DynamicOverlay }, + DynamicDialog, + { provide: Dialog, useExisting: DynamicDialog }, + DynamicMatDialog, + { provide: DEFAULT_DIALOG_CONFIG, useValue: new DialogConfig() } + ], + parent: parentInjector + }); + + return customInjector.get(DynamicMatDialog); } } ]; -@NgModule( { +@NgModule({ imports: [ OverlayModule, DialogModule, MatDialogModule ], providers: DYNAMIC_MAT_DIALOG_PROVIDERS -} ) +}) export class DynamicMatDialogModule { } diff --git a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts index 2fdc63f381..df707ad49d 100644 --- a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts +++ b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.ts @@ -14,18 +14,11 @@ /// limitations under the License. /// -import { Location } from "@angular/common"; -import { Inject, Injectable, Injector, Optional, SkipSelf, TemplateRef } from '@angular/core'; -import { - MAT_DIALOG_DEFAULT_OPTIONS, - MAT_DIALOG_SCROLL_STRATEGY, - MatDialog, - MatDialogConfig, MatDialogRef -} from '@angular/material/dialog'; -import { DynamicOverlay } from "./dynamic-overlay"; -import { DynamicOverlayContainer } from '@shared/components/dialog/dynamic/dynamic-overlay-container'; -import { ComponentType, ScrollStrategy } from '@angular/cdk/overlay'; -import { DEFAULT_DIALOG_CONFIG, Dialog, DialogConfig } from '@angular/cdk/dialog'; +import { inject, Injectable, TemplateRef } from '@angular/core'; +import { MatDialog, MatDialogConfig, MatDialogRef } from '@angular/material/dialog'; +import { DynamicOverlay } from './dynamic-overlay'; +import { ComponentType } from '@angular/cdk/overlay'; +import { Dialog } from '@angular/cdk/dialog'; export interface DynamicMatDialogConfig extends MatDialogConfig { containerElement?: HTMLElement; @@ -34,52 +27,23 @@ export interface DynamicMatDialogConfig extends MatDialogConfig { @Injectable() export class DynamicMatDialog extends MatDialog { - private _customOverlay: DynamicOverlay; + private _customOverlay = inject(DynamicOverlay); - constructor( _overlay: DynamicOverlay, - _injector: Injector, - @Optional() location: Location, - @Inject( MAT_DIALOG_DEFAULT_OPTIONS ) _defaultOptions: MatDialogConfig, - @Inject( MAT_DIALOG_SCROLL_STRATEGY ) _scrollStrategy: ScrollStrategy, - @Optional() @SkipSelf() _parentDialog:DynamicMatDialog, - _overlayContainer: DynamicOverlayContainer) { - - super( _overlay, _injector, location, _defaultOptions, _scrollStrategy, _parentDialog, _overlayContainer ); - this._dialog = _injector.get(DynamicDialog); - this._customOverlay = _overlay; - } - - public open(component: ComponentType | TemplateRef, config?: DynamicMatDialogConfig): MatDialogRef { + public override open(component: ComponentType | TemplateRef, config?: DynamicMatDialogConfig): MatDialogRef { if (config?.containerElement) { config.containerElement.style.transform = 'translateZ(0)'; - this._customOverlay.setContainerElement( config.containerElement ); + this._customOverlay.setContainerElement(config.containerElement); } - const ref = super.open(component, config); - if (config?.containerElement) { - ref.afterClosed().subscribe( - { - next: () => { - this._customOverlay.setContainerElement(null); - }, - error: () => { - this._customOverlay.setContainerElement(null); - } - } - ); + try { + return super.open(component, config); + } finally { + if (config?.containerElement) { + this._customOverlay.setContainerElement(null); + } } - return ref; } } @Injectable() export class DynamicDialog extends Dialog { - constructor( _overlay: DynamicOverlay, - _injector: Injector, - @Inject( DEFAULT_DIALOG_CONFIG ) _defaultOptions: DialogConfig, - @Inject( MAT_DIALOG_SCROLL_STRATEGY ) _scrollStrategy: ScrollStrategy, - @Optional() @SkipSelf() _parentDialog: DynamicDialog, - _overlayContainer: DynamicOverlayContainer) { - - super( _overlay, _injector, _defaultOptions, _parentDialog, _overlayContainer, _scrollStrategy ); - } } diff --git a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts index 83feda56fc..99a84f5a6a 100644 --- a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts +++ b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay-container.ts @@ -15,13 +15,21 @@ /// import { OverlayContainer } from "@angular/cdk/overlay"; -import { Injectable } from "@angular/core"; +import { inject, Injectable, InjectionToken } from "@angular/core"; + +export const PARENT_OVERLAY_CONTAINER = new InjectionToken('PARENT_OVERLAY_CONTAINER'); @Injectable() export class DynamicOverlayContainer extends OverlayContainer { - public setContainerElement( containerElement:HTMLElement ):void { + private _globalContainer = inject(PARENT_OVERLAY_CONTAINER); + private _customElement: HTMLElement | null = null; + + public override getContainerElement(): HTMLElement { + return this._customElement || this._globalContainer.getContainerElement(); + } - this._containerElement = containerElement; + setContainerElement(element: HTMLElement | null): void { + this._customElement = element; } -} +} \ No newline at end of file diff --git a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay.ts b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay.ts index 0a809d1c3d..4d5a6836c9 100644 --- a/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay.ts +++ b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-overlay.ts @@ -14,49 +14,16 @@ /// limitations under the License. /// -import { - Overlay, - ScrollStrategyOptions, - OverlayKeyboardDispatcher, OverlayOutsideClickDispatcher, OverlayPositionBuilder -} from '@angular/cdk/overlay'; -import { ComponentFactoryResolver, Inject, Injectable, Injector, NgZone } from '@angular/core'; +import { Overlay } from '@angular/cdk/overlay'; +import { inject, Injectable } from '@angular/core'; import { DynamicOverlayContainer } from './dynamic-overlay-container'; -import { DOCUMENT, Location } from '@angular/common'; -import { Directionality } from '@angular/cdk/bidi'; @Injectable() export class DynamicOverlay extends Overlay { - private _dynamicOverlayContainer: DynamicOverlayContainer; + private _dynamicOverlayContainer = inject(DynamicOverlayContainer); - constructor( scrollStrategies: ScrollStrategyOptions, - _overlayContainer: DynamicOverlayContainer, - _componentFactoryResolver: ComponentFactoryResolver, - _positionBuilder: OverlayPositionBuilder, - _keyboardDispatcher: OverlayKeyboardDispatcher, - _injector: Injector, - _ngZone: NgZone, - @Inject(DOCUMENT) document: Document, - _directionality: Directionality, - _location: Location, - _outsideClickDispatcher: OverlayOutsideClickDispatcher) { - - super( scrollStrategies, - _overlayContainer, - _componentFactoryResolver, - _positionBuilder, - _keyboardDispatcher, - _injector, - _ngZone, - document, - _directionality, - _location, - _outsideClickDispatcher); - - this._dynamicOverlayContainer = _overlayContainer; - } - - public setContainerElement(containerElement:HTMLElement ): void { - this._dynamicOverlayContainer.setContainerElement( containerElement ); + public setContainerElement(containerElement: HTMLElement): void { + this._dynamicOverlayContainer.setContainerElement(containerElement); } } diff --git a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts index 657bf94e8b..b50670e693 100644 --- a/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/entity-conflict-dialog/entity-conflict-dialog.component.ts @@ -18,7 +18,6 @@ import { Component, Inject } from '@angular/core'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { SharedModule } from '@shared/shared.module'; import { ImportExportService } from '@shared/import-export/import-export.service'; -import { CommonModule } from '@angular/common'; import { entityTypeTranslations } from '@shared/models/entity-type.models'; import { EntityInfoData, VersionedEntity } from '@shared/models/entity.models'; import { EntityId } from '@shared/models/id/entity-id'; @@ -30,14 +29,12 @@ interface EntityConflictDialogData { } @Component({ - selector: 'tb-entity-conflict-dialog', - templateUrl: 'entity-conflict-dialog.component.html', - styleUrls: ['./entity-conflict-dialog.component.scss'], - standalone: true, - imports: [ - CommonModule, - SharedModule, - ], + selector: 'tb-entity-conflict-dialog', + templateUrl: 'entity-conflict-dialog.component.html', + styleUrls: ['./entity-conflict-dialog.component.scss'], + imports: [ + SharedModule +] }) export class EntityConflictDialogComponent { diff --git a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts index ccbc3174d7..0ed168bef2 100644 --- a/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/entity-limit-exceeded-dialog.component.ts @@ -34,10 +34,11 @@ export interface EntityLimitExceededDialogData { // @dynamic @Component({ - selector: 'tb-entity-limit-exceeded-dialog', - templateUrl: './entity-limit-exceeded-dialog.component.html', - styleUrls: ['./entity-limit-exceeded-dialog.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-entity-limit-exceeded-dialog', + templateUrl: './entity-limit-exceeded-dialog.component.html', + styleUrls: ['./entity-limit-exceeded-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class EntityLimitExceededDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html index 379a7266db..73c19ea831 100644 --- a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.html @@ -20,10 +20,12 @@
{{ message }}
dialog.error-message-title
{{ errorMessage }}
- - {{ 'dialog.error-details-title' | translate }} - - + @if (errorDetails) { + + {{ 'dialog.error-details-title' | translate }} + + + }
diff --git a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.ts index 6ef547ccbe..e155015c1c 100644 --- a/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/error-alert-dialog.component.ts @@ -29,9 +29,10 @@ export interface ErrorAlertDialogData { } @Component({ - selector: 'tb-error-alert-dialog', - templateUrl: './error-alert-dialog.component.html', - styleUrls: ['./error-alert-dialog.component.scss'] + selector: 'tb-error-alert-dialog', + templateUrl: './error-alert-dialog.component.html', + styleUrls: ['./error-alert-dialog.component.scss'], + standalone: false }) export class ErrorAlertDialogComponent extends DialogComponent{ diff --git a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts index b46a3ad1d2..e70bcacec7 100644 --- a/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/json-object-edit-dialog.component.ts @@ -34,9 +34,10 @@ export interface JsonObjectEditDialogData { } @Component({ - selector: 'tb-object-edit-dialog', - templateUrl: './json-object-edit-dialog.component.html', - styleUrls: [] + selector: 'tb-object-edit-dialog', + templateUrl: './json-object-edit-dialog.component.html', + styleUrls: [], + standalone: false }) export class JsonObjectEditDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts index 32afd3bfe8..a95d7766eb 100644 --- a/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/material-icons-dialog.component.ts @@ -32,10 +32,11 @@ export interface MaterialIconsDialogResult { } @Component({ - selector: 'tb-material-icons-dialog', - templateUrl: './material-icons-dialog.component.html', - providers: [], - styleUrls: ['./material-icons-dialog.component.scss'] + selector: 'tb-material-icons-dialog', + templateUrl: './material-icons-dialog.component.html', + providers: [], + styleUrls: ['./material-icons-dialog.component.scss'], + standalone: false }) export class MaterialIconsDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html index 16ef833ec3..0878443cfa 100644 --- a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.html @@ -34,9 +34,10 @@
-
+
@@ -60,7 +61,8 @@
diff --git a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts index 859a66ea1d..1dc8d05bba 100644 --- a/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/node-script-test-dialog.component.ts @@ -58,11 +58,12 @@ export interface NodeScriptTestDialogData { // @dynamic @Component({ - selector: 'tb-node-script-test-dialog', - templateUrl: './node-script-test-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: NodeScriptTestDialogComponent}], - styleUrls: ['./node-script-test-dialog.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-node-script-test-dialog', + templateUrl: './node-script-test-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: NodeScriptTestDialogComponent }], + styleUrls: ['./node-script-test-dialog.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class NodeScriptTestDialogComponent extends DialogComponent implements OnInit, AfterViewInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts index 4ba35a287b..23e58b14d3 100644 --- a/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/dialog/todo-dialog.component.ts @@ -18,9 +18,10 @@ import { Component } from '@angular/core'; import { MatDialogRef } from '@angular/material/dialog'; @Component({ - selector: 'tb-todo-dialog', - templateUrl: './todo-dialog.component.html', - styleUrls: ['./todo-dialog.component.scss'] + selector: 'tb-todo-dialog', + templateUrl: './todo-dialog.component.html', + styleUrls: ['./todo-dialog.component.scss'], + standalone: false }) export class TodoDialogComponent { constructor(public dialogRef: MatDialogRef) { diff --git a/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts b/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts index 19692f4d38..37c638eb91 100644 --- a/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts +++ b/ui-ngx/src/app/shared/components/directives/component-outlet.directive.ts @@ -26,9 +26,10 @@ import { } from '@angular/core'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[tbComponentOutlet]', - exportAs: 'tbComponentOutlet' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tbComponentOutlet]', + exportAs: 'tbComponentOutlet', + standalone: false }) export class TbComponentOutletDirective<_T = unknown> implements OnChanges { private componentRef: ComponentRef | null = null; diff --git a/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts b/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts index ebd82e58c9..402797507e 100644 --- a/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts +++ b/ui-ngx/src/app/shared/components/directives/sring-template-outlet.directive.ts @@ -26,9 +26,10 @@ import { } from '@angular/core'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[tbStringTemplateOutlet]', - exportAs: 'tbStringTemplateOutlet' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tbStringTemplateOutlet]', + exportAs: 'tbStringTemplateOutlet', + standalone: false }) export class TbStringTemplateOutletDirective<_T = unknown> implements OnChanges { private embeddedViewRef: EmbeddedViewRef | null = null; diff --git a/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts b/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts index 71f86774c6..1646879c5b 100644 --- a/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts +++ b/ui-ngx/src/app/shared/components/directives/tb-json-to-string.directive.ts @@ -29,25 +29,26 @@ import { ErrorStateMatcher } from '@angular/material/core'; import { isObject } from '@core/utils'; @Directive({ - selector: '[tb-json-to-string]', - // eslint-disable-next-line @angular-eslint/no-host-metadata-property - host: { - '(blur)': 'onTouched()' - }, - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TbJsonToStringDirective), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TbJsonToStringDirective), - multi: true, - }, - { - provide: ErrorStateMatcher, - useExisting: TbJsonToStringDirective - }] + selector: '[tb-json-to-string]', + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { + '(blur)': 'onTouched()' + }, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbJsonToStringDirective), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TbJsonToStringDirective), + multi: true, + }, + { + provide: ErrorStateMatcher, + useExisting: TbJsonToStringDirective + }], + standalone: false }) export class TbJsonToStringDirective implements ControlValueAccessor, Validator, ErrorStateMatcher { diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html index 8a576133f9..6624bd5e5d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.html @@ -22,7 +22,9 @@ [appearance]="inlineField ? 'outline' : appearance" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing" [class]="additionalClasses"> - {{ label | translate }} + @if (!inlineField) { + {{ label | translate }} + } - - {{ displayEntityFn(selectEntityFormGroup.get('entity').value) }} - - - - - warning - + @if (showEntityLink) { + + {{ displayEntityFn(selectEntityFormGroup.get('entity').value) }} + + } + @if (selectEntityFormGroup.get('entity').value && !disabled) { + + } + @if (allowCreateNew && !selectEntityFormGroup.get('entity').value && !disabled) { + + } + @if (inlineField && selectEntityFormGroup.get('entity').hasError('required') + && selectEntityFormGroup.get('entity').touched) { + + warning + + } - - - - -
-
- {{ notFoundEntities | translate }} -
- - - {{ noEntitiesMatchingText | translate: {entity: searchText} }} - - @if (allowCreateNew) { + @for (entity of filteredEntities | async; track entity) { + + + + } + @if (!(filteredEntities | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ notFoundEntities | translate }} +
+ } @else { - entity.create-new-key + {{ noEntitiesMatchingText | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + } } - -
-
+
+
+ }
- - {{ requiredErrorText | translate }} - + @if (!inlineField && selectEntityFormGroup.get('entity').hasError('required')) { + + {{ requiredErrorText | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts index 26f4530431..7e7e381d0f 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-autocomplete.component.ts @@ -31,14 +31,15 @@ import { getEntityDetailsPageURL, isDefinedAndNotNull, isEqual } from '@core/uti import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-entity-autocomplete', - templateUrl: './entity-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityAutocompleteComponent), - multi: true - }] + selector: 'tb-entity-autocomplete', + templateUrl: './entity-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityAutocompleteComponent), + multi: true + }], + standalone: false }) export class EntityAutocompleteComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html index f143fd227c..1b4e58c21e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.html @@ -23,34 +23,43 @@ (keydown)="gatewayNameEnter($event)" (keypress)="gatewayNameEnter($event)" [matAutocomplete]="gatewayAutocomplete" - > - + > + @if (selectDeviceGatewayFormGroup.get('gateway').value && !isStateForm) { + + } - - - - -
-
- gateway.no-gateway-found -
- - - gateway.no-gateway-matching + @for (gateway of filteredGateways | async; track gateway) { + + + + } + @if (!(filteredGateways | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ gateway.no-gateway-found +
+ } @else { + + gateway.no-gateway-matching + + } + + gateway.create-new-gateway - - - gateway.create-new-gateway - -
-
+
+
+ }
- - {{ 'gateway.gateway-name-required' | translate }} - + @if (selectDeviceGatewayFormGroup.get('gateway').hasError('required')) { + + {{ 'gateway.gateway-name-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts index 3c1f452113..e71e6c8d89 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-gateway-select.component.ts @@ -32,14 +32,15 @@ import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; @Component({ - selector: 'tb-entity-gateway-select', - templateUrl: './entity-gateway-select.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityGatewaySelectComponent), - multi: true - }] + selector: 'tb-entity-gateway-select', + templateUrl: './entity-gateway-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityGatewaySelectComponent), + multi: true + }], + standalone: false }) export class EntityGatewaySelectComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html index defcb90725..c6f55849f9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.html @@ -43,7 +43,7 @@ @for (key of filteredKeys$ | async; track key) { } @empty { - @if (!this.keyControl.value && enableAutocomplete) { + @if (!keyControl.value && enableAutocomplete) { {{ 'entity.no-keys-found' | translate }} } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts index 6aef1d513d..ab8f5f241c 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts @@ -45,20 +45,21 @@ import { isEqual } from '@core/utils'; import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: 'tb-entity-key-autocomplete', - templateUrl: './entity-key-autocomplete.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityKeyAutocompleteComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => EntityKeyAutocompleteComponent), - multi: true - } - ], + selector: 'tb-entity-key-autocomplete', + templateUrl: './entity-key-autocomplete.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityKeyAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Validator, OnChanges { @@ -139,8 +140,10 @@ export class EntityKeyAutocompleteComponent implements ControlValueAccessor, Val changes.dataKeyType.currentValue !== changes.dataKeyType.previousValue; if (filterChanged || keyScopeChanged || keyTypeChanged) { - this.keyControl.setValue('', {emitEvent: false}); this.cachedResult = null; + if (!this.keyControl.disabled) { + this.keyControl.setValue('', {emitEvent: false}); + } } } diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html index f48bf48804..8ecebe7d0d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.html @@ -15,15 +15,21 @@ limitations under the License. --> - - - - {{key}} - close - + + @if (label) { + {{ label | translate }} +} + + @for (key of modelValue; track key) { + + {{key}} + @if (!disabled) { + close + } + + } - - - + @for (key of filteredKeys | async; track key) { + + + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts index afbd6144d1..5aa5cf3eb5 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-keys-list.component.ts @@ -29,18 +29,20 @@ import { MatChipInputEvent, MatChipGrid } from '@angular/material/chips'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { DataKeyType } from '@shared/models/telemetry/telemetry.models'; import { isEqual } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-entity-keys-list', - templateUrl: './entity-keys-list.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityKeysListComponent), - multi: true - } - ] + selector: 'tb-entity-keys-list', + templateUrl: './entity-keys-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityKeysListComponent), + multi: true + } + ], + standalone: false }) export class EntityKeysListComponent implements ControlValueAccessor, OnInit, AfterViewInit { @@ -61,9 +63,15 @@ export class EntityKeysListComponent implements ControlValueAccessor, OnInit, Af @Input() keysText: string; + @Input() + label: string; + @Input() dataKeyType: DataKeyType; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html index 0469adca74..034f118b4d 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.html @@ -16,27 +16,32 @@ -->
- - - - + @if (displayEntityTypeSelect) { + + + } + @if (modelValue.entityType) { + + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts index 9d209739bf..1bf532af2e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list-select.component.ts @@ -21,6 +21,7 @@ import { EntityService } from '@core/http/entity.service'; import { EntityId } from '@shared/models/id/entity-id'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isDefinedAndNotNull } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; interface EntityListSelectModel { entityType: EntityType | AliasEntityType; @@ -28,14 +29,15 @@ interface EntityListSelectModel { } @Component({ - selector: 'tb-entity-list-select', - templateUrl: './entity-list-select.component.html', - styleUrls: ['./entity-list-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityListSelectComponent), - multi: true - }] + selector: 'tb-entity-list-select', + templateUrl: './entity-list-select.component.html', + styleUrls: ['./entity-list-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListSelectComponent), + multi: true + }], + standalone: false }) export class EntityListSelectComponent implements ControlValueAccessor, OnInit { @@ -71,6 +73,9 @@ export class EntityListSelectComponent implements ControlValueAccessor, OnInit { @Input({transform: booleanAttribute}) useEntityDisplayName = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + displayEntityTypeSelect: boolean; private defaultEntityType: EntityType | AliasEntityType = null; diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-list.component.html index 589019b9fa..8781908d82 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.html @@ -21,16 +21,21 @@ [class.tb-chips]="inlineField" [class.flex]="inlineField" [subscriptSizing]="inlineField ? 'dynamic' : subscriptSizing"> - {{ labelText }} + @if (!inlineField && labelText) { + {{ labelText }} + } - - {{ displayEntityFn(entity) }} - close - + @for (entity of entities; track entity) { + + {{ displayEntityFn(entity) }} + @if (!disabled) { + close + } + + } - + @if (allowCreateNew && !disabled) { + + } - - - - -
-
- {{ 'entity.no-entities-text' | translate }} -
- - - {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} - - @if (allowCreateNew) { + @for (entity of filteredEntities | async; track entity) { + + + + } + @if (!(filteredEntities | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'entity.no-entities-text' | translate }} +
+ } @else { - entity.create-new-key + {{ 'entity.no-entities-matching' | translate: {entity: searchText} }} + @if (allowCreateNew) { + + entity.create-new-key + + } } - -
-
+
+
+ }
- - {{ hint }} - - - {{ requiredText }} - + @if (!inlineField && hint) { + + {{ hint }} + + } + @if (!inlineField && entityListFormGroup.get('entities').hasError('required')) { + + {{ requiredText }} + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts index 6f31dcb948..af0b0a654f 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-list.component.ts @@ -50,21 +50,22 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { isArray } from 'lodash'; @Component({ - selector: 'tb-entity-list', - templateUrl: './entity-list.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityListComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => EntityListComponent), - multi: true - } - ] + selector: 'tb-entity-list', + templateUrl: './entity-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityListComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => EntityListComponent), + multi: true + } + ], + standalone: false }) export class EntityListComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 1cb0a5b5f1..e76db03f77 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.html @@ -16,28 +16,30 @@ -->
- - - - + @if (displayEntityTypeSelect) { + + + } + @if (modelValue.entityType && !entityTypeNullUUID.has(modelValue.entityType)) { + + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts index 62acb20ae5..0b7271966e 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-select.component.ts @@ -30,14 +30,15 @@ import { BaseData } from '@shared/models/base-data'; import { EntityAutocompleteComponent } from '@shared/components/entity/entity-autocomplete.component'; @Component({ - selector: 'tb-entity-select', - templateUrl: './entity-select.component.html', - styleUrls: ['./entity-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntitySelectComponent), - multi: true - }] + selector: 'tb-entity-select', + templateUrl: './entity-select.component.html', + styleUrls: ['./entity-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySelectComponent), + multi: true + }], + standalone: false }) export class EntitySelectComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html index 942ef0632b..117742515b 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.html @@ -23,24 +23,32 @@ (focusin)="onFocus()" [required]="required" [matAutocomplete]="subTypeAutocomplete"> - + @if (subTypeFormGroup.get('subType').value && !disabled) { + + } - - - + @for (subType of filteredSubTypes | async; track subType) { + + + + } - - {{ entitySubtypeRequiredText | translate }} - - - {{ entitySubtypeMaxLength | translate }} - + @if (subTypeFormGroup.get('subType').hasError('required')) { + + {{ entitySubtypeRequiredText | translate }} + + } + @if (subTypeFormGroup.get('subType').hasError('maxlength')) { + + {{ entitySubtypeMaxLength | translate }} + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts index 90959c379c..4fdfb33994 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-autocomplete.component.ts @@ -36,14 +36,15 @@ import { MatFormFieldAppearance } from '@angular/material/form-field'; import { EntityService } from '@core/http/entity.service'; @Component({ - selector: 'tb-entity-subtype-autocomplete', - templateUrl: './entity-subtype-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntitySubTypeAutocompleteComponent), - multi: true - }] + selector: 'tb-entity-subtype-autocomplete', + templateUrl: './entity-subtype-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeAutocompleteComponent), + multi: true + }], + standalone: false }) export class EntitySubTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html index b6320bacf7..261ecea281 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.html @@ -18,16 +18,21 @@ - {{ label }} + @if (label) { + {{ label }} + } - - {{customTranslate(entitySubtype)}} - close - + @for (entitySubtype of entitySubtypeList; track entitySubtype) { + + {{customTranslate(entitySubtype)}} + @if (!disabled) { + close + } + + } - - - + @for (entitySubtype of filteredEntitySubtypeList | async; track entitySubtype) { + + + + }
- - {{ subtypeListEmptyText | translate }} - + @if (entitySubtypeListFormGroup.get('entitySubtypeList').hasError('required')) { + + {{ subtypeListEmptyText | translate }} + + }
diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts index ca579f1ac0..fb855de0b4 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-list.component.ts @@ -35,16 +35,17 @@ import { CalculatedFieldType } from "@shared/models/calculated-field.models"; import { CalculatedFieldsService } from "@core/http/calculated-fields.service"; @Component({ - selector: 'tb-entity-subtype-list', - templateUrl: './entity-subtype-list.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntitySubTypeListComponent), - multi: true - } - ] + selector: 'tb-entity-subtype-list', + templateUrl: './entity-subtype-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeListComponent), + multi: true + } + ], + standalone: false }) export class EntitySubTypeListComponent implements ControlValueAccessor, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html index f9fa081c4d..1f62aa4f7b 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.html @@ -16,13 +16,19 @@ --> - {{ entitySubtypeTitle | translate }} + @if (showLabel) { + {{ entitySubtypeTitle | translate }} + } - - {{ displaySubTypeFn(subType) }} - + @for (subType of subTypesOptions | async; track subType) { + + {{ displaySubTypeFn(subType) }} + + } - - {{ entitySubtypeRequiredText | translate }} - + @if (subTypeFormGroup.get('subType').hasError('required')) { + + {{ entitySubtypeRequiredText | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts index 5e21a34ddc..9cd3dce2c3 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-subtype-select.component.ts @@ -29,14 +29,15 @@ import { EntityService } from '@core/http/entity.service'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-entity-subtype-select', - templateUrl: './entity-subtype-select.component.html', - styleUrls: ['./entity-subtype-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntitySubTypeSelectComponent), - multi: true - }] + selector: 'tb-entity-subtype-select', + templateUrl: './entity-subtype-select.component.html', + styleUrls: ['./entity-subtype-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntitySubTypeSelectComponent), + multi: true + }], + standalone: false }) export class EntitySubTypeSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html index 15b4c6ae5e..f929e34199 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.html @@ -20,15 +20,20 @@ class="mat-block" [class]="additionalClasses" [floatLabel]="floatLabel" subscriptSizing="{{ subscriptSizing }}"> - {{ label }} + @if (label) { + {{ label }} + } - - {{entityType.name}} - close - + @for (entityType of entityTypeList; track entityType) { + + {{entityType.name}} + @if (!disabled) { + close + } + + } - - - - - + @for (entityType of filteredEntityTypeList | async; track entityType) { + + + + } + @if (!(filteredEntityTypeList | async)?.length) { + + {{ translate.get('entity.no-entity-types-matching', {entityType: searchText}) | async }} - - + + + }
- - {{ 'entity.entity-type-list-required' | translate }} - + @if (entityTypeListFormGroup.get('entityTypeList').hasError('required')) { + + {{ 'entity.entity-type-list-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts index 7d9d6c7ac4..4f857859d9 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-list.component.ts @@ -34,16 +34,17 @@ interface EntityTypeInfo { } @Component({ - selector: 'tb-entity-type-list', - templateUrl: './entity-type-list.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityTypeListComponent), - multi: true - } - ] + selector: 'tb-entity-type-list', + templateUrl: './entity-type-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityTypeListComponent), + multi: true + } + ], + standalone: false }) export class EntityTypeListComponent implements ControlValueAccessor, OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index 65e3615887..190eff28e1 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html @@ -18,13 +18,22 @@ - {{ label }} + @if (showLabel && !inlineField) { + {{ label }} + } - - {{ displayEntityTypeFn(type) }} - + @for (type of entityTypes; track type) { + + {{ displayEntityTypeFn(type) }} + + } - - {{ 'entity.type-required' | translate }} - + @if (!inlineField && subscriptSizing === 'dynamic') { + + } + @if (!inlineField && entityTypeFormGroup.get('entityType').hasError('required')) { + + {{ 'entity.type-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts index 53e1d59cc1..99d3a83c64 100644 --- a/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.ts @@ -24,14 +24,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-entity-type-select', - templateUrl: './entity-type-select.component.html', - styleUrls: ['./entity-type-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => EntityTypeSelectComponent), - multi: true - }] + selector: 'tb-entity-type-select', + templateUrl: './entity-type-select.component.html', + styleUrls: ['./entity-type-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => EntityTypeSelectComponent), + multi: true + }], + standalone: false }) export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, OnChanges { @@ -172,9 +173,9 @@ export class EntityTypeSelectComponent implements ControlValueAccessor, OnInit, } } - displayEntityTypeFn(entityType?: EntityType | AliasEntityType | null): string | undefined { - if (this.additionEntityTypes[entityType]) { - return this.additionEntityTypes[entityType]; + displayEntityTypeFn(entityType?: EntityType | AliasEntityType | string | null): string | undefined { + if (this.additionEntityTypes[entityType as EntityType]) { + return this.additionEntityTypes[entityType as EntityType]; } else if (entityType) { return this.translate.instant(entityTypeTranslations.get(entityType as EntityType).type); } else { diff --git a/ui-ngx/src/app/shared/components/fab-toolbar.component.ts b/ui-ngx/src/app/shared/components/fab-toolbar.component.ts index 10082102e1..3a7696c17b 100644 --- a/ui-ngx/src/app/shared/components/fab-toolbar.component.ts +++ b/ui-ngx/src/app/shared/components/fab-toolbar.component.ts @@ -28,19 +28,20 @@ import { ViewEncapsulation } from '@angular/core'; import { WINDOW } from '@core/services/window.service'; -import { _Constructor, CanColor, mixinColor, ThemePalette } from '@angular/material/core'; +import { ThemePalette } from '@angular/material/core'; export declare type FabToolbarDirection = 'left' | 'right'; class MatFabToolbarBase { + // eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle, id-blacklist, id-match constructor(public _elementRef: ElementRef) {} } -const MatFabToolbarMixinBase: _Constructor & typeof MatFabToolbarBase = mixinColor(MatFabToolbarBase); @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: 'mat-fab-trigger' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'mat-fab-trigger', + standalone: false }) export class FabTriggerDirective { @@ -50,8 +51,9 @@ export class FabTriggerDirective { } @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: 'mat-fab-actions' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'mat-fab-actions', + standalone: false }) export class FabActionsDirective implements OnInit { @@ -68,13 +70,17 @@ export class FabActionsDirective implements OnInit { // @dynamic @Component({ - // eslint-disable-next-line @angular-eslint/component-selector - selector: 'mat-fab-toolbar', - templateUrl: './fab-toolbar.component.html', - styleUrls: ['./fab-toolbar.component.scss'], - encapsulation: ViewEncapsulation.None + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'mat-fab-toolbar', + templateUrl: './fab-toolbar.component.html', + styleUrls: ['./fab-toolbar.component.scss'], + host: { + '[class]': 'color ? "mat-" + color : ""', + }, + encapsulation: ViewEncapsulation.None, + standalone: false }) -export class FabToolbarComponent extends MatFabToolbarMixinBase implements OnInit, OnDestroy, AfterViewInit, OnChanges { +export class FabToolbarComponent extends MatFabToolbarBase implements OnInit, OnDestroy, AfterViewInit, OnChanges { private fabToolbarResize$: ResizeObserver; @@ -87,6 +93,9 @@ export class FabToolbarComponent extends MatFabToolbarMixinBase implements OnIni @Input() color: ThemePalette; + @Input() + defaultColor: ThemePalette | undefined; + constructor(private el: ElementRef, @Inject(WINDOW) private window: Window) { super(el); diff --git a/ui-ngx/src/app/shared/components/file-input.component.html b/ui-ngx/src/app/shared/components/file-input.component.html index 86e1d4c7e5..44b5442fac 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.html +++ b/ui-ngx/src/app/shared/components/file-input.component.html @@ -16,11 +16,13 @@ -->
- + @if (label) { + + }
@@ -43,7 +45,7 @@ {{ dropLabel }}
- -
{{ noFileText }}
-
{{ fileName }}
-
dashboard.maximum-upload-file-size
+ @if (!fileName && required && requiredAsError) { + + } + @if (!fileName && !requiredAsError) { +
{{ noFileText }}
+ } + @if (fileName) { +
{{ fileName }}
+ } + @if (maxSizeByte && !disabled) { +
dashboard.maximum-upload-file-size
+ }
diff --git a/ui-ngx/src/app/shared/components/file-input.component.ts b/ui-ngx/src/app/shared/components/file-input.component.ts index 9fa293de05..727b1628dc 100644 --- a/ui-ngx/src/app/shared/components/file-input.component.ts +++ b/ui-ngx/src/app/shared/components/file-input.component.ts @@ -32,7 +32,7 @@ import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { Subscription } from 'rxjs'; -import { FlowDirective } from '@flowjs/ngx-flow'; +import { FlowConfig } from '@flowjs/ngx-flow'; import { TranslateService } from '@ngx-translate/core'; import { UtilsService } from '@core/services/utils.service'; import { DialogService } from '@core/services/dialog.service'; @@ -40,16 +40,17 @@ import { FileSizePipe } from '@shared/pipe/file-size.pipe'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-file-input', - templateUrl: './file-input.component.html', - styleUrls: ['./file-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FileInputComponent), - multi: true - } - ] + selector: 'tb-file-input', + templateUrl: './file-input.component.html', + styleUrls: ['./file-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FileInputComponent), + multi: true + } + ], + standalone: false }) export class FileInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor, OnChanges { @@ -139,7 +140,7 @@ export class FileInputComponent extends PageComponent implements AfterViewInit, mediaType: string; @ViewChild('flow', {static: true}) - flow: FlowDirective; + flow: FlowConfig; @ViewChild('flowInput', {static: true}) flowInput: ElementRef; diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html index 1ea0f1d6da..d2d408e9ff 100644 --- a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.html @@ -26,14 +26,16 @@ {{ footerFabButtons.fabTogglerIcon }}
- + @for (btn of buttons; track btn) { + + }
diff --git a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts index b0d6f03318..810fa4c97b 100644 --- a/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts +++ b/ui-ngx/src/app/shared/components/footer-fab-buttons.component.ts @@ -34,10 +34,11 @@ export interface FooterFabButtons { } @Component({ - selector: 'tb-footer-fab-buttons', - templateUrl: './footer-fab-buttons.component.html', - styleUrls: ['./footer-fab-buttons.component.scss'], - animations: speedDialFabAnimations + selector: 'tb-footer-fab-buttons', + templateUrl: './footer-fab-buttons.component.html', + styleUrls: ['./footer-fab-buttons.component.scss'], + animations: speedDialFabAnimations, + standalone: false }) export class FooterFabButtonsComponent extends PageComponent { diff --git a/ui-ngx/src/app/shared/components/footer.component.ts b/ui-ngx/src/app/shared/components/footer.component.ts index 3ec45e08ed..3a80bcbd01 100644 --- a/ui-ngx/src/app/shared/components/footer.component.ts +++ b/ui-ngx/src/app/shared/components/footer.component.ts @@ -17,9 +17,10 @@ import { Component } from '@angular/core'; @Component({ - selector: 'tb-footer', - templateUrl: './footer.component.html', - styleUrls: ['./footer.component.scss'] + selector: 'tb-footer', + templateUrl: './footer.component.html', + styleUrls: ['./footer.component.scss'], + standalone: false }) export class FooterComponent { diff --git a/ui-ngx/src/app/shared/components/fullscreen.directive.ts b/ui-ngx/src/app/shared/components/fullscreen.directive.ts index 9d1162cac7..99ff3f6b39 100644 --- a/ui-ngx/src/app/shared/components/fullscreen.directive.ts +++ b/ui-ngx/src/app/shared/components/fullscreen.directive.ts @@ -32,8 +32,9 @@ import { TbAnchorComponent } from '@shared/components/tb-anchor.component'; import { DomSanitizer, SafeStyle } from '@angular/platform-browser'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[tb-fullscreen]' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tb-fullscreen]', + standalone: false }) export class FullscreenDirective implements OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/grid/scroll-grid.component.html b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.html index d83594e0b7..ef57d199dc 100644 --- a/ui-ngx/src/app/shared/components/grid/scroll-grid.component.html +++ b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.html @@ -18,28 +18,31 @@ -
-
- - - - - - + @if (itemsRow) { +
+ @for (item of itemsRow; track trackByItem(col, item); let col = $index) { +
+ @if (item === 'loadingCell') { + + } + @if (isObject(item)) { + + } +
+ }
-
+ } - +@if (dataSource.isEmpty) { - +} - + @if (dataSource.initialDataLoading) { - - - - + } @else { + + }
diff --git a/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts index 33646befe6..6c7106cef9 100644 --- a/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts +++ b/ui-ngx/src/app/shared/components/grid/scroll-grid.component.ts @@ -43,10 +43,11 @@ export interface ItemSizeStrategy { } @Component({ - selector: 'tb-scroll-grid', - templateUrl: './scroll-grid.component.html', - styleUrls: ['./scroll-grid.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-scroll-grid', + templateUrl: './scroll-grid.component.html', + styleUrls: ['./scroll-grid.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ScrollGridComponent implements OnInit, AfterViewInit, OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/help-markdown.component.ts b/ui-ngx/src/app/shared/components/help-markdown.component.ts index 181c762409..37f3ed37c1 100644 --- a/ui-ngx/src/app/shared/components/help-markdown.component.ts +++ b/ui-ngx/src/app/shared/components/help-markdown.component.ts @@ -28,9 +28,10 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { base64toString } from '@core/utils'; @Component({ - selector: 'tb-help-markdown', - templateUrl: './help-markdown.component.html', - styleUrls: ['./help-markdown.component.scss'] + selector: 'tb-help-markdown', + templateUrl: './help-markdown.component.html', + styleUrls: ['./help-markdown.component.scss'], + standalone: false }) export class HelpMarkdownComponent implements OnDestroy, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/help-popup.component.html b/ui-ngx/src/app/shared/components/help-popup.component.html index bec404b063..65edb22cc3 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.html +++ b/ui-ngx/src/app/shared/components/help-popup.component.html @@ -15,44 +15,54 @@ limitations under the License. --> -
-
- -
-
-
-
- +
+
+} +@if (textMode) { +
+
+ -
-
+ + @if (triggerSafeHtml) { + + } + @if ((!popoverVisible || popoverReady)) { + open_in_new + } + @if (popoverVisible && !popoverReady) { + + } + + +
+
+} diff --git a/ui-ngx/src/app/shared/components/help-popup.component.ts b/ui-ngx/src/app/shared/components/help-popup.component.ts index b94688061b..2a2b80ed4c 100644 --- a/ui-ngx/src/app/shared/components/help-popup.component.ts +++ b/ui-ngx/src/app/shared/components/help-popup.component.ts @@ -34,10 +34,11 @@ import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; @Component({ - selector: '[tb-help-popup], [tb-help-popup-content], [tb-help-popup-content-base64], [tb-help-popup-async-content]', - templateUrl: './help-popup.component.html', - styleUrls: ['./help-popup.component.scss'], - encapsulation: ViewEncapsulation.None + selector: '[tb-help-popup], [tb-help-popup-content], [tb-help-popup-content-base64], [tb-help-popup-async-content]', + templateUrl: './help-popup.component.html', + styleUrls: ['./help-popup.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class HelpPopupComponent implements OnChanges { diff --git a/ui-ngx/src/app/shared/components/help.component.ts b/ui-ngx/src/app/shared/components/help.component.ts index 5c034bd1b5..fc6de0072a 100644 --- a/ui-ngx/src/app/shared/components/help.component.ts +++ b/ui-ngx/src/app/shared/components/help.component.ts @@ -18,8 +18,9 @@ import { Component, Input } from '@angular/core'; import { HelpLinks } from '@shared/models/constants'; @Component({ - selector: '[tb-help]', - templateUrl: './help.component.html' + selector: '[tb-help]', + templateUrl: './help.component.html', + standalone: false }) export class HelpComponent { diff --git a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html index ae84c5633f..f2f0983975 100644 --- a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html +++ b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.html @@ -16,7 +16,8 @@ --> -{{ hintIcon }} +@if (tooltipText) { + {{ hintIcon }} +} diff --git a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.ts b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.ts index 4e742dd150..9a80893053 100644 --- a/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.ts +++ b/ui-ngx/src/app/shared/components/hint-tooltip-icon.component.ts @@ -18,9 +18,10 @@ import { Component, HostBinding, Input } from '@angular/core'; import { TooltipPosition } from '@angular/material/tooltip'; @Component({ - selector: '[tb-hint-tooltip-icon]', - templateUrl: './hint-tooltip-icon.component.html', - styleUrls: ['./hint-tooltip-icon.component.scss'] + selector: '[tb-hint-tooltip-icon]', + templateUrl: './hint-tooltip-icon.component.html', + styleUrls: ['./hint-tooltip-icon.component.scss'], + standalone: false }) export class HintTooltipIconComponent { diff --git a/ui-ngx/src/app/shared/components/hotkeys.directive.ts b/ui-ngx/src/app/shared/components/hotkeys.directive.ts index e556eca931..e6740c9705 100644 --- a/ui-ngx/src/app/shared/components/hotkeys.directive.ts +++ b/ui-ngx/src/app/shared/components/hotkeys.directive.ts @@ -21,8 +21,9 @@ import Mousetrap from 'mousetrap'; import { TbCheatSheetComponent } from '@shared/components/cheatsheet.component'; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector : '[tb-hotkeys]' + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tb-hotkeys]', + standalone: false }) export class TbHotkeysDirective implements OnInit, OnDestroy { @Input('tb-hotkeys') hotkeys: Hotkey[] = []; diff --git a/ui-ngx/src/app/shared/components/html.component.html b/ui-ngx/src/app/shared/components/html.component.html index f17272bf71..6520340374 100644 --- a/ui-ngx/src/app/shared/components/html.component.html +++ b/ui-ngx/src/app/shared/components/html.component.html @@ -21,9 +21,11 @@
- + @if (!disabled) { + + }
HtmlComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => HtmlComponent), - multi: true, - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-html', + templateUrl: './html.component.html', + styleUrls: ['./html.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => HtmlComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => HtmlComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class HtmlComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/icon.component.ts b/ui-ngx/src/app/shared/components/icon.component.ts index cd26ecca89..405dc0f370 100644 --- a/ui-ngx/src/app/shared/components/icon.component.ts +++ b/ui-ngx/src/app/shared/components/icon.component.ts @@ -14,15 +14,15 @@ /// limitations under the License. /// -import { CanColor, mixinColor } from '@angular/material/core'; +import { ThemePalette } from '@angular/material/core'; import { AfterContentInit, AfterViewChecked, ChangeDetectionStrategy, Component, ElementRef, - ErrorHandler, - Inject, + ErrorHandler, inject, + Inject, Input, OnDestroy, Renderer2, ViewChild, @@ -37,12 +37,6 @@ import { isTbImage } from '@shared/models/resource.models'; import { ImagePipe } from '@shared/pipe/image.pipe'; import { DomSanitizer } from '@angular/platform-browser'; -const _TbIconBase = mixinColor( - class { - constructor(public _elementRef: ElementRef) {} - }, -); - const funcIriAttributes = [ 'clip-path', 'color-profile', @@ -63,30 +57,42 @@ const funcIriAttributeSelector = funcIriAttributes.map(attr => `[${attr}]`).join const funcIriPattern = /^url\(['"]?#(.*?)['"]?\)$/; @Component({ - template: '', - selector: 'tb-icon', - exportAs: 'tbIcon', - styleUrls: [], - // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property - inputs: ['color'], - // eslint-disable-next-line @angular-eslint/no-host-metadata-property - host: { - role: 'img', - class: 'mat-icon notranslate', - '[attr.data-mat-icon-type]': '_useSvgIcon ? "svg" : (_useImageIcon ? null : "font")', - '[attr.data-mat-icon-name]': '_svgName', - '[attr.data-mat-icon-namespace]': '_svgNamespace', - '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', - }, - encapsulation: ViewEncapsulation.None, - changeDetection: ChangeDetectionStrategy.OnPush, + template: '', + selector: 'tb-icon', + exportAs: 'tbIcon', + styleUrls: [], + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { + role: 'img', + class: 'mat-icon notranslate', + '[class]': 'color ? "mat-" + color : ""', + '[attr.data-mat-icon-type]': '_useSvgIcon ? "svg" : (_useImageIcon ? null : "font")', + '[attr.data-mat-icon-name]': '_svgName', + '[attr.data-mat-icon-namespace]': '_svgNamespace', + '[class.mat-icon-no-color]': 'color !== "primary" && color !== "accent" && color !== "warn"', + }, + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) -export class TbIconComponent extends _TbIconBase - implements AfterContentInit, AfterViewChecked, CanColor, OnDestroy { +export class TbIconComponent implements AfterContentInit, AfterViewChecked, OnDestroy { @ViewChild('iconNameContent', {static: true}) _iconNameContent: ElementRef; + readonly _elementRef = inject>(ElementRef); + + private _defaultColor: ThemePalette; + + @Input() + get color() { + return this._color || this._defaultColor; + } + set color(value: string | null | undefined) { + this._color = value; + } + private _color: string | null | undefined; + private icon: string; get viewValue(): string { @@ -111,15 +117,13 @@ export class TbIconComponent extends _TbIconBase private _currentIconFetch = Subscription.EMPTY; - constructor(elementRef: ElementRef, - private contentObserver: ContentObserver, + constructor(private contentObserver: ContentObserver, private renderer: Renderer2, private _iconRegistry: MatIconRegistry, private imagePipe: ImagePipe, private sanitizer: DomSanitizer, @Inject(MAT_ICON_LOCATION) private _location: MatIconLocation, private readonly _errorHandler: ErrorHandler) { - super(elementRef); } ngAfterContentInit(): void { diff --git a/ui-ngx/src/app/shared/components/image-input.component.html b/ui-ngx/src/app/shared/components/image-input.component.html index 99fae6649c..6171d0d8be 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.html +++ b/ui-ngx/src/app/shared/components/image-input.component.html @@ -16,55 +16,74 @@ -->
- + @if (label) { + + }
-
-
-
{{ 'dashboard.empty-image' | translate }}
- -
- -
-
-
- cloud_upload -
- image-input.drag-and-drop -
- image-input.or - - -
+ @if (showPreview && (safeImageUrl || disabled)) { +
+
+ @if (!safeImageUrl && disabled) { +
{{ 'dashboard.empty-image' | translate }}
+ } @else { + + }
-
-
- -
+ close + + } +
+ } + @if (!disabled) { +
+
+ cloud_upload +
+ image-input.drag-and-drop +
+ image-input.or + + +
+
+
+
+ } + @if (!showPreview && safeImageUrl && showClearButton && !disabled) { + + } +
-
-
{{ fileName }}
-
dashboard.maximum-upload-file-size
-
+ @if ((showFileName && fileName) || (maxSizeByte && !disabled)) { +
+ @if (showFileName && fileName) { +
{{ fileName }}
+ } + @if (maxSizeByte && !disabled) { +
dashboard.maximum-upload-file-size
+ } +
+ }
diff --git a/ui-ngx/src/app/shared/components/image-input.component.ts b/ui-ngx/src/app/shared/components/image-input.component.ts index 172d5d139d..c41e622787 100644 --- a/ui-ngx/src/app/shared/components/image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image-input.component.ts @@ -30,7 +30,7 @@ import { AppState } from '@core/core.state'; import { ControlValueAccessor, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { Subscription } from 'rxjs'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { FlowDirective } from '@flowjs/ngx-flow'; +import { FlowConfig } from '@flowjs/ngx-flow'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { UtilsService } from '@core/services/utils.service'; import { DialogService } from '@core/services/dialog.service'; @@ -40,16 +40,17 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { ImagePipe } from '@shared/pipe/image.pipe'; @Component({ - selector: 'tb-image-input', - templateUrl: './image-input.component.html', - styleUrls: ['./image-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ImageInputComponent), - multi: true - } - ] + selector: 'tb-image-input', + templateUrl: './image-input.component.html', + styleUrls: ['./image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ImageInputComponent), + multi: true + } + ], + standalone: false }) export class ImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { @@ -115,7 +116,7 @@ export class ImageInputComponent extends PageComponent implements AfterViewInit, safeImageUrl: SafeUrl; @ViewChild('flow', {static: true}) - flow: FlowDirective; + flow: FlowConfig; autoUploadSubscription: Subscription; diff --git a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html index cf6a054af4..c5a1a749ef 100644 --- a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.html @@ -24,36 +24,45 @@ close - - -
+@if (isLoading$ | async) { + + +} @else { +
+}
-
- - - -
- -
{{ 'image.embed-to-html' | translate }}
-
-
-
image.embed-to-html
-
-
- -
- -
-
-
+ @if (!readonly || image.public) { +
+ + + + @if (!readonly) { +
+ +
{{ 'image.embed-to-html' | translate }}
+
+
+ } + @if (readonly) { +
image.embed-to-html
+ } +
+
+ +
+ +
+
+
+ }
image.embed-to-angular-template
+ [data]="embedToAngularTemplateCode()">
diff --git a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts index 7169acf0dc..312a1865e6 100644 --- a/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/embed-image-dialog.component.ts @@ -31,9 +31,10 @@ export interface EmbedImageDialogData { } @Component({ - selector: 'tb-embed-image-dialog', - templateUrl: './embed-image-dialog.component.html', - styleUrls: ['./embed-image-dialog.component.scss'] + selector: 'tb-embed-image-dialog', + templateUrl: './embed-image-dialog.component.html', + styleUrls: ['./embed-image-dialog.component.scss'], + standalone: false }) export class EmbedImageDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html index 2ebe68d897..be6344ffae 100644 --- a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.html @@ -16,79 +16,95 @@ -->
- + @if (label) { + + }
- + @if (linkType !== ImageLinkType.none && imageUrl) { + + } @else { +
{{ (disabled ? 'image.no-image' : 'image.no-image-selected') | translate }}
+ }
-
-
-
-
- -
- {{ imageResource.title }} + @if (linkType !== ImageLinkType.none && (linkType !== ImageLinkType.base64 || !disabled)) { +
+ @if (linkType === ImageLinkType.base64) { +
-
-
{{ imageResource.descriptor.width }}x{{ imageResource.descriptor.height }}
- -
{{ imageResource.descriptor.size | fileSize }}
+ } + @if (linkType === ImageLinkType.resource) { +
+ @if (!loadingImageResource) { +
+ {{ imageResource.title }} +
+
+
{{ imageResource.descriptor.width }}x{{ imageResource.descriptor.height }}
+ +
{{ imageResource.descriptor.size | fileSize }}
+
+ } @else { + + }
- -
-
- - +
+ } + @if (!disabled && linkType !== ImageLinkType.external) { + -
+ }
- -
-
- - -
+ } + @if (linkType === ImageLinkType.none && !disabled) { +
+ + +
+ }
- -
{{ (disabled ? 'image.no-image' : 'image.no-image-selected') | translate }}
-
- - - diff --git a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts index 421d0ec559..b485de772f 100644 --- a/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image/gallery-image-input.component.ts @@ -45,16 +45,17 @@ export enum ImageLinkType { } @Component({ - selector: 'tb-gallery-image-input', - templateUrl: './gallery-image-input.component.html', - styleUrls: ['./gallery-image-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GalleryImageInputComponent), - multi: true - } - ] + selector: 'tb-gallery-image-input', + templateUrl: './gallery-image-input.component.html', + styleUrls: ['./gallery-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GalleryImageInputComponent), + multi: true + } + ], + standalone: false }) export class GalleryImageInputComponent extends PageComponent implements OnInit, OnDestroy, ControlValueAccessor { @@ -171,9 +172,9 @@ export class GalleryImageInputComponent extends PageComponent implements OnInit, } } - private updateModel(value: string) { + private updateModel(value: string, forcedToUpdate = false): void { this.cd.markForCheck(); - if (this.imageUrl !== value) { + if (this.imageUrl !== value || forcedToUpdate) { this.imageUrl = value; this.propagateChange(prependTbImagePrefix(this.imageUrl)); } @@ -211,9 +212,10 @@ export class GalleryImageInputComponent extends PageComponent implements OnInit, } }).afterClosed().subscribe((image) => { if (image) { + const forcedToUpdate = this.imageUrl === image.link && this.imageResource?.descriptor?.etag !== image.descriptor.etag; this.linkType = ImageLinkType.resource; this.imageResource = image; - this.updateModel(image.link); + this.updateModel(image.link, forcedToUpdate); } }); } diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-dialog.component.html index 0eefce39cf..0fe1a4be59 100644 --- a/ui-ngx/src/app/shared/components/image/image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.html @@ -25,36 +25,45 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + image.name - - {{ 'image.name-required' | translate }} - - - + @if (imageFormGroup.get('title').hasError('required')) { + + {{ 'image.name-required' | translate }} + + } + @if (!readonly && imageFormGroup.dirty) { + + } + @if (!readonly && !imageFormGroup.invalid && imageFormGroup.dirty) { + + }
@@ -82,14 +91,16 @@ code
- + @if (!readonly) { + + }
diff --git a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts index b0a24ab244..0e93daf60f 100644 --- a/ui-ngx/src/app/shared/components/image/image-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-dialog.component.ts @@ -37,9 +37,10 @@ export interface ImageDialogData { } @Component({ - selector: 'tb-image-dialog', - templateUrl: './image-dialog.component.html', - styleUrls: ['./image-dialog.component.scss'] + selector: 'tb-image-dialog', + templateUrl: './image-dialog.component.html', + styleUrls: ['./image-dialog.component.scss'], + standalone: false }) export class ImageDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts index 34c04fa806..f1a666a0dd 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-gallery-dialog.component.ts @@ -27,9 +27,10 @@ export interface ImageGalleryDialogData { } @Component({ - selector: 'tb-image-gallery-dialog', - templateUrl: './image-gallery-dialog.component.html', - styleUrls: ['./image-gallery-dialog.component.scss'] + selector: 'tb-image-gallery-dialog', + templateUrl: './image-gallery-dialog.component.html', + styleUrls: ['./image-gallery-dialog.component.scss'], + standalone: false }) export class ImageGalleryDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index f442556b18..8e4bd5a294 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.html +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.html @@ -24,7 +24,9 @@
{{ (isScada ? 'scada.symbols' : 'image.gallery') | translate }}
-
+ @if (isScada) { +
+ }
@@ -44,10 +46,12 @@
- {{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }} + @if (!isSysAdmin) { + {{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }} + }
@@ -74,10 +78,10 @@ mdi:file-import
- {{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }} + @if (!isSysAdmin) { + {{ (isScada ? 'scada.include-system-symbols' : 'image.include-system-images') | translate }} + }
@@ -132,200 +138,208 @@
-
-
- - - - - - - - - - - - - - -
- {{ image.title }} -
-
-
- - {{ 'image.name' | translate }} - - {{ image.title }} - - - - {{ 'image.created-time' | translate }} - - {{ image.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} - - - - {{ 'image.resolution' | translate }} - - {{ image.descriptor.width }}x{{ image.descriptor.height }} - - - - {{ 'image.size' | translate }} - - {{ image.descriptor.size | fileSize }} - - - - {{ 'image.system' | translate }} - - {{isSystem(image) ? 'check_box' : 'check_box_outline_blank'}} - - - - - - -
- - - - - -
-
- - -
+ + + + + + + + + + + + + +
+ {{ image.title }} +
+
+
+ + {{ 'image.name' | translate }} + + {{ image.title }} + + + + {{ 'image.created-time' | translate }} + + {{ image.createdTime | date:'yyyy-MM-dd HH:mm:ss' }} + + + + {{ 'image.resolution' | translate }} + + {{ image.descriptor.width }}x{{ image.descriptor.height }} + + + + {{ 'image.size' | translate }} + + {{ image.descriptor.size | fileSize }} + + + + {{ 'image.system' | translate }} + + {{isSystem(image) ? 'check_box' : 'check_box_outline_blank'}} + + + + + + +
+ - - - + } + - - -
-
-
- - - - - - - - -
- - - - {{ 'common.loading' | translate }} +
+
+ + + + + @if (!isScada) { + + } + + + +
+ + + + + + + + + + + + @if ((dataSource.isEmpty() | async) && !dataSource.dataLoading) { + + } + {{ 'common.loading' | translate }} +
+ +
- - -
-
- - -
+ } + @if (mode === 'grid') { +
+ + +
+ }
- - {{ 'common.loading' | translate }} - + + {{ 'common.loading' | translate }} +
@@ -337,49 +351,57 @@
-
- - - - -
+ @if (!selectionMode) { +
+ + + @if (!isScada) { + + } + @if (deleteEnabled(item)) { + + } +
+ }
- - + @if (!selectionMode) { + + } + @if (selectionMode) { + + }
{{ item.title }} @@ -389,7 +411,9 @@
{{ item.title }}
-
sys
+ @if (isSystem(item)) { +
sys
+ }
{{ item.descriptor.width }}x{{ item.descriptor.height }}
diff --git a/ui-ngx/src/app/shared/components/image/image-gallery.component.ts b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts index ef3f8cc510..c2f0b23e22 100644 --- a/ui-ngx/src/app/shared/components/image/image-gallery.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-gallery.component.ts @@ -100,10 +100,11 @@ const dialogGridColumns: ScrollGridColumns = { }; @Component({ - selector: 'tb-image-gallery', - templateUrl: './image-gallery.component.html', - styleUrls: ['./image-gallery.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-image-gallery', + templateUrl: './image-gallery.component.html', + styleUrls: ['./image-gallery.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class ImageGalleryComponent extends PageComponent implements OnInit, OnDestroy, AfterViewInit { diff --git a/ui-ngx/src/app/shared/components/image/image-references.component.html b/ui-ngx/src/app/shared/components/image/image-references.component.html index 32ce614dc2..421dac3caf 100644 --- a/ui-ngx/src/app/shared/components/image/image-references.component.html +++ b/ui-ngx/src/app/shared/components/image/image-references.component.html @@ -15,40 +15,45 @@ limitations under the License. --> - +@if (simpleList) { - - - +} @else { + @if (contentReady) {
    - -
  • -
    {{ 'image.system-entities' | translate }}
    - -
  • - + @for (entry of referencedEntitiesEntries; track entry) { + @if (isSystem(entry[0])) { +
  • +
    {{ 'image.system-entities' | translate }}
    + +
  • + } @else {
  • {{ 'tenant.tenant' | translate }} {{ entry[1].tenantName }} {{ 'image.entities' | translate }}
  • -
    -
    + } + }
-
-
+ } @else { + + } +} - - - - + @for (referencedEntity of entriesList; track referencedEntity) { + + + + + }
{{ referencedEntity.typeName }} - {{ referencedEntity.entity.name }} - {{ referencedEntity.entity.name }} -
{{ referencedEntity.typeName }} + @if (showDetailsLink) { + {{ referencedEntity.entity.name }} + } + @if (!showDetailsLink) { + {{ referencedEntity.entity.name }} + } +
- - - diff --git a/ui-ngx/src/app/shared/components/image/image-references.component.ts b/ui-ngx/src/app/shared/components/image/image-references.component.ts index f6c25042bc..d4776903f7 100644 --- a/ui-ngx/src/app/shared/components/image/image-references.component.ts +++ b/ui-ngx/src/app/shared/components/image/image-references.component.ts @@ -47,9 +47,10 @@ type ReferencedEntities = {[tenantId: string]: TenantReferencedEntities}; type ReferencedEntitiesEntry = [string, TenantReferencedEntities]; @Component({ - selector: 'tb-image-references', - templateUrl: './image-references.component.html', - styleUrls: ['./image-references.component.scss'] + selector: 'tb-image-references', + templateUrl: './image-references.component.html', + styleUrls: ['./image-references.component.scss'], + standalone: false }) export class ImageReferencesComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html index 3108e6a67c..9844484987 100644 --- a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html +++ b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.html @@ -16,95 +16,114 @@ -->
- + @if (label) { + + }
-
- {{ 'image-input.images' | translate }} [{{ $index }}] -
-
- drag_indicator -
-
- -
-
- + @for (imageUrl of imageUrls; track imageUrl) { +
+ {{ 'image-input.images' | translate }} [{{ $index }}] +
+ @if (!disabled) { +
+ drag_indicator +
+ } +
+ +
+ @if (!disabled) { +
+ +
+ }
-
-
{{ 'image-input.no-images' | translate }}
+ } + @if (!imageUrls.length) { +
{{ 'image-input.no-images' | translate }}
+ }
-
-
- -
-
-
- + } + @if (linkType === ImageLinkType.none) { +
+ -
-
+ }
-
- - -
-
+ }
- -
{{ 'image.no-image-selected' | translate }}
-
diff --git a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts index 52ed0331d9..9ba83ee8ec 100644 --- a/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts +++ b/ui-ngx/src/app/shared/components/image/multiple-gallery-image-input.component.ts @@ -37,16 +37,17 @@ import { } from '@shared/components/image/image-gallery-dialog.component'; @Component({ - selector: 'tb-multiple-gallery-image-input', - templateUrl: './multiple-gallery-image-input.component.html', - styleUrls: ['./multiple-gallery-image-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MultipleGalleryImageInputComponent), - multi: true - } - ] + selector: 'tb-multiple-gallery-image-input', + templateUrl: './multiple-gallery-image-input.component.html', + styleUrls: ['./multiple-gallery-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MultipleGalleryImageInputComponent), + multi: true + } + ], + standalone: false }) export class MultipleGalleryImageInputComponent extends PageComponent implements OnDestroy, ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.html b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.html index abaa800acf..99068df58e 100644 --- a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.html +++ b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.html @@ -16,50 +16,62 @@ -->
- + @if (label) { + + }
- + @if (linkType !== ScadaSymbolLinkType.none && scadaSymbolUrl) { + + } @else { +
{{ (disabled ? 'scada.no-symbol' : 'scada.no-symbol-selected') | translate }}
+ }
-
-
- -
- {{ imageResource.title | customTranslate }} + @if (linkType !== ScadaSymbolLinkType.none && !disabled) { +
+ @if (linkType === ScadaSymbolLinkType.resource) { +
+ @if (!loadingImageResource) { +
+ {{ imageResource.title | customTranslate }} +
+ } @else { + + }
- + } + @if (linkType === ScadaSymbolLinkType.content) { +
+
+ {{ scadaSymbolMetadata.title | customTranslate }} +
+
+ } + @if (linkType === ScadaSymbolLinkType.resource && !disabled) { + + }
-
-
- {{ scadaSymbolMetadata.title | customTranslate }} -
+ } + @if (linkType === ScadaSymbolLinkType.none && !disabled) { +
+
- -
-
- -
+ }
- -
{{ (disabled ? 'scada.no-symbol' : 'scada.no-symbol-selected') | translate }}
-
- - - diff --git a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts index 724547d389..2b732b4b2d 100644 --- a/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts +++ b/ui-ngx/src/app/shared/components/image/scada-symbol-input.component.ts @@ -44,16 +44,17 @@ export enum ScadaSymbolLinkType { } @Component({ - selector: 'tb-scada-symbol-input', - templateUrl: './scada-symbol-input.component.html', - styleUrls: ['./scada-symbol-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ScadaSymbolInputComponent), - multi: true - } - ] + selector: 'tb-scada-symbol-input', + templateUrl: './scada-symbol-input.component.html', + styleUrls: ['./scada-symbol-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ScadaSymbolInputComponent), + multi: true + } + ], + standalone: false }) export class ScadaSymbolInputComponent extends PageComponent implements OnInit, OnDestroy, ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html index 61da3149b9..3e7b85268a 100644 --- a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html @@ -25,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- - image.name - - - {{ 'image.name-required' | translate }} - - + @if (uploadImage && uploadImageFormGroup.get('file').value) { + + image.name + + @if (uploadImageFormGroup.get('title').hasError('required')) { + + {{ 'image.name-required' | translate }} + + } + + }
diff --git a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts index 1e242342ef..13e5e378b8 100644 --- a/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.ts @@ -54,10 +54,11 @@ export interface UploadImageDialogResult { } @Component({ - selector: 'tb-upload-image-dialog', - templateUrl: './upload-image-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: UploadImageDialogComponent}], - styleUrls: [] + selector: 'tb-upload-image-dialog', + templateUrl: './upload-image-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: UploadImageDialogComponent }], + styleUrls: [], + standalone: false }) export class UploadImageDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/shared/components/js-func-module-row.component.html b/ui-ngx/src/app/shared/components/js-func-module-row.component.html index 26a8f4645b..5e6463ddd5 100644 --- a/ui-ngx/src/app/shared/components/js-func-module-row.component.html +++ b/ui-ngx/src/app/shared/components/js-func-module-row.component.html @@ -18,14 +18,15 @@
- - warning - + @if (moduleRowFormGroup.get('alias').invalid && moduleRowFormGroup.get('alias').touched) { + + warning + + }
-
-
+
+ help-icon-tooltip="{{ 'js-func.show-module-source-code' | translate }}">
-
-
- - + @if (modulesFormArray().controls.length) { +
+ @for (moduleControl of modulesFormArray().controls; track trackByModule($index, moduleControl)) { +
+ + +
+ }
-
- + } @else { + {{ 'js-func.no-modules' | translate }} + } + @if (modulesFormGroup.hasError('moduleAliasNotUnique')) { + + }
- - {{ 'js-func.no-modules' | translate }} - diff --git a/ui-ngx/src/app/shared/components/js-func-modules.component.ts b/ui-ngx/src/app/shared/components/js-func-modules.component.ts index f09b469ab2..c4cf374741 100644 --- a/ui-ngx/src/app/shared/components/js-func-modules.component.ts +++ b/ui-ngx/src/app/shared/components/js-func-modules.component.ts @@ -38,10 +38,11 @@ const modulesValidator: ValidatorFn = control => { }; @Component({ - selector: 'tb-js-func-modules', - templateUrl: './js-func-modules.component.html', - styleUrls: ['./js-func-modules.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-js-func-modules', + templateUrl: './js-func-modules.component.html', + styleUrls: ['./js-func-modules.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class JsFuncModulesComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/js-func.component.html b/ui-ngx/src/app/shared/components/js-func.component.html index e5d663152d..6cd69d6301 100644 --- a/ui-ngx/src/app/shared/components/js-func.component.html +++ b/ui-ngx/src/app/shared/components/js-func.component.html @@ -19,29 +19,37 @@ tb-fullscreen [fullscreen]="fullscreen">
- + @if (!hideLabel) { + + } -
-
- -
-
- + @if (!disabled && withModules) { +
+
+ +
+
+ } + @if (!disabled) { + + } -
+ @if (helpId) { +
+ }
-
- -
+ @if (!hideBrackets) { +
+ +
+ }
diff --git a/ui-ngx/src/app/shared/components/js-func.component.ts b/ui-ngx/src/app/shared/components/js-func.component.ts index 54c830f9b5..f23285cc0d 100644 --- a/ui-ngx/src/app/shared/components/js-func.component.ts +++ b/ui-ngx/src/app/shared/components/js-func.component.ts @@ -53,22 +53,23 @@ import { catchError } from 'rxjs/operators'; import { tbelUtilsAutocompletes, tbelUtilsFuncHighlightRules } from '@shared/models/ace/tbel-utils.models'; @Component({ - selector: 'tb-js-func', - templateUrl: './js-func.component.html', - styleUrls: ['./js-func.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => JsFuncComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => JsFuncComponent), - multi: true, - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-js-func', + templateUrl: './js-func.component.html', + styleUrls: ['./js-func.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsFuncComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsFuncComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class JsFuncComponent implements OnInit, OnChanges, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/json-content.component.html b/ui-ngx/src/app/shared/components/json-content.component.html index 67bdbf732c..665c78d17a 100644 --- a/ui-ngx/src/app/shared/components/json-content.component.html +++ b/ui-ngx/src/app/shared/components/json-content.component.html @@ -18,28 +18,34 @@
-
- - - - -
-
- -
-
-
+ } + @if (!readonly && !disabled) { + + } +
+
+ +
+
+
+ }
diff --git a/ui-ngx/src/app/shared/components/json-content.component.ts b/ui-ngx/src/app/shared/components/json-content.component.ts index 52308fbe90..9ca37e52e0 100644 --- a/ui-ngx/src/app/shared/components/json-content.component.ts +++ b/ui-ngx/src/app/shared/components/json-content.component.ts @@ -40,22 +40,23 @@ import { beautifyJs } from '@shared/models/beautify.models'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-json-content', - templateUrl: './json-content.component.html', - styleUrls: ['./json-content.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => JsonContentComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => JsonContentComponent), - multi: true, - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-json-content', + templateUrl: './json-content.component.html', + styleUrls: ['./json-content.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => JsonContentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => JsonContentComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class JsonContentComponent implements OnInit, ControlValueAccessor, Validator, OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/json-object-edit.component.html b/ui-ngx/src/app/shared/components/json-object-edit.component.html index 3c18ae38d6..13d2c5fa8a 100644 --- a/ui-ngx/src/app/shared/components/json-object-edit.component.html +++ b/ui-ngx/src/app/shared/components/json-object-edit.component.html @@ -24,14 +24,18 @@ 'tb-readonly': readonly, 'tb-error': !objectValid}">{{ label }} - - + @if (!readonly && !disabled) { + + } + @if (!readonly && !disabled) { + + } -
+ @for (keyValControl of keyValsFormArray().controls; track keyValControl) { +
+ + + + + + + +
+ } {{noDataText ? noDataText : 'key-val.no-data'}} + class="no-data-found flex items-center justify-center" translate>{{noDataText ? noDataText : 'key-val.no-data'}}
+ mat-button (click)="toggleEditMode()">{{ 'markdown.edit' | translate }} -
+ mat-button (click)="toggleEditMode()">{{ 'markdown.preview' | translate }} + @if (helpId) { +
+ }
-
- -
+ @if (readonly || disabled) { +
+ +
+ }
diff --git a/ui-ngx/src/app/shared/components/markdown-editor.component.ts b/ui-ngx/src/app/shared/components/markdown-editor.component.ts index 1f9dca4344..72d9c9064b 100644 --- a/ui-ngx/src/app/shared/components/markdown-editor.component.ts +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.ts @@ -32,17 +32,18 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { CancelAnimationFrame, RafService } from '@core/services/raf.service'; @Component({ - selector: 'tb-markdown-editor', - templateUrl: './markdown-editor.component.html', - styleUrls: ['./markdown-editor.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MarkdownEditorComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-markdown-editor', + templateUrl: './markdown-editor.component.html', + styleUrls: ['./markdown-editor.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MarkdownEditorComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, OnDestroy { @@ -54,6 +55,8 @@ export class MarkdownEditorComponent implements OnInit, ControlValueAccessor, On @Input() helpId: string; + @Input() helpPopupStyle: { [klass: string]: any } = {}; + @Input() @coerceBoolean() required: boolean; diff --git a/ui-ngx/src/app/shared/components/markdown-sanitize.helper.ts b/ui-ngx/src/app/shared/components/markdown-sanitize.helper.ts new file mode 100644 index 0000000000..942cc257f3 --- /dev/null +++ b/ui-ngx/src/app/shared/components/markdown-sanitize.helper.ts @@ -0,0 +1,258 @@ +/// +/// Copyright © 2016-2026 The Thingsboard Authors +/// +/// Licensed under the Apache License, Version 2.0 (the "License"); +/// you may not use this file except in compliance with the License. +/// You may obtain a copy of the License at +/// +/// http://www.apache.org/licenses/LICENSE-2.0 +/// +/// Unless required by applicable law or agreed to in writing, software +/// distributed under the License is distributed on an "AS IS" BASIS, +/// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +/// See the License for the specific language governing permissions and +/// limitations under the License. +/// + +/** Block keywords: @keyword (...) { body } */ +const BLOCK_KEYWORDS = [ + 'if', 'else if', 'else', 'for', 'empty', 'switch', 'case', + 'default', 'defer', 'placeholder', 'loading', 'error', +]; + +/** Inline keywords: @keyword identifier = expr; */ +const INLINE_KEYWORDS = ['let']; + +/** Chain keywords that can follow a closing '}' */ +const CHAIN_KEYWORDS = [ + 'else if', 'else', 'empty', 'error', 'placeholder', 'loading', +]; + +/** Validation: inline keyword → regex that must match after keyword */ +const INLINE_VALIDATORS: Record = { + let: /^\s*[a-zA-Z_$][a-zA-Z0-9_$]*\s*=/, +}; + +/** HTML entities decoded in preserved blocks (order matters: & must be last) */ +const HTML_ENTITIES: [RegExp, string][] = [ + [/'/g, "'"], + [/"/g, '"'], + [/</g, '<'], + [/>/g, '>'], + [/&/g, '&'], +]; + +/** Matches any known @keyword at position 0 */ +const KEYWORD_RE = new RegExp( + `@(${[...BLOCK_KEYWORDS, ...INLINE_KEYWORDS] + .sort((a, b) => b.length - a.length).join('|')})(?=[\\s(;{])`, +); + +/** Matches a chain keyword after '}' */ +const CHAIN_RE = new RegExp( + `^\\s*@(${CHAIN_KEYWORDS + .sort((a, b) => b.length - a.length).join('|')})(?=[\\s(;{])`, +); + +// ─── Public API ────────────────────────────────────────────────────── + +/** + * Sanitize a markdown-compiled HTML template for safe Angular compilation. + * + * Escapes `{`, `}`, `@` that are not part of Angular control flow, + * preserves recognized control flow blocks (@if, @for, @switch, @let, @defer …), + * decodes HTML entities inside them, and protects `
` code blocks.
+ */
+export function sanitizeTemplate(template: string): string {
+  // Phase 1 — protect zones that must not be processed
+  const protectedZones: string[] = [];
+  template = extractProtectedZones(template, protectedZones);
+
+  // Phase 2 — extract Angular control flow blocks
+  const blocks: string[] = [];
+  template = extractControlFlowBlocks(template, blocks);
+
+  // Phase 3 — decode HTML entities inside control flow blocks
+  decodeHtmlEntities(blocks);
+
+  // Phase 4 — escape Angular-sensitive chars in remaining text
+  template = escapeAngularChars(template);
+
+  // Phase 5 — restore control flow blocks (as-is)
+  template = restoreBlocks(template, '__NG_BLOCK_', blocks);
+
+  // Phase 6 — restore protected zones (with escaping)
+  template = restoreBlocks(template, '__PROTECTED_', protectedZones, true);
+
+  return template;
+}
+
+// ─── Phase helpers ───────────────────────────────────────────────────
+
+function extractProtectedZones(template: string, zones: string[]): string {
+  return template.replace(//g, match => {
+    zones.push(match);
+    return `__PROTECTED_${zones.length - 1}__`;
+  });
+}
+
+function decodeHtmlEntities(blocks: string[]): void {
+  for (let i = 0; i < blocks.length; i++) {
+    for (const [pattern, replacement] of HTML_ENTITIES) {
+      blocks[i] = blocks[i].replace(pattern, replacement);
+    }
+  }
+}
+
+function escapeAngularChars(text: string): string {
+  return text.replace(/{/g, '{').replace(/}/g, '}').replace(/@/g, '@');
+}
+
+function restoreBlocks(template: string, prefix: string, blocks: string[], escape = false): string {
+  let restored = '';
+  let searchStart = 0;
+  for (let i = 0; i < blocks.length; i++) {
+    const token = `${prefix}${i}__`;
+    const tokenIndex = template.indexOf(token, searchStart);
+    if (tokenIndex === -1) continue;
+    const content = escape ? escapeAngularChars(blocks[i]) : blocks[i];
+    restored += template.slice(searchStart, tokenIndex) + content;
+    searchStart = tokenIndex + token.length;
+  }
+  restored += template.slice(searchStart);
+  return restored;
+}
+
+// ─── Control flow extraction ─────────────────────────────────────────
+
+function extractControlFlowBlocks(template: string, blocks: string[]): string {
+  let result = '';
+  let i = 0;
+  while (i < template.length) {
+    if (template[i] !== '@') {
+      result += template[i++];
+      continue;
+    }
+    const extracted = tryExtractInline(template, i)
+                   ?? tryExtractBlock(template, i);
+    if (extracted) {
+      blocks.push(extracted.content);
+      result += `__NG_BLOCK_${blocks.length - 1}__`;
+      i = extracted.end;
+    } else {
+      result += template[i++];
+    }
+  }
+  return result;
+}
+
+/** Try to extract an inline statement: @let identifier = expr; */
+function tryExtractInline(template: string, pos: number): { content: string; end: number } | null {
+  const match = matchKeywordAt(template, pos);
+  if (!match || !INLINE_KEYWORDS.includes(match.keyword)) {
+    return null;
+  }
+  const validator = INLINE_VALIDATORS[match.keyword];
+  if (validator && !validator.test(template.substring(pos + match.raw.length))) {
+    return null;
+  }
+  const semicolonIdx = findStatementSemicolon(template, pos);
+  if (semicolonIdx === -1) {
+    return null;
+  }
+  return { content: template.substring(pos, semicolonIdx + 1), end: semicolonIdx + 1 };
+}
+
+/** Try to extract a block statement: @keyword (...) { body } with optional chains */
+function tryExtractBlock(template: string, start: number): { content: string; end: number } | null {
+  let i = start;
+  const len = template.length;
+
+  while (i < len) {
+    const match = matchKeywordAt(template, i);
+    if (!match) {
+      return null;
+    }
+    i += match.raw.length;
+
+    // optional parenthesized params
+    i = skipWhitespace(template, i);
+    if (i < len && template[i] === '(') {
+      const parenEnd = findMatchingPair(template, i, '(', ')');
+      if (parenEnd === -1) return null;
+      i = skipWhitespace(template, parenEnd + 1);
+    }
+
+    // required { body }
+    if (i >= len || template[i] !== '{') return null;
+    const braceEnd = findMatchingPair(template, i, '{', '}');
+    if (braceEnd === -1) return null;
+    i = braceEnd + 1;
+
+    // check for chain keyword (@else, @empty, etc.)
+    const chainMatch = template.substring(i).match(CHAIN_RE);
+    if (chainMatch) {
+      i += chainMatch[0].indexOf('@');
+      continue;
+    }
+
+    return { content: template.substring(start, i), end: i };
+  }
+  return null;
+}
+
+// ─── Low-level helpers ───────────────────────────────────────────────
+
+function matchKeywordAt(template: string, pos: number): { keyword: string; raw: string } | null {
+  const remaining = template.substring(pos);
+  const m = KEYWORD_RE.exec(remaining);
+  return m && m.index === 0 ? { keyword: m[1], raw: m[0] } : null;
+}
+
+function skipWhitespace(str: string, pos: number): number {
+  while (pos < str.length && (str[pos] === ' ' || str[pos] === '\n' || str[pos] === '\r' || str[pos] === '\t')) {
+    pos++;
+  }
+  return pos;
+}
+
+function findMatchingPair(template: string, start: number, open: string, close: string): number {
+  let depth = 0;
+  return scanOutsideStrings(template, start, pos => {
+    if (template[pos] === open) depth++;
+    else if (template[pos] === close) {
+      if (--depth === 0) return pos;
+    }
+  });
+}
+
+/** Find the real statement-terminating ; (skipping ; inside HTML entities and entity-encoded strings) */
+function findStatementSemicolon(template: string, start: number): number {
+  return scanOutsideStrings(template, start, pos => {
+    if (template[pos] === ';') {
+      const before = template.substring(Math.max(start, pos - 12), pos);
+      if (!/&[a-zA-Z0-9#]+$/.test(before)) return pos;
+    }
+  });
+}
+
+/** Scan template skipping entity-encoded strings ("...", '...'), call handler for each char outside strings */
+function scanOutsideStrings(template: string, start: number, onChar: (pos: number) => number): number {
+  const ENTITY_QUOTES = ['"', '''];
+  let inString: string | null = null;
+  let i = start;
+  while (i < template.length) {
+    if (!inString) {
+      const quote = ENTITY_QUOTES.find(q => template.startsWith(q, i));
+      if (quote) { inString = quote; i += quote.length; continue; }
+    } else if (template.startsWith(inString, i)) {
+      i += inString.length; inString = null; continue;
+    }
+    if (!inString) {
+      const result = onChar(i);
+      if (result !== undefined) return result;
+    }
+    i++;
+  }
+  return -1;
+}
diff --git a/ui-ngx/src/app/shared/components/markdown.component.html b/ui-ngx/src/app/shared/components/markdown.component.html
index 62709f4f90..203900dd96 100644
--- a/ui-ngx/src/app/shared/components/markdown.component.html
+++ b/ui-ngx/src/app/shared/components/markdown.component.html
@@ -17,10 +17,12 @@
 -->
 
 
-
- {{error}} -
+ {{error}} +
+}
diff --git a/ui-ngx/src/app/shared/components/markdown.component.ts b/ui-ngx/src/app/shared/components/markdown.component.ts index 36005fee3e..e7d9df1eb3 100644 --- a/ui-ngx/src/app/shared/components/markdown.component.ts +++ b/ui-ngx/src/app/shared/components/markdown.component.ts @@ -38,13 +38,15 @@ import { SHARED_MODULE_TOKEN } from '@shared/components/tokens'; import { guid, isDefinedAndNotNull } from '@core/utils'; import { Observable, of, ReplaySubject } from 'rxjs'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { sanitizeTemplate } from './markdown-sanitize.helper'; let defaultMarkdownStyle: string; @Component({ - selector: 'tb-markdown', - templateUrl: './markdown.component.html', - styleUrls: ['./markdown.component.scss'] + selector: 'tb-markdown', + templateUrl: './markdown.component.html', + styleUrls: ['./markdown.component.scss'], + standalone: false }) export class TbMarkdownComponent implements OnChanges { @@ -113,7 +115,7 @@ export class TbMarkdownComponent implements OnChanges { } private render(markdown: string) { - const compiled = this.markdownService.parse(markdown, { decodeHtml: false }); + const compiled = this.markdownService.parse(markdown, { decodeHtml: false, disableSanitizer: true }); let markdownClass = 'tb-markdown-view'; if (this.markdownClass) { markdownClass += ` ${this.markdownClass}`; @@ -132,7 +134,7 @@ export class TbMarkdownComponent implements OnChanges { const preHtml = preElements.item(i).outerHTML.replace('ngnonbindable=""', 'ngNonBindable'); template = template.replace(matches[i][0], preHtml); } - template = this.sanitize(template); + template = sanitizeTemplate(template); this.markdownContainer.clear(); let styles: string[] = []; let readyObservable: Observable; @@ -266,10 +268,6 @@ export class TbMarkdownComponent implements OnChanges { } } - private sanitize(template: string): string { - return template.replace(/{/g, '{').replace(/}/g, '}').replace(/@/g, '@'); - } - private destroyMarkdownInstanceResources() { if (this.tbMarkdownInstanceComponentType) { this.dynamicComponentFactoryService.destroyDynamicComponent(this.tbMarkdownInstanceComponentType); diff --git a/ui-ngx/src/app/shared/components/marked-options.service.ts b/ui-ngx/src/app/shared/components/marked-options.service.ts index faa05ac0a6..9e6328b059 100644 --- a/ui-ngx/src/app/shared/components/marked-options.service.ts +++ b/ui-ngx/src/app/shared/components/marked-options.service.ts @@ -15,17 +15,17 @@ /// import { MarkedOptions, MarkedRenderer } from 'ngx-markdown'; -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, DOCUMENT } from '@angular/core'; import { TranslateService } from '@ngx-translate/core'; -import { DOCUMENT } from '@angular/common'; + import { WINDOW } from '@core/services/window.service'; -import { Tokens, marked, TokenizerObject } from 'marked'; +import { Parser, Tokens, marked, TokenizerObject } from 'marked'; import { Clipboard } from '@angular/cdk/clipboard'; const copyCodeBlock = '{:copy-code}'; const codeStyleRegex = '^{:code-style="(.*)"}\n'; const autoBlock = '{:auto}'; -const targetBlankBlock = '{:target="_blank"}'; +const targetBlankBlock = '{:target="_blank"}'; // @dynamic @Injectable({ @@ -42,7 +42,9 @@ export class MarkedOptionsService implements MarkedOptions { smartypants = false; mangle = false; - private renderer2 = new MarkedRenderer(); + private parser = new Parser({ + renderer: this.renderer + }); private id = 1; @@ -70,47 +72,66 @@ export class MarkedOptionsService implements MarkedOptions { } }; marked.use({tokenizer}); - this.renderer.code = (code: string, language: string | undefined, isEscaped: boolean) => { - const codeContext = processCode(code); + + const origCode = this.renderer.code.bind(this.renderer); + const origTable = this.renderer.table.bind(this.renderer); + const origTablecell = this.renderer.tablecell.bind(this.renderer); + const origLink = this.renderer.link.bind(this.renderer); + + this.renderer.code = (code: Tokens.Code) => { + const codeContext = processCode(code.text); + code.text = codeContext.code; if (codeContext.copyCode) { - const content = postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext); + const content = postProcessCodeContent(origCode(code), codeContext); this.id++; return this.wrapCopyCode(this.id, content, codeContext); } else { - return this.wrapDiv(postProcessCodeContent(this.renderer2.code(codeContext.code, language, isEscaped), codeContext)); + return this.wrapDiv(postProcessCodeContent(origCode(code), codeContext)); } }; - this.renderer.table = (header: string, body: string) => { + this.renderer.table = (token: Tokens.Table) => { let autoLayout = false; - if (header.includes(autoBlock)) { - autoLayout = true; - header = header.replace(autoBlock, ''); + for (const h of token.header) { + if (h.text.includes(autoBlock)) { + autoLayout = true; + if (h.tokens?.length) { + h.tokens.filter(t => t.type === 'text').forEach((t: Tokens.Text) => t.text = t.text.replace(autoBlock, '')); + } + } } - let table = this.renderer2.table(header, body); + let table = origTable(token); if (autoLayout) { table = table.replace(' { - const codeContext = processCode(content); + this.renderer.tablecell = (token: Tokens.TableCell) => { + const codeContext = processCode(token.text); codeContext.multiline = false; if (codeContext.copyCode) { this.id++; - content = this.wrapCopyCode(this.id, codeContext.code, codeContext); + if (token.tokens?.length && token.tokens[token.tokens.length - 1].type === 'text') { + const textToken = token.tokens[token.tokens.length - 1] as Tokens.HTML; + textToken.text = this.wrapCopyCode(this.id, codeContext.code, codeContext); + textToken.type = 'html'; + } else { + token.text = this.wrapCopyCode(this.id, codeContext.code, codeContext); + } } - return this.renderer2.tablecell(content, flags); + return origTablecell(token); }; - this.renderer.link = (href: string | null, title: string | null, text: string) => { - if (text.endsWith(targetBlankBlock)) { - text = text.substring(0, text.length - targetBlankBlock.length); - const content = this.renderer2.link(href, title, text); + this.renderer.link = (token: Tokens.Link) => { + if (token.text.endsWith(targetBlankBlock)) { + if (token.tokens?.length && token.tokens[token.tokens.length - 1].type === 'text') { + const textToken = token.tokens[token.tokens.length - 1] as Tokens.Text; + textToken.text = textToken.text.substring(0, textToken.text.length - targetBlankBlock.length); + } else { + token.text = token.text.substring(0, token.text.length - targetBlankBlock.length); + } + const content = origLink(token); return content.replace(' -
- {{materialIconFormGroup.get('icon').value}} - - {{ label }} - - - -
- +@if (!asBoxInput) { +
+ {{materialIconFormGroup.get('icon').value}} + + {{ label }} + + @if (iconClearButton) { + + } + +
+} @else { -
+} diff --git a/ui-ngx/src/app/shared/components/material-icon-select.component.ts b/ui-ngx/src/app/shared/components/material-icon-select.component.ts index 92cae93988..ec040bd488 100644 --- a/ui-ngx/src/app/shared/components/material-icon-select.component.ts +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.ts @@ -36,18 +36,20 @@ import { TbPopoverService } from '@shared/components/popover.service'; import { MaterialIconsComponent } from '@shared/components/material-icons.component'; import { MatButton } from '@angular/material/button'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-material-icon-select', - templateUrl: './material-icon-select.component.html', - styleUrls: ['./material-icon-select.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MaterialIconSelectComponent), - multi: true - } - ] + selector: 'tb-material-icon-select', + templateUrl: './material-icon-select.component.html', + styleUrls: ['./material-icon-select.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MaterialIconSelectComponent), + multi: true + } + ], + standalone: false }) export class MaterialIconSelectComponent extends PageComponent implements OnInit, ControlValueAccessor { @@ -75,6 +77,9 @@ export class MaterialIconSelectComponent extends PageComponent implements OnInit @coerceBoolean() allowedCustomIcon = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { return this.requiredValue; diff --git a/ui-ngx/src/app/shared/components/material-icons.component.html b/ui-ngx/src/app/shared/components/material-icons.component.html index 97a661bfb1..bb9e3fca58 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.html +++ b/ui-ngx/src/app/shared/components/material-icons.component.html @@ -17,7 +17,9 @@ -->
- icon.icons + @if (showTitle) { + icon.icons + } @if (allowedCustomIcon) { {{ 'resource.system' | translate }} @@ -30,61 +32,73 @@ search - + @if (searchIconControl.value) { + + }
- - - - + @for (icon of iconRow; track icon) { + @if (icon.name === selectedIcon) { + + } + @if (icon.name !== selectedIcon) { + + } + }
- + @if (notFound) {
{{ 'icon.no-icons-found' | translate:{iconSearch: searchIconControl.value} }}
-
-
- - - -
+ } + @if (iconClearButton || !showAllSubject.value) { +
+ @if (iconClearButton) { + + } + + @if (!showAllSubject.value) { + + } +
+ } } @else { diff --git a/ui-ngx/src/app/shared/components/material-icons.component.ts b/ui-ngx/src/app/shared/components/material-icons.component.ts index 8556bdbad4..101f7756aa 100644 --- a/ui-ngx/src/app/shared/components/material-icons.component.ts +++ b/ui-ngx/src/app/shared/components/material-icons.component.ts @@ -40,11 +40,12 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { isTbImage } from '@shared/models/resource.models'; @Component({ - selector: 'tb-material-icons', - templateUrl: './material-icons.component.html', - providers: [], - styleUrls: ['./material-icons.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-material-icons', + templateUrl: './material-icons.component.html', + providers: [], + styleUrls: ['./material-icons.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class MaterialIconsComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html index b073d93a4e..c63cbf82fb 100644 --- a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ 'rulenode.message-type' | translate }} - + @if (messageTypeFormGroup.get('messageType').value && !disabled) { + + } - - - + @for (messageType of filteredMessageTypes | async; track messageType) { + + + + } - - {{ 'rulenode.message-type-required' | translate }} - + @if (messageTypeFormGroup.get('messageType').hasError('required')) { + + {{ 'rulenode.message-type-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts index aa71898acb..b9719a6863 100644 --- a/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.ts @@ -24,16 +24,18 @@ import { TranslateService } from '@ngx-translate/core'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { MessageType, messageTypeNames } from '@shared/models/rule-node.models'; import { objectValues } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-message-type-autocomplete', - templateUrl: './message-type-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MessageTypeAutocompleteComponent), - multi: true - }] + selector: 'tb-message-type-autocomplete', + templateUrl: './message-type-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MessageTypeAutocompleteComponent), + multi: true + }], + standalone: false }) export class MessageTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { @@ -50,6 +52,9 @@ export class MessageTypeAutocompleteComponent implements ControlValueAccessor, O this.requiredValue = coerceBooleanProperty(value); } + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/components/mqtt-version-select.component.ts b/ui-ngx/src/app/shared/components/mqtt-version-select.component.ts index d16f0ba852..08ddbe560d 100644 --- a/ui-ngx/src/app/shared/components/mqtt-version-select.component.ts +++ b/ui-ngx/src/app/shared/components/mqtt-version-select.component.ts @@ -21,14 +21,15 @@ import { SubscriptSizing, MatFormFieldAppearance } from '@angular/material/form- import { MqttVersionTranslation, MqttVersion } from '@shared/models/mqtt.models'; @Component({ - selector: 'tb-mqtt-version-select', - templateUrl: './mqtt-version-select.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MqttVersionSelectComponent), - multi: true - }] + selector: 'tb-mqtt-version-select', + templateUrl: './mqtt-version-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MqttVersionSelectComponent), + multi: true + }], + standalone: false }) export class MqttVersionSelectComponent implements ControlValueAccessor, OnChanges { diff --git a/ui-ngx/src/app/shared/components/multiple-image-input.component.html b/ui-ngx/src/app/shared/components/multiple-image-input.component.html index a906430a7a..da82cf54db 100644 --- a/ui-ngx/src/app/shared/components/multiple-image-input.component.html +++ b/ui-ngx/src/app/shared/components/multiple-image-input.component.html @@ -16,7 +16,9 @@ -->
- + @if (label) { + + }
@@ -24,46 +26,58 @@ [dndDisableIf]="disabled" (dndDrop)="imageDrop($event)" [class.no-images]="!safeImageUrls.length">
-
- {{ 'image-input.images' | translate }} [{{ $index }}] -
-
- drag_indicator -
-
- -
-
- + @for (safeImageUrl of safeImageUrls; track safeImageUrl) { +
+ {{ 'image-input.images' | translate }} [{{ $index }}] +
+ @if (!disabled) { +
+ drag_indicator +
+ } +
+ +
+ @if (!disabled) { +
+ +
+ }
-
-
{{ 'image-input.no-images' | translate }}
+ } + @if (!safeImageUrls.length) { +
{{ 'image-input.no-images' | translate }}
+ }
-
-
- cloud_upload - image-input.drop-images-or - - + @if (!disabled) { +
+
+ cloud_upload + image-input.drop-images-or + + +
-
+ }
-
dashboard.maximum-upload-file-size
+ @if (maxSizeByte && !disabled) { +
dashboard.maximum-upload-file-size
+ }
diff --git a/ui-ngx/src/app/shared/components/multiple-image-input.component.ts b/ui-ngx/src/app/shared/components/multiple-image-input.component.ts index ebec289de8..3e341e00ad 100644 --- a/ui-ngx/src/app/shared/components/multiple-image-input.component.ts +++ b/ui-ngx/src/app/shared/components/multiple-image-input.component.ts @@ -21,7 +21,7 @@ import { AppState } from '@core/core.state'; import { ControlValueAccessor, FormArray, NG_VALUE_ACCESSOR, } from '@angular/forms'; import { Subscription } from 'rxjs'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; -import { DropDirective, FlowDirective } from '@flowjs/ngx-flow'; +import { FlowConfig, FlowDrop } from '@flowjs/ngx-flow'; import { DomSanitizer, SafeUrl } from '@angular/platform-browser'; import { UtilsService } from '@core/services/utils.service'; import { DialogService } from '@core/services/dialog.service'; @@ -32,16 +32,17 @@ import { DndDropEvent } from 'ngx-drag-drop'; import { isUndefined } from '@core/utils'; @Component({ - selector: 'tb-multiple-image-input', - templateUrl: './multiple-image-input.component.html', - styleUrls: ['./multiple-image-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => MultipleImageInputComponent), - multi: true - } - ] + selector: 'tb-multiple-image-input', + templateUrl: './multiple-image-input.component.html', + styleUrls: ['./multiple-image-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => MultipleImageInputComponent), + multi: true + } + ], + standalone: false }) export class MultipleImageInputComponent extends PageComponent implements AfterViewInit, OnDestroy, ControlValueAccessor { @@ -77,10 +78,10 @@ export class MultipleImageInputComponent extends PageComponent implements AfterV dragIndex: number; @ViewChild('flow', {static: true}) - flow: FlowDirective; + flow: FlowConfig; @ViewChild('flowDrop', {static: true}) - flowDrop: DropDirective; + flowDrop: FlowDrop; autoUploadSubscription: Subscription; diff --git a/ui-ngx/src/app/shared/components/nav-tree.component.ts b/ui-ngx/src/app/shared/components/nav-tree.component.ts index 6339dd20f3..c2567fe44a 100644 --- a/ui-ngx/src/app/shared/components/nav-tree.component.ts +++ b/ui-ngx/src/app/shared/components/nav-tree.component.ts @@ -59,10 +59,11 @@ export type NodeSelectedCallback = (node: NavTreeNode, event: Event) => void; export type NodesInsertedCallback = (nodes: string[], parent: string) => void; @Component({ - selector: 'tb-nav-tree', - templateUrl: './nav-tree.component.html', - styleUrls: ['./nav-tree.component.scss'], - encapsulation: ViewEncapsulation.None + selector: 'tb-nav-tree', + templateUrl: './nav-tree.component.html', + styleUrls: ['./nav-tree.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class NavTreeComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.html b/ui-ngx/src/app/shared/components/notification/notification.component.html index 86a634697a..5a7a35eb2a 100644 --- a/ui-ngx/src/app/shared/components/notification/notification.component.html +++ b/ui-ngx/src/app/shared/components/notification/notification.component.html @@ -17,35 +17,44 @@ -->
-
- - {{ notification.additionalConfig.icon.icon }} - -
- - - {{ notificationTypeIcons.get(notification.type) }} - - + @if (showIcon) { +
+ + {{ notification.additionalConfig.icon.icon }} + +
+ } @else { + @if (notificationTypeIcons.get(notification.type)) { + + {{ notificationTypeIcons.get(notification.type) }} + + } + }
-
-
- +
+
+ @if (showButton) { + + }
{{ notification.createdTime ?? currentDate | dateAgo }} - -
- - {{alarmSeverityTranslations.get(notification.info.alarmSeverity) | translate}} - -
+ @if (!hideMarkAsReadButton) { + + } + @if (notification.type === notificationType.ALARM && !notification.info.cleared) { +
+ + {{alarmSeverityTranslations.get(notification.info.alarmSeverity) | translate}} + +
+ }
diff --git a/ui-ngx/src/app/shared/components/notification/notification.component.ts b/ui-ngx/src/app/shared/components/notification/notification.component.ts index 7acdfba0c7..d4f58f6989 100644 --- a/ui-ngx/src/app/shared/components/notification/notification.component.ts +++ b/ui-ngx/src/app/shared/components/notification/notification.component.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { Component, EventEmitter, Input, OnInit, Output, SecurityContext } from '@angular/core'; import { ActionButtonLinkType, Notification, @@ -33,11 +33,13 @@ import tinycolor from 'tinycolor2'; import { StateObject } from '@core/api/widget-api.models'; import { objToBase64URI } from '@core/utils'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { DomSanitizer } from '@angular/platform-browser'; @Component({ - selector: 'tb-notification', - templateUrl: './notification.component.html', - styleUrls: ['./notification.component.scss'] + selector: 'tb-notification', + templateUrl: './notification.component.html', + styleUrls: ['./notification.component.scss'], + standalone: false }) export class NotificationComponent implements OnInit { @@ -70,7 +72,8 @@ export class NotificationComponent implements OnInit { constructor( private utils: UtilsService, - private router: Router + private router: Router, + private sanitizer: DomSanitizer ) { } @@ -78,11 +81,10 @@ export class NotificationComponent implements OnInit { this.showIcon = this.notification.additionalConfig?.icon?.enabled; this.showButton = this.notification.additionalConfig?.actionButtonConfig?.enabled; this.hideMarkAsReadButton = this.notification.status === NotificationStatus.READ; - this.title = this.utils.customTranslation(this.notification.subject, this.notification.subject); - this.message = this.utils.customTranslation(this.notification.text, this.notification.text); + this.title = this.sanitizer.sanitize(SecurityContext.HTML, this.utils.customTranslation(this.notification.subject)); + this.message = this.sanitizer.sanitize(SecurityContext.HTML, this.utils.customTranslation(this.notification.text)); if (this.showButton) { - this.buttonLabel = this.utils.customTranslation(this.notification.additionalConfig.actionButtonConfig.text, - this.notification.additionalConfig.actionButtonConfig.text); + this.buttonLabel = this.utils.customTranslation(this.notification.additionalConfig.actionButtonConfig.text); } } diff --git a/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html b/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html index 29eaa77881..26ff960db8 100644 --- a/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + notification.template - - - + @if (selectTemplateFormGroup.get('templateName').value && !disabled) { + + } + @if (selectTemplateFormGroup.get('templateName').value?.id && !disabled && allowEdit) { + + } + @if (allowCreate && !selectTemplateFormGroup.get('templateName').value && !disabled) { + + } - - - - - {{ notificationDeliveryMethodInfoMap.get(method.key).name | translate }} - - - - -
- - + + } - - {{ 'notification.template-required' | translate }} - + @if (selectTemplateFormGroup.get('templateName').hasError('required')) { + + {{ 'notification.template-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts b/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts index 6bd0c3f5fe..477f93d208 100644 --- a/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/notification/template-autocomplete.component.ts @@ -28,6 +28,7 @@ import { PageLink } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { emptyPageData } from '@shared/models/page/page-data'; import { + NotificationDeliveryMethod, NotificationDeliveryMethodInfoMap, NotificationTemplate, NotificationType @@ -41,17 +42,19 @@ import { import { MatDialog } from '@angular/material/dialog'; import { MatButton } from '@angular/material/button'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-template-autocomplete', - templateUrl: './template-autocomplete.component.html', - styleUrls: ['./template-autocomplete.component.scss'], - encapsulation: ViewEncapsulation.None, - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TemplateAutocompleteComponent), - multi: true - }] + selector: 'tb-template-autocomplete', + templateUrl: './template-autocomplete.component.html', + styleUrls: ['./template-autocomplete.component.scss'], + encapsulation: ViewEncapsulation.None, + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TemplateAutocompleteComponent), + multi: true + }], + standalone: false }) export class TemplateAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -70,6 +73,8 @@ export class TemplateAutocompleteComponent implements ControlValueAccessor, OnIn @coerceBoolean() allowEdit = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; @Input() disabled: boolean; @@ -216,7 +221,7 @@ export class TemplateAutocompleteComponent implements ControlValueAccessor, OnIn button._elementRef.nativeElement.blur(); this.createTemplateByName($event); } - + createTemplateByName($event: Event, name?: string) { $event?.stopPropagation(); this.openNotificationTemplateDialog({ @@ -264,4 +269,8 @@ export class TemplateAutocompleteComponent implements ControlValueAccessor, OnIn this.updateView(null); this.dirty = true; } + + getNotificationDeliveryMethodInfoMap(key: string) { + return this.notificationDeliveryMethodInfoMap.get(key as NotificationDeliveryMethod); + } } diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html index 7e0a03e52b..7ff7f7ba2c 100644 --- a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ placeholderText | translate }} - - {{ displayPackageFn(otaPackageFormGroup.get('packageId').value) }} - - + @if (showDetailsPageLink && usePackageLink && otaPackageFormGroup.get('packageId').value && disabled) { + + {{ displayPackageFn(otaPackageFormGroup.get('packageId').value) }} + + } + @if (otaPackageFormGroup.get('packageId').value && !disabled) { + + } - - - - -
-
- {{ notFoundPackage | translate }} -
- - + @for (package of filteredPackages | async; track package) { + + + + } + @if (!(filteredPackages | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ notFoundPackage | translate }} +
+ } @else { + {{ translate.get(notMatchingPackage, - {entity: truncate.transform(searchText, true, 6, '...')}) | async }} - - -
-
+ {entity: truncate.transform(searchText, true, 6, '...')}) | async }} +
+ } +
+
+ }
- - {{ requiredErrorText | translate }} - - {{ hintText | translate }} + @if (otaPackageFormGroup.get('packageId').hasError('required')) { + + {{ requiredErrorText | translate }} + + } + @if (!disabled) { + {{ hintText | translate }} + }
diff --git a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts index f768ed5394..a7208d4507 100644 --- a/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/ota-package/ota-package-autocomplete.component.ts @@ -35,16 +35,18 @@ import { getEntityDetailsPageURL, isDefinedAndNotNull } from '@core/utils'; import { AuthUser } from '@shared/models/user.model'; import { getCurrentAuthUser } from '@core/auth/auth.selectors'; import { Authority } from '@shared/models/authority.enum'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-ota-package-autocomplete', - templateUrl: './ota-package-autocomplete.component.html', - styleUrls: ['./ota-package-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => OtaPackageAutocompleteComponent), - multi: true - }] + selector: 'tb-ota-package-autocomplete', + templateUrl: './ota-package-autocomplete.component.html', + styleUrls: ['./ota-package-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => OtaPackageAutocompleteComponent), + multi: true + }], + standalone: false }) export class OtaPackageAutocompleteComponent implements ControlValueAccessor, OnInit, OnDestroy { @@ -92,6 +94,9 @@ export class OtaPackageAutocompleteComponent implements ControlValueAccessor, On @Input() showDetailsPageLink = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + private requiredValue: boolean; get required(): boolean { diff --git a/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts index 391ebf1d69..83fceaa374 100644 --- a/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts +++ b/ui-ngx/src/app/shared/components/password-requirements-tooltip.component.ts @@ -22,10 +22,11 @@ import { UserPasswordPolicy } from '@shared/models/settings.models'; import { POSITION_MAP } from '@shared/models/overlay.models'; @Component({ - selector: 'tb-password-requirements-tooltip', - templateUrl: './password-requirements-tooltip.component.html', - styleUrl: './password-requirements-tooltip.component.scss', - encapsulation: ViewEncapsulation.None + selector: 'tb-password-requirements-tooltip', + templateUrl: './password-requirements-tooltip.component.html', + styleUrl: './password-requirements-tooltip.component.scss', + encapsulation: ViewEncapsulation.None, + standalone: false }) export class PasswordRequirementsTooltipComponent { @Input() passwordControl: AbstractControl; diff --git a/ui-ngx/src/app/shared/components/phone-input.component.html b/ui-ngx/src/app/shared/components/phone-input.component.html index 663576bd70..2590b5d913 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.html +++ b/ui-ngx/src/app/shared/components/phone-input.component.html @@ -17,16 +17,24 @@ -->
-
- {{ flagIcon }} - - - - {{country.flag}} - {{' ' + country.name + ' +' + country.dialCode }} - - -
+ @if (showFlagSelect) { +
+ @if (!isLoad) { + {{ flagIcon }} + } + @if (isLoad) { + + } + + @for (country of allCountries; track country) { + + {{country.flag}} + {{' ' + country.name + ' +' + country.dialCode }} + + } + +
+ } {{ label }} - - {{ requiredErrorText }} - - - {{ validationErrorText }} - + @if (phoneFormGroup.get('phoneNumber').hasError('required')) { + + {{ requiredErrorText }} + + } + @if (phoneFormGroup.get('phoneNumber').hasError('invalidPhoneNumber')) { + + {{ validationErrorText }} + + }
diff --git a/ui-ngx/src/app/shared/components/phone-input.component.ts b/ui-ngx/src/app/shared/components/phone-input.component.ts index 435605cb55..680d382c38 100644 --- a/ui-ngx/src/app/shared/components/phone-input.component.ts +++ b/ui-ngx/src/app/shared/components/phone-input.component.ts @@ -34,22 +34,23 @@ import { Subscription } from 'rxjs'; import { FloatLabelType, MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-phone-input', - templateUrl: './phone-input.component.html', - styleUrls: ['./phone-input.component.scss'], - providers: [ - CountryData, - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => PhoneInputComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => PhoneInputComponent), - multi: true - } - ] + selector: 'tb-phone-input', + templateUrl: './phone-input.component.html', + styleUrls: ['./phone-input.component.scss'], + providers: [ + CountryData, + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => PhoneInputComponent), + multi: true + } + ], + standalone: false }) export class PhoneInputComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/popover.component.ts b/ui-ngx/src/app/shared/components/popover.component.ts index bf8d81cac8..f971d94ec3 100644 --- a/ui-ngx/src/app/shared/components/popover.component.ts +++ b/ui-ngx/src/app/shared/components/popover.component.ts @@ -30,7 +30,7 @@ import { OnInit, Optional, Output, - Renderer2, + Renderer2, runInInjectionContext, SimpleChanges, TemplateRef, Type, @@ -63,17 +63,23 @@ import { distinctUntilChanged, take, takeUntil } from 'rxjs/operators'; import { isNotEmptyStr, onParentScrollOrWindowResize } from '@core/utils'; import { animate, AnimationBuilder, AnimationMetadata, style } from '@angular/animations'; import { coerceBoolean } from '@shared/decorators/coercion'; +import { + widgetContextToken, + widgetErrorMessagesToken, + widgetTitlePanelToken +} from '@home/models/widget-component.models'; export type TbPopoverTrigger = 'click' | 'focus' | 'hover' | null; @Directive({ - // eslint-disable-next-line @angular-eslint/directive-selector - selector: '[tb-popover]', - exportAs: 'tbPopover', - // eslint-disable-next-line @angular-eslint/no-host-metadata-property - host: { - '[class.tb-popover-open]': 'visible' - } + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[tb-popover]', + exportAs: 'tbPopover', + // eslint-disable-next-line @angular-eslint/no-host-metadata-property + host: { + '[class.tb-popover-open]': 'visible' + }, + standalone: false }) export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { @@ -156,7 +162,7 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { this.renderer.parentNode(this.elementRef.nativeElement), componentRef.location.nativeElement ); - this.component.setOverlayOrigin(new CdkOverlayOrigin(this.origin || this.elementRef)); + this.component.setOriginElement(this.origin || this.elementRef); this.initProperties(); @@ -300,13 +306,13 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { } @Component({ - selector: 'tb-popover', - exportAs: 'tbPopoverComponent', - animations: [popoverMotion], - changeDetection: ChangeDetectionStrategy.OnPush, - encapsulation: ViewEncapsulation.None, - styleUrls: ['./popover.component.scss'], - template: ` + selector: 'tb-popover', + exportAs: 'tbPopoverComponent', + animations: [popoverMotion], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + styleUrls: ['./popover.component.scss'], + template: ` + >
+ (@popoverMotion.done)="animationDone()">
+ >
@@ -360,7 +370,8 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
- ` + `, + standalone: false }) export class TbPopoverComponent implements OnDestroy, OnInit { @@ -484,6 +495,7 @@ export class TbPopoverComponent implements OnDestroy, OnInit { preferredPlacement: PopoverPlacement = 'top'; strictPosition = false; origin!: CdkOverlayOrigin; + originElement: ElementRef; public dir: Direction = 'ltr'; classMap: { [klass: string]: any } = {}; positions: ConnectionPositionPair[] = [...DEFAULT_POPOVER_POSITIONS]; @@ -502,6 +514,7 @@ export class TbPopoverComponent implements OnDestroy, OnInit { public cdr: ChangeDetectorRef, private renderer: Renderer2, private animationBuilder: AnimationBuilder, + private injector: Injector, @Optional() private directionality: Directionality ) {} @@ -544,7 +557,27 @@ export class TbPopoverComponent implements OnDestroy, OnInit { if (this.tbVisible) { return; } + if (!this.origin) { + const injector: Injector = Injector.create( + { + providers: [ + { + provide: ElementRef, + useValue: this.originElement + } + ], + parent: this.injector + }); + runInInjectionContext(injector, () => { + this.origin = new CdkOverlayOrigin(); + this.doShow(); + }); + } else { + this.doShow(); + } + } + private doShow(): void { if (!this.isEmpty()) { this.tbVisible = true; this.tbVisibleChange.next(true); @@ -646,9 +679,8 @@ export class TbPopoverComponent implements OnDestroy, OnInit { } } - setOverlayOrigin(origin: CdkOverlayOrigin): void { - this.origin = origin; - this.cdr.markForCheck(); + setOriginElement(originElement: ElementRef): void { + this.originElement = originElement; } onClickOutside(event: MouseEvent): void { diff --git a/ui-ngx/src/app/shared/components/popover.service.ts b/ui-ngx/src/app/shared/components/popover.service.ts index ca525dc909..74155cff87 100644 --- a/ui-ngx/src/app/shared/components/popover.service.ts +++ b/ui-ngx/src/app/shared/components/popover.service.ts @@ -142,7 +142,7 @@ export class TbPopoverService { conf.componentRef.location.nativeElement ); const originElementRef = new ElementRef(conf.trigger); - component.setOverlayOrigin(new CdkOverlayOrigin(originElementRef)); + component.setOriginElement(originElementRef); component.tbPlacement = conf.preferredPlacement; component.tbComponent = conf.componentType; component.tbComponentInjector = conf.injector; @@ -198,7 +198,7 @@ export class TbPopoverService { const originElementRef = new ElementRef(trigger); component.tbAnimationState = 'void'; component.tbOverlayStyle = {...overlayStyle, opacity: '0' }; - component.setOverlayOrigin(new CdkOverlayOrigin(originElementRef)); + component.setOriginElement(originElementRef); component.tbPlacement = preferredPlacement; component.tbComponent = this.helpMarkdownComponent; component.tbComponentInjector = injector; diff --git a/ui-ngx/src/app/shared/components/protobuf-content.component.html b/ui-ngx/src/app/shared/components/protobuf-content.component.html index 167b68d2aa..ae485347d1 100644 --- a/ui-ngx/src/app/shared/components/protobuf-content.component.html +++ b/ui-ngx/src/app/shared/components/protobuf-content.component.html @@ -21,10 +21,12 @@
- + @if (!readonly && !disabled) { + + }
ProtobufContentComponent), - multi: true - } - ] + selector: 'tb-protobuf-content', + templateUrl: './protobuf-content.component.html', + styleUrls: ['./protobuf-content.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ProtobufContentComponent), + multi: true + } + ], + standalone: false }) export class ProtobufContentComponent implements OnInit, ControlValueAccessor, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html index 1c23ef70d9..89c9dc234f 100644 --- a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.html @@ -16,7 +16,7 @@ --> + class="mat-block autocomplete-queue" [subscriptSizing]="subscriptSizing" [appearance]="appearance"> {{ 'queue.queue-name' | translate }} - + @if (selectQueueFormGroup.get('queueName').value && !disabled) { + + } - - - {{getDescription(queue)}} - - -
-
- queue.no-queues-found -
- - + @for (queue of filteredQueues | async; track queue) { + + + {{getDescription(queue)}} + + } + @if (!(filteredQueues | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ queue.no-queues-found +
+ } @else { + {{ translate.get('queue.no-queues-matching', - {queue: truncate.transform(searchText, true, 6, '...')}) | async }} - - -
-
+ {queue: truncate.transform(searchText, true, 6, '...')}) | async }} +
+ } +
+
+ }
- {{ autocompleteHint | translate }} - - {{ 'queue.queue-required' | translate }} - + @if (autocompleteHint) { + {{ autocompleteHint | translate }} + } + @if (selectQueueFormGroup.get('queueName').hasError('required')) { + + {{ 'queue.queue-required' | translate }} + + }
diff --git a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts index c1219575cd..bc06d55941 100644 --- a/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/queue/queue-autocomplete.component.ts @@ -31,17 +31,18 @@ import { QueueService } from '@core/http/queue.service'; import { PageLink } from '@shared/models/page/page-link'; import { Direction } from '@shared/models/page/sort-order'; import { emptyPageData } from '@shared/models/page/page-data'; -import { SubscriptSizing } from '@angular/material/form-field'; +import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-queue-autocomplete', - templateUrl: './queue-autocomplete.component.html', - styleUrls: ['./queue-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => QueueAutocompleteComponent), - multi: true - }] + selector: 'tb-queue-autocomplete', + templateUrl: './queue-autocomplete.component.html', + styleUrls: ['./queue-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => QueueAutocompleteComponent), + multi: true + }], + standalone: false }) export class QueueAutocompleteComponent implements ControlValueAccessor, OnInit { @@ -76,6 +77,9 @@ export class QueueAutocompleteComponent implements ControlValueAccessor, OnInit @Input() disabled: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @ViewChild('queueInput', {static: true}) queueInput: ElementRef; filteredQueues: Observable>>; diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html index 8e4542bad2..397c58dc78 100644 --- a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.html @@ -17,7 +17,9 @@ --> - {{ 'relation.relation-type' | translate }} + @if (showLabel) { + {{ 'relation.relation-type' | translate }} + } - + @if (relationTypeFormGroup.get('relationType').value && !disabled) { + + } - - - + @for (relationType of filteredRelationTypes | async; track relationType) { + + + + } - - {{ 'relation.relation-type-required' | translate }} - - - {{ 'relation.relation-type-max-length' | translate }} - + @if (relationTypeFormGroup.get('relationType').hasError('required')) { + + {{ 'relation.relation-type-required' | translate }} + + } + @if (relationTypeFormGroup.get('relationType').hasError('maxlength')) { + + {{ 'relation.relation-type-max-length' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts index 90e1682ecd..9528d24958 100644 --- a/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/relation/relation-type-autocomplete.component.ts @@ -27,14 +27,15 @@ import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form- import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-relation-type-autocomplete', - templateUrl: './relation-type-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RelationTypeAutocompleteComponent), - multi: true - }] + selector: 'tb-relation-type-autocomplete', + templateUrl: './relation-type-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RelationTypeAutocompleteComponent), + multi: true + }], + standalone: false }) export class RelationTypeAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html index 5f3160168e..f461ad16cb 100644 --- a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.html @@ -29,36 +29,44 @@ [required]="required" [matAutocomplete]="entityAutocomplete" [matAutocompleteDisabled]="!allowAutocomplete"> - + @if (resourceFormGroup.get('resource').value && !disabled) { + + } - - - - - -
-
- {{ 'js-func.no-js-module-text' | translate }} -
- - - {{ 'js-func.no-js-module-matching' | translate: {module: searchText | truncate: true: 6: '...'} }} - - -
-
-
- - - {{ searchText }} + @for (resource of filteredResources$ | async; track resource) { + + - + } + @if (subType !== ResourceSubType.EXTENSION) { + @if (!(filteredResources$ | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ {{ 'js-func.no-js-module-text' | translate }} +
+ } @else { + + {{ 'js-func.no-js-module-matching' | translate: {module: searchText | truncate: true: 6: '...'} }} + + } +
+
+ } + } @else { + @if (!(filteredResources$ | async)?.length) { + + {{ searchText }} + + } + }
diff --git a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts index 5ce4952aa5..000dcf10ec 100644 --- a/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/resource/resource-autocomplete.component.ts @@ -35,14 +35,15 @@ import { PageLink } from '@shared/models/page/page-link'; import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-resource-autocomplete', - templateUrl: './resource-autocomplete.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ResourceAutocompleteComponent), - multi: true - }] + selector: 'tb-resource-autocomplete', + templateUrl: './resource-autocomplete.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ResourceAutocompleteComponent), + multi: true + }], + standalone: false }) export class ResourceAutocompleteComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html index 5b357b3ed0..253a92e50a 100644 --- a/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html +++ b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.html @@ -18,7 +18,7 @@

{{configuration.title}}

- + @if (data.multiple) {
@@ -35,20 +35,21 @@ - - - - {{ image.title }} - - + @if (displayPreview) { + + + + {{ image.title }} + + + } - + @if (dataSource.selection.isEmpty()) { {{ 'resource.title' | translate }} - - + } @else { {{ translate.get(configuration.selectedText, {count: dataSource.selection.selected.length}) | async }} - + } {{ resource.title }} @@ -60,23 +61,22 @@ + (click)="toggleShowReferences($event, resource, showReferencesButton)">{{ 'image.references' | translate }} + *matRowDef="let resource; columns: configuration.columns;">
-
+ } @else { + +
+ }
+ cdkFocusInitial>{{ (data.multiple ? 'action.delete-selected' : 'action.delete-anyway') | translate}}
- - -
-
diff --git a/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts index 22fe4a4e3f..2f8c9c59ae 100644 --- a/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/resource/resources-in-use-dialog.component.ts @@ -47,9 +47,10 @@ export interface ResourcesInUseDialogData { } @Component({ - selector: 'tb-resources-in-use-dialog', - templateUrl: './resources-in-use-dialog.component.html', - styleUrls: ['./resources-in-use-dialog.component.scss'] + selector: 'tb-resources-in-use-dialog', + templateUrl: './resources-in-use-dialog.component.html', + styleUrls: ['./resources-in-use-dialog.component.scss'], + standalone: false }) export class ResourcesInUseDialogComponent extends DialogComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html index beb6f09081..62584f5336 100644 --- a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html +++ b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.html @@ -25,19 +25,25 @@ - - check - - - -
- - {{ 'rulechain.no-rulechains-matching' | translate : {entity: searchText} }} - -
-
+ @for (ruleChain of filteredRuleChains | async; track ruleChain) { + + @if (ruleChainId === ruleChain.id.id) { + check + } + + + } + @if (!(filteredRuleChains | async)?.length) { + +
+ + {{ 'rulechain.no-rulechains-matching' | translate : {entity: searchText} }} + +
+
+ }
diff --git a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.ts b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.ts index e79bdbc27e..3f39b8cc8a 100644 --- a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.ts +++ b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select-panel.component.ts @@ -34,9 +34,10 @@ export interface RuleChainSelectPanelData { } @Component({ - selector: 'tb-rule-chain-select-panel', - templateUrl: './rule-chain-select-panel.component.html', - styleUrls: ['./rule-chain-select-panel.component.scss'] + selector: 'tb-rule-chain-select-panel', + templateUrl: './rule-chain-select-panel.component.html', + styleUrls: ['./rule-chain-select-panel.component.scss'], + standalone: false }) export class RuleChainSelectPanelComponent implements AfterViewInit { diff --git a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select.component.ts b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select.component.ts index 2f89f67059..0483ff5ed5 100644 --- a/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select.component.ts +++ b/ui-ngx/src/app/shared/components/rule-chain/rule-chain-select.component.ts @@ -29,14 +29,15 @@ import { ComponentPortal } from '@angular/cdk/portal'; import { POSITION_MAP } from '@shared/models/overlay.models'; @Component({ - selector: 'tb-rule-chain-select', - templateUrl: './rule-chain-select.component.html', - styleUrls: ['./rule-chain-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => RuleChainSelectComponent), - multi: true - }] + selector: 'tb-rule-chain-select', + templateUrl: './rule-chain-select.component.html', + styleUrls: ['./rule-chain-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RuleChainSelectComponent), + multi: true + }], + standalone: false }) export class RuleChainSelectComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/script-lang.component.scss b/ui-ngx/src/app/shared/components/script-lang.component.scss index 6309681f15..a228450e61 100644 --- a/ui-ngx/src/app/shared/components/script-lang.component.scss +++ b/ui-ngx/src/app/shared/components/script-lang.component.scss @@ -23,7 +23,7 @@ } } .mat-button-toggle { - --mat-standard-button-toggle-disabled-selected-state-background-color: rgba(0, 0, 0, 0.06); + --mat-button-toggle-disabled-selected-state-background-color: rgba(0, 0, 0, 0.06); background: rgba(0, 0, 0, 0.06); height: 36px; align-items: center; diff --git a/ui-ngx/src/app/shared/components/script-lang.component.ts b/ui-ngx/src/app/shared/components/script-lang.component.ts index 0ff3e5b1b0..65244c86c0 100644 --- a/ui-ngx/src/app/shared/components/script-lang.component.ts +++ b/ui-ngx/src/app/shared/components/script-lang.component.ts @@ -23,17 +23,18 @@ import { ScriptLanguage } from '@shared/models/rule-node.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-script-lang', - templateUrl: './script-lang.component.html', - styleUrls: ['./script-lang.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TbScriptLangComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-script-lang', + templateUrl: './script-lang.component.html', + styleUrls: ['./script-lang.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbScriptLangComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class TbScriptLangComponent extends PageComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.html b/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.html index fe67e82143..a0d3c4023a 100644 --- a/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - + {{ labelText }} - + @if (conversationSlackFormGroup.get('conversation').value && !disabled) { + + } - - - - -
-
- notification.not-found-slack-recipient + @for (template of slackConversation$ | async; track template) { + + + + } + @if (!(slackConversation$ | async)?.length) { + +
+ @if (!textIsNotEmpty(slackSearchText)) { +
+ notification.not-found-slack-recipient +
+ } @else { + Not slack recipient matches {{ truncate.transform(slackSearchText, true, 6, '...') }} + }
- - Not slack recipient matches {{ truncate.transform(slackSearchText, true, 6, '...') }} - -
- + + } - - {{ requiredText }} - + @if (conversationSlackFormGroup.get('conversation').hasError('required')) { + + {{ requiredText }} + + } diff --git a/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts b/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts index 9e6a7add29..5a6ac46d1c 100644 --- a/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/slack-conversation-autocomplete.component.ts @@ -27,16 +27,18 @@ import { TruncatePipe } from '@shared/pipe/truncate.pipe'; import { SlackChanelType, SlackConversation } from '@shared/models/notification.models'; import { NotificationService } from '@core/http/notification.service'; import { isEqual } from '@core/utils'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-slack-conversation-autocomplete', - templateUrl: './slack-conversation-autocomplete.component.html', - styleUrls: ['./slack-conversation-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => SlackConversationAutocompleteComponent), - multi: true - }] + selector: 'tb-slack-conversation-autocomplete', + templateUrl: './slack-conversation-autocomplete.component.html', + styleUrls: ['./slack-conversation-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SlackConversationAutocompleteComponent), + multi: true + }], + standalone: false }) export class SlackConversationAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { @@ -66,6 +68,9 @@ export class SlackConversationAutocompleteComponent implements ControlValueAcces @Input() token: string; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + @ViewChild('slackInput', {static: true}) slackInput: ElementRef; slackConversation$: Observable>; diff --git a/ui-ngx/src/app/shared/components/snack-bar-component.html b/ui-ngx/src/app/shared/components/snack-bar-component.html index 2197cebdcc..0bd630d154 100644 --- a/ui-ngx/src/app/shared/components/snack-bar-component.html +++ b/ui-ngx/src/app/shared/components/snack-bar-component.html @@ -24,11 +24,10 @@ [class.warn-toast]="notification.type === 'warn'" [class.success-toast]="notification.type === 'success'" [class.info-toast]="notification.type === 'info'"> - + @if (!notification.modern) {
-
- + } @else {
- + }
diff --git a/ui-ngx/src/app/shared/components/socialshare-panel.component.ts b/ui-ngx/src/app/shared/components/socialshare-panel.component.ts index ef1c00f331..04576a2bcc 100644 --- a/ui-ngx/src/app/shared/components/socialshare-panel.component.ts +++ b/ui-ngx/src/app/shared/components/socialshare-panel.component.ts @@ -18,9 +18,10 @@ import { Component, Input, OnInit } from '@angular/core'; import { isLocalUrl } from '@core/utils'; @Component({ - selector: 'tb-social-share-panel', - templateUrl: './socialshare-panel.component.html', - styleUrls: ['./socialshare-panel.component.scss'] + selector: 'tb-social-share-panel', + templateUrl: './socialshare-panel.component.html', + styleUrls: ['./socialshare-panel.component.scss'], + standalone: false }) export class SocialSharePanelComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-autocomplete.component.html index 8891fad40f..2471b5caf3 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.html @@ -17,35 +17,45 @@ --> - {{label}} + @if (label) { + {{label}} + } - - - warning - - - {{ getErrorMessage }} - + @if (selectionFormControl.value && !disabled) { + + } + @if (selectionFormControl.errors && selectionFormControl.touched && !showInlineError) { + + warning + + } + @if (selectionFormControl.errors && showInlineError) { + + {{ getErrorMessage }} + + } - - - + @for (option of filteredOptions$ | async; track option) { + + + + } diff --git a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts index 641720d3e2..0399eddc88 100644 --- a/ui-ngx/src/app/shared/components/string-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/string-autocomplete.component.ts @@ -34,16 +34,17 @@ export interface ErrorMessageConfig { } @Component({ - selector: 'tb-string-autocomplete', - templateUrl: './string-autocomplete.component.html', - styleUrls: ['./string-autocomplete.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => StringAutocompleteComponent), - multi: true - } - ] + selector: 'tb-string-autocomplete', + templateUrl: './string-autocomplete.component.html', + styleUrls: ['./string-autocomplete.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class StringAutocompleteComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.html b/ui-ngx/src/app/shared/components/string-items-list.component.html index 106b79ab9a..dd8f280aab 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.html +++ b/ui-ngx/src/app/shared/components/string-items-list.component.html @@ -20,18 +20,23 @@ [floatLabel]="floatLabel" [appearance]="appearance" [subscriptSizing]="subscriptSizing"> - {{ label }} + @if (label) { + {{ label }} + } - - {{ item.name }} - close - + @for (item of itemList; track item) { + + {{ item.name }} + @if (!disabled) { + close + } + + } + [matAutocompleteDisabled]="!predefinedValues?.length && !fetchOptionsFn"> @for (value of filteredValues | async; track value.value) { - - } @empty { + + } @empty { @if (!allowUserValue) { - {{ 'common.not-found' | translate }} + {{ 'common.not-found' | translate }} } } @@ -66,7 +73,9 @@
- - {{ requiredText }} - + @if (stringItemsForm.get('items').hasError('required')) { + + {{ requiredText }} + + } diff --git a/ui-ngx/src/app/shared/components/string-items-list.component.ts b/ui-ngx/src/app/shared/components/string-items-list.component.ts index c9be6b3c09..6d0c57ab0a 100644 --- a/ui-ngx/src/app/shared/components/string-items-list.component.ts +++ b/ui-ngx/src/app/shared/components/string-items-list.component.ts @@ -29,7 +29,7 @@ import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angula import { coerceArray, coerceBoolean } from '@shared/decorators/coercion'; import { Observable, of } from 'rxjs'; import { filter, mergeMap, share, tap } from 'rxjs/operators'; -import { MatAutocompleteTrigger } from '@angular/material/autocomplete'; +import { MatAutocompleteSelectedEvent, MatAutocompleteTrigger } from '@angular/material/autocomplete'; import { isDefined, isUndefined } from '@core/utils'; export interface StringItemsOption { @@ -47,7 +47,8 @@ export interface StringItemsOption { multi: true } ], - encapsulation: ViewEncapsulation.None + encapsulation: ViewEncapsulation.None, + standalone: false }) export class StringItemsListComponent implements ControlValueAccessor, OnInit { @@ -148,9 +149,7 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { this.filteredValues = this.itemControl.valueChanges .pipe( tap((value) => { - if (value && typeof value !== 'string') { - this.add(value); - } else if (value === null) { + if (value === null) { this.clear(); } }), @@ -208,16 +207,17 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { this.dirty = true; } - addOnBlur(event: FocusEvent) { - const target: HTMLElement = event.relatedTarget as HTMLElement; - if ((target && target.tagName !== 'MAT-OPTION') || this.allowUserValue) { - this.addItem(this.stringItemInput.nativeElement.value ?? ''); + onOptionSelected(event: MatAutocompleteSelectedEvent): void { + if (event.option.value != null) { + this.add(event.option.value); + } else { + this.clear(); } - this.onTouched(); } addOnEnd(event: MatChipInputEvent): void { - this.addItem(event.value ?? '') + this.addItem(event.value ?? ''); + this.onTouched(); } removeItems(item: StringItemsOption) { @@ -294,6 +294,8 @@ export class StringItemsListComponent implements ControlValueAccessor, OnInit { this.stringItemInput.nativeElement.value = value; this.itemControl.patchValue(value, {emitEvent: true}); setTimeout(() => { + this.stringItemInput.nativeElement.value = value; + this.itemControl.patchValue(null, {emitEvent: false}); this.stringItemInput.nativeElement.blur(); this.stringItemInput.nativeElement.focus(); }, 0); diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html index ef2dec83fe..1f10bc4974 100644 --- a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.html @@ -17,7 +17,9 @@ --> - {{label}} + @if (label) { + {{label}} + }

   mdi:variable
     
   }
-  
-    warning
-  
-  
-    {{errorText}}
-  
+  @if (selectionFormControl.hasError('required') && !showInlineError) {
+    
+      warning
+    
+  }
+  @if (selectionFormControl.hasError('required') && showInlineError) {
+    
+      {{errorText}}
+    
+  }
 
diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss index aa68113205..acd26041ef 100644 --- a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.scss @@ -29,10 +29,10 @@ text-decoration: inherit; text-transform: inherit; overflow: hidden; - color: var(--mdc-filled-text-field-input-text-color, var(--mat-app-on-surface)); + color: var(--mat-form-field-filled-input-text-color, var(--mat-app-on-surface)); &.disabled { - color: var(--mdc-filled-text-field-disabled-input-text-color); + color: var(--mat-form-field-filled-disabled-input-text-color); } } @@ -63,7 +63,7 @@ &.disabled { .highlight { color: $tb-primary-color-light; - text-shadow: 0 0 0.5px var($tb-primary-color-light, var(--mdc-filled-text-field-disabled-input-text-color)); + text-shadow: 0 0 0.5px var($tb-primary-color-light, var(--mat-form-field-filled-disabled-input-text-color)); } } } diff --git a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts index 17bce4b9d7..60f001f188 100644 --- a/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/string-pattern-autocomplete.component.ts @@ -47,16 +47,17 @@ import { import { TemplatePortal } from '@angular/cdk/portal'; @Component({ - selector: 'tb-string-pattern-autocomplete', - templateUrl: './string-pattern-autocomplete.component.html', - styleUrls: ['./string-pattern-autocomplete.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => StringPatternAutocompleteComponent), - multi: true - } - ] + selector: 'tb-string-pattern-autocomplete', + templateUrl: './string-pattern-autocomplete.component.html', + styleUrls: ['./string-pattern-autocomplete.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringPatternAutocompleteComponent), + multi: true + } + ], + standalone: false }) export class StringPatternAutocompleteComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/svg-xml.component.html b/ui-ngx/src/app/shared/components/svg-xml.component.html index 55ba188547..a720568c47 100644 --- a/ui-ngx/src/app/shared/components/svg-xml.component.html +++ b/ui-ngx/src/app/shared/components/svg-xml.component.html @@ -19,7 +19,9 @@ tb-fullscreen [fullscreen]="fullscreen">
- + @if (!noLabel) { + + }
SvgXmlComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => SvgXmlComponent), - multi: true, - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-svg-xml', + templateUrl: './svg-xml.component.html', + styleUrls: ['./svg-xml.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => SvgXmlComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => SvgXmlComponent), + multi: true, + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class SvgXmlComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/components/tb-anchor.component.ts b/ui-ngx/src/app/shared/components/tb-anchor.component.ts index d44f5b83c0..5e26fd833d 100644 --- a/ui-ngx/src/app/shared/components/tb-anchor.component.ts +++ b/ui-ngx/src/app/shared/components/tb-anchor.component.ts @@ -17,8 +17,9 @@ import { Component, ViewContainerRef } from '@angular/core'; @Component({ - selector: 'tb-anchor', - template: '' + selector: 'tb-anchor', + template: '', + standalone: false }) export class TbAnchorComponent { constructor(public viewContainerRef: ViewContainerRef) { } diff --git a/ui-ngx/src/app/shared/components/tb-checkbox.component.ts b/ui-ngx/src/app/shared/components/tb-checkbox.component.ts index c5e7ea382e..1d8848cfcd 100644 --- a/ui-ngx/src/app/shared/components/tb-checkbox.component.ts +++ b/ui-ngx/src/app/shared/components/tb-checkbox.component.ts @@ -18,15 +18,16 @@ import { Component, EventEmitter, forwardRef, Input, Output } from '@angular/cor import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ - selector: 'tb-checkbox', - templateUrl: './tb-checkbox.component.html', - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TbCheckboxComponent), - multi: true - } - ] + selector: 'tb-checkbox', + templateUrl: './tb-checkbox.component.html', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TbCheckboxComponent), + multi: true + } + ], + standalone: false }) export class TbCheckboxComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/tb-error.component.ts b/ui-ngx/src/app/shared/components/tb-error.component.ts index 50714dea36..0c0366de6f 100644 --- a/ui-ngx/src/app/shared/components/tb-error.component.ts +++ b/ui-ngx/src/app/shared/components/tb-error.component.ts @@ -19,32 +19,33 @@ import { animate, state, style, transition, trigger } from '@angular/animations' import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-error', - template: ` + selector: 'tb-error', + template: `
{{message}}
`, - styles: [` + styles: [` :host { height: 24px; } `], - animations: [ - trigger('animation', [ - state('show', style({ - opacity: 1, - transform: 'translateY(0)' - })), - state('hide', style({ - opacity: 0, - transform: 'translateY(-1rem)' - })), - transition('* <=> *', animate('200ms ease-out')) - ]), - ] + animations: [ + trigger('animation', [ + state('show', style({ + opacity: 1, + transform: 'translateY(0)' + })), + state('hide', style({ + opacity: 0, + transform: 'translateY(-1rem)' + })), + transition('* <=> *', animate('200ms ease-out')) + ]), + ], + standalone: false }) export class TbErrorComponent { errorValue: string; diff --git a/ui-ngx/src/app/shared/components/time-unit-input.component.html b/ui-ngx/src/app/shared/components/time-unit-input.component.html index 1edb3361e1..a34d65ff25 100644 --- a/ui-ngx/src/app/shared/components/time-unit-input.component.html +++ b/ui-ngx/src/app/shared/components/time-unit-input.component.html @@ -27,19 +27,24 @@
@if (inlineField) { - - warning - + @if (hasError) { + + warning + + } + } + @if ((subscriptSizing === 'fixed') && !inlineField) { + {{ hintText }} + } + @if (hasError && !inlineField) { + + {{ hasError }} + } - {{ hintText }} - - {{ hasError }} - TimeUnitInputComponent), - multi: true - },{ - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TimeUnitInputComponent), - multi: true - }] + selector: 'tb-time-unit-input', + templateUrl: './time-unit-input.component.html', + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeUnitInputComponent), + multi: true + }, { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TimeUnitInputComponent), + multi: true + }], + standalone: false }) export class TimeUnitInputComponent implements ControlValueAccessor, Validator, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html index 8738fd2506..3341556e75 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.html @@ -24,9 +24,11 @@
- - {{ aggregationTypesTranslations.get(aggregationTypes[type]) | translate }} - + @for (type of allAggregationTypes; track type) { + + {{ aggregationTypesTranslations.get(aggregationTypes[type]) | translate }} + + }
diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts index 4de0d81aa7..00e240642d 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-options-config-panel.component.ts @@ -20,9 +20,10 @@ import { FormBuilder, FormGroup } from '@angular/forms'; import { TbPopoverComponent } from '@shared/components/popover.component'; @Component({ - selector: 'tb-aggregation-options-config-panel', - templateUrl: './aggregation-options-config-panel.component.html', - styleUrls: ['./aggregation-options-config-panel.component.scss'] + selector: 'tb-aggregation-options-config-panel', + templateUrl: './aggregation-options-config-panel.component.html', + styleUrls: ['./aggregation-options-config-panel.component.scss'], + standalone: false }) export class AggregationOptionsConfigPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html index a63ee7540b..bd2a4aee7d 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html +++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.html @@ -18,11 +18,15 @@ - {{ label }} + @if (displayLabel) { + {{ label }} + } - - {{ displayAggregationTypeFn(type) }} - + @for (type of aggregationTypes; track type) { + + {{ displayAggregationTypeFn(type) }} + + } diff --git a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts index 027f63dea0..3ffe025309 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts +++ b/ui-ngx/src/app/shared/components/time/aggregation/aggregation-type-select.component.ts @@ -24,14 +24,15 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isEqual } from '@core/utils'; @Component({ - selector: 'tb-aggregation-type-select', - templateUrl: './aggregation-type-select.component.html', - styleUrls: ['./aggregation-type-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => AggregationTypeSelectComponent), - multi: true - }] + selector: 'tb-aggregation-type-select', + templateUrl: './aggregation-type-select.component.html', + styleUrls: ['./aggregation-type-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => AggregationTypeSelectComponent), + multi: true + }], + standalone: false }) export class AggregationTypeSelectComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html index 75e53e4404..f3c474d2a4 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html +++ b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.html @@ -20,12 +20,16 @@ [subscriptSizing]="subscriptSizing" [appearance]="appearance"> - - {{ 'timewindow.all' | translate }} - - - {{ interval.name | translate:interval.translateParams }} - + @if (timeintervalFormGroup.get('aggIntervals').value?.length === allIntervals.length) { + + {{ 'timewindow.all' | translate }} + + } + @for (interval of allIntervals; track interval) { + + {{ interval.name | translate:interval.translateParams }} + + } -- - - {{ interval.name | translate:interval.translateParams }} - + @for (interval of selectedIntervals; track interval) { + + {{ interval.name | translate:interval.translateParams }} + + } diff --git a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts index f55400b91c..69bfd89604 100644 --- a/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts +++ b/ui-ngx/src/app/shared/components/time/aggregation/grouping-interval-options.component.ts @@ -29,16 +29,17 @@ import { isDefined } from '@core/utils'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; @Component({ - selector: 'tb-grouping-interval-options', - templateUrl: './grouping-interval-options.component.html', - styleUrls: ['./grouping-interval-options.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => GroupingIntervalOptionsComponent), - multi: true - } - ] + selector: 'tb-grouping-interval-options', + templateUrl: './grouping-interval-options.component.html', + styleUrls: ['./grouping-interval-options.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => GroupingIntervalOptionsComponent), + multi: true + } + ], + standalone: false }) export class GroupingIntervalOptionsComponent implements OnInit, ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts b/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts index aa10635542..7594e1f4a3 100644 --- a/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts +++ b/ui-ngx/src/app/shared/components/time/datapoints-limit.component.ts @@ -32,21 +32,22 @@ import { Subject } from 'rxjs'; import { isDefined } from '@core/utils'; @Component({ - selector: 'tb-datapoints-limit', - templateUrl: './datapoints-limit.component.html', - styleUrls: ['./datapoints-limit.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatapointsLimitComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => DatapointsLimitComponent), - multi: true - } - ] + selector: 'tb-datapoints-limit', + templateUrl: './datapoints-limit.component.html', + styleUrls: ['./datapoints-limit.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatapointsLimitComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => DatapointsLimitComponent), + multi: true + } + ], + standalone: false }) export class DatapointsLimitComponent implements ControlValueAccessor, Validator, OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts index c5f772036e..52df96f754 100644 --- a/ui-ngx/src/app/shared/components/time/datetime-period.component.ts +++ b/ui-ngx/src/app/shared/components/time/datetime-period.component.ts @@ -27,16 +27,17 @@ interface DateTimePeriod { } @Component({ - selector: 'tb-datetime-period', - templateUrl: './datetime-period.component.html', - styleUrls: ['./datetime-period.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatetimePeriodComponent), - multi: true - } - ] + selector: 'tb-datetime-period', + templateUrl: './datetime-period.component.html', + styleUrls: ['./datetime-period.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatetimePeriodComponent), + multi: true + } + ], + standalone: false }) export class DatetimePeriodComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.html b/ui-ngx/src/app/shared/components/time/datetime.component.html index 8ef3ee3c21..25861aa4d4 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.html +++ b/ui-ngx/src/app/shared/components/time/datetime.component.html @@ -21,14 +21,18 @@ [class.tb-time-field]="!fieldClass && type === 'time'" [class.with-clear]="allowClear" [subscriptSizing]="subscriptSizing" [appearance]="appearance" [floatLabel]="floatLabel"> - {{ dateText }} + @if (showLabel) { + {{ dateText }} + }
- + @if (allowClear && date && !disabled) { + + }
diff --git a/ui-ngx/src/app/shared/components/time/datetime.component.ts b/ui-ngx/src/app/shared/components/time/datetime.component.ts index 50a15f7b47..8306b14872 100644 --- a/ui-ngx/src/app/shared/components/time/datetime.component.ts +++ b/ui-ngx/src/app/shared/components/time/datetime.component.ts @@ -18,20 +18,20 @@ import { Component, forwardRef, Input, OnInit } from '@angular/core'; import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; import { coerceBooleanProperty } from '@angular/cdk/coercion'; import { FloatLabelType, MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; -import { MatDatetimepickerType } from '@mat-datetimepicker/core/datetimepicker/datetimepicker-type'; import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-datetime', - templateUrl: './datetime.component.html', - styleUrls: ['./datetime.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => DatetimeComponent), - multi: true - } - ] + selector: 'tb-datetime', + templateUrl: './datetime.component.html', + styleUrls: ['./datetime.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatetimeComponent), + multi: true + } + ], + standalone: false }) export class DatetimeComponent implements OnInit, ControlValueAccessor { @@ -40,7 +40,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor { return this.requiredValue; } @Input() - set required(value: boolean) { + set required(value: unknown) { this.requiredValue = coerceBooleanProperty(value); } @@ -54,7 +54,7 @@ export class DatetimeComponent implements OnInit, ControlValueAccessor { appearance: MatFormFieldAppearance = 'fill'; @Input() - type: MatDatetimepickerType = 'datetime'; + type: 'date' | 'time' | 'month' | 'year' | 'datetime' = 'datetime'; @Input() disabled: boolean; diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html index f9d176eccd..152f4ca083 100644 --- a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html @@ -16,39 +16,49 @@ -->
-
- - -
- - -
- {{ this.currentTime | date:'medium'}} - {{ "widget.no-data-found" | translate}} -
-
- - - - - - {{speedValue}} - +
+ + +
+ + +
+ @if (currentTime) { + {{ currentTime | date:'medium'}} + } + @if (!currentTime) { + {{ "widget.no-data-found" | translate}} + } +
+ + + @if (!playing) { + + } + @if (playing) { + + } + + @for (speedValue of speeds; track speedValue) { + {{speedValue}} + } + +
diff --git a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts index 0f540f3bd9..e354860e14 100644 --- a/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts +++ b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.ts @@ -20,9 +20,10 @@ import { filter } from 'rxjs/operators'; import { HistorySelectSettings } from '@app/modules/home/components/widget/lib/maps-legacy/map-models'; @Component({ - selector: 'tb-history-selector', - templateUrl: './history-selector.component.html', - styleUrls: ['./history-selector.component.scss'] + selector: 'tb-history-selector', + templateUrl: './history-selector.component.html', + styleUrls: ['./history-selector.component.scss'], + standalone: false }) export class HistorySelectorComponent implements OnChanges { diff --git a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html index 8953db42b1..646c63df94 100644 --- a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.html @@ -32,20 +32,20 @@ {{ "timewindow.interval" | translate }}
- + @if (aggregation) {
{{"timewindow.allowed-agg-intervals" | translate }}
{{"timewindow.default-agg-interval" | translate }}
-
+ }
- + @for (interval of intervalsFormArray.controls; track trackByElement(i, interval); let i = $index) {
{{ interval.get('name').value }}
- + @if (aggregation) {
-
+ }
-
+ }
diff --git a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts index b52a6903cc..6f38283a1a 100644 --- a/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/interval-options-config-panel.component.ts @@ -39,9 +39,10 @@ export interface IntervalOptionsConfigPanelData { } @Component({ - selector: 'tb-interval-options-config-panel', - templateUrl: './interval-options-config-panel.component.html', - styleUrls: ['./interval-options-config-panel.component.scss'] + selector: 'tb-interval-options-config-panel', + templateUrl: './interval-options-config-panel.component.html', + styleUrls: ['./interval-options-config-panel.component.scss'], + standalone: false }) export class IntervalOptionsConfigPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html index 66bbb01b14..0b95467753 100644 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.html @@ -17,11 +17,15 @@ -->
- timewindow.interval + @if (displayLabel) { + timewindow.interval + } - - {{ timeIntervalTranslationMap.get(interval) | translate}} - + @for (interval of intervals; track interval) { + + {{ timeIntervalTranslationMap.get(interval) | translate}} + + } diff --git a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts index aa3a3dce97..974b1802ae 100644 --- a/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts +++ b/ui-ngx/src/app/shared/components/time/quick-time-interval.component.ts @@ -23,16 +23,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { isEqual } from '@core/utils'; @Component({ - selector: 'tb-quick-time-interval', - templateUrl: './quick-time-interval.component.html', - styleUrls: ['./quick-time-interval.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => QuickTimeIntervalComponent), - multi: true - } - ] + selector: 'tb-quick-time-interval', + templateUrl: './quick-time-interval.component.html', + styleUrls: ['./quick-time-interval.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => QuickTimeIntervalComponent), + multi: true + } + ], + standalone: false }) export class QuickTimeIntervalComponent implements OnInit, ControlValueAccessor, OnChanges { diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.html b/ui-ngx/src/app/shared/components/time/timeinterval.component.html index cc9793155c..b0955afcc3 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.html +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.html @@ -19,11 +19,15 @@
- {{ predefinedName }} + @if (predefinedName) { + {{ predefinedName }} + } - - {{ interval.name | translate:interval.translateParams }} - + @for (interval of intervals; track interval) { + + {{ interval.name | translate:interval.translateParams }} + + } diff --git a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts index 29a628fdd0..4726c01b60 100644 --- a/ui-ngx/src/app/shared/components/time/timeinterval.component.ts +++ b/ui-ngx/src/app/shared/components/time/timeinterval.component.ts @@ -36,16 +36,17 @@ import { takeUntil } from 'rxjs/operators'; import { Subject } from 'rxjs'; @Component({ - selector: 'tb-timeinterval', - templateUrl: './timeinterval.component.html', - styleUrls: ['./timeinterval.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimeintervalComponent), - multi: true - } - ] + selector: 'tb-timeinterval', + templateUrl: './timeinterval.component.html', + styleUrls: ['./timeinterval.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimeintervalComponent), + multi: true + } + ], + standalone: false }) export class TimeintervalComponent implements OnInit, ControlValueAccessor, OnChanges, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html index c3aee54ac9..272fa6239c 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.html @@ -19,7 +19,7 @@

{{ 'timewindow.timewindow-settings' | translate }}

-
+
-
-
{{ 'timewindow.timewindow' | translate }}
-
- - {{ 'timewindow.hide-timewindow-section' | translate }} - -
- - - - - + @if (timewindowForm.get('selectedTab').value === timewindowTypes.REALTIME) { +
+
{{ 'timewindow.timewindow' | translate }}
- - {{ 'timewindow.disable-custom-interval' | translate }} + + {{ 'timewindow.hide-timewindow-section' | translate }}
-
- -
- {{ 'timewindow.hide' | translate }} -
-
- + + } + @if (timewindowForm.get('realtime.realtimeType').value === realtimeTypes.LAST_INTERVAL) { +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ @if (!quickIntervalOnly) { + +
+ {{ 'timewindow.hide' | translate }} +
+
+ } + - - -
- - -
- -
- {{ 'timewindow.hide' | translate }} + @if (!(timewindowForm.get('realtime.hideLastInterval').value || + timewindowForm.get('realtime.hideInterval').value)) { + + } +
-
- + @if (!quickIntervalOnly) { + +
+ {{ 'timewindow.hide' | translate }} +
+
+ } + - - -
-
- -
-
{{ 'timewindow.timewindow' | translate }}
-
- - {{ 'timewindow.hide-timewindow-section' | translate }} - -
- - - + edit + + } + +
+ } + + } - + @if (timewindowForm.get('selectedTab').value === timewindowTypes.HISTORY) { +
+
{{ 'timewindow.timewindow' | translate }}
- - {{ 'timewindow.disable-custom-interval' | translate }} + + {{ 'timewindow.hide-timewindow-section' | translate }}
-
- -
- {{ 'timewindow.hide' | translate }} -
-
- + + @if (timewindowForm.get('history.historyType').value === historyTypes.LAST_INTERVAL) { +
+ + {{ 'timewindow.disable-custom-interval' | translate }} + +
+
+ +
+ {{ 'timewindow.hide' | translate }} +
+
+ - - -
- - -
- -
- {{ 'timewindow.hide' | translate }} + @if (!(timewindowForm.get('history.hideLastInterval').value || + timewindowForm.get('history.hideInterval').value)) { + + } +
-
- + +
+ {{ 'timewindow.hide' | translate }} +
+
+ - -
- -
- -
- {{ 'timewindow.hide' | translate }} -
-
- +
+ } + @if (timewindowForm.get('history.historyType').value === historyTypes.INTERVAL) { +
+ +
+ {{ 'timewindow.hide' | translate }} +
+
+ - - -
-
+ @if (!(timewindowForm.get('history.hideQuickInterval').value || + timewindowForm.get('history.hideInterval').value)) { + + } + +
+ } + + } - + @if (aggregation) {
{{ 'aggregation.aggregation' | translate }}
@@ -198,37 +215,38 @@ - + @if (!timewindowForm.get('hideAggregation').value) { + + }
- -
-
{{ 'aggregation.limit' | translate }}
-
- -
- {{ 'timewindow.hide' | translate }} -
-
- - - - -
-
- + @if (timewindowForm.get('aggregation.type').value === aggregationTypes.NONE) { +
+
{{ 'aggregation.limit' | translate }}
+
+ +
+ {{ 'timewindow.hide' | translate }} +
+
+ + + + +
+
+ }
{{ 'aggregation.group-interval' | translate }}
- - + @if (timewindowForm.get('selectedTab').value === timewindowTypes.REALTIME) {
{{ 'timewindow.disable-custom-interval' | translate }} @@ -250,8 +268,8 @@
-
- + } + @if (timewindowForm.get('selectedTab').value === timewindowTypes.HISTORY) {
{{ 'timewindow.disable-custom-interval' | translate }} @@ -273,8 +291,7 @@
-
- + }
@@ -283,7 +300,7 @@
-
+ }
{{ 'timezone.timezone' | translate }}
diff --git a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts index ed56dfa46f..8d6116bef7 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-config-dialog.component.ts @@ -67,9 +67,10 @@ export interface TimewindowConfigDialogData { } @Component({ - selector: 'tb-timewindow-config-dialog', - templateUrl: './timewindow-config-dialog.component.html', - styleUrls: ['./timewindow-config-dialog.component.scss', './timewindow-form.scss'] + selector: 'tb-timewindow-config-dialog', + templateUrl: './timewindow-config-dialog.component.html', + styleUrls: ['./timewindow-config-dialog.component.scss', './timewindow-form.scss'], + standalone: false }) export class TimewindowConfigDialogComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html index 6a6ca10734..322da81efb 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.html @@ -16,163 +16,194 @@ -->
-
- - - -
+ @if (!historyOnly) { +
+ + + @if (isEdit) { + + } +
+ }
- -
-
-
-
{{ 'timewindow.timewindow' | translate }}
- - - + @if (timewindowForm.get('selectedTab').value === timewindowTypes.REALTIME) { + @if (realtimeIntervalSelectionAvailable) { +
+
+
+
{{ 'timewindow.timewindow' | translate }}
+ @if (realtimeTypeSelectionAvailable) { + + + + + } +
+
- - - -
- -
- + @if (timewindowForm.get('realtime.realtimeType').value === realtimeTypes.LAST_INTERVAL) { + - - - + } + @if (timewindowForm.get('realtime.realtimeType').value === realtimeTypes.INTERVAL) { + - -
-
-
- - -
-
-
-
{{ 'timewindow.timewindow' | translate }}
- - - - + + }
- +
+ } @else { +
+
{{ 'timezone.timezone' | translate }}
-
+
+ } + } -
- +
+
+
{{ 'timewindow.timewindow' | translate }}
+ @if (historyTypeSelectionAvailable) { + + + + + } +
+ + +
+ @if (historyIntervalSelectionAvailable && + timewindowForm.get('history.historyType').value !== historyTypes.FOR_ALL_TIME) { +
+ @if (timewindowForm.get('history.historyType').value === historyTypes.LAST_INTERVAL) { + - - - + } + @if (timewindowForm.get('history.historyType').value === historyTypes.FIXED) { + - - - + } + @if (timewindowForm.get('history.historyType').value === historyTypes.INTERVAL) { + - -
- - - - - -
-
{{ 'aggregation.aggregation' | translate }}
- - + + } +
+ } - -
-
{{ 'aggregation.limit' | translate }}
- - + } @else { +
+
{{ 'timezone.timezone' | translate }}
+ +
- + } + } -
- - -
{{ 'aggregation.group-interval' | translate }}
-
-
- - -
{{ 'aggregation.group-interval' | translate }}
-
-
-
- + @if (aggregationOptionsAvailable) { + + @if (isEdit || !timewindow.hideAggregation) { +
+
{{ 'aggregation.aggregation' | translate }}
+ + +
+ } + @if (timewindowForm.get('aggregation.type').value === aggregationTypes.NONE && (isEdit || !timewindow.hideAggInterval)) { +
+
{{ 'aggregation.limit' | translate }}
+ + +
+ } +
+ @if (timewindowForm.get('aggregation.type').value !== aggregationTypes.NONE) { +
+ @if (timewindowForm.get('selectedTab').value === timewindowTypes.REALTIME) { + + +
{{ 'aggregation.group-interval' | translate }}
+
+
+ } + @if (timewindowForm.get('selectedTab').value === timewindowTypes.HISTORY) { + + +
{{ 'aggregation.group-interval' | translate }}
+
+
+ } +
+ } + } @if (saveAsDefaultAvailable) { {{ 'timewindow.save-current-settings-as-default' | translate }} @@ -180,34 +211,33 @@ }
- -
-
{{ 'timezone.timezone' | translate }}
- - -
-
- - + @if (timezone && (isEdit || !timewindow.hideTimezone)) { + + + } - -
- - -
+ @if (panelMode) { + + } + @if (panelMode) { +
+ + +
+ } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss index 8eabb2df49..b278b59a85 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.scss @@ -41,7 +41,7 @@ } &-checkbox { - --mdc-checkbox-state-layer-size: 24px; + --mat-checkbox-state-layer-size: 24px; } } } diff --git a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts index ea14ea334f..32f4c46596 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow-panel.component.ts @@ -76,9 +76,10 @@ export interface TimewindowPanelData { export const TIMEWINDOW_PANEL_DATA = new InjectionToken('TimewindowPanelData'); @Component({ - selector: 'tb-timewindow-panel', - templateUrl: './timewindow-panel.component.html', - styleUrls: ['./timewindow-panel.component.scss', './timewindow-form.scss'] + selector: 'tb-timewindow-panel', + templateUrl: './timewindow-panel.component.html', + styleUrls: ['./timewindow-panel.component.scss', './timewindow-form.scss'], + standalone: false }) export class TimewindowPanelComponent extends PageComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.html b/ui-ngx/src/app/shared/components/time/timewindow.component.html index 55c2f58120..5a2d5016a0 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.html +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.html @@ -15,44 +15,56 @@ limitations under the License. --> - - - -
- {{ computedTimewindowStyle.icon }} -
- {{innerValue?.displayValue}} | {{innerValue?.displayTimezoneAbbr}} -
- {{ computedTimewindowStyle.icon }} -
+@if (asButton && panelMode && !strokedButton && !flatButton) { + +} +@if (asButton && panelMode && strokedButton) { + +} +@if (asButton && panelMode && flatButton) { + +} +@if (!asButton && panelMode) { +
+ @if (computedTimewindowStyle.showIcon && computedTimewindowStyle.iconPosition === 'left') { + {{ computedTimewindowStyle.icon }} + } +
+ {{innerValue?.displayValue}} | {{innerValue?.displayTimezoneAbbr}} +
+ @if (computedTimewindowStyle.showIcon && computedTimewindowStyle.iconPosition === 'right') { + {{ computedTimewindowStyle.icon }} + } +
+} diff --git a/ui-ngx/src/app/shared/components/time/timewindow.component.ts b/ui-ngx/src/app/shared/components/time/timewindow.component.ts index cf5c1e5852..3d98ab0022 100644 --- a/ui-ngx/src/app/shared/components/time/timewindow.component.ts +++ b/ui-ngx/src/app/shared/components/time/timewindow.component.ts @@ -71,16 +71,17 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; // @dynamic @Component({ - selector: 'tb-timewindow', - templateUrl: './timewindow.component.html', - styleUrls: ['./timewindow.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimewindowComponent), - multi: true - } - ] + selector: 'tb-timewindow', + templateUrl: './timewindow.component.html', + styleUrls: ['./timewindow.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimewindowComponent), + multi: true + } + ], + standalone: false }) export class TimewindowComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts b/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts index 1653851023..d57e7a3bbc 100644 --- a/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts +++ b/ui-ngx/src/app/shared/components/time/timezone-panel.component.ts @@ -26,9 +26,10 @@ export interface TimezoneSelectionResult { } @Component({ - selector: 'tb-timezone-panel', - templateUrl: './timezone-panel.component.html', - styleUrls: ['./timezone-panel.component.scss'] + selector: 'tb-timezone-panel', + templateUrl: './timezone-panel.component.html', + styleUrls: ['./timezone-panel.component.scss'], + standalone: false }) export class TimezonePanelComponent extends PageComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/time/timezone-select.component.html b/ui-ngx/src/app/shared/components/time/timezone-select.component.html index 73343b71e3..5e343ce946 100644 --- a/ui-ngx/src/app/shared/components/time/timezone-select.component.html +++ b/ui-ngx/src/app/shared/components/time/timezone-select.component.html @@ -17,35 +17,45 @@ --> - timezone.timezone + @if (displayLabel) { + timezone.timezone + } - + @if (selectTimezoneFormGroup.get('timezone').value && !disabled) { + + } - - - - - + @for (timezone of filteredTimezones | async; track timezone) { + + + + } + @if (!(filteredTimezones | async)?.length) { + + {{ translate.get('timezone.no-timezones-matching', {timezone: searchText}) | async }} - - + + + } - - {{ 'timezone.timezone-required' | translate }} - + @if (selectTimezoneFormGroup.get('timezone').hasError('required')) { + + {{ 'timezone.timezone-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/time/timezone-select.component.ts b/ui-ngx/src/app/shared/components/time/timezone-select.component.ts index 903cbab4a5..54d1ff6cad 100644 --- a/ui-ngx/src/app/shared/components/time/timezone-select.component.ts +++ b/ui-ngx/src/app/shared/components/time/timezone-select.component.ts @@ -30,14 +30,15 @@ import { coerceBoolean } from '@shared/decorators/coercion'; import { TimeService } from '@core/services/time.service'; @Component({ - selector: 'tb-timezone-select', - templateUrl: './timezone-select.component.html', - styleUrls: [], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimezoneSelectComponent), - multi: true - }] + selector: 'tb-timezone-select', + templateUrl: './timezone-select.component.html', + styleUrls: [], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimezoneSelectComponent), + multi: true + }], + standalone: false }) export class TimezoneSelectComponent implements ControlValueAccessor, OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/shared/components/time/timezone.component.html b/ui-ngx/src/app/shared/components/time/timezone.component.html index 1ca6c746b8..3f37bcdec9 100644 --- a/ui-ngx/src/app/shared/components/time/timezone.component.html +++ b/ui-ngx/src/app/shared/components/time/timezone.component.html @@ -15,40 +15,48 @@ limitations under the License. --> - - - -
-
- {{displayValue()}} -
-
+@if (asButton && !strokedButton && !flatButton) { + +} +@if (asButton && strokedButton) { + +} +@if (asButton && flatButton) { + +} +@if (!asButton) { +
+
+ {{displayValue()}} +
+
+} diff --git a/ui-ngx/src/app/shared/components/time/timezone.component.ts b/ui-ngx/src/app/shared/components/time/timezone.component.ts index 7b8a3bf21e..fd5ddda489 100644 --- a/ui-ngx/src/app/shared/components/time/timezone.component.ts +++ b/ui-ngx/src/app/shared/components/time/timezone.component.ts @@ -36,16 +36,17 @@ import { TimeService } from '@core/services/time.service'; // @dynamic @Component({ - selector: 'tb-timezone', - templateUrl: './timezone.component.html', - styleUrls: ['./timezone.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TimezoneComponent), - multi: true - } - ] + selector: 'tb-timezone', + templateUrl: './timezone.component.html', + styleUrls: ['./timezone.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimezoneComponent), + multi: true + } + ], + standalone: false }) export class TimezoneComponent implements ControlValueAccessor, OnInit { diff --git a/ui-ngx/src/app/shared/components/toast.directive.ts b/ui-ngx/src/app/shared/components/toast.directive.ts index 38960b1e2b..773a6d298e 100644 --- a/ui-ngx/src/app/shared/components/toast.directive.ts +++ b/ui-ngx/src/app/shared/components/toast.directive.ts @@ -38,7 +38,8 @@ import { MatButton } from '@angular/material/button'; import Timeout = NodeJS.Timeout; @Directive({ - selector: '[tb-toast]' + selector: '[tb-toast]', + standalone: false }) export class ToastDirective implements AfterViewInit, OnDestroy { @@ -134,8 +135,10 @@ export class ToastDirective implements AfterViewInit, OnDestroy { notification: notificationMessage, panelClass, destroyToastComponent: () => { - this.viewContainerRef.detach(0); - this.toastComponentRef.destroy(); + if (this.toastComponentRef) { + this.viewContainerRef.detach(0); + this.toastComponentRef.destroy(); + } } }; const providers: StaticProvider[] = [ @@ -272,10 +275,11 @@ export const toastAnimations: { export type ToastAnimationState = 'default' | 'opened' | 'closing'; @Component({ - selector: 'tb-snack-bar-component', - templateUrl: 'snack-bar-component.html', - styleUrls: ['snack-bar-component.scss'], - animations: [toastAnimations.showHideToast] + selector: 'tb-snack-bar-component', + templateUrl: 'snack-bar-component.html', + styleUrls: ['snack-bar-component.scss'], + animations: [toastAnimations.showHideToast], + standalone: false }) export class TbSnackBarComponent implements AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/toggle-header.component.html b/ui-ngx/src/app/shared/components/toggle-header.component.html index bddaa896b6..e9063b3be2 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.html +++ b/ui-ngx/src/app/shared/components/toggle-header.component.html @@ -26,33 +26,48 @@ [class.tb-mat-24]="isMdLg"> chevron_left -
- - - {{ option.name }} - - warning - - - -
+@if ((useSelect$ | async) === false) { +
+ + @for (option of options; track trackByHeaderOption($index, option)) { + + {{ option.name }} + @if ((option.error$ | async) && value !== option.value) { + + warning + + } + + } + +
+} @else { + + + @for (option of options; track option) { + {{ option.name }} + } + + +} - - - - {{ option.name }} - - - diff --git a/ui-ngx/src/app/shared/components/toggle-header.component.ts b/ui-ngx/src/app/shared/components/toggle-header.component.ts index d7179afc5d..c60faf810a 100644 --- a/ui-ngx/src/app/shared/components/toggle-header.component.ts +++ b/ui-ngx/src/app/shared/components/toggle-header.component.ts @@ -61,7 +61,8 @@ export type ScrollDirection = 'after' | 'before'; { // eslint-disable-next-line @angular-eslint/directive-selector selector: 'tb-toggle-option', - } + standalone: false +} ) // eslint-disable-next-line @angular-eslint/directive-class-suffix export class ToggleOption implements OnChanges, OnDestroy { @@ -136,9 +137,10 @@ export abstract class _ToggleBase extends PageComponent implements AfterContentI } @Component({ - selector: 'tb-toggle-header', - templateUrl: './toggle-header.component.html', - styleUrls: ['./toggle-header.component.scss'] + selector: 'tb-toggle-header', + templateUrl: './toggle-header.component.html', + styleUrls: ['./toggle-header.component.scss'], + standalone: false }) export class ToggleHeaderComponent extends _ToggleBase implements OnInit, AfterViewInit, AfterContentInit, AfterContentChecked, AfterViewChecked, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/toggle-select.component.ts b/ui-ngx/src/app/shared/components/toggle-select.component.ts index 643c7bd35f..107cb2bf43 100644 --- a/ui-ngx/src/app/shared/components/toggle-select.component.ts +++ b/ui-ngx/src/app/shared/components/toggle-select.component.ts @@ -22,16 +22,17 @@ import { _ToggleBase, ToggleHeaderAppearance } from '@shared/components/toggle-h import { coerceBoolean } from '@shared/decorators/coercion'; @Component({ - selector: 'tb-toggle-select', - templateUrl: './toggle-select.component.html', - styleUrls: ['./toggle-select.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ToggleSelectComponent), - multi: true - } - ] + selector: 'tb-toggle-select', + templateUrl: './toggle-select.component.html', + styleUrls: ['./toggle-select.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ToggleSelectComponent), + multi: true + } + ], + standalone: false }) export class ToggleSelectComponent extends _ToggleBase implements ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/unit-input.component.html b/ui-ngx/src/app/shared/components/unit-input.component.html index 66d92c83d1..c2cd671072 100644 --- a/ui-ngx/src/app/shared/components/unit-input.component.html +++ b/ui-ngx/src/app/shared/components/unit-input.component.html @@ -23,27 +23,33 @@ (focusin)="onFocus()" [matAutocomplete]="unitsAutocomplete" [matAutocompleteDisabled]="supportsUnitConversion"> - - - warning - - - mdi:swap-vertical-circle-outline - + @if (unitsFormControl.value && !disabled && unitsFormControl.valid && !isUnitMapping) { + + } + @if (unitsFormControl.hasError('required')) { + + warning + + } + @if (supportsUnitConversion && isUnitMapping) { + + mdi:swap-vertical-circle-outline + + } } - } + } diff --git a/ui-ngx/src/app/shared/components/unit-input.component.ts b/ui-ngx/src/app/shared/components/unit-input.component.ts index a064376041..ad391aa70a 100644 --- a/ui-ngx/src/app/shared/components/unit-input.component.ts +++ b/ui-ngx/src/app/shared/components/unit-input.component.ts @@ -48,17 +48,18 @@ import { UnitSettingsPanelComponent } from '@shared/components/unit-settings-pan import { isDefinedAndNotNull, isEqual } from '@core/utils'; @Component({ - selector: 'tb-unit-input', - templateUrl: './unit-input.component.html', - styleUrls: ['./unit-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => UnitInputComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None + selector: 'tb-unit-input', + templateUrl: './unit-input.component.html', + styleUrls: ['./unit-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => UnitInputComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class UnitInputComponent implements ControlValueAccessor, OnInit, OnChanges { diff --git a/ui-ngx/src/app/shared/components/unit-settings-panel.component.html b/ui-ngx/src/app/shared/components/unit-settings-panel.component.html index 41875971f8..c27f56dd3f 100644 --- a/ui-ngx/src/app/shared/components/unit-settings-panel.component.html +++ b/ui-ngx/src/app/shared/components/unit-settings-panel.component.html @@ -22,9 +22,9 @@
unit.unit-settings.source-unit
+ translate>unit.unit-settings.source-unit
+ [measure]="measure" [tagFilter]="tagFilter">
@@ -36,7 +36,7 @@
unit.unit-settings.target-metric-unit
+ translate>unit.unit-settings.target-metric-unit
unit.unit-settings.target-imperial-unit
+ translate>unit.unit-settings.target-imperial-unit
unit.unit-settings.target-hybrid-unit
+ translate>unit.unit-settings.target-hybrid-unit
- + @if (!disabled) { + + } - + @if (!disabled) { + + }
diff --git a/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts b/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts index a011cca121..1c3a4d777c 100644 --- a/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts +++ b/ui-ngx/src/app/shared/components/unit-settings-panel.component.ts @@ -24,11 +24,12 @@ import { debounceTime } from 'rxjs/operators'; import { isUndefinedOrNull } from '@core/utils'; @Component({ - selector: 'tb-covert-unit-settings-panel', - templateUrl: './unit-settings-panel.component.html', - styleUrls: ['./unit-settings-panel.component.scss'], - providers: [], - encapsulation: ViewEncapsulation.None + selector: 'tb-covert-unit-settings-panel', + templateUrl: './unit-settings-panel.component.html', + styleUrls: ['./unit-settings-panel.component.scss'], + providers: [], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class UnitSettingsPanelComponent implements OnInit { diff --git a/ui-ngx/src/app/shared/components/user-menu.component.html b/ui-ngx/src/app/shared/components/user-menu.component.html index 30b67fd38c..91454db185 100644 --- a/ui-ngx/src/app/shared/components/user-menu.component.html +++ b/ui-ngx/src/app/shared/components/user-menu.component.html @@ -16,26 +16,34 @@ -->
-
diff --git a/ui-ngx/src/app/shared/components/user-menu.component.ts b/ui-ngx/src/app/shared/components/user-menu.component.ts index 1fea13df7b..15c56dfb3f 100644 --- a/ui-ngx/src/app/shared/components/user-menu.component.ts +++ b/ui-ngx/src/app/shared/components/user-menu.component.ts @@ -25,10 +25,11 @@ import { AuthService } from '@core/auth/auth.service'; import { Router } from '@angular/router'; @Component({ - selector: 'tb-user-menu', - templateUrl: './user-menu.component.html', - styleUrls: ['./user-menu.component.scss'], - changeDetection: ChangeDetectionStrategy.OnPush + selector: 'tb-user-menu', + templateUrl: './user-menu.component.html', + styleUrls: ['./user-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: false }) export class UserMenuComponent implements OnInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/components/value-input.component.html b/ui-ngx/src/app/shared/components/value-input.component.html index a2f9426aa3..1ee5ac89b3 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.html +++ b/ui-ngx/src/app/shared/components/value-input.component.html @@ -17,85 +17,107 @@ -->
- - - -
- - {{ valueTypes.get(valueType).name | translate }} -
-
- - - {{ valueTypes.get(valueTypeEnum[valueType]).name | translate }} - -
-
- - - - warning - - - - - + + +
+ + {{ valueTypes.get(valueType).name | translate }} +
+
+ @for (valueType of valueTypeKeys; track valueType) { + + + {{ valueTypes.get(valueTypeEnum[valueType]).name | translate }} + + } +
+
+ } + @if (valueType === valueTypeEnum.STRING) { + + + @if (value.hasError('required') && value.control.touched) { + + warning + + } + + } + @if (valueType === valueTypeEnum.INTEGER) { + + + @if (value.control.invalid && value.control.touched) { + - warning - - - - - - warning - - - - - {{ trueLabel }} - {{ falseLabel }} - - - {{ modelValue ? trueLabel : falseLabel }} - - -
- - - + warning + + } + + } + @if (valueType === valueTypeEnum.DOUBLE) { + + + @if (value.hasError('required') && value.control.touched) { + + warning + + } + + } + @if (valueType === valueTypeEnum.BOOLEAN) { + @if (!shortBooleanField) { + + {{ trueLabel }} + {{ falseLabel }} + + } + @if (shortBooleanField) { + + {{ modelValue ? trueLabel : falseLabel }} + + } + } + @if (valueType === valueTypeEnum.JSON) { +
+ + + @if (value.control.invalid && value.control.touched) { + - warning - - - -
+ class="tb-error"> + warning + + } + + @if (!hideJsonEdit) { + + } +
+ }
diff --git a/ui-ngx/src/app/shared/components/value-input.component.ts b/ui-ngx/src/app/shared/components/value-input.component.ts index a26c1783e4..bfc2df96c4 100644 --- a/ui-ngx/src/app/shared/components/value-input.component.ts +++ b/ui-ngx/src/app/shared/components/value-input.component.ts @@ -46,16 +46,17 @@ export interface ValueInputLayout { } @Component({ - selector: 'tb-value-input', - templateUrl: './value-input.component.html', - styleUrls: ['./value-input.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => ValueInputComponent), - multi: true - } - ] + selector: 'tb-value-input', + templateUrl: './value-input.component.html', + styleUrls: ['./value-input.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => ValueInputComponent), + multi: true + } + ], + standalone: false }) export class ValueInputComponent implements OnInit, OnDestroy, OnChanges, ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html index 26df6befba..40ddca3dce 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.html @@ -29,25 +29,37 @@ (blur)="onBlur()" [required]="required" [matAutocomplete]="branchAutocomplete"> - + @if (branchFormGroup.get('branch').value && !disabled) { + + } - - check - - - {{ 'version-control.default' | translate }} - + @for (branch of filteredBranches | async; track branch) { + + @if (selectionMode && branch.name === modelValue) { + check + } + @if (selectionMode && branch.name !== modelValue) { + + } + + @if (branch.default) { + {{ 'version-control.default' | translate }} + } + + } - - {{ 'version-control.branch-required' | translate }} - + @if (branchFormGroup.get('branch').hasError('required')) { + + {{ 'version-control.branch-required' | translate }} + + } diff --git a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts index 8afaca5b8e..ece8c1b228 100644 --- a/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts +++ b/ui-ngx/src/app/shared/components/vc/branch-autocomplete.component.ts @@ -38,15 +38,16 @@ import { MatAutocomplete, MatAutocompleteTrigger } from '@angular/material/autoc import { MatFormFieldAppearance, SubscriptSizing } from '@angular/material/form-field'; @Component({ - selector: 'tb-branch-autocomplete', - templateUrl: './branch-autocomplete.component.html', - styleUrls: ['./branch-autocomplete.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => BranchAutocompleteComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None + selector: 'tb-branch-autocomplete', + templateUrl: './branch-autocomplete.component.html', + styleUrls: ['./branch-autocomplete.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => BranchAutocompleteComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class BranchAutocompleteComponent implements ControlValueAccessor, OnInit, AfterViewInit { diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html index 8332d0a268..270284dc11 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.html @@ -23,7 +23,9 @@ (focus)="toggleFocus()" (blur)="toggleFocus()" [placeholder]=placeholder> - + @if (searchText) { + + }
diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts index 1585304552..8f15905141 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts +++ b/ui-ngx/src/app/shared/components/widgets-bundle-search.component.ts @@ -18,15 +18,16 @@ import { Component, ElementRef, forwardRef, Input, ViewChild, ViewEncapsulation import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'; @Component({ - selector: 'tb-widgets-bundle-search', - templateUrl: './widgets-bundle-search.component.html', - styleUrls: ['./widgets-bundle-search.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetsBundleSearchComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None + selector: 'tb-widgets-bundle-search', + templateUrl: './widgets-bundle-search.component.html', + styleUrls: ['./widgets-bundle-search.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetsBundleSearchComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetsBundleSearchComponent implements ControlValueAccessor { diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html index 04355f2bc1..d383c7ba71 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html @@ -15,7 +15,7 @@ limitations under the License. --> - +
{{widgetsBundle?.title}} - widgets-bundle.system + @if (isSystem(widgetsBundle)) { + widgets-bundle.system + }
- - widgets-bundle.create-new - - - - - -
- {{widgetsBundle.title}} - widgets-bundle.system -
-
+ @if (createNew) { + + widgets-bundle.create-new + + } @else { + @if (!required) { + + } + } + @for (widgetsBundle of widgetsBundles$ | async; track widgetsBundle) { + +
+ {{widgetsBundle.title}} + @if (isSystem(widgetsBundle)) { + widgets-bundle.system + } +
+
+ }
diff --git a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts index 3bd1a5229f..e8bd78262b 100644 --- a/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts +++ b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.ts @@ -40,17 +40,19 @@ import { WidgetsBundleDialogComponent, WidgetsBundleDialogData } from '@home/pages/widget/widgets-bundle-dialog.component'; +import { MatFormFieldAppearance } from '@angular/material/form-field'; @Component({ - selector: 'tb-widgets-bundle-select', - templateUrl: './widgets-bundle-select.component.html', - styleUrls: ['./widgets-bundle-select.component.scss'], - providers: [{ - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => WidgetsBundleSelectComponent), - multi: true - }], - encapsulation: ViewEncapsulation.None + selector: 'tb-widgets-bundle-select', + templateUrl: './widgets-bundle-select.component.html', + styleUrls: ['./widgets-bundle-select.component.scss'], + providers: [{ + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => WidgetsBundleSelectComponent), + multi: true + }], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnInit, OnChanges { @@ -78,6 +80,9 @@ export class WidgetsBundleSelectComponent implements ControlValueAccessor, OnIni @coerceBoolean() createNew: boolean; + @Input() + appearance: MatFormFieldAppearance = 'fill'; + widgetsBundles$: Observable>; widgetsBundles: Array; diff --git a/ui-ngx/src/app/shared/directives/context-menu.directive.ts b/ui-ngx/src/app/shared/directives/context-menu.directive.ts index 60b0ec1771..245caae68f 100644 --- a/ui-ngx/src/app/shared/directives/context-menu.directive.ts +++ b/ui-ngx/src/app/shared/directives/context-menu.directive.ts @@ -18,7 +18,8 @@ import { Directive, ElementRef, EventEmitter, OnDestroy, Output } from '@angular import { TbContextMenuEvent } from '@shared/models/jquery-event.models'; @Directive({ - selector: '[tbcontextmenu]' + selector: '[tbcontextmenu]', + standalone: false }) export class ContextMenuDirective implements OnDestroy { diff --git a/ui-ngx/src/app/shared/directives/truncate-with-tooltip.directive.ts b/ui-ngx/src/app/shared/directives/truncate-with-tooltip.directive.ts index 12816d75a7..8aaeead53b 100644 --- a/ui-ngx/src/app/shared/directives/truncate-with-tooltip.directive.ts +++ b/ui-ngx/src/app/shared/directives/truncate-with-tooltip.directive.ts @@ -21,11 +21,12 @@ import { merge } from 'rxjs'; import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; @Directive({ - selector: '[tbTruncateWithTooltip]', - hostDirectives: [{ - directive: MatTooltip, - inputs: ['matTooltipClass', 'matTooltipTouchGestures'], - }] + selector: '[tbTruncateWithTooltip]', + hostDirectives: [{ + directive: MatTooltip, + inputs: ['matTooltipClass', 'matTooltipTouchGestures'], + }], + standalone: false }) export class TruncateWithTooltipDirective implements OnInit { diff --git a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html index ef27998db3..7bd900f1cf 100644 --- a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html +++ b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.html @@ -24,8 +24,10 @@ close - - +@if ((isLoading$ | async) && !ignoreLoading) { + + +}
{{ prompt | translate }} diff --git a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts index 3d434a4565..3692c57d93 100644 --- a/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/export-resource-dialog.component.ts @@ -35,9 +35,10 @@ export interface ExportResourceDialogDialogResult { } @Component({ - selector: 'tb-export-resource-dialog', - templateUrl: './export-resource-dialog.component.html', - styleUrls: [] + selector: 'tb-export-resource-dialog', + templateUrl: './export-resource-dialog.component.html', + styleUrls: [], + standalone: false }) export class ExportResourceDialogComponent extends DialogComponent { diff --git a/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html index 12f7adfe3d..bf0f0401c8 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html +++ b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html @@ -27,8 +27,10 @@ close - - + @if (isLoading$ | async) { + + + }
@@ -47,23 +49,25 @@
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(2)">{{ 'action.continue' | translate }}
-
+ {{ 'import.stepper-text.configuration' | translate }}
- + import.csv-delimiter - - {{ delimiter.value }} - + @for (delimiter of delimiters; track delimiter) { + + {{ delimiter.value }} + + }
@@ -79,15 +83,15 @@
+ (click)="previousStep()">{{ 'action.back' | translate }}
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(3)">{{ 'action.continue' | translate }}
@@ -99,15 +103,15 @@
+ (click)="previousStep()">{{ 'action.back' | translate }}
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(4)">{{ 'action.continue' | translate }}
@@ -119,32 +123,39 @@ {{ 'action.done' | translate }}
-

- {{ translate.instant('import.message.create-entities', {count: this.statistical.created}) }} -

-

- {{ translate.instant('import.message.update-entities', {count: this.statistical.updated}) }} -

-

- {{ translate.instant('import.message.error-entities', {count: this.statistical.errors}) }} -

- - - -
import.details
-
-
- -
-
+ @if (statistical?.created) { +

+ {{ translate.instant('import.message.create-entities', {count: statistical.created}) }} +

+ } + @if (statistical?.updated) { +

+ {{ translate.instant('import.message.update-entities', {count: statistical.updated}) }} +

+ } + @if (statistical?.errors) { +

+ {{ translate.instant('import.message.error-entities', {count: statistical.errors}) }} +

+ } + @if (statistical?.errorsList?.length) { + + + +
import.details
+
+
+ +
+
+ }
+ (click)="nextStep(6)">{{ 'action.ok' | translate }}
diff --git a/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.ts b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.ts index 198d6fecf2..9cea16b3e0 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.ts +++ b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.ts @@ -48,10 +48,11 @@ export interface ImportDialogCsvData { } @Component({ - selector: 'tb-import-csv-dialog', - templateUrl: './import-dialog-csv.component.html', - providers: [], - styleUrls: ['./import-dialog-csv.component.scss'] + selector: 'tb-import-csv-dialog', + templateUrl: './import-dialog-csv.component.html', + providers: [], + styleUrls: ['./import-dialog-csv.component.scss'], + standalone: false }) export class ImportDialogCsvComponent extends DialogComponent implements AfterViewInit, OnDestroy { diff --git a/ui-ngx/src/app/shared/import-export/import-dialog.component.html b/ui-ngx/src/app/shared/import-export/import-dialog.component.html index af672e4281..ec6ed1ff6b 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog.component.html +++ b/ui-ngx/src/app/shared/import-export/import-dialog.component.html @@ -25,32 +25,40 @@ close - - + @if (isLoading$ | async) { + + + }
- - {{ importFileLabel | translate }} - {{ importContentLabel | translate }} - - - - - + @if (enableImportFromContent) { + + {{ importFileLabel | translate }} + {{ importContentLabel | translate }} + + } + @if (importFormGroup.get('importType').value === 'file') { + + + } + @if (importFormGroup.get('importType').value === 'content') { + + + }
diff --git a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts index 7c5a2ae408..2b953456d4 100644 --- a/ui-ngx/src/app/shared/import-export/import-dialog.component.ts +++ b/ui-ngx/src/app/shared/import-export/import-dialog.component.ts @@ -34,10 +34,11 @@ export interface ImportDialogData { } @Component({ - selector: 'tb-import-dialog', - templateUrl: './import-dialog.component.html', - providers: [{provide: ErrorStateMatcher, useExisting: ImportDialogComponent}], - styleUrls: [] + selector: 'tb-import-dialog', + templateUrl: './import-dialog.component.html', + providers: [{ provide: ErrorStateMatcher, useExisting: ImportDialogComponent }], + styleUrls: [], + standalone: false }) export class ImportDialogComponent extends DialogComponent implements OnInit, ErrorStateMatcher { diff --git a/ui-ngx/src/app/shared/import-export/import-export.models.ts b/ui-ngx/src/app/shared/import-export/import-export.models.ts index 2476922bf9..ab9def1697 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.models.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.models.ts @@ -210,7 +210,7 @@ function splitCSV(str: string, sep: string): string[] { foo = foo.shift().split(sep).concat(foo); } } else { - foo[x].replace(/""/g, '"'); + foo[x] = foo[x].replace(/""/g, '"'); } } return foo; diff --git a/ui-ngx/src/app/shared/import-export/import-export.service.ts b/ui-ngx/src/app/shared/import-export/import-export.service.ts index 5bb73b846a..6593e8c9bf 100644 --- a/ui-ngx/src/app/shared/import-export/import-export.service.ts +++ b/ui-ngx/src/app/shared/import-export/import-export.service.ts @@ -14,7 +14,7 @@ /// limitations under the License. /// -import { Inject, Injectable } from '@angular/core'; +import { Inject, Injectable, DOCUMENT } from '@angular/core'; import { DashboardService } from '@core/http/dashboard.service'; import { TranslateService } from '@ngx-translate/core'; import { select, Store } from '@ngrx/store'; @@ -22,7 +22,7 @@ import { AppState } from '@core/core.state'; import { ActionNotificationShow } from '@core/notification/notification.actions'; import { BreakpointId, Dashboard, DashboardLayoutId } from '@shared/models/dashboard.models'; import { deepClone, guid, isDefined, isNotEmptyStr, isObject, isString, isUndefined } from '@core/utils'; -import { DOCUMENT } from '@angular/common'; + import { AliasesInfo, AliasFilterType, diff --git a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html index 46be8d8c57..7f51f98303 100644 --- a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html +++ b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html @@ -32,25 +32,33 @@ {{ 'import.column-type.column-type' | translate }} - - {{ columnTypesTranslations.get(type.value) | translate }} - - - - {{ columnTypesTranslations.get(credential.value) | translate }} + @for (type of columnTypes; track type) { + + {{ columnTypesTranslations.get(type.value) | translate }} - + } + @if (entityType === entityTypeDevice) { + + @for (credential of columnDeviceCredentials; track credential) { + + {{ columnTypesTranslations.get(credential.value) | translate }} + + } + + } {{ 'import.column-key' | translate }} - - - + @if (isColumnTypeDiffers(column.type)) { + + + + } diff --git a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss index 778d3c4486..f8d56f1c49 100644 --- a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss +++ b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.scss @@ -32,4 +32,10 @@ flex: 0 0 120px; min-width: 120px; } + mat-row { + mat-cell { + padding-top: 4px; + padding-bottom: 4px; + } + } } diff --git a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.ts b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.ts index 3ace0800b2..b066f6d2e0 100644 --- a/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.ts +++ b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.ts @@ -28,21 +28,22 @@ import { BehaviorSubject, Observable } from 'rxjs'; import { CollectionViewer, DataSource } from '@angular/cdk/collections'; @Component({ - selector: 'tb-table-columns-assignment', - templateUrl: './table-columns-assignment.component.html', - styleUrls: ['./table-columns-assignment.component.scss'], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => TableColumnsAssignmentComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => TableColumnsAssignmentComponent), - multi: true, - } - ] + selector: 'tb-table-columns-assignment', + templateUrl: './table-columns-assignment.component.html', + styleUrls: ['./table-columns-assignment.component.scss'], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TableColumnsAssignmentComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => TableColumnsAssignmentComponent), + multi: true, + } + ], + standalone: false }) export class TableColumnsAssignmentComponent implements OnInit, ControlValueAccessor, Validator { diff --git a/ui-ngx/src/app/shared/models/ai-model.models.ts b/ui-ngx/src/app/shared/models/ai-model.models.ts index c861b33225..504e4d2659 100644 --- a/ui-ngx/src/app/shared/models/ai-model.models.ts +++ b/ui-ngx/src/app/shared/models/ai-model.models.ts @@ -110,6 +110,10 @@ export const AiModelMap = new Map { let displayName = ''; - if (isNotEmptyStr(alarmAssignee.firstName) || isNotEmptyStr(alarmAssignee.lastName)) { - if (alarmAssignee.firstName) { - displayName += alarmAssignee.firstName; - } - if (alarmAssignee.lastName) { - if (displayName.length > 0) { - displayName += ' '; + if (isDefinedAndNotNull(alarmAssignee)) { + if (isNotEmptyStr(alarmAssignee.firstName) || isNotEmptyStr(alarmAssignee.lastName)) { + if (alarmAssignee.firstName) { + displayName += alarmAssignee.firstName; + } + if (alarmAssignee.lastName) { + if (displayName.length > 0) { + displayName += ' '; + } + displayName += alarmAssignee.lastName; } - displayName += alarmAssignee.lastName; + } else { + displayName = alarmAssignee.email; } - } else { - displayName = alarmAssignee.email; } return displayName; }; diff --git a/ui-ngx/src/app/shared/models/calculated-field.models.ts b/ui-ngx/src/app/shared/models/calculated-field.models.ts index 201d23f7d0..552d2a3a95 100644 --- a/ui-ngx/src/app/shared/models/calculated-field.models.ts +++ b/ui-ngx/src/app/shared/models/calculated-field.models.ts @@ -38,6 +38,7 @@ export const FORBIDDEN_NAMES = ['ctx', 'e', 'pi']; interface BaseCalculatedField extends Omit, 'label'>, HasVersion, HasEntityDebugSettings, HasTenantId, ExportableEntity { entityId: EntityId; + additionalInfo?: any; } export interface CalculatedFieldSimple extends BaseCalculatedField { @@ -79,7 +80,9 @@ export type CalculatedField = | CalculatedFieldRelatedEntityAggregation | CalculatedFieldAlarmRule; -export type CalculatedFieldInfo = CalculatedField & {entityName: string}; +export type WithCalculatedFieldInfo = T & {entityName: string}; +export type CalculatedFieldInfo = WithCalculatedFieldInfo; +export type CalculatedFieldAlarmRuleInfo = WithCalculatedFieldInfo; export enum CalculatedFieldType { SIMPLE = 'SIMPLE', @@ -191,7 +194,7 @@ interface BasePropagationConfiguration { output: CalculatedFieldOutput; } -interface CalculatedFieldAlarmRuleConfiguration { +export interface CalculatedFieldAlarmRuleConfiguration { type: CalculatedFieldType.ALARM; arguments: Record; createRules: Record; diff --git a/ui-ngx/src/app/shared/models/constants.ts b/ui-ngx/src/app/shared/models/constants.ts index 2890b4827c..afc68fdfcd 100644 --- a/ui-ngx/src/app/shared/models/constants.ts +++ b/ui-ngx/src/app/shared/models/constants.ts @@ -56,6 +56,12 @@ export const serverErrorCodesTranslations = new Map([ [Constants.serverErrorCode.entitiesLimitExceeded, 'server-error.entities-limit-exceeded'], ]); +export const httpStatusMessageMap = new Map([ + [502, 'Server is temporarily unavailable (Bad Gateway)'], + [503, 'Server is temporarily unavailable'], + [504, 'Server did not respond in time (Gateway Timeout)'], +]); + export const MediaBreakpoints = { xs: 'screen and (max-width: 599px)', sm: 'screen and (min-width: 600px) and (max-width: 959px)', diff --git a/ui-ngx/src/app/shared/models/entity-type.models.ts b/ui-ngx/src/app/shared/models/entity-type.models.ts index 71fd1be479..4ee4674c75 100644 --- a/ui-ngx/src/app/shared/models/entity-type.models.ts +++ b/ui-ngx/src/app/shared/models/entity-type.models.ts @@ -490,6 +490,7 @@ export const entityTypeTranslations = new Map; + keyFiltersOperation?: ComplexOperation; } export interface FiltersInfo { datasourceFilters: {[datasourceIndex: number]: FilterInfo}; } -export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array): string { +export function keyFiltersToText(translate: TranslateService, datePipe: DatePipe, keyFilters: Array, + operation: ComplexOperation = ComplexOperation.AND): string { const filtersText = keyFilters.map(keyFilter => - keyFilterToText(translate, datePipe, keyFilter, - keyFilters.length > 1 ? ComplexOperation.AND : undefined)); + keyFilterToText(translate, datePipe, keyFilter, operation)); let result: string; if (filtersText.length > 1) { - const andText = translate.instant('filter.operation.and'); - result = filtersText.join(' ' + andText + ' '); + const opText = translate.instant(complexOperationTranslationMap.get(operation)); + result = filtersText.join(' ' + opText + ' '); } else { result = filtersText[0]; } @@ -802,6 +803,7 @@ export const singleEntityFilterFromDeviceId = (deviceId: string): EntityFilter = export interface EntityCountQuery { entityFilter: EntityFilter; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; } export interface AbstractDataQuery extends EntityCountQuery { @@ -940,13 +942,15 @@ export const getFilterId = (filters: Filters, filterInfo: FilterInfo): string => const newFilterName = createFilterName(filters, filterInfo.filter); newFilterId = guid(); filters[newFilterId] = {id: newFilterId, filter: newFilterName, - keyFilters: filterInfo.keyFilters, editable: filterInfo.editable}; + keyFilters: filterInfo.keyFilters, editable: filterInfo.editable, + keyFiltersOperation: filterInfo.keyFiltersOperation}; } return newFilterId; } const isFilterEqual = (filter1: FilterInfo, filter2: FilterInfo): boolean => { - return isEqual(filter1.keyFilters, filter2.keyFilters); + return isEqual(filter1.keyFilters, filter2.keyFilters) && + filter1.keyFiltersOperation === filter2.keyFiltersOperation; } const createFilterName = (filters: Filters, filter: string): string => { diff --git a/ui-ngx/src/app/shared/models/rule-chain.models.ts b/ui-ngx/src/app/shared/models/rule-chain.models.ts index cf859e859f..feb4952236 100644 --- a/ui-ngx/src/app/shared/models/rule-chain.models.ts +++ b/ui-ngx/src/app/shared/models/rule-chain.models.ts @@ -18,7 +18,7 @@ import { BaseData, ExportableEntity } from '@shared/models/base-data'; import { TenantId } from '@shared/models/id/tenant-id'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id'; -import { RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; +import { FcRuleNote, RuleNode, RuleNodeComponentDescriptor, RuleNodeType } from '@shared/models/rule-node.models'; import { ComponentClusteringMode, ComponentType } from '@shared/models/component-descriptor.models'; import { HasTenantId, HasVersion } from '@shared/models/entity.models'; @@ -39,6 +39,7 @@ export interface RuleChainMetaData extends HasVersion { firstNodeIndex?: number; nodes: Array; connections: Array; + notes?: Array; } export interface RuleChainImport { diff --git a/ui-ngx/src/app/shared/models/rule-node.models.ts b/ui-ngx/src/app/shared/models/rule-node.models.ts index 30e1648f7e..1789e5c5d8 100644 --- a/ui-ngx/src/app/shared/models/rule-node.models.ts +++ b/ui-ngx/src/app/shared/models/rule-node.models.ts @@ -18,7 +18,7 @@ import { BaseData } from '@shared/models/base-data'; import { RuleChainId } from '@shared/models/id/rule-chain-id'; import { RuleNodeId } from '@shared/models/id/rule-node-id'; import { ComponentDescriptor } from '@shared/models/component-descriptor.models'; -import { FcEdge, FcNode } from 'ngx-flowchart'; +import { FcEdge, FcNode, FcNote } from 'ngx-flowchart'; import { Observable } from 'rxjs'; import { PageComponent } from '@shared/components/page.component'; import { AfterViewInit, DestroyRef, Directive, EventEmitter, inject, OnInit } from '@angular/core'; @@ -27,6 +27,7 @@ import { RuleChainType } from '@shared/models/rule-chain.models'; import { DebugRuleNodeEventBody } from '@shared/models/event.models'; import { EntityTestScriptResult, HasEntityDebugSettings } from '@shared/models/entity.models'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { strings } from '@angular-devkit/core'; export interface RuleNodeConfiguration { [key: string]: any; @@ -358,6 +359,19 @@ export interface FcRuleEdge extends FcEdge { labels?: string[]; } +export const FC_RULE_NOTE_DEFAULT_BACKGROUND_COLOR = '#FFF9C4'; +export const FC_RULE_NOTE_DEFAULT_BORDER_WIDTH = 1; +export const FC_RULE_NOTE_DEFAULT_APPLY_MARKDOWN_STYLE = true; + +export interface FcRuleNote extends FcNote { + content?: string; + backgroundColor?: string; + borderColor?: string; + borderWidth?: number; + applyDefaultMarkdownStyle?: boolean; + markdownCss?: string; +} + export enum ScriptLanguage { JS = 'JS', TBEL = 'TBEL' diff --git a/ui-ngx/src/app/shared/models/widget.models.ts b/ui-ngx/src/app/shared/models/widget.models.ts index 1a2f7e1c8b..982f492eba 100644 --- a/ui-ngx/src/app/shared/models/widget.models.ts +++ b/ui-ngx/src/app/shared/models/widget.models.ts @@ -24,6 +24,7 @@ import { EntityId } from '@shared/models/id/entity-id'; import { AlarmFilter, AlarmFilterConfig, + ComplexOperation, EntityDataPageLink, EntityFilter, KeyFilter @@ -450,6 +451,7 @@ export interface Datasource { origDatasourceIndex?: number; pageLink?: EntityDataPageLink; keyFilters?: Array; + keyFiltersOperation?: ComplexOperation; entityFilter?: EntityFilter; alarmFilterConfig?: AlarmFilterConfig; alarmFilter?: AlarmFilter; diff --git a/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts index 38cc0b52f4..94eced7fcf 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map-model.definition.ts @@ -27,8 +27,10 @@ import { import { additionalMapDataSourcesToDatasources, BaseMapSettings, + latestMapDataLayerTypes, MapDataLayerSettings, MapDataLayerType, + mapDataLayerTypes, MapDataSourceSettings, mapDataSourceSettingsToDatasource, MapType @@ -46,30 +48,21 @@ interface MapDataLayerDsInfo extends AliasFilterPair { type ExportDataSourceInfo = {[dataLayerIndex: number]: MapDataLayerDsInfo}; -interface MapDatasourcesInfo { - trips?: ExportDataSourceInfo; - markers?: ExportDataSourceInfo; - polygons?: ExportDataSourceInfo; - circles?: ExportDataSourceInfo; +type MapDatasourcesInfo = { + [K in MapDataLayerType]?: ExportDataSourceInfo; +} & { additionalDataSources?: ExportDataSourceInfo; -} +}; export const MapModelDefinition: WidgetModelDefinition = { testWidget(widget: Widget): boolean { if (widget?.config?.settings) { const settings = widget.config.settings; if (settings.mapType && [MapType.image, MapType.geoMap].includes(settings.mapType)) { - if (settings.trips && Array.isArray(settings.trips)) { - return true; - } - if (settings.markers && Array.isArray(settings.markers)) { - return true; - } - if (settings.polygons && Array.isArray(settings.polygons)) { - return true; - } - if (settings.circles && Array.isArray(settings.circles)) { - return true; + for (const layerType of mapDataLayerTypes) { + if (Array.isArray(settings[layerType])) { + return true; + } } } } @@ -78,17 +71,11 @@ export const MapModelDefinition: WidgetModelDefinition = { prepareExportInfo(dashboard: Dashboard, widget: Widget): MapDatasourcesInfo { const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; const info: MapDatasourcesInfo = {}; - if (settings.trips?.length) { - info.trips = prepareExportDataSourcesInfo(dashboard, settings.trips); - } - if (settings.markers?.length) { - info.markers = prepareExportDataSourcesInfo(dashboard, settings.markers); - } - if (settings.polygons?.length) { - info.polygons = prepareExportDataSourcesInfo(dashboard, settings.polygons); - } - if (settings.circles?.length) { - info.circles = prepareExportDataSourcesInfo(dashboard, settings.circles); + for (const layerType of mapDataLayerTypes) { + const dataLayerSettings = settings[layerType]; + if (dataLayerSettings?.length) { + info[layerType] = prepareExportDataSourcesInfo(dashboard, dataLayerSettings); + } } if (settings.additionalDataSources?.length) { info.additionalDataSources = prepareExportDataSourcesInfo(dashboard, settings.additionalDataSources); @@ -96,59 +83,36 @@ export const MapModelDefinition: WidgetModelDefinition = { return info; }, updateFromExportInfo(widget: Widget, entityAliases: EntityAliases, filters: Filters, info: MapDatasourcesInfo): void { - const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; - if (info?.trips) { - updateMapDatasourceFromExportInfo(entityAliases, filters, settings.trips, info.trips); - } - if (info?.markers) { - updateMapDatasourceFromExportInfo(entityAliases, filters, settings.markers, info.markers); - } - if (info?.polygons) { - updateMapDatasourceFromExportInfo(entityAliases, filters, settings.polygons, info.polygons); - } - if (info?.circles) { - updateMapDatasourceFromExportInfo(entityAliases, filters, settings.circles, info.circles); - } - if (info?.additionalDataSources) { - updateMapDatasourceFromExportInfo(entityAliases, filters, settings.additionalDataSources, info.additionalDataSources); + if (info && Object.keys(info).length) { + const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; + for (const layerType of mapDataLayerTypes) { + const layerInfo = info[layerType]; + const dataLayerSettings = settings[layerType]; + if (layerInfo && dataLayerSettings) { + updateMapDatasourceFromExportInfo(entityAliases, filters, dataLayerSettings, layerInfo); + } + } + if (info.additionalDataSources) { + updateMapDatasourceFromExportInfo(entityAliases, filters, settings.additionalDataSources, info.additionalDataSources); + } } }, datasources(widget: Widget): Datasource[] { - const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; - const datasources: Datasource[] = []; - if (settings.trips?.length) { - datasources.push(...getMapDataLayersDatasources(settings.trips)); - } - if (settings.markers?.length) { - datasources.push(...getMapDataLayersDatasources(settings.markers)); - } - if (settings.polygons?.length) { - datasources.push(...getMapDataLayersDatasources(settings.polygons)); - } - if (settings.circles?.length) { - datasources.push(...getMapDataLayersDatasources(settings.circles)); - } - if (settings.additionalDataSources?.length) { - datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources)); - } - return datasources; + return getMapDataLayersDatasources(widget.config.settings as BaseMapSettings, mapDataLayerTypes); }, hasTimewindow(widget: Widget): boolean { const settings: BaseMapSettings = widget.config.settings as BaseMapSettings; - if (settings.trips?.length) { + const timeSeriesDataLayerTypes = mapDataLayerTypes.filter(t => !latestMapDataLayerTypes.includes(t)); + if (timeSeriesDataLayerTypes.some(layerType => settings[layerType]?.length)) { return true; - } else { - const datasources: Datasource[] = getMapLatestDataLayersDatasources(settings); - return datasourcesHasAggregation(datasources); } + return datasourcesHasAggregation(getMapDataLayersDatasources(settings, latestMapDataLayerTypes, true)); }, datasourcesHasAggregation(widget: Widget): boolean { - const datasources: Datasource[] = getMapLatestDataLayersDatasources(widget.config.settings as BaseMapSettings); - return datasourcesHasAggregation(datasources); + return datasourcesHasAggregation(getMapDataLayersDatasources(widget.config.settings as BaseMapSettings, latestMapDataLayerTypes, true)); }, datasourcesHasOnlyComparisonAggregation(widget: Widget): boolean { - const datasources: Datasource[] = getMapLatestDataLayersDatasources(widget.config.settings as BaseMapSettings); - return datasourcesHasOnlyComparisonAggregation(datasources); + return datasourcesHasOnlyComparisonAggregation(getMapDataLayersDatasources(widget.config.settings as BaseMapSettings, latestMapDataLayerTypes, true)); } }; @@ -236,7 +200,7 @@ const prepareAliasAndFilterPair = (dashboard: Dashboard, settings: MapDataSource } } -const getMapDataLayersDatasources = (settings: MapDataLayerSettings[], +const getMapDataLayerDatasources = (settings: MapDataLayerSettings[], includeDataKeys = false, dataLayerType?: MapDataLayerType): Datasource[] => { const datasources: Datasource[] = []; settings.forEach((dsSettings) => { @@ -255,16 +219,14 @@ const getMapDataLayersDatasources = (settings: MapDataLayerSettings[], return datasources; }; -const getMapLatestDataLayersDatasources = (settings: BaseMapSettings): Datasource[] => { +const getMapDataLayersDatasources = (settings: BaseMapSettings, + layerTypes: readonly MapDataLayerType[], includeDataKeys = false): Datasource[] => { const datasources: Datasource[] = []; - if (settings.markers?.length) { - datasources.push(...getMapDataLayersDatasources(settings.markers, true, 'markers')); - } - if (settings.polygons?.length) { - datasources.push(...getMapDataLayersDatasources(settings.polygons, true, 'polygons')); - } - if (settings.circles?.length) { - datasources.push(...getMapDataLayersDatasources(settings.circles, true, 'circles')); + for (const layerType of layerTypes) { + const dataLayerSettings = settings[layerType]; + if (dataLayerSettings?.length) { + datasources.push(...getMapDataLayerDatasources(dataLayerSettings, includeDataKeys, layerType)); + } } if (settings.additionalDataSources?.length) { datasources.push(...additionalMapDataSourcesToDatasources(settings.additionalDataSources)); diff --git a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts index 0f1c979425..1d82139050 100644 --- a/ui-ngx/src/app/shared/models/widget/maps/map.models.ts +++ b/ui-ngx/src/app/shared/models/widget/maps/map.models.ts @@ -200,9 +200,11 @@ export const defaultBaseDataLayerSettings = (mapType: MapType): Partial { if (!dataLayer.dsType || ![DatasourceType.function, DatasourceType.device, DatasourceType.entity].includes(dataLayer.dsType)) { @@ -929,6 +931,7 @@ export const defaultMapActionButtonSettings: MapActionButtonSettings = { } export enum MapProvider { + openfreemap = 'openfreemap', openstreet = 'openstreet', google = 'google', here = 'here', @@ -940,6 +943,7 @@ export const mapProviders = Object.keys(MapProvider) as MapProvider[]; export const mapProviderTranslationMap = new Map( [ + [MapProvider.openfreemap, 'widgets.maps.layer.provider.openfreemap.title'], [MapProvider.openstreet, 'widgets.maps.layer.provider.openstreet.title'], [MapProvider.google, 'widgets.maps.layer.provider.google.title'], [MapProvider.here, 'widgets.maps.layer.provider.here.title'], @@ -975,6 +979,9 @@ export const mapLayerValid = (layer: MapLayerSettings): boolean => { return false; } switch (layer.provider) { + case MapProvider.openfreemap: + const openFreeMapLayer = layer as OpenFreeMapLayerSettings; + return !!openFreeMapLayer.layerType; case MapProvider.openstreet: const openStreetLayer = layer as OpenStreetMapLayerSettings; return !!openStreetLayer.layerType; @@ -1008,6 +1015,9 @@ export const defaultLayerTitle = (layer: MapLayerSettings): string => { return null; } switch (layer.provider) { + case MapProvider.openfreemap: + const ofmLayer = layer as OpenFreeMapLayerSettings; + return openFreeMapStyleTranslationMap.get(ofmLayer.layerType); case MapProvider.openstreet: const openStreetLayer = layer as OpenStreetMapLayerSettings; return openStreetMapLayerTranslationMap.get(openStreetLayer.layerType); @@ -1059,6 +1069,32 @@ export const defaultOpenStreetMapLayerSettings: OpenStreetMapLayerSettings = { layerType: OpenStreetLayerType.openStreetMapnik } +export enum OpenFreeMapStyleType { + bright = 'bright', + positron = 'positron', + liberty = 'liberty' +} + +export const openFreeMapStyleTypes = Object.values(OpenFreeMapStyleType) as OpenFreeMapStyleType[]; + +export const openFreeMapStyleTranslationMap = new Map( + [ + [OpenFreeMapStyleType.liberty, 'widgets.maps.layer.provider.openfreemap.liberty'], + [OpenFreeMapStyleType.bright, 'widgets.maps.layer.provider.openfreemap.bright'], + [OpenFreeMapStyleType.positron, 'widgets.maps.layer.provider.openfreemap.positron'] + ] +); + +export interface OpenFreeMapLayerSettings extends MapLayerSettings { + provider: MapProvider.openfreemap; + layerType: OpenFreeMapStyleType; +} + +export const defaultOpenFreeMapLayerSettings: OpenFreeMapLayerSettings = { + provider: MapProvider.openfreemap, + layerType: OpenFreeMapStyleType.bright +} + export enum GoogleLayerType { roadmap = 'roadmap', satellite = 'satellite', @@ -1148,6 +1184,8 @@ export const defaultTencentMapLayerSettings: TencentMapLayerSettings = { export interface CustomMapLayerSettings extends MapLayerSettings { provider: MapProvider.custom; tileUrl: string; + vectorTiles?: boolean; + customAttribution?: string; } export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { @@ -1157,6 +1195,8 @@ export const defaultCustomMapLayerSettings: CustomMapLayerSettings = { export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings => { switch (provider) { + case MapProvider.openfreemap: + return defaultOpenFreeMapLayerSettings; case MapProvider.openstreet: return defaultOpenStreetMapLayerSettings; case MapProvider.google: @@ -1173,9 +1213,9 @@ export const defaultMapLayerSettings = (provider: MapProvider): MapLayerSettings export const defaultMapLayers: MapLayerSettings[] = [ { label: '{i18n:widgets.maps.layer.roadmap}', - provider: MapProvider.openstreet, - layerType: OpenStreetLayerType.openStreetMapnik, - } as OpenStreetMapLayerSettings, + provider: MapProvider.openfreemap, + layerType: OpenFreeMapStyleType.bright, + } as OpenFreeMapLayerSettings, { label: '{i18n:widgets.maps.layer.satellite}', provider: MapProvider.openstreet, diff --git a/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts index 3029a8ff4b..d693349a44 100644 --- a/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/custom-translate.pipe.ts @@ -18,7 +18,8 @@ import { Pipe, PipeTransform } from '@angular/core'; import { UtilsService } from '@core/services/utils.service'; @Pipe({ - name: 'customTranslate' + name: 'customTranslate', + standalone: false }) export class CustomTranslatePipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts index 486a05d519..05bdb035ff 100644 --- a/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/date-ago.pipe.ts @@ -29,7 +29,8 @@ const intervals = { }; @Pipe({ - name: 'dateAgo' + name: 'dateAgo', + standalone: false }) export class DateAgoPipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/date-expiration.pipe.ts b/ui-ngx/src/app/shared/pipe/date-expiration.pipe.ts index b040a4d4b4..faaafc92e4 100644 --- a/ui-ngx/src/app/shared/pipe/date-expiration.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/date-expiration.pipe.ts @@ -20,7 +20,8 @@ import { MillisecondsToTimeStringPipe } from '@shared/pipe/milliseconds-to-time- import { isDefined } from '@core/utils'; @Pipe({ - name: 'dateExpiration' + name: 'dateExpiration', + standalone: false }) export class DateExpirationPipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts index c336de1c19..88f83f24ad 100644 --- a/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/enum-to-array.pipe.ts @@ -17,7 +17,8 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'enumToArray' + name: 'enumToArray', + standalone: false }) export class EnumToArrayPipe implements PipeTransform { transform(data: object): string[] { diff --git a/ui-ngx/src/app/shared/pipe/file-size.pipe.ts b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts index 6921a0d6c2..87406aa136 100644 --- a/ui-ngx/src/app/shared/pipe/file-size.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/file-size.pipe.ts @@ -30,7 +30,10 @@ const defaultPrecisionMap: unitPrecisionMap = { PB: 2 }; -@Pipe({ name: 'fileSize' }) +@Pipe({ + name: 'fileSize', + standalone: false +}) export class FileSizePipe implements PipeTransform { private readonly units: unit[] = ['bytes', 'KB', 'MB', 'GB', 'TB', 'PB']; diff --git a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts index 34d3ffb552..cff363a084 100644 --- a/ui-ngx/src/app/shared/pipe/highlight.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/highlight.pipe.ts @@ -16,7 +16,10 @@ import { Pipe, PipeTransform } from '@angular/core'; -@Pipe({ name: 'highlight' }) +@Pipe({ + name: 'highlight', + standalone: false +}) export class HighlightPipe implements PipeTransform { transform(text: string, search: string, includes = false, flags = 'i'): string { const pattern = search diff --git a/ui-ngx/src/app/shared/pipe/image.pipe.ts b/ui-ngx/src/app/shared/pipe/image.pipe.ts index d17ed0ab76..2964cb6d75 100644 --- a/ui-ngx/src/app/shared/pipe/image.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/image.pipe.ts @@ -30,7 +30,8 @@ export interface UrlHolder { } @Pipe({ - name: 'image' + name: 'image', + standalone: false }) export class ImagePipe implements PipeTransform { @@ -38,7 +39,7 @@ export class ImagePipe implements PipeTransform { private sanitizer: DomSanitizer, private zone: NgZone) { } - transform(urlData: string | UrlHolder, args?: any): Observable { + transform(urlData: string | UrlHolder, args?: any, triggerUpdate?: number): Observable { const ignoreLoadingImage = !!args?.ignoreLoadingImage; const asString = !!args?.asString; const emptyUrl = args?.emptyUrl || NO_IMAGE_DATA_URI; diff --git a/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts index b0b520be39..92e2208b88 100644 --- a/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/keyboard-shortcut.pipe.ts @@ -19,7 +19,8 @@ import { WINDOW } from '@core/services/window.service'; // @dynamic @Pipe({ - name: 'keyboardShortcut' + name: 'keyboardShortcut', + standalone: false }) export class KeyboardShortcutPipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts index fe9564f07c..523755ea30 100644 --- a/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/milliseconds-to-time-string.pipe.ts @@ -19,7 +19,8 @@ import { TranslateService } from '@ngx-translate/core'; import { DAY, HOUR, MINUTE, SECOND, YEAR } from '@shared/models/time/time.models'; @Pipe({ - name: 'milliSecondsToTimeString' + name: 'milliSecondsToTimeString', + standalone: false }) export class MillisecondsToTimeStringPipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/nospace.pipe.ts b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts index 96e4be3cb1..8c1f317d81 100644 --- a/ui-ngx/src/app/shared/pipe/nospace.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/nospace.pipe.ts @@ -17,7 +17,8 @@ import { Pipe, PipeTransform } from '@angular/core'; @Pipe({ - name: 'nospace' + name: 'nospace', + standalone: false }) export class NospacePipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/safe.pipe.ts b/ui-ngx/src/app/shared/pipe/safe.pipe.ts index ac5d776b3c..7267a8625c 100644 --- a/ui-ngx/src/app/shared/pipe/safe.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/safe.pipe.ts @@ -18,7 +18,8 @@ import { Pipe, PipeTransform } from '@angular/core'; import { DomSanitizer, SafeHtml, SafeStyle, SafeScript, SafeUrl, SafeResourceUrl } from '@angular/platform-browser'; @Pipe({ - name: 'safe' + name: 'safe', + standalone: false }) export class SafePipe implements PipeTransform { diff --git a/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts index ffdf250e3a..1e87ada5a4 100644 --- a/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/selectable-columns.pipe.ts @@ -17,7 +17,10 @@ import { Pipe, PipeTransform } from '@angular/core'; import { DisplayColumn } from '@home/components/widget/lib/table-widget.models'; -@Pipe({ name: 'selectableColumns' }) +@Pipe({ + name: 'selectableColumns', + standalone: false +}) export class SelectableColumnsPipe implements PipeTransform { transform(allColumns: DisplayColumn[]): DisplayColumn[] { return allColumns.filter(column => column.selectable); diff --git a/ui-ngx/src/app/shared/pipe/short-number.pipe.ts b/ui-ngx/src/app/shared/pipe/short-number.pipe.ts index 6738b552f2..fedf958067 100644 --- a/ui-ngx/src/app/shared/pipe/short-number.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/short-number.pipe.ts @@ -16,19 +16,26 @@ import { Pipe, PipeTransform } from '@angular/core'; +export interface ShortNumberArgs { + long?: boolean; + roundDown?: boolean; +} + @Pipe({ - name: 'shortNumber' + name: 'shortNumber', + standalone: false }) export class ShortNumberPipe implements PipeTransform { - transform(number: number, args?: any): any { - if (isNaN(number)) return 0; - if (number === null) return 0; - if (number === 0) return 0; + transform(number: number, args?: ShortNumberArgs): string { + if (!Number.isFinite(number) || number === 0) { + return '0'; + } let abs = Math.abs(number); const rounder = Math.pow(10, 1); const isNegative = number < 0; - const isLong = args && args.long; + const isLong = args?.long; + const roundDown = args?.roundDown; let key = ''; const powers = [ @@ -41,7 +48,7 @@ export class ShortNumberPipe implements PipeTransform { for (let i = 0; i < powers.length; i++) { let reduced = abs / powers[i].value; - reduced = Math.round(reduced * rounder) / rounder; + reduced = (roundDown ? Math.floor : Math.round)(reduced * rounder) / rounder; if (reduced >= 1) { abs = reduced; key = isLong ? powers[i].longKey : powers[i].key; diff --git a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts index 175045f9f2..52805f52b2 100644 --- a/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/tbJson.pipe.ts @@ -17,7 +17,10 @@ import { Pipe, PipeTransform } from '@angular/core'; import { isNumber, isObject } from '@core/utils'; -@Pipe({name: 'tbJson'}) +@Pipe({ + name: 'tbJson', + standalone: false +}) export class TbJsonPipe implements PipeTransform { transform(value: any): string { if (isObject(value)) { diff --git a/ui-ngx/src/app/shared/pipe/truncate.pipe.ts b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts index 9d4159f94f..ab02af7443 100644 --- a/ui-ngx/src/app/shared/pipe/truncate.pipe.ts +++ b/ui-ngx/src/app/shared/pipe/truncate.pipe.ts @@ -17,7 +17,10 @@ import { Pipe, PipeTransform } from '@angular/core'; import { isString } from '@core/utils'; -@Pipe({ name: 'truncate' }) +@Pipe({ + name: 'truncate', + standalone: false +}) export class TruncatePipe implements PipeTransform { transform(text: string, wordwise: boolean, max: any, tail: string): string { if (!text) { return ''; } diff --git a/ui-ngx/src/app/shared/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index 6f06744d60..cefc1c8824 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -238,6 +238,10 @@ import { TimeUnitInputComponent } from '@shared/components/time-unit-input.compo import { DateExpirationPipe } from '@shared/pipe/date-expiration.pipe'; import { EntityLimitExceededDialogComponent } from '@shared/components/dialog/entity-limit-exceeded-dialog.component'; import { DynamicMatDialogModule } from '@shared/components/dialog/dynamic/dynamic-dialog.module'; +import { MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS } from '@angular/material/button-toggle'; +import { RgbaInputComponent } from '@shared/components/color-picker/rgba-input.component'; +import { HslaInputComponent } from '@shared/components/color-picker/hsla-input.component'; +import { InputChangeDirective } from '@shared/components/color-picker/input-change.directive'; export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) { return markedOptionsService; @@ -298,6 +302,13 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) disableTooltipInteractivity: true } }, + { + provide: MAT_BUTTON_TOGGLE_DEFAULT_OPTIONS, + useValue: { + hideSingleSelectionIndicator: true, + hideMultipleSelectionIndicator: true + } + }, CountryData ], declarations: [ @@ -458,7 +469,10 @@ export function MarkedOptionsFactory(markedOptionsService: MarkedOptionsService) MqttVersionSelectComponent, PasswordRequirementsTooltipComponent, TimeUnitInputComponent, - StringPatternAutocompleteComponent + StringPatternAutocompleteComponent, + RgbaInputComponent, + HslaInputComponent, + InputChangeDirective ], imports: [ CommonModule, diff --git a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json index f09ab73de7..9fb2d25cf8 100644 --- a/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json +++ b/ui-ngx/src/assets/dashboard/tenant_admin_home_page.json @@ -224,7 +224,7 @@ "padding": "16px", "settings": { "useMarkdownTextFunction": false, - "markdownTextPattern": "", + "markdownTextPattern": "", "applyDefaultMarkdownStyle": false, "markdownCss": ".tb-card-content {\n width: 100%;\n height: 100%;\n display: flex;\n flex-direction: row;\n}\n\n.tb-content-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n justify-content: flex-start;\n gap: 12px;\n}\n\n.tb-card-header {\n height: 36px;\n display: flex;\n flex-direction: row;\n justify-content: space-between;\n}\n\n.tb-item-cards {\n flex: 1;\n display: flex;\n flex-direction: row;\n gap: 12px;\n overflow: hidden;\n}\n\na.tb-item-card {\n flex: 1;\n display: flex;\n flex-direction: column;\n padding: 8px 12px;\n border: 1px solid;\n border-radius: 10px;\n margin-bottom: 12px;\n overflow: hidden;\n justify-content: space-evenly;\n}\n\na.tb-item-card.tb-inactive {\n background: rgba(209, 39, 48, 0.04);\n border-color: rgba(209, 39, 48, 0.06);\n}\n\na.tb-item-card.tb-active {\n background: rgba(48, 86, 128, 0.04);\n border-color: rgba(48, 86, 128, 0.12);\n}\n\na.tb-item-card.tb-total {\n background: rgba(0, 0, 0, 0.01);\n border-color: rgba(0, 0, 0, 0.05);\n}\n\n.tb-item-title-container {\n display: grid;\n}\n\n.tb-item-title {\n font-weight: 400;\n font-size: 14px;\n line-height: 20px;\n letter-spacing: 0.2px;\n white-space: nowrap;\n overflow: hidden;\n text-overflow: ellipsis; \n color: rgba(0, 0, 0, 0.76);\n}\n\n.tb-item-title.tb-home-widget-link:after {\n position: absolute;\n right: 0;\n}\n\na.tb-item-card:hover .tb-item-title.tb-home-widget-link:after { \n color: rgba(0, 0, 0, 0.38);\n}\n\na.tb-item-card:hover {\n box-shadow: 0px 4px 10px rgba(23, 33, 90, 0.08);\n}\n\n.tb-count-container {\n flex: 1;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n justify-content: center;\n}\n\n.tb-count {\n font-style: normal;\n font-weight: 500;\n font-size: 24px;\n line-height: 36px;\n white-space: nowrap;\n color: rgba(0, 0, 0, 0.87);\n}\n\n@media screen and (max-width: 959px) {\n .tb-item-cards {\n flex-direction: column;\n }\n a.tb-item-card {\n margin-bottom: 0;\n }\n}\n\n@media screen and (max-width: 1279px) {\n a.tb-item-card {\n flex-direction: row;\n align-items: center;\n }\n .tb-item-title.tb-home-widget-link:after {\n position: relative;\n }\n .tb-count-container {\n align-items: flex-end;\n }\n}\n\n@media screen and (min-width: 960px) and (max-width: 1819px) {\n .tb-item-title {\n font-size: 11px;\n line-height: 16px;\n }\n .tb-count {\n font-size: 16px;\n line-height: 24px;\n }\n a.tb-item-card {\n padding: 4px 8px;\n margin-bottom: 6px;\n }\n a.tb-item-card:hover {\n box-shadow: 0px 2px 5px rgba(23, 33, 90, 0.08);\n }\n}\n" }, diff --git a/ui-ngx/src/assets/help/en_US/rulechain/note_content.md b/ui-ngx/src/assets/help/en_US/rulechain/note_content.md new file mode 100644 index 0000000000..d21167be7f --- /dev/null +++ b/ui-ngx/src/assets/help/en_US/rulechain/note_content.md @@ -0,0 +1,195 @@ +#### Markdown/HTML note content + +
+
+ +Notes support **Markdown** and **HTML** markup for rich text formatting directly on the rule chain canvas. + +
+
+ +#### Headings + +Use `#` to create headings. The number of `#` characters defines the level: + +```markdown +# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 +{:copy-code} +``` + +
+
+ +#### Text formatting + +```markdown +**Bold text** +*Italic text* +~~Strikethrough~~ +**_Bold and italic_** +{:copy-code} +``` + +**Bold text** +*Italic text* +~~Strikethrough~~ +**_Bold and italic_** + +
+
+ +#### Lists + +Unordered list using `-` or `*`: + +```markdown +- Step 1: Receive telemetry +- Step 2: Filter by threshold + - Value > 100 → alarm branch + - Value ≤ 100 → success branch +- Step 3: Save to time series +{:copy-code} +``` + +- Step 1: Receive telemetry +- Step 2: Filter by threshold + - Value > 100 → alarm branch + - Value ≤ 100 → success branch +- Step 3: Save to time series + +Ordered list using numbers: + +```markdown +1. Validate message type +2. Enrich with device attributes +3. Route to appropriate handler +{:copy-code} +``` + +1. Validate message type +2. Enrich with device attributes +3. Route to appropriate handler + +
+
+ +#### Blockquotes + +Use `>` for notes, warnings, or highlighted information: + +```markdown +> **Note:** This node requires a valid device alias to be configured upstream. + +> **Warning:** Debug mode increases load — disable in production. +{:copy-code} +``` + +> **Note:** This node requires a valid device alias to be configured upstream. + +> **Warning:** Debug mode increases load — disable in production. + +
+
+ +#### Inline code and code blocks + +Use backticks for inline code and triple backticks for blocks: + +```markdown +The message type must be `POST_TELEMETRY_REQUEST`. +{:copy-code} +``` + +The message type must be `POST_TELEMETRY_REQUEST`. + +````markdown +```json +{ + "temperature": 42.5, + "humidity": 68 +} +``` +{:copy-code} +```` + +```json +{ + "temperature": 42.5, + "humidity": 68 +} +``` + +
+
+ +#### Tables + +```markdown +| Branch | Condition | Next node | +|----------|------------------|--------------------| +| True | temperature > 80 | Create Alarm | +| False | temperature ≤ 80 | Save Timeseries | +{:copy-code} +``` + +| Branch | Condition | Next node | +|----------|------------------|--------------------| +| True | temperature > 80 | Create Alarm | +| False | temperature ≤ 80 | Save Timeseries | + +
+
+ +#### Horizontal rule + +Use `---` to visually separate sections: + +```markdown +### Input + +Device telemetry message. + +--- + +### Output + +Enriched message with customer attributes. +{:copy-code} +``` + +
+
+ +#### HTML elements + +When **Apply default markdown style** is enabled, you can also use HTML tags: + +```html +🔴 Critical path
+🟢 Happy path +{:copy-code} +``` + +
+
+ +#### Tips for rule chain notes + +Use notes to annotate the canvas with context that helps your team: + +```markdown +## 🌡️ Temperature monitoring flow + +Processes telemetry from HVAC sensors. + +| Threshold | Severity | +|-----------|----------| +| > 85 °C | CRITICAL | +| > 70 °C | WARNING | + +> **Owner:** Platform team · **Updated:** 2026-02 +{:copy-code} +``` diff --git a/ui-ngx/src/assets/locale/locale.constant-da_DK.json b/ui-ngx/src/assets/locale/locale.constant-da_DK.json index 69ab79f43b..fc39de2abc 100644 --- a/ui-ngx/src/assets/locale/locale.constant-da_DK.json +++ b/ui-ngx/src/assets/locale/locale.constant-da_DK.json @@ -803,7 +803,7 @@ "no-assets-matching": "Ingen assets matcher '{{entity}}'.", "asset-required": "Asset er påkrævet", "name-starts-with": "Asset-navneudtryk", - "help-text": "Brug '%' efter behov: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Brug '%' efter behov: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Søg assets", "import": "Importér assets", "asset-file": "Assetfil", @@ -910,9 +910,9 @@ "processing-timeouts": "${entityName} behandlingstimeouts", "rule-chain": "Rule Chain", "rule-engine": "Rule Engine", - "rule-engine-daily-activity": "Daglig aktivitet for Rule Engine", "rule-engine-executions": "Rule Engine-udførelser", "rule-engine-hourly-activity": "Timebaseret aktivitet for Rule Engine", + "rule-engine-daily-activity": "Daglig aktivitet for Rule Engine", "rule-engine-monthly-activity": "Månedlig aktivitet for Rule Engine", "rule-engine-statistics": "Statistik for Rule Engine", "rule-node": "Rule Node", @@ -1095,6 +1095,7 @@ "expression": "Udtryk", "no-found": "Ingen beregnede felter fundet", "list": "{ count, plural, =1 {Et beregnet felt} other {Liste over # beregnede felter} }", + "add": "Tilføj beregnet felt", "selected-fields": "{ count, plural, =1 {1 beregnet felt} other {# beregnede felter} } valgt", "type": { "simple": "Simpel", @@ -2124,7 +2125,7 @@ "remove-alias": "Fjern enhedsalias", "add-alias": "Tilføj enhedsalias", "name-starts-with": "Udtryk for enhedsnavn", - "help-text": "Brug '%' efter behov: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Brug '%' efter behov: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Enhedsliste", "use-device-name-filter": "Brug filter", "device-list-empty": "Ingen enheder valgt.", @@ -3039,7 +3040,7 @@ "no-entities-text": "Ingen enheder fundet", "no-entity-types-matching": "Ingen enhedstyper matcher '{{entityType}}'.", "name-starts-with": "Navneudtryk", - "help-text": "Brug '%' efter behov: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Brug '%' efter behov: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Brug filter", "entity-list-empty": "Ingen enheder valgt.", "entity-type-list-required": "Mindst én enhedstype skal vælges.", @@ -3242,7 +3243,7 @@ "remove-alias": "Fjern entitetsvisningsalias", "add-alias": "Tilføj entitetsvisningsalias", "name-starts-with": "Entitetsvisningsnavn udtryk", - "help-text": "Brug '%' efter behov: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Brug '%' efter behov: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Entitetsvisningsliste", "use-entity-view-name-filter": "Brug filter", "entity-view-list-empty": "Ingen entitetsvisninger valgt.", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "Håndtér funktion for tomt resultat", "handle-error-function": "Håndtér fejlfunktion", "handle-non-mobile-fallback-function": "Håndtér fallbackfunktion for ikke-mobil", - "save-to-gallery": "Gem i galleri", + "save-to-gallery": "Gem i 'Billedgalleri'", "provision-type": "Provisioneringstype", "auto": "Auto", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-de_DE.json b/ui-ngx/src/assets/locale/locale.constant-de_DE.json index 78a744a68d..2dfdd9645d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-de_DE.json +++ b/ui-ngx/src/assets/locale/locale.constant-de_DE.json @@ -803,7 +803,7 @@ "no-assets-matching": "Keine Assets gefunden, die mit '{{entity}}' übereinstimmen.", "asset-required": "Asset ist erforderlich", "name-starts-with": "Ausdruck für Assetnamen", - "help-text": "Verwenden Sie '%' je nach Bedarf: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Verwenden Sie '%' nach Bedarf: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Assets suchen", "import": "Assets importieren", "asset-file": "Asset-Datei", @@ -910,9 +910,9 @@ "processing-timeouts": "${entityName} Verarbeitungszeitüberschreitungen", "rule-chain": "Regelkette", "rule-engine": "Regel-Engine", - "rule-engine-daily-activity": "Tägliche Aktivität der Regel-Engine", "rule-engine-executions": "Ausführungen der Regel-Engine", "rule-engine-hourly-activity": "Stündliche Aktivität der Regel-Engine", + "rule-engine-daily-activity": "Tägliche Aktivität der Regel-Engine", "rule-engine-monthly-activity": "Monatliche Aktivität der Regel-Engine", "rule-engine-statistics": "Regel-Engine-Statistiken", "rule-node": "Regelknoten", @@ -1095,6 +1095,7 @@ "expression": "Ausdruck", "no-found": "Keine berechneten Felder gefunden", "list": "{ count, plural, =1 {Ein berechnetes Feld} other {Liste von # berechneten Feldern} }", + "add": "Berechnetes Feld hinzufügen", "selected-fields": "{ count, plural, =1 {1 berechnetes Feld} other {# berechnete Felder} } ausgewählt", "type": { "simple": "Einfach", @@ -2124,7 +2125,7 @@ "remove-alias": "Gerätealias entfernen", "add-alias": "Gerätealias hinzufügen", "name-starts-with": "Ausdruck für Gerätename", - "help-text": "Verwenden Sie '%' bei Bedarf: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Verwenden Sie '%' nach Bedarf: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Geräteliste", "use-device-name-filter": "Filter verwenden", "device-list-empty": "Keine Geräte ausgewählt.", @@ -3039,7 +3040,7 @@ "no-entities-text": "Keine Entitäten gefunden", "no-entity-types-matching": "Keine Entitätstypen gefunden, die mit '{{entityType}}' übereinstimmen.", "name-starts-with": "Namensausdruck", - "help-text": "Verwenden Sie '%' nach Bedarf: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Verwenden Sie '%' nach Bedarf: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Filter verwenden", "entity-list-empty": "Keine Entitäten ausgewählt.", "entity-type-list-required": "Mindestens ein Entitätstyp sollte ausgewählt werden.", @@ -3242,7 +3243,7 @@ "remove-alias": "Entitätsansicht-Alias entfernen", "add-alias": "Entitätsansicht-Alias hinzufügen", "name-starts-with": "Namensausdruck für Entitätsansicht", - "help-text": "Verwenden Sie '%' nach Bedarf: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Verwenden Sie '%' nach Bedarf: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Liste der Entitätsansichten", "use-entity-view-name-filter": "Filter verwenden", "entity-view-list-empty": "Keine Entitätsansichten ausgewählt.", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "Funktion zum Verarbeiten eines leeren Ergebnisses", "handle-error-function": "Funktion zum Verarbeiten eines Fehlers", "handle-non-mobile-fallback-function": "Fallback-Funktion für Nicht-Mobile", - "save-to-gallery": "In Galerie speichern", + "save-to-gallery": "In 'Bildergalerie' speichern", "provision-type": "Provisioning-Typ", "auto": "Auto", "wi-fi": "WLAN", diff --git a/ui-ngx/src/assets/locale/locale.constant-el_GR.json b/ui-ngx/src/assets/locale/locale.constant-el_GR.json index 31f5d49666..ebd4d4df2d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -78,6 +78,7 @@ "show-more": "Εμφάνιση περισσότερων", "dont-show-again": "Να μην εμφανιστεί ξανά", "see-documentation": "Δείτε την τεκμηρίωση", + "see-debug-events": "Προβολή συμβάντων αποσφαλμάτωσης", "clear": "Καθαρισμός", "upload": "Μεταφόρτωση", "delete-anyway": "Διαγραφή ούτως ή άλλως", @@ -485,6 +486,7 @@ "2fa": { "2fa": "Έλεγχος ταυτότητας δύο παραγόντων", "available-providers": "Διαθέσιμοι πάροχοι", + "available-providers-required": "Πρέπει να έχει ρυθμιστεί τουλάχιστον ένας πάροχος 2FA.", "issuer-name": "Όνομα εκδότη", "issuer-name-required": "Το όνομα εκδότη είναι υποχρεωτικό.", "max-verification-failures-before-user-lockout": "Μέγιστος αριθμός αποτυχημένων ελέγχων πριν από κλείδωμα χρήστη", @@ -513,7 +515,9 @@ "verification-message-template-required": "Το πρότυπο μηνύματος επαλήθευσης είναι υποχρεωτικό.", "within-time": "Εντός χρόνου (δευτ.)", "within-time-pattern": "Ο χρόνος πρέπει να είναι θετικός ακέραιος.", - "within-time-required": "Ο χρόνος είναι υποχρεωτικός." + "within-time-required": "Ο χρόνος είναι υποχρεωτικός.", + "force-2fa": "Επιβολή ελέγχου ταυτότητας δύο παραγόντων", + "enforce-for": "Επιβολή για" }, "jwt": { "security-settings": "Ρυθμίσεις ασφαλείας JWT", @@ -545,16 +549,11 @@ "slack-settings": "Ρυθμίσεις Slack", "mobile-settings": "Ρυθμίσεις κινητού", "firebase-service-account-file": "Αρχείο διαπιστευτηρίων λογαριασμού υπηρεσίας Firebase (JSON)", - "select-firebase-service-account-file": "Σύρετε και αποθέστε το αρχείο διαπιστευτηρίων λογαριασμού υπηρεσίας Firebase ή ", - "trendz": "Trendz", - "trendz-settings": "Ρυθμίσεις Trendz", - "trendz-url": "Διεύθυνση URL Trendz", - "trendz-url-required": "Απαιτείται η διεύθυνση URL του Trendz", - "trendz-api-key": "API κλειδί Trendz", - "trendz-enable": "Ενεργοποίηση Trendz" + "select-firebase-service-account-file": "Σύρετε και αποθέστε το αρχείο διαπιστευτηρίων λογαριασμού υπηρεσίας Firebase ή " }, "alarm": { "alarm": "Συναγερμός", + "alarm-list": "Λίστα συναγερμών", "alarms": "Συναγερμοί", "all-alarms": "Όλοι οι συναγερμοί", "select-alarm": "Επιλογή συναγερμού", @@ -655,7 +654,16 @@ "alarm-type": "Τύπος συναγερμού", "enter-alarm-type": "Εισαγωγή τύπου συναγερμού", "no-alarm-types-matching": "Δεν βρέθηκαν τύποι συναγερμού που να ταιριάζουν με '{{entitySubtype}}'.", - "alarm-type-list-empty": "Δεν έχουν επιλεγεί τύποι συναγερμού." + "alarm-type-list-empty": "Δεν έχουν επιλεγεί τύποι συναγερμού.", + "system-comments": { + "acked-by-user": "Ο συναγερμός επιβεβαιώθηκε από τον χρήστη {{userName}}", + "cleared-by-user": "Ο συναγερμός εκκαθαρίστηκε από τον χρήστη {{userName}}", + "assigned-to-user": "Ο συναγερμός ανατέθηκε από τον χρήστη {{userName}} στον χρήστη {{assigneeName}}", + "unassigned-to-user": "Η ανάθεση του συναγερμού καταργήθηκε από τον χρήστη {{userName}}", + "unassigned-from-deleted-user": "Η ανάθεση του συναγερμού καταργήθηκε επειδή ο χρήστης {{userName}} - διαγράφηκε", + "comment-deleted": "Ο χρήστης {{userName}} διέγραψε το σχόλιό του", + "severity-changed": "Η σοβαρότητα του συναγερμού ενημερώθηκε από {{oldSeverity}} σε {{newSeverity}}" + } }, "alarm-activity": { "add": "Προσθήκη σχολίου...", @@ -760,6 +768,7 @@ "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες", "label-max-length": "Η ετικέτα πρέπει να είναι μικρότερη από 256 χαρακτήρες", "description": "Περιγραφή", + "description-required": "Απαιτείται περιγραφή.", "type": "Τύπος", "type-required": "Ο τύπος είναι υποχρεωτικός.", "details": "Λεπτομέρειες", @@ -794,7 +803,7 @@ "no-assets-matching": "Δεν βρέθηκαν περιουσιακά στοιχεία που να ταιριάζουν με '{{entity}}'.", "asset-required": "Απαιτείται περιουσιακό στοιχείο", "name-starts-with": "Έκφραση ονόματος περιουσιακού στοιχείου", - "help-text": "Χρησιμοποιήστε το '%' κατά περίπτωση: '%περιουσιακό_όνομα%', '%περιουσιακό_όνομα_λήγει', 'περιουσιακό_ξεκινά_με'.", + "help-text": "Χρησιμοποιήστε '%' ανάλογα με τις ανάγκες: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Αναζήτηση περιουσιακών στοιχείων", "import": "Εισαγωγή περιουσιακών στοιχείων", "asset-file": "Αρχείο περιουσιακών στοιχείων", @@ -873,6 +882,9 @@ "alarms-created-monthly-activity": "Μηνιαία δραστηριότητα δημιουργίας συναγερμών", "data-points": "Σημεία δεδομένων", "data-points-storage-days": "Ημέρες αποθήκευσης σημείων δεδομένων", + "data-points-storage-days-hourly-activity": "Ωριαία δραστηριότητα ημερών αποθήκευσης σημείων δεδομένων", + "data-points-storage-days-daily-activity": "Ημερήσια δραστηριότητα ημερών αποθήκευσης σημείων δεδομένων", + "data-points-storage-days-monthly-activity": "Μηνιαία δραστηριότητα ημερών αποθήκευσης σημείων δεδομένων", "device-api": "API Συσκευής", "email": "Email", "email-messages": "Μηνύματα email", @@ -898,14 +910,15 @@ "processing-timeouts": "Χρονικά όρια επεξεργασίας ${entityName}", "rule-chain": "Αλυσίδα κανόνων", "rule-engine": "Μηχανή κανόνων", - "rule-engine-daily-activity": "Ημερήσια δραστηριότητα μηχανής κανόνων", "rule-engine-executions": "Εκτελέσεις μηχανής κανόνων", "rule-engine-hourly-activity": "Ωριαία δραστηριότητα μηχανής κανόνων", + "rule-engine-daily-activity": "Ημερήσια δραστηριότητα μηχανής κανόνων", "rule-engine-monthly-activity": "Μηνιαία δραστηριότητα μηχανής κανόνων", "rule-engine-statistics": "Στατιστικά μηχανής κανόνων", "rule-node": "Κόμβος κανόνα", "sms": "SMS", "sms-messages": "Μηνύματα SMS", + "sms-messages-hourly-activity": "Ωριαία δραστηριότητα μηνυμάτων SMS", "sms-messages-daily-activity": "Ημερήσια δραστηριότητα SMS", "sms-messages-monthly-activity": "Μηνιαία δραστηριότητα SMS", "successful": "Επιτυχίες ${entityName}", @@ -915,13 +928,40 @@ "telemetry-persistence-hourly-activity": "Ωριαία δραστηριότητα διατήρησης τηλεμετρίας", "telemetry-persistence-monthly-activity": "Μηνιαία δραστηριότητα διατήρησης τηλεμετρίας", "transport": "Μεταφορά", + "transport-msg-hourly-activity": "Ωριαία δραστηριότητα μηνυμάτων transport", + "transport-msg-daily-activity": "Ημερήσια δραστηριότητα μηνυμάτων transport", + "transport-msg-monthly-activity": "Μηνιαία δραστηριότητα μηνυμάτων transport", "transport-daily-activity": "Ημερήσια δραστηριότητα μεταφοράς", "transport-data-points": "Σημεία δεδομένων μεταφοράς", - "transport-hourly-activity": "Ωριαία δραστηριότητα μεταφοράς", - "transport-messages": "Μηνύματα μεταφοράς", - "transport-monthly-activity": "Μηνιαία δραστηριότητα μεταφοράς", + "transport-data-points-hourly-activity": "Ωριαία δραστηριότητα σημείων δεδομένων transport", + "transport-data-points-daily-activity": "Ημερήσια δραστηριότητα σημείων δεδομένων transport", + "transport-data-points-monthly-activity": "Μηνιαία δραστηριότητα σημείων δεδομένων transport", "view-details": "Προβολή λεπτομερειών", - "view-statistics": "Προβολή στατιστικών" + "view-statistics": "Προβολή στατιστικών", + "transport-messages": "Μηνύματα transport", + "transport-messages-hourly-activity": "Ωριαία δραστηριότητα μηνυμάτων transport", + "transport-data-point-hourly-activity": "Ωριαία δραστηριότητα σημείου δεδομένων transport", + "javascript-function-executions": "Εκτελέσεις συναρτήσεων JavaScript", + "javascript-function-executions-hourly-activity": "Ωριαία δραστηριότητα εκτελέσεων συναρτήσεων JavaScript", + "javascript-function-executions-daily-activity": "Ημερήσια δραστηριότητα εκτελέσεων συναρτήσεων JavaScript", + "javascript-function-executions-monthly-activity": "Μηνιαία δραστηριότητα εκτελέσεων συναρτήσεων JavaScript", + "tbel-function-executions": "Εκτελέσεις συναρτήσεων TBEL", + "tbel-function-executions-hourly-activity": "Ωριαία δραστηριότητα εκτελέσεων συναρτήσεων TBEL", + "tbel-function-executions-daily-activity": "Ημερήσια δραστηριότητα εκτελέσεων συναρτήσεων TBEL", + "tbel-function-executions-monthly-activity": "Μηνιαία δραστηριότητα εκτελέσεων συναρτήσεων TBEL", + "created-reports": "Δημιουργημένες αναφορές", + "created-reports-hourly-activity": "Ωριαία δραστηριότητα δημιουργημένων αναφορών", + "created-reports-daily-activity": "Ημερήσια δραστηριότητα δημιουργημένων αναφορών", + "created-reports-monthly-activity": "Μηνιαία δραστηριότητα δημιουργημένων αναφορών", + "emails": "emails", + "emails-hourly-activity": "Ωριαία δραστηριότητα emails", + "emails-daily-activity": "Ημερήσια δραστηριότητα emails", + "emails-monthly-activity": "Μηνιαία δραστηριότητα emails", + "status": { + "enabled": "Ενεργό", + "disabled": "Ανενεργό", + "warning": "Προειδοποίηση" + } }, "api-limit": { "cassandra-write-queries-core": "Ερωτήματα εγγραφής Cassandra από REST API", @@ -946,6 +986,40 @@ "edge-uplink-messages": "Μηνύματα uplink από Edge", "edge-uplink-messages-per-edge": "Μηνύματα uplink από Edge ανά μονάδα Edge" }, + "api-key": { + "api-key": "Κλειδί API", + "api-keys": "Κλειδιά API", + "delete-api-key-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε το κλειδί API '{{name}}';", + "delete-api-key-text": "Προσοχή: μετά την επιβεβαίωση, το κλειδί δεν θα μπορεί να ανακτηθεί.", + "delete-api-keys-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 κλειδί API} other {# κλειδιά API} };", + "delete-api-keys-text": "Προσοχή: μετά την επιβεβαίωση, όλα τα επιλεγμένα κλειδιά δεν θα μπορούν να ανακτηθούν.", + "expiration-date": "Ημερομηνία λήξης", + "date": "Ημερομηνία", + "description": "Περιγραφή", + "disable": "Απενεργοποίηση", + "edit-description": "Επεξεργασία περιγραφής", + "enable": "Ενεργοποίηση κλειδιού API ", + "expiration-time": "Χρόνος λήξης", + "expiration-time-never": "Ποτέ", + "expiration-time-custom": "Προσαρμοσμένο", + "generate": "Δημιουργία", + "generate-title": "Δημιουργία κλειδιού API", + "generate-text": "Σημείωση: το κλειδί API κληρονομεί τα δικαιώματα του χρήστη για τον οποίο δημιουργείται.", + "generated-api-key-title": "Το κλειδί API δημιουργήθηκε. Ας ελέγξουμε τη συνδεσιμότητα!", + "generated-api-key-copy": "Βεβαιωθείτε ότι αντιγράψατε και αποθηκεύσατε τώρα το κλειδί API σας, καθώς δεν θα μπορείτε να το δείτε ξανά.", + "generated-api-key-command": "Χρησιμοποιήστε τις ακόλουθες οδηγίες για να ελέγξετε τη συνδεσιμότητα. Ως αποτέλεσμα, θα πρέπει να λάβετε τις τρέχουσες πληροφορίες χρήστη:", + "generated-api-key-insecure-url": "Η εκτέλεση εντολών μέσω μη ασφαλούς σύνδεσης HTTP θα αποστείλει το κλειδί API σας χωρίς κρυπτογράφηση, καθιστώντας το ευάλωτο σε υποκλοπή.", + "list": "{ count, plural, =1 {Ένα κλειδί API} other {Λίστα # κλειδιών API} }", + "manage": "Διαχείριση", + "manage-api-keys": "Διαχείριση κλειδιών API", + "no-found": "Δεν βρέθηκαν κλειδιά API", + "selected-api-keys": "{ count, plural, =1 {1 κλειδί API επιλεγμένο} other {# κλειδιά API επιλεγμένα} }", + "search": "Αναζήτηση κλειδιών API", + "status": "Κατάσταση", + "status-active": "Ενεργό", + "status-inactive": "Ανενεργό", + "status-expired": "Ληγμένο" + }, "audit-log": { "audit": "Έλεγχος", "audit-logs": "Αρχεία καταγραφής ελέγχου", @@ -999,7 +1073,11 @@ "type-provision-failure": "Η εγκατάσταση της συσκευής απέτυχε", "type-timeseries-updated": "Ενημερώθηκε τηλεμετρία", "type-timeseries-deleted": "Διαγράφηκε τηλεμετρία", - "type-sms-sent": "SMS στάλθηκε" + "type-sms-sent": "SMS στάλθηκε", + "any-type": "Οποιοσδήποτε τύπος", + "audit-log-filter-title": "Φίλτρο καταγραφής ελέγχου", + "filter-title": "Φίλτρο", + "filter-types": "Τύποι καταγραφής ελέγχου" }, "debug-settings": { "label": "Ρυθμίσεις εντοπισμού σφαλμάτων", @@ -1017,15 +1095,29 @@ "expression": "Έκφραση", "no-found": "Δεν βρέθηκαν υπολογιζόμενα πεδία", "list": "{ count, plural, =1 {Ένα υπολογιζόμενο πεδίο} other {Λίστα με # υπολογιζόμενα πεδία} }", + "add": "Προσθήκη υπολογιζόμενου πεδίου", "selected-fields": "{ count, plural, =1 {1 υπολογιζόμενο πεδίο} other {# υπολογιζόμενα πεδία} } επιλεγμένα", "type": { "simple": "Απλό", - "script": "Script" + "simple-hint": "Απλός αριθμητικός υπολογισμός με βάση τα ορίσματα εισόδου.", + "script": "Σενάριο", + "script-hint": "Υπολογισμός πάνω σε καθορισμένα ορίσματα με χρήση σεναρίου TBEL.", + "geofencing": "Γεωπερίφραξη", + "geofencing-hint": "Αξιολόγηση της θέσης GPS της οντότητας και των μεταβάσεων σε σχέση με τις ρυθμισμένες ομάδες ζωνών γεωπερίφραξης.", + "propagation": "Διάδοση", + "propagation-hint": "Διάδοση δεδομένων σε γονικές ή θυγατρικές οντότητες με βάση την κατεύθυνση και τον τύπο της σχέσης.", + "related-entities-aggregation": "Συγκέντρωση σχετικών οντοτήτων", + "related-entities-aggregation-hint": "Συγκέντρωση των πιο πρόσφατων δεδομένων από σχετικές οντότητες.", + "time-series-data-aggregation": "Συγκέντρωση δεδομένων χρονοσειράς", + "time-series-data-aggregation-hint": "Συγκέντρωση ιστορικών δεδομένων από την τρέχουσα οντότητα." }, + "preview": "Προεπισκόπηση", "arguments": "Ορίσματα", "decimals-by-default": "Αριθμοί μετά την υποδιαστολή (προεπιλογή)", "debugging": "Εντοπισμός σφαλμάτων υπολογιζόμενου πεδίου", + "calculated-field-details": "Λεπτομέρειες υπολογιζόμενου πεδίου", "argument-name": "Όνομα ορίσματος", + "name": "Όνομα", "datasource": "Πηγή δεδομένων", "add-argument": "Προσθήκη ορίσματος", "test-script-function": "Δοκιμή συνάρτησης script", @@ -1037,8 +1129,9 @@ "argument-asset": "Περιουσιακό στοιχείο", "argument-customer": "Πελάτης", "argument-tenant": "Τρέχων ενοικιαστής", + "argument-owner": "Τρέχων ιδιοκτήτης", + "argument-relation-query": "Σχετικές οντότητες", "argument-type": "Τύπος ορίσματος", - "see-debug-events": "Προβολή γεγονότων εντοπισμού σφαλμάτων", "attribute": "Χαρακτηριστικό", "copy-argument-name": "Αντιγραφή ονόματος ορίσματος", "timeseries-key": "Κλειδί χρονοσειράς", @@ -1051,12 +1144,14 @@ "shared-attributes": "Κοινόχρηστα χαρακτηριστικά", "attribute-key": "Κλειδί χαρακτηριστικού", "default-value": "Προεπιλεγμένη τιμή", + "default-value-required": "Απαιτείται προεπιλεγμένη τιμή.", "limit": "Μέγιστες τιμές", "time-window": "Παράθυρο χρόνου", "customer-name": "Όνομα πελάτη", "asset-name": "Όνομα περιουσιακού στοιχείου", "timeseries": "Χρονοσειρά", "output": "Έξοδος", + "output-hint": "Καθορίζει τον τρόπο επεξεργασίας της εξόδου.", "create": "Δημιουργία νέου υπολογιζόμενου πεδίου", "file": "Αρχείο υπολογιζόμενου πεδίου", "invalid-file-error": "Μη έγκυρη μορφή αρχείου. Βεβαιωθείτε ότι είναι έγκυρο αρχείο JSON.", @@ -1070,9 +1165,175 @@ "delete-multiple-text": "Προσοχή, μετά την επιβεβαίωση όλα τα επιλεγμένα υπολογιζόμενα πεδία θα διαγραφούν και όλα τα σχετικά δεδομένα δεν θα είναι ανακτήσιμα.", "test-with-this-message": "Δοκιμή με αυτό το μήνυμα", "use-latest-timestamp": "Χρήση πιο πρόσφατης χρονικής σήμανσης", + "entity-coordinates": "Συντεταγμένες οντότητας", + "latitude-time-series-key": "Κλειδί χρονοσειράς γεωγραφικού πλάτους", + "latitude-time-series-key-required": "Απαιτείται κλειδί χρονοσειράς γεωγραφικού πλάτους.", + "longitude-time-series-key": "Κλειδί χρονοσειράς γεωγραφικού μήκους", + "longitude-time-series-key-required": "Απαιτείται κλειδί χρονοσειράς γεωγραφικού μήκους.", + "geofencing-zone-groups": "Ομάδες ζωνών γεωπερίφραξης", + "geofencing-zone-groups-settings": "Ρυθμίσεις ομάδας ζωνών γεωπερίφραξης", + "target-zone": "Ζώνη στόχος", + "perimeter-key": "Κλειδί περιμέτρου", + "report-strategy": "Στρατηγική αναφοράς", + "no-zone-configured": "Απαιτείται τουλάχιστον μία ζώνη.", + "no-zone-configured-required": "Πρέπει να έχει ρυθμιστεί τουλάχιστον μία ομάδα ζωνών.", + "add-zone-group": "Προσθήκη ομάδας ζωνών", + "report-transition-event-only": "Μόνο συμβάντα μετάβασης", + "report-presence-status-only": "Μόνο κατάσταση παρουσίας", + "report-transition-event-and-presence": "Κατάσταση παρουσίας και συμβάντα μετάβασης", + "perimeter-attribute-key": "Κλειδί χαρακτηριστικού περιμέτρου", + "perimeter-attribute-key-required": "Απαιτείται κλειδί χαρακτηριστικού περιμέτρου.", + "perimeter-attribute-key-pattern": "Το κλειδί χαρακτηριστικού περιμέτρου δεν είναι έγκυρο.", + "entity-zone-relationship": "Διαδρομή από την οντότητα στις ζώνες", + "direction": "Κατεύθυνση σχέσης", + "direction-from": "Από την οντότητα στη ζώνη", + "direction-to": "Από τη ζώνη στην οντότητα", + "relation-type": "Τύπος σχέσης", + "create-relation-with-matched-zones": "Δημιουργία σχέσεων για την οντότητα προέλευσης με τις ζώνες που αντιστοιχούν", + "relation-level": "Επίπεδο σχέσης", + "fetch-last-available-level": "Λήψη μόνο του τελευταίου διαθέσιμου επιπέδου", + "zone-group-refresh-interval": "Διάστημα ανανέωσης ομάδων ζωνών", + "copy-zone-group-name": "Αντιγραφή ονόματος ομάδας ζωνών", + "open-details-page": "Άνοιγμα σελίδας λεπτομερειών οντότητας", + "level": "Επίπεδο", + "direction-level": "Κατεύθυνση", + "direction-up": "Προς τα πάνω", + "direction-up-parent": "Προς τα πάνω έως τη γονική", + "direction-down": "Προς τα κάτω", + "direction-down-child": "Προς τα κάτω έως τη θυγατρική", + "add-level": "Προσθήκη επιπέδου", + "delete-level": "Διαγραφή επιπέδου", + "no-level": "Δεν έχει ρυθμιστεί επίπεδο", + "levels-required": "Πρέπει να έχει ρυθμιστεί τουλάχιστον ένα επίπεδο.", + "max-allowed-levels-error": "Το επίπεδο σχέσης υπερβαίνει το μέγιστο επιτρεπόμενο.", + "propagation-path-related-entities": "Διαδρομή διάδοσης προς σχετικές οντότητες", + "propagate-type": { + "arguments-only": "Μόνο ορίσματα", + "expression-result": "Αποτέλεσμα υπολογισμού" + }, + "script": "Σενάριο", + "data-propagate": "Δεδομένα προς διάδοση", + "output-key": "Κλειδί εξόδου", + "copy-output-key": "Αντιγραφή κλειδιού εξόδου", + "aggregation-path-related-entities": "Διαδρομή συγκέντρωσης προς σχετικές οντότητες", + "deduplication-interval": "Διάστημα αποδιπλοεγγραφής", + "deduplication-interval-min": "Το διάστημα αποδιπλοεγγραφής πρέπει να είναι τουλάχιστον {{ sec }} δευτερόλεπτα.", + "deduplication-interval-hint": "Ελάχιστος χρόνος μεταξύ συγκεντρώσεων τηλεμετρίας.", + "deduplication-interval-required": "Απαιτείται διάστημα αποδιπλοεγγραφής.", + "calculated-field-filter-title": "Φίλτρο υπολογιζόμενου πεδίου", + "filter-title": "Φίλτρο", + "calculated-field-types": "Τύποι υπολογιζόμενου πεδίου", + "events": "Συμβάντα", + "any-type": "Οποιοσδήποτε τύπος", + "metrics": { + "metrics": "Μετρικές", + "metrics-empty": "Πρέπει να έχει ρυθμιστεί τουλάχιστον μία μετρική.", + "metric-name": "Όνομα μετρικής", + "metric-name-required": "Απαιτείται όνομα μετρικής.", + "metric-name-pattern": "Το όνομα μετρικής δεν είναι έγκυρο.", + "metric-name-duplicate": "Υπάρχει ήδη μετρική με τέτοιο όνομα.", + "metric-name-max-length": "Το όνομα μετρικής πρέπει να είναι μικρότερο από 256 χαρακτήρες.", + "metric-name-forbidden": "Το όνομα μετρικής είναι δεσμευμένο και δεν μπορεί να χρησιμοποιηθεί.", + "copy-metric-name": "Αντιγραφή ονόματος μετρικής", + "argument-name": "Όνομα ορίσματος", + "aggregation": "Συγκέντρωση", + "aggregation-type": { + "avg": "Μέσος όρος", + "min": "Ελάχιστο", + "max": "Μέγιστο", + "sum": "Άθροισμα", + "count": "Πλήθος", + "count-unique": "Πλήθος μοναδικών" + }, + "filtered": "Φιλτραρισμένο", + "value-source": "Πηγή τιμής", + "value-source-hint": "Καθορίζει πώς λαμβάνεται η τιμή για τη συγκέντρωση.", + "value-source-type": { + "key": "Κλειδί", + "function": "Συνάρτηση" + }, + "no-metrics-configured": "Απαιτείται τουλάχιστον μία μετρική.", + "add-metric": "Προσθήκη μετρικής", + "max-metrics": "Έχει επιτευχθεί ο μέγιστος αριθμός μετρικών.", + "metric-settings": "Ρυθμίσεις μετρικής", + "filter": "Φίλτρο", + "filter-hint": "Ενεργοποιεί το φιλτράρισμα οντοτήτων κατά τη συγκέντρωση. Η συνάρτηση φίλτρου πρέπει να επιστρέφει μια λογική τιμή (boolean) και μπορεί να χρησιμοποιεί όλα τα ρυθμισμένα ορίσματα." + }, + "output-strategy": { + "strategy": "Στρατηγική", + "process-right-away": "Άμεση επεξεργασία", + "process-rule-chains": "Επεξεργασία μέσω αλυσίδων κανόνων", + "save-time-series": "Αποθήκευση σε χρονοσειρά", + "save-database": "Αποθήκευση στη βάση δεδομένων", + "save-latest-values": "Αποθήκευση στις πιο πρόσφατες τιμές", + "send-web-sockets": "Αποστολή σε WebSockets", + "save-calculated-fields": "Αποστολή σε υπολογιζόμενα πεδία", + "update-attribute-only-on-value-change": "Ενημέρωση χαρακτηριστικού μόνο όταν αλλάζει η τιμή", + "send-attributes-updated-notification": "Αποστολή ειδοποίησης ενημέρωσης χαρακτηριστικών", + "ttl": "Προσαρμοσμένο TTL", + "ttl-required": "Απαιτείται TTL", + "ttl-min": "Επιτρέπεται μόνο ελάχιστο TTL 0", + "processing-parameters": "Παράμετροι επεξεργασίας", + "hint": { + "strategy": "Ελέγχει αν το αποτέλεσμα επεξεργάζεται άμεσα ή αποστέλλεται σε αλυσίδα κανόνων για πρόσθετη επεξεργασία.", + "processing-options": "Επιλογές επεξεργασίας", + "update-attribute-only-on-value-change": "Ενημερώνει το χαρακτηριστικό σε κάθε εισερχόμενο μήνυμα, ανεξάρτητα από το αν η τιμή έχει αλλάξει. Αυτό αυξάνει τη χρήση API και μειώνει την απόδοση.", + "update-attribute-only-on-value-change-enabled": "Ενημερώνει το χαρακτηριστικό μόνο όταν αλλάζει η τιμή. Αν η τιμή δεν αλλάξει, δεν ενημερώνονται οι χρονοσφραγίδες και δεν αποστέλλονται ειδοποιήσεις.", + "send-attributes-updated-notification": "Αποστέλλει συμβάν ενημέρωσης χαρακτηριστικών (Attributes Updated) στην προεπιλεγμένη αλυσίδα κανόνων.", + "save-time-series": "Αποθηκεύει δεδομένα χρονοσειράς στον πίνακα ts_kv στη βάση δεδομένων.", + "save-database": "Αποθηκεύει δεδομένα χαρακτηριστικών στη βάση δεδομένων.", + "save-latest-values": "Ενημερώνει τα δεδομένα χρονοσειράς στον πίνακα ts_kv_latest στη βάση δεδομένων, αν η νέα τιμή είναι πιο πρόσφατη.", + "send-web-sockets-attribute": "Ειδοποιεί τις συνδρομές WebSocket για ενημερώσεις στα δεδομένα χαρακτηριστικών.", + "send-web-sockets-time-series": "Ειδοποιεί τις συνδρομές WebSocket για ενημερώσεις στα δεδομένα χρονοσειράς.", + "save-calculated-fields-attribute": "Ειδοποιεί τα υπολογιζόμενα πεδία για ενημερώσεις στα δεδομένα χαρακτηριστικών.", + "save-calculated-fields-time-series": "Ειδοποιεί τα υπολογιζόμενα πεδία για ενημερώσεις στα δεδομένα χρονοσειράς.", + "ttl": "Καθορίζει την περίοδο διατήρησης για τα δεδομένα χρονοσειράς. Αν είναι απενεργοποιημένο, χρησιμοποιείται το TTL του προφίλ ενοικιαστή." + } + }, + "aggregate-interval-type": "Τύπος διαστήματος συγκέντρωσης", + "aggregate-interval-value": "Τιμή διαστήματος συγκέντρωσης", + "aggregate-interval-value-required": "Απαιτείται τιμή διαστήματος συγκέντρωσης.", + "aggregate-interval-value-min": "Η τιμή διαστήματος συγκέντρωσης πρέπει να είναι τουλάχιστον { sec, plural, =0 {0 δευτερόλεπτα} =1 {1 δευτερόλεπτο} other {# δευτερόλεπτα} }.", + "aggregate-interval-value-step-multiple-of": "Η τιμή διαστήματος συγκέντρωσης πρέπει να είναι διαιρέτης ή πολλαπλάσιο της 1 ημέρας.", + "aggregate-period": { + "hour": "Ώρα", + "day": "Ημέρα", + "week": "Εβδομάδα (Δευ - Κυρ)", + "week-sun-sat": "Εβδομάδα (Κυρ - Σαβ)", + "month": "Μήνας", + "quarter": "Τρίμηνο", + "year": "Έτος", + "custom": "Προσαρμοσμένο" + }, + "aggregate-period-hint-offset": "Το διάστημα συγκέντρωσής σας θα είναι: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "Το διάστημα συγκέντρωσής σας θα είναι: {{ interval }} κ.ο.κ.", + "entity-aggregation": { + "argument-hint": "Τα δεδομένα θα ληφθούν από την τρέχουσα οντότητα.", + "argument-title-hint": "Καθορίζει τα ορίσματα εισόδου που χρησιμοποιούνται για τη συγκέντρωση.", + "argument-setting-hint": "Η πιο πρόσφατη τηλεμετρία είναι ο μόνος διαθέσιμος τύπος ορίσματος για αυτό το υπολογιζόμενο πεδίο.", + "aggregation-interval": "Διάστημα συγκέντρωσης", + "aggregation-interval-hint": "Καθορίζει πόσο συχνά εκτελείται η συγκέντρωση. Παράδειγμα: κάθε 1 ώρα συγκεντρώνει δεδομένα στις 00:00, 01:00, 02:00 κ.λπ. Τα αποτελέσματα συγκέντρωσης αποθηκεύονται με τη χρονοσφραγίδα που αντιστοιχεί στην αρχή του διαστήματος συγκέντρωσης.", + "apply-offset": "Εφαρμογή μετατόπισης στο διάστημα συγκέντρωσης", + "apply-offset-hint": "Καθορίζει πόσο θα μετατοπιστεί η αρχή κάθε περιόδου συγκέντρωσης (π.χ. +10 λεπτά - 00:10, 01:10).", + "offset-value": "Τιμή μετατόπισης", + "offset-value-required": "Απαιτείται τιμή μετατόπισης.", + "offset-value-min": "Η τιμή μετατόπισης πρέπει να είναι θετικός ακέραιος.", + "offset-value-max": "Η τιμή μετατόπισης πρέπει να είναι μικρότερη από την τιμή διαστήματος συγκέντρωσης.", + "wait-delay": "Εφαρμογή χρονικού ορίου αναμονής για καθυστερημένη τηλεμετρία", + "wait-delay-hint": "Καθορίζει πόσο θα περιμένετε για καθυστερημένη τηλεμετρία μετά τη λήξη του διαστήματος. Αν φτάσει τέτοια τηλεμετρία, το αποτέλεσμα για εκείνο το διάστημα θα επανυπολογιστεί.", + "duration": "Διάρκεια", + "duration-required": "Απαιτείται διάρκεια.", + "duration-min": "Η διάρκεια πρέπει να είναι τουλάχιστον 1 λεπτό.", + "duration-hint": "Πόσο θα περιμένετε για καθυστερημένα δεδομένα μετά τη λήξη του διαστήματος.", + "produce-intermediate-result": "Παραγωγή ενδιάμεσου αποτελέσματος", + "produce-intermediate-result-hint": "Υπολογίζει μετρικές κατά τη διάρκεια του τρέχοντος διαστήματος για την παραγωγή ενδιάμεσου αποτελέσματος. Οι ενημερώσεις δεν πραγματοποιούνται συχνότερα από μία φορά κάθε {{ time }}." + }, "hint": { - "arguments-simple-with-rolling": "Τα υπολογιζόμενα πεδία τύπου Simple δεν πρέπει να περιέχουν κλειδιά τύπου time series rolling.", - "arguments-empty": "Τα ορίσματα δεν πρέπει να είναι κενά.", + "arguments-simple-with-rolling": "Το υπολογιζόμενο πεδίο τύπου «Απλό» δεν πρέπει να περιέχει κλειδιά με τύπο «Κυλιόμενη χρονοσειρά».", + "arguments-propagate-arguments-with-rolling": "Ο τύπος «Κυλιόμενη χρονοσειρά» δεν είναι συμβατός με τη διάδοση «Μόνο ορίσματα».", + "arguments-propagate-argument-entity-type": "Ο τύπος οντότητας δεν είναι συμβατός με τη διάδοση «Μόνο ορίσματα».", + "arguments-propagate-argument-must-current-entity": "Πρέπει να έχει ρυθμιστεί τουλάχιστον ένα όρισμα με τον τύπο οντότητας προέλευσης «Τρέχουσα οντότητα».", + "arguments-empty": "Πρέπει να καθοριστεί τουλάχιστον ένα όρισμα.", "expression-required": "Απαιτείται έκφραση.", "expression-invalid": "Η έκφραση δεν είναι έγκυρη.", "expression-max-length": "Το μήκος της έκφρασης πρέπει να είναι μικρότερο από 255 χαρακτήρες.", @@ -1081,12 +1342,218 @@ "argument-name-duplicate": "Υπάρχει ήδη όρισμα με αυτό το όνομα.", "argument-name-max-length": "Το όνομα του ορίσματος πρέπει να είναι μικρότερο από 256 χαρακτήρες.", "argument-name-forbidden": "Το όνομα του ορίσματος είναι δεσμευμένο και δεν μπορεί να χρησιμοποιηθεί.", + "output-key-required": "Απαιτείται κλειδί εξόδου.", + "output-key-pattern": "Το κλειδί εξόδου δεν είναι έγκυρο.", + "output-key-duplicate": "Υπάρχει ήδη κλειδί με τέτοιο όνομα.", + "output-key-max-length": "Το κλειδί εξόδου πρέπει να είναι μικρότερο από 256 χαρακτήρες.", + "output-key-forbidden": "Το κλειδί εξόδου είναι δεσμευμένο και δεν μπορεί να χρησιμοποιηθεί.", + "entity-type-required": "Απαιτείται τύπος οντότητας.", + "name-required": "Απαιτείται όνομα.", + "name-pattern": "Το όνομα δεν είναι έγκυρο.", + "name-duplicate": "Υπάρχει ήδη όνομα με αυτήν την τιμή.", + "name-max-length": "Το όνομα πρέπει να είναι μικρότερο από 256 χαρακτήρες.", + "name-forbidden": "Το όνομα είναι δεσμευμένο και δεν μπορεί να χρησιμοποιηθεί.", "argument-type-required": "Απαιτείται τύπος ορίσματος.", "max-args": "Έχει επιτευχθεί το μέγιστο πλήθος ορισμάτων.", "decimals-range": "Ο αριθμός δεκαδικών πρέπει να είναι από 0 έως 15.", "expression": "Η προεπιλεγμένη έκφραση δείχνει πώς να μετατρέψετε τη θερμοκρασία από Φαρενάιτ σε Κελσίου.", "arguments-entity-not-found": "Η οντότητα στόχος του ορίσματος δεν βρέθηκε.", - "use-latest-timestamp": "Αν είναι ενεργοποιημένο, η τιμή θα αποθηκευτεί με τη πιο πρόσφατη χρονική σήμανση από τα τηλεμετρικά δεδομένα των ορισμάτων, αντί για την ώρα του διακομιστή." + "use-latest-timestamp": "Αν είναι ενεργοποιημένο, η τιμή θα αποθηκευτεί με τη πιο πρόσφατη χρονική σήμανση από τα τηλεμετρικά δεδομένα των ορισμάτων, αντί για την ώρα του διακομιστή.", + "entity-coordinates": "Καθορίστε τα κλειδιά χρονοσειράς που παρέχουν τις συντεταγμένες GPS της οντότητας (γεωγραφικό πλάτος και γεωγραφικό μήκος).", + "geofencing-zone-groups": "Ορίστε μία ή περισσότερες ομάδες ζωνών γεωπερίφραξης για έλεγχο (π.χ. 'allowedZones', 'restrictedZones'). Κάθε ομάδα πρέπει να έχει μοναδικό όνομα, το οποίο χρησιμοποιείται ως πρόθεμα για τα κλειδιά τηλεμετρίας εξόδου του υπολογιζόμενου πεδίου.", + "perimeter-attribute-key": "Ορίστε το κλειδί χαρακτηριστικού που περιέχει τον ορισμό της περιμέτρου της ζώνης γεωπερίφραξης. Η περίμετρος λαμβάνεται πάντα από χαρακτηριστικά πλευράς διακομιστή της οντότητας ζώνης.", + "report-strategy": "Οι αναφορές κατάστασης παρουσίας υποδεικνύουν αν η οντότητα βρίσκεται αυτή τη στιγμή ΕΝΤΟΣ ή ΕΚΤΟΣ της ομάδας ζωνών. Τα συμβάντα μετάβασης αναφέρουν πότε η οντότητα ΕΙΣΗΛΘΕ ή ΕΞΗΛΘΕ από την ομάδα ζωνών.", + "create-relation-with-matched-zones": "Αυτόματη δημιουργία και διατήρηση σχέσεων μεταξύ της οντότητας και των ζωνών στις οποίες βρίσκεται αυτή τη στιγμή. Οι σχέσεις αφαιρούνται όταν η οντότητα εξέρχεται από μια ζώνη και δημιουργούνται όταν εισέρχεται σε μια νέα.", + "relation-type-required": "Απαιτείται τύπος σχέσης.", + "relation-level-required": "Απαιτείται επίπεδο σχέσης.", + "relation-level-min": "Η ελάχιστη τιμή επιπέδου σχέσης είναι 1.", + "relation-level-max": "Η μέγιστη τιμή επιπέδου σχέσης είναι {{max}}.", + "geofencing-empty": "Πρέπει να έχει ρυθμιστεί τουλάχιστον μία ομάδα ζωνών.", + "geofencing-entity-not-found": "Δεν βρέθηκε η οντότητα στόχος γεωπερίφραξης.", + "max-geofencing-zone": "Έχει επιτευχθεί ο μέγιστος αριθμός ζωνών γεωπερίφραξης.", + "zone-group-refresh-interval": "Καθορίζει πόσο συχνά ανανεώνονται οι ομάδες ζωνών που έχουν ρυθμιστεί μέσω σχετικών οντοτήτων.", + "zone-group-refresh-interval-required": "Απαιτείται διάστημα ανανέωσης ομάδων ζωνών.", + "zone-group-refresh-interval-min": "Το διάστημα ανανέωσης ομάδων ζωνών πρέπει να είναι τουλάχιστον {{ min }} δευτερόλεπτα.", + "propagation-path-related-entities": "Καθορίζει μια άμεση διαδρομή ενός επιπέδου προς σχετική οντότητα με βάση την επιλεγμένη κατεύθυνση και τον τύπο σχέσης. Υποστηρίζονται μόνο σχέσεις μεταξύ οντοτήτων συσκευής, περιουσιακού στοιχείου, πελάτη και ενοικιαστή. Το μέγιστο πλήθος οντοτήτων που επιλύονται μέσω της διαδρομής σχέσεων είναι {{ max }}.", + "data-propagate": "Καθορίζει τα δεδομένα που θα διαδοθούν από τα ορίσματα που ρυθμίζονται παρακάτω. Το 'Arguments only' χρησιμοποιεί απευθείας τα ανακτημένα δεδομένα, ενώ το 'Expression result' υπολογίζει μια νέα τιμή από αυτά τα δεδομένα.", + "aggregation-path-related-entities": "Καθορίζει μια διαδρομή συγκέντρωσης ενός επιπέδου μέσω άμεσων σχέσεων με γονικές ή θυγατρικές οντότητες, με βάση την κατεύθυνση και τον τύπο σχέσης. Υποστηρίζονται μόνο σχέσεις μεταξύ οντοτήτων συσκευής, περιουσιακού στοιχείου, πελάτη και ενοικιαστή. Το μέγιστο πλήθος οντοτήτων που επιλύονται μέσω της διαδρομής σχέσεων είναι {{ max }}.", + "arguments-aggregation": "Καθορίζει τα ορίσματα εισόδου που χρησιμοποιούνται για φιλτράρισμα και συγκέντρωση.", + "setting-arguments-aggregation": "Τα δεδομένα θα ληφθούν από σχετικές οντότητες που έχουν ρυθμιστεί στη διαδρομή συγκέντρωσης.", + "metrics": "Καθορίζει τις μετρικές που συγκεντρώνονται με βάση τα ρυθμισμένα ορίσματα.", + "entity-aggregation-metrics": "Καθορίζει τις μετρικές που συγκεντρώνονται με βάση τα ρυθμισμένα ορίσματα στα καθορισμένα χρονικά διαστήματα.", + "import-invalid-calculated-field-type": "Δεν είναι δυνατή η εισαγωγή υπολογιζόμενου πεδίου: μη έγκυρη δομή υπολογιζόμενου πεδίου.", + "simple-expression-title": "Αριθμητική έκφραση που καθορίζει πώς υπολογίζεται η υπολογιζόμενη τιμή.", + "script-title": "Σενάριο TBEL που καθορίζει τη λογική υπολογισμού και τις τιμές εξόδου.", + "simple-arguments": "Αριθμητική έκφραση που καθορίζει πώς υπολογίζεται η υπολογιζόμενη τιμή.", + "script-arguments": "Καθορίζει τα ορίσματα εισόδου που είναι διαθέσιμα στο σενάριο." + } + }, + "alarm-rule": { + "alarm-rules-tab": "Κανόνες συναγερμού", + "alarm-rule": "Κανόνας συναγερμού", + "alarm-rules": "Κανόνες συναγερμού", + "alarm-rules-old": "Παλαιό", + "alarm-rules-actual": "Τρέχον", + "severities": "Επίπεδα σοβαρότητας", + "cleared": "Συνθήκη εκκαθάρισης", + "delete-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε τον κανόνα συναγερμού '{{title}}';", + "delete-text": "Προσοχή: μετά την επιβεβαίωση, ο κανόνας συναγερμού και όλα τα σχετικά δεδομένα δεν θα μπορούν να ανακτηθούν.", + "delete-multiple-title": "Είστε βέβαιοι ότι θέλετε να διαγράψετε { count, plural, =1 {1 κανόνα συναγερμού} other {# κανόνες συναγερμού} };", + "delete-multiple-text": "Προσοχή: μετά την επιβεβαίωση, όλοι οι επιλεγμένοι κανόνες συναγερμού θα αφαιρεθούν και όλα τα σχετικά δεδομένα δεν θα μπορούν να ανακτηθούν.", + "create": "Δημιουργία νέου κανόνα συναγερμού", + "add": "Προσθήκη κανόνα συναγερμού", + "copy": "Αντιγραφή διαμόρφωσης κανόνα συναγερμού", + "details": "Λεπτομέρειες κανόνα συναγερμού", + "no-found": "Δεν βρέθηκαν κανόνες συναγερμού", + "list": "{ count, plural, =1 {Ένας κανόνας συναγερμού} other {Λίστα # κανόνων συναγερμού} }", + "selected-fields": "{ count, plural, =1 {Επιλεγμένος 1 κανόνας συναγερμού} other {Επιλεγμένοι # κανόνες συναγερμού} }", + "import": "Εισαγωγή κανόνα συναγερμού", + "file": "Αρχείο κανόνα συναγερμού", + "export": "Εξαγωγή κανόνα συναγερμού", + "export-failed-error": "Δεν είναι δυνατή η εξαγωγή του κανόνα συναγερμού: {{error}}", + "entity-type": "Τύπος οντότητας", + "entity-type-required": "Απαιτείται τύπος οντότητας.", + "alarm-type": "Τύπος συναγερμού", + "alarm-type-hint": "Μοναδικό αναγνωριστικό (π.χ. HighTempAlarm) στο εύρος του δημιουργού του συναγερμού (Συσκευή, Περιουσιακό στοιχείο κ.λπ.) για την αποφυγή συγκρούσεων.", + "alarm-type-required": "Απαιτείται τύπος συναγερμού.", + "alarm-type-pattern": "Ο τύπος συναγερμού δεν είναι έγκυρος.", + "alarm-type-max-length": "Ο τύπος συναγερμού πρέπει να είναι μικρότερος από 256 χαρακτήρες.", + "clear-alarm": "Εκκαθάριση συναγερμού", + "value-argument": "Όρισμα", + "value-argument-required": "Απαιτείται όρισμα.", + "static-settings": "Στατικές ρυθμίσεις", + "configuration": "Διαμόρφωση", + "static-schedule": "Στατικό", + "dynamic-schedule": "Δυναμικό", + "operation-and": "ΚΑΙ", + "operation-or": "Ή", + "condition-during": "Κατά τη διάρκεια {{during}}", + "condition-during-dynamic": "Κατά τη διάρκεια \"{{ attribute }}\"", + "condition-repeat-times": "Επαναλαμβάνεται { count, plural, =1 {1 φορά} other {# φορές} }", + "condition-repeat-times-dynamic": "Επαναλαμβάνεται \"{{ attribute }}\" φορές", + "filter-preview": "Προεπισκόπηση φίλτρου", + "condition-settings": "Ρυθμίσεις συνθήκης", + "static": "Στατικό", + "dynamic": "Δυναμικό", + "argument-filters": "Φίλτρα ορισμάτων", + "argument-name": "Όνομα ορίσματος", + "value-type": "Τύπος τιμής", + "general": "Γενικά", + "filters": "Φίλτρα", + "date-time-hint": "Το όρισμα πρέπει να είναι σε epoch milliseconds. Παράδειγμα: το 1698839340000 ισοδυναμεί με 2023-11-01 12:49:00 UTC.", + "operation": "Τελεστής", + "value-source": "Πηγή τιμής", + "value": "Τιμή", + "ignore-case": "Παράβλεψη πεζών-κεφαλαίων", + "condition": "Συνθήκη", + "script": "Σενάριο", + "add-filter": "Προσθήκη φίλτρου ορίσματος", + "edit-filter": "Φίλτρο ορίσματος", + "remove-filter": "Αφαίρεση φίλτρου ορίσματος", + "no-filter": "Απαιτείται τουλάχιστον ένα φίλτρο.", + "conditions": { + "simple": "Απλό", + "duration": "Διάρκεια", + "repeating": "Επαναλαμβανόμενο" + }, + "schedule-title": "Χρονοπρόγραμμα", + "edit-schedule": "Επεξεργασία χρονοπρογράμματος συναγερμού", + "schedule-type": "Τύπος χρονοπρογραμματιστή", + "schedule-type-required": "Απαιτείται τύπος χρονοπρογραμματιστή.", + "schedule": { + "any-time": "Ενεργό συνεχώς", + "specific-time": "Ενεργό σε συγκεκριμένη ώρα", + "custom": "Προσαρμοσμένο" + }, + "schedule-day": { + "monday": "Δευτέρα", + "tuesday": "Τρίτη", + "wednesday": "Τετάρτη", + "thursday": "Πέμπτη", + "friday": "Παρασκευή", + "saturday": "Σάββατο", + "sunday": "Κυριακή" + }, + "schedule-days": "Ημέρες", + "schedule-time": "Ώρα", + "schedule-time-from": "Από", + "schedule-time-to": "Έως", + "schedule-days-of-week-required": "Πρέπει να επιλεγεί τουλάχιστον μία ημέρα της εβδομάδας.", + "tbel": "TBEL", + "expression-type": { + "simple": "Απλό", + "script": "Σενάριο" + }, + "operation-type": { + "and": "Και", + "or": "Ή" + }, + "filter-predicate-type": { + "string": "Συμβολοσειρά", + "numeric": "Αριθμητικό", + "boolean": "Λογικό", + "complex": "Σύνθετο" + }, + "alarm-rule-additional-info": "Πρόσθετες πληροφορίες", + "edit-alarm-rule-additional-info": "Επεξεργασία πρόσθετων πληροφοριών", + "alarm-rule-additional-info-placeholder": "Παρακαλούμε καταχωρίστε εδώ τα σχόλια και τις προσαρμογές σας, ώστε να εμφανίζονται στις λεπτομέρειες συναγερμού, στην ενότητα «Πρόσθετες πληροφορίες»", + "alarm-rule-additional-info-hint": "Υπόδειξη: χρησιμοποιήστε ${Argument name} για να αντικαθιστάτε τιμές των ορισμάτων που χρησιμοποιούνται στη συνθήκη κανόνα συναγερμού.", + "alarm-rule-additional-info-icon-hint": "Χρησιμοποιήστε το Argument name για να αντικαθιστάτε τιμές των ορισμάτων που χρησιμοποιούνται στη συνθήκη κανόνα συναγερμού.", + "alarm-rule-mobile-dashboard": "Πίνακας ελέγχου για κινητά", + "alarm-rule-mobile-dashboard-hint": "Χρησιμοποιείται από την εφαρμογή για κινητά ως πίνακας ελέγχου λεπτομερειών συναγερμού.", + "alarm-rule-no-mobile-dashboard": "Δεν έχει επιλεγεί πίνακας ελέγχου", + "alarm-rule-condition": "Συνθήκη κανόνα συναγερμού", + "enter-alarm-rule-condition-prompt": "Προσθήκη συνθήκης", + "enter-alarm-rule-clear-condition-prompt": "Προσθήκη συνθήκης εκκαθάρισης", + "edit-alarm-rule-condition": "Συνθήκη συναγερμού", + "condition-type": "Τύπος συνθήκης", + "condition-type-hint": "Οι επιλογές \"Διάρκεια\" και \"Επαναλαμβανόμενο\" δεν είναι διαθέσιμες όταν χρησιμοποιείται στο φίλτρο ο τελεστής \"Missing for\".", + "select-alarm-severity": "Επιλογή σοβαρότητας συναγερμού", + "add-create-alarm-rule-prompt": "Απαιτείται τουλάχιστον μία συνθήκη ενεργοποίησης.", + "add-create-alarm-rule": "Προσθήκη συνθήκης ενεργοποίησης", + "add-clear-alarm-rule": "Προσθήκη συνθήκης εκκαθάρισης", + "condition-duration": "Διάρκεια συνθήκης", + "condition-duration-value": "Τιμή διάρκειας", + "condition-duration-time-unit": "Μονάδα χρόνου", + "condition-duration-value-range": "Η τιμή διάρκειας πρέπει να βρίσκεται στο εύρος από 1 έως 2147483647.", + "condition-duration-value-pattern": "Η τιμή διάρκειας πρέπει να είναι ακέραιοι αριθμοί.", + "condition-duration-value-required": "Απαιτείται τιμή διάρκειας.", + "condition-duration-time-unit-required": "Απαιτείται μονάδα χρόνου.", + "condition-repeating-value": "Πλήθος συμβάντων", + "condition-repeating-value-hint": "Η ενημέρωση οποιουδήποτε ορίσματος κανόνα συναγερμού θα μετράται ως συμβάν", + "condition-repeating-value-range": "Το πλήθος συμβάντων πρέπει να βρίσκεται στο εύρος από 1 έως 2147483647.", + "condition-repeating-value-pattern": "Το πλήθος συμβάντων πρέπει να είναι ακέραιοι αριθμοί.", + "condition-repeating-value-required": "Απαιτείται πλήθος συμβάντων.", + "create-conditions": "Συνθήκες ενεργοποίησης", + "clear-condition": "Συνθήκη εκκαθάρισης", + "no-clear-alarm-rule": "Δεν έχει ρυθμιστεί συνθήκη εκκαθάρισης.", + "advanced-settings": "Ρυθμίσεις για προχωρημένους", + "propagate-alarm": "Διάδοση συναγερμού σε σχετικές οντότητες", + "alarm-rule-relation-types-list": "Τύποι σχέσεων", + "alarm-rule-relation-types-list-hint": "Καθορίζει τους τύπους σχέσεων για φιλτράρισμα των σχετικών οντοτήτων. Αν δεν οριστεί, ο συναγερμός θα διαδοθεί σε όλες τις σχετικές οντότητες.", + "propagate-alarm-to-owner": "Διάδοση συναγερμού στον κάτοχο οντότητας (Πελάτης ή Ενοικιαστής)", + "propagate-alarm-to-tenant": "Διάδοση συναγερμού στον ενοικιαστή", + "alarm-rule-filter-title": "Φίλτρο κανόνα συναγερμού", + "filter-title": "Φίλτρο", + "debugging": "Αποσφαλμάτωση κανόνα συναγερμού", + "any-type": "Οποιοσδήποτε τύπος", + "enter-alarm-rule-type": "Εισαγωγή τύπου συναγερμού", + "no-alarm-rule-types-matching": "Δεν βρέθηκαν τύποι συναγερμού που να αντιστοιχούν στο '{{entitySubtype}}'.", + "alarm-rule-type-list-empty": "Δεν έχουν επιλεγεί τύποι συναγερμού.", + "alarm-rule-type-list": "Λίστα τύπων συναγερμού", + "alarm-rule-entity-list": "Λίστα οντοτήτων", + "missing-for": "λείπει για", + "time-unit": "Μονάδα", + "mode": "Λειτουργία", + "type": "Τύπος", + "value-required": "Απαιτείται τιμή.", + "min-value": "Η τιμή πρέπει να είναι 1 ή μεγαλύτερη.", + "argument-in-use": "Το όρισμα χρησιμοποιείται ως γενικό όρισμα.", + "import-invalid-alarm-rule-type": "Δεν είναι δυνατή η εισαγωγή κανόνα συναγερμού: μη έγκυρη δομή κανόνα συναγερμού.", + "no-filter-preview": "Δεν έχει καθοριστεί φίλτρο", + "filter-operation": { + "and": "Και", + "or": "Ή" } }, "ai-models": { @@ -1193,6 +1660,7 @@ "contact": { "country": "Χώρα", "country-required": "Η χώρα είναι υποχρεωτική.", + "country-object-required": "Παρακαλούμε επιλέξτε μια έγκυρη χώρα από τη λίστα.", "city": "Πόλη", "state": "Πολιτεία / Περιφέρεια", "postal-code": "Ταχυδρομικός Κώδικας", @@ -1229,6 +1697,8 @@ "documentation": "Τεκμηρίωση", "time-left": "Απομένει {{time}}", "output": "Έξοδος", + "sort-asc": "Αύξουσα", + "sort-desc": "Φθίνουσα", "suffix": { "s": "δ", "ms": "ms" @@ -1365,6 +1835,8 @@ "mobile-order": "Σειρά πίνακα ελέγχου στην εφαρμογή κινητού", "mobile-hide": "Απόκρυψη πίνακα ελέγχου στην εφαρμογή κινητού", "update-image": "Ενημέρωση εικόνας πίνακα ελέγχου", + "update-new-version": "Μεταφόρτωση νέας έκδοσης", + "upload-file-to-update": "Μεταφόρτωση αρχείου για ενημέρωση", "take-screenshot": "Λήψη στιγμιότυπου", "select-widget-title": "Επιλογή widget", "select-widget-value": "{{title}}: επιλογή widget", @@ -1653,7 +2125,7 @@ "remove-alias": "Αφαίρεση ψευδωνύμου συσκευής", "add-alias": "Προσθήκη ψευδωνύμου συσκευής", "name-starts-with": "Έκφραση ονόματος συσκευής", - "help-text": "Χρησιμοποιήστε '%' όπως απαιτείται: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Χρησιμοποιήστε '%' ανάλογα με τις ανάγκες: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Λίστα συσκευών", "use-device-name-filter": "Χρήση φίλτρου", "device-list-empty": "Δεν έχουν επιλεγεί συσκευές.", @@ -1733,6 +2205,8 @@ "bootstrap-tab": "Bootstrap πελάτη", "bootstrap-server": "Διακομιστής Bootstrap", "lwm2m-server": "Διακομιστής LwM2M", + "client-reboot": "Έναυσμα ενημέρωσης καταχώρισης", + "bootstrap-reboot": "Έναυσμα αίτησης bootstrap", "client-publicKey-or-id": "Δημόσιο κλειδί ή Id πελάτη", "client-publicKey-or-id-required": "Απαιτείται Δημόσιο κλειδί ή Id πελάτη.", "client-publicKey-or-id-tooltip-psk": "Το αναγνωριστικό PSK είναι ένα αυθαίρετο αναγνωριστικό PSK έως 128 byte, όπως περιγράφεται στο πρότυπο [RFC7925].\nΤο αναγνωριστικό PSK ΠΡΕΠΕΙ να μετατραπεί πρώτα σε συμβολοσειρά χαρακτήρων και στη συνέχεια να κωδικοποιηθεί σε οκτάδες χρησιμοποιώντας UTF-8.", @@ -1780,7 +2254,6 @@ "unable-delete-device-alias-text": "Το ψευδώνυμο συσκευής '{{deviceAlias}}' δεν μπορεί να διαγραφεί επειδή χρησιμοποιείται από τα παρακάτω widget(s):
{{widgetsList}}", "is-gateway": "Είναι gateway", "overwrite-activity-time": "Αντικατάσταση χρόνου δραστηριότητας για συνδεδεμένη συσκευή", - "device-filter": "Φίλτρο συσκευής", "device-filter-title": "Φίλτρο συσκευής", "filter-title": "Φίλτρο", "device-state": "Κατάσταση συσκευής", @@ -2234,7 +2707,8 @@ "short-id-required": "Το σύντομο αναγνωριστικό διακομιστή είναι υποχρεωτικό.", "short-id-range": "Το σύντομο αναγνωριστικό διακομιστή πρέπει να είναι εντός του εύρους από {{ min }} έως {{ max }}.", "short-id-pattern": "Το σύντομο αναγνωριστικό διακομιστή πρέπει να είναι θετικός ακέραιος αριθμός.", - "lifetime": "Διάρκεια εγγραφής πελάτη", + "short-id-pattern-bs": "Το σύντομο αναγνωριστικό διακομιστή πρέπει να είναι μόνο null", + "lifetime": "Διάρκεια ζωής καταχώρισης πελάτη", "lifetime-required": "Η διάρκεια εγγραφής πελάτη είναι υποχρεωτική.", "lifetime-pattern": "Η διάρκεια εγγραφής πελάτη πρέπει να είναι θετικός ακέραιος αριθμός.", "default-min-period": "Ελάχιστη περίοδος μεταξύ δύο ειδοποιήσεων (δλ)", @@ -2328,7 +2802,9 @@ "composite-all-description": "Όλοι οι πόροι παρακολουθούνται με ένα ενιαίο αίτημα Composite Observe (πιο αποδοτικό, λιγότερο ευέλικτο)", "composite-by-object": "Συνδυασμένο ανά αντικείμενο", "composite-by-object-description": "Οι πόροι ομαδοποιούνται ανά τύπο αντικειμένου και παρακολουθούνται με ξεχωριστά αιτήματα Composite Observe (ισορροπημένη προσέγγιση)" - } + }, + "init-attr-tel-as-obs-strategy": "Αρχικοποίηση χαρακτηριστικών και τηλεμετρίας με χρήση στρατηγικής Observe", + "init-attr-tel-as-obs-strategy-hint": "Αν είναι false - τα χαρακτηριστικά και η τηλεμετρία αρχικοποιούνται με ανάγνωση των τιμών τους μία προς μία.\\nΑν είναι true - τα χαρακτηριστικά και η τηλεμετρία αρχικοποιούνται με εγγραφή στις τιμές τους χρησιμοποιώντας τη στρατηγική Observe." }, "snmp": { "add-communication-config": "Προσθήκη ρύθμισης επικοινωνίας", @@ -2564,7 +3040,7 @@ "no-entities-text": "Δεν βρέθηκαν οντότητες", "no-entity-types-matching": "Δεν βρέθηκαν τύποι οντοτήτων που να ταιριάζουν με '{{entityType}}'.", "name-starts-with": "Έκφραση ονόματος", - "help-text": "Χρησιμοποιήστε '%' ανάλογα με την ανάγκη: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Χρησιμοποιήστε '%' ανάλογα με τις ανάγκες: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Χρήση φίλτρου", "entity-list-empty": "Δεν επιλέχθηκαν οντότητες.", "entity-type-list-required": "Πρέπει να επιλεγεί τουλάχιστον ένας τύπος οντότητας.", @@ -2644,6 +3120,8 @@ "type-rulenodes": "Κόμβοι κανόνα", "list-of-rulenodes": "{ count, plural, =1 {Ένας κόμβος κανόνα} other {Λίστα με # κόμβους κανόνα} }", "rulenode-name-starts-with": "Κόμβοι κανόνα με ονόματα που ξεκινούν με '{{prefix}}'", + "type-api-key": "Κλειδί API", + "type-api-keys": "Κλειδιά API", "type-current-customer": "Τρέχων πελάτης", "type-current-tenant": "Τρέχων ενοικιαστής", "type-current-user": "Τρέχων χρήστης", @@ -2665,6 +3143,7 @@ "details": "Λεπτομέρειες οντότητας", "no-entities-prompt": "Δεν βρέθηκαν οντότητες", "no-data": "Δεν υπάρχουν δεδομένα για εμφάνιση", + "show-all-columns": "Εμφάνιση όλων", "columns-to-display": "Στήλες προς εμφάνιση", "type-api-usage-state": "Κατάσταση Χρήσης API", "type-edge": "Edge", @@ -2710,7 +3189,15 @@ "list-of-mobile-apps": "{ count, plural, =1 {Μία εφαρμογή κινητού} other {Λίστα με # εφαρμογές κινητού} }", "type-mobile-app-bundle": "Πακέτο εφαρμογής κινητού", "type-mobile-app-bundles": "Πακέτα εφαρμογής κινητού", - "list-of-mobile-app-bundles": "{ count, plural, =1 {Ένα πακέτο κινητού} other {Λίστα με # πακέτα κινητού} }" + "list-of-mobile-app-bundles": "{ count, plural, =1 {Ένα πακέτο κινητού} other {Λίστα με # πακέτα κινητού} }", + "limit-reached": "Το όριο επιτεύχθηκε", + "limit-reached-text": "Έχετε φτάσει το όριο των {{ entities }}. Για να προσθέσετε περισσότερα, παρακαλούμε ζητήστε από τον διαχειριστή συστήματος να αυξήσει το όριό σας για {{ entity }}.", + "request-limit-increase": "Αίτημα αύξησης ορίου", + "request-sysadmin-text": "Είστε ο διαχειριστής συστήματος;", + "login-here": "Σύνδεση εδώ", + "to-increase-limit": "για αύξηση του ορίου.", + "increase-limit-request-sent-title": "Στείλαμε ένα αυτοματοποιημένο αίτημα στον διαχειριστή συστήματος για αύξηση του ορίου", + "increase-limit-request-sent-text": "Παρακαλούμε δώστε τους λίγο χρόνο για να εξετάσουν το αίτημα και να ενημερώσουν τις ρυθμίσεις. Ενδέχεται να χρειαστεί να ανανεώσετε αυτή τη σελίδα για να δείτε τις αλλαγές." }, "entity-field": { "created-time": "Χρόνος δημιουργίας", @@ -2756,7 +3243,7 @@ "remove-alias": "Αφαίρεση ψευδωνύμου προβολής οντότητας", "add-alias": "Προσθήκη ψευδωνύμου προβολής οντότητας", "name-starts-with": "Έκφραση ονόματος προβολής οντότητας", - "help-text": "Χρησιμοποιήστε '%' όπως απαιτείται: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Χρησιμοποιήστε '%' ανάλογα με τις ανάγκες: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Λίστα προβολών οντοτήτων", "use-entity-view-name-filter": "Χρήση φίλτρου", "entity-view-list-empty": "Δεν έχουν επιλεγεί προβολές οντοτήτων.", @@ -3787,6 +4274,7 @@ "two-factor-authentication": "Έλεγχος ταυτότητας δύο παραγόντων", "passwords-mismatch-error": "Οι κωδικοί πρέπει να είναι ίδιοι!", "password-again": "Επαλήθευση κωδικού", + "sign-in": "Παρακαλώ συνδεθείτε", "username": "Όνομα χρήστη (email)", "remember-me": "Να με θυμάσαι", "forgot-password": "Ξεχάσατε τον κωδικό;", @@ -3797,7 +4285,8 @@ "password-link-sent-message": "Ο σύνδεσμος επαναφοράς έχει σταλεί", "email": "Email", "invalid-email-format": "Μη έγκυρη μορφή email.", - "login-with": "Σύνδεση με {{name}}", + "sign-in-with": "Σύνδεση με {{name}}", + "sign-in-to-your-account": "Σύνδεση στον λογαριασμό σας", "or": "ή", "error": "Σφάλμα σύνδεσης", "verify-your-identity": "Επαληθεύστε την ταυτότητά σας", @@ -3816,7 +4305,51 @@ "activation-link-expired": "Ο σύνδεσμος ενεργοποίησης έχει λήξει", "activation-link-expired-message": "Ο σύνδεσμος ενεργοποίησης του προφίλ σας έχει λήξει. Μπορείτε να επιστρέψετε στη σελίδα σύνδεσης για να λάβετε νέο email.", "reset-password-link-expired": "Ο σύνδεσμος επαναφοράς κωδικού έχει λήξει", - "reset-password-link-expired-message": "Ο σύνδεσμος για την επαναφορά του κωδικού έχει λήξει. Μπορείτε να επιστρέψετε στη σελίδα σύνδεσης για να λάβετε νέο email." + "reset-password-link-expired-message": "Ο σύνδεσμος για την επαναφορά του κωδικού έχει λήξει. Μπορείτε να επιστρέψετε στη σελίδα σύνδεσης για να λάβετε νέο email.", + "two-fa": "Έλεγχος ταυτότητας δύο παραγόντων", + "two-fa-required": "Απαιτείται έλεγχος ταυτότητας δύο παραγόντων", + "set-up-verification-method": "Ρύθμιση μεθόδου επαλήθευσης για συνέχεια", + "set-up-verification-method-login": "Ρύθμιση μεθόδου επαλήθευσης ή σύνδεση", + "enable-authenticator-app": "Ενεργοποίηση εφαρμογής ελέγχου ταυτότητας", + "enable-authenticator-app-description": "Παρακαλούμε εισαγάγετε τον κωδικό ασφαλείας από την εφαρμογή ελέγχου ταυτότητας", + "enable-authenticator-sms": "Ενεργοποίηση ελέγχου ταυτότητας SMS", + "enable-authenticator-sms-description": "Εισαγάγετε τον 6ψήφιο κωδικό που μόλις στείλαμε στο ", + "enable-authenticator-email": "Ενεργοποίηση ελέγχου ταυτότητας Email", + "enable-authenticator-email-description": "Ένας κωδικός ασφαλείας στάλθηκε στη διεύθυνση Email σας στο ", + "enter-key-manually": "ή εισαγάγετε χειροκίνητα αυτό το κλειδί 32 ψηφίων:", + "continue": "Συνέχεια", + "confirm": "Επιβεβαίωση", + "authenticator-app-success": "Η εφαρμογή ελέγχου ταυτότητας ενεργοποιήθηκε με επιτυχία", + "authenticator-app-success-description": "Την επόμενη φορά που θα συνδεθείτε, θα χρειαστεί να παρέχετε κωδικό ελέγχου ταυτότητας δύο παραγόντων", + "authenticator-sms-success": "Ο έλεγχος ταυτότητας SMS ενεργοποιήθηκε με επιτυχία", + "authenticator-sms-success-description": "Την επόμενη φορά που θα συνδεθείτε, θα σας ζητηθεί να εισαγάγετε τον κωδικό ασφαλείας που θα σταλεί στον αριθμό τηλεφώνου σας", + "authenticator-email-success": "Ο έλεγχος ταυτότητας Email ενεργοποιήθηκε με επιτυχία", + "authenticator-email-success-description": "Την επόμενη φορά που θα συνδεθείτε, θα σας ζητηθεί να εισαγάγετε τον κωδικό ασφαλείας που θα σταλεί στη διεύθυνση Email σας", + "authenticator-backup-code-success": "Ο εφεδρικός κωδικός ενεργοποιήθηκε με επιτυχία", + "authenticator-backup-code-success-description": "Την επόμενη φορά που θα συνδεθείτε, θα σας ζητηθεί να εισαγάγετε τον κωδικό ασφαλείας ή να χρησιμοποιήσετε έναν από τους εφεδρικούς κωδικούς.", + "add-verification-method": "Προσθήκη μεθόδου επαλήθευσης", + "get-backup-code": "Λήψη εφεδρικού κωδικού", + "copy-key": "Αντιγραφή κλειδιού", + "send-code": "Αποστολή κωδικού", + "email-label": "Email", + "email-description": "Εισαγάγετε ένα Email για χρήση ως έλεγχος ταυτότητας.", + "sms-description": "Εισαγάγετε έναν αριθμό τηλεφώνου για χρήση ως έλεγχος ταυτότητας.", + "backup-code-description": "Εκτυπώστε τους κωδικούς ώστε να τους έχετε διαθέσιμους όταν χρειαστεί να τους χρησιμοποιήσετε για σύνδεση στον λογαριασμό σας. Μπορείτε να χρησιμοποιήσετε κάθε εφεδρικό κωδικό μία φορά.", + "backup-code-warn": "Μόλις φύγετε από αυτή τη σελίδα, αυτοί οι κωδικοί δεν μπορούν να εμφανιστούν ξανά. Αποθηκεύστε τους με ασφάλεια χρησιμοποιώντας τις παρακάτω επιλογές.", + "download-txt": "Λήψη (txt)", + "print": "Εκτύπωση", + "verification-code": "6ψήφιος κωδικός", + "verification-code-invalid": "Μη έγκυρη μορφή κωδικού επαλήθευσης", + "verification-code-incorrect": "Ο κωδικός επαλήθευσης είναι λανθασμένος", + "verification-code-many-request": "Πάρα πολλά αιτήματα για έλεγχο κωδικού επαλήθευσης", + "scan-qr-code": "Σαρώστε αυτόν τον κωδικό QR με την εφαρμογή επαλήθευσής σας", + "phone-input": { + "phone-input-label": "Αριθμός τηλεφώνου", + "phone-input-required": "Απαιτείται αριθμός τηλεφώνου", + "phone-input-validation": "Ο αριθμός τηλεφώνου δεν είναι έγκυρος ή δεν είναι δυνατός", + "phone-input-pattern": "Μη έγκυρος αριθμός τηλεφώνου. Πρέπει να είναι σε μορφή E.164, π.χ. {{phoneNumber}}", + "phone-input-hint": "Αριθμός τηλεφώνου σε μορφή E.164, π.χ. {{phoneNumber}}" + } }, "mobile": { "add-application": "Προσθήκη εφαρμογής", @@ -4184,6 +4717,7 @@ "api-usage-limit": "Όριο χρήσης API", "device-activity": "Δραστηριότητα συσκευής", "entities-limit": "Όριο οντοτήτων", + "entities-limit-increase-request": "Αίτημα αύξησης ορίου οντοτήτων", "entity-action": "Ενέργεια οντότητας", "general": "Γενικό", "rule-engine-lifecycle-event": "Γεγονός κύκλου ζωής μηχανής κανόνων", @@ -4401,6 +4935,12 @@ "at-least": "Τουλάχιστον:", "character": "{ count, plural, =1 {1 χαρακτήρας} other {# χαρακτήρες} }", "digit": "{ count, plural, =1 {1 ψηφίο} other {# ψηφία} }", + "password-tooltip-min-length": "Τουλάχιστον {{minimumLength}} χαρακτήρες", + "password-tooltip-max-length": "Το πολύ {{maximumLength}} χαρακτήρες", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} κεφαλαίος χαρακτήρας", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} πεζός χαρακτήρας", + "password-tooltip-digit": "{{minimumDigits}} αριθμός", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} ειδικός χαρακτήρας", "incorrect-password-try-again": "Λανθασμένος κωδικός. Προσπαθήστε ξανά", "lowercase-letter": "{ count, plural, =1 {1 πεζό γράμμα} other {# πεζά γράμματα} }", "new-passwords-not-match": "Οι νέοι κωδικοί δεν ταιριάζουν", @@ -4459,7 +4999,8 @@ "additional-info": "Επιπλέον πληροφορίες (JSON)", "invalid-additional-info": "Δεν ήταν δυνατή η ανάλυση του JSON των επιπλέον πληροφοριών.", "no-relations-text": "Δεν βρέθηκαν συσχετίσεις", - "not": "Όχι" + "not": "Όχι", + "copy-type": "Αντιγραφή τύπου" }, "resource": { "add": "Προσθήκη πόρου", @@ -5410,7 +5951,7 @@ "time-series": "Χρονοσειρές", "latest": "Τελευταίες τιμές", "web-sockets": "WebSockets", - "calculated-fields": "Υπολογιζόμενα πεδία" + "calculated-fields-and-alarm-rules": "Υπολογιζόμενα πεδία και κανόνες συναγερμού" }, "save-attribute": { "processing-settings": "Ρυθμίσεις επεξεργασίας", @@ -5605,7 +6146,8 @@ "bad-request-params": "Μη έγκυρες παράμετροι αιτήματος", "item-not-found": "Το στοιχείο δεν βρέθηκε", "too-many-requests": "Πάρα πολλά αιτήματα", - "too-many-updates": "Πάρα πολλές ενημερώσεις" + "too-many-updates": "Πάρα πολλές ενημερώσεις", + "entities-limit-exceeded": "Υπέρβαση ορίου οντοτήτων" }, "tenant": { "tenant": "Ενοικιαστής", @@ -5743,6 +6285,27 @@ "max-arguments-per-cf": "Μέγιστος αριθμός παραμέτρων ανά υπολογισμένο πεδίο", "max-arguments-per-cf-range": "Ο μέγιστος αριθμός παραμέτρων δεν μπορεί να είναι αρνητικός", "max-arguments-per-cf-required": "Απαιτείται μέγιστος αριθμός παραμέτρων ανά υπολογισμένο πεδίο", + "max-related-level-per-argument": "Μέγιστο επίπεδο σχέσης ανά όρισμα «Σχετικές οντότητες»", + "max-related-level-per-argument-range": "Το μέγιστο επίπεδο σχέσης ανά όρισμα «Σχετικές οντότητες» δεν μπορεί να είναι μικρότερο από «1»", + "max-related-level-per-argument-required": "Απαιτείται το μέγιστο επίπεδο σχέσης ανά όρισμα «Σχετικές οντότητες»", + "min-allowed-scheduled-update-interval": "Ελάχιστο επιτρεπόμενο διάστημα ενημέρωσης για ορίσματα «Σχετικές οντότητες» (δευτερόλεπτα)", + "min-allowed-scheduled-update-interval-range": "Η ελάχιστη τιμή του ελάχιστου επιτρεπόμενου διαστήματος ενημέρωσης δεν μπορεί να είναι αρνητική", + "min-allowed-deduplication-interval": "Ελάχιστο επιτρεπόμενο διάστημα αποδιπλοεγγραφής (δευτερόλεπτα)", + "min-allowed-deduplication-interval-range": "Η τιμή του ελάχιστου επιτρεπόμενου διαστήματος αποδιπλοεγγραφής δεν μπορεί να είναι αρνητική", + "min-allowed-deduplication-interval-required": "Απαιτείται ελάχιστο επιτρεπόμενο διάστημα αποδιπλοεγγραφής", + "intermediate-aggregation-interval": "Διάστημα ενδιάμεσης συγκέντρωσης (δευτερόλεπτα)", + "intermediate-aggregation-interval-range": "Η τιμή του διαστήματος ενδιάμεσης συγκέντρωσης δεν μπορεί να είναι μικρότερη από «1»", + "intermediate-aggregation-interval-required": "Απαιτείται διάστημα ενδιάμεσης συγκέντρωσης", + "reevaluation-check-interval": "Διάστημα ελέγχου επαναξιολόγησης (δευτερόλεπτα)", + "reevaluation-check-interval-range": "Η τιμή του διαστήματος ελέγχου επαναξιολόγησης δεν μπορεί να είναι μικρότερη από «1»", + "reevaluation-check-interval-required": "Απαιτείται διάστημα ελέγχου επαναξιολόγησης", + "alarms-reevaluation-interval": "Διάστημα επαναξιολόγησης συναγερμών (δευτερόλεπτα)", + "alarms-reevaluation-interval-range": "Η τιμή του διαστήματος επαναξιολόγησης συναγερμών δεν μπορεί να είναι μικρότερη από «1»", + "alarms-reevaluation-interval-required": "Απαιτείται διάστημα επαναξιολόγησης συναγερμών", + "min-allowed-aggregation-interval": "Ελάχιστο επιτρεπόμενο διάστημα συγκέντρωσης (δευτερόλεπτα)", + "min-allowed-aggregation-interval-range": "Η τιμή του ελάχιστου επιτρεπόμενου διαστήματος συγκέντρωσης δεν μπορεί να είναι αρνητική", + "min-allowed-aggregation-interval-required": "Απαιτείται ελάχιστο επιτρεπόμενο διάστημα συγκέντρωσης", + "min-allowed-scheduled-update-interval-required": "Απαιτείται η ελάχιστη τιμή του ελάχιστου επιτρεπόμενου διαστήματος ενημέρωσης", "max-state-size": "Μέγιστο μέγεθος κατάστασης σε KB", "max-state-size-range": "Το μέγιστο μέγεθος κατάστασης δεν μπορεί να είναι αρνητικό", "max-state-size-required": "Απαιτείται μέγιστο μέγεθος κατάστασης", @@ -5818,6 +6381,10 @@ "ws-limit-max-subscriptions-per-regular-user": "Μέγιστος αριθμός εγγραφών ανά χρήστη", "ws-limit-max-subscriptions-per-public-user": "Μέγιστος αριθμός εγγραφών ανά δημόσιο χρήστη", "ws-limit-updates-per-session": "WS ενημερώσεις ανά συνεδρία", + "relation-search-entity-limit": "Όριο οντοτήτων αναζήτησης σχέσεων", + "relation-search-entity-limit-hint": "Περιορίζει τον αριθμό των οντοτήτων που επιλύονται στο τελευταίο επίπεδο της διαδρομής σχέσεων. Εφαρμόζεται στα ορίσματα «Σχετικές οντότητες» και στα πεδία διάδοσης.", + "relation-search-entity-limit-required": "Απαιτείται όριο οντοτήτων αναζήτησης σχέσεων", + "relation-search-entity-limit-range": "Το όριο οντοτήτων αναζήτησης σχέσεων δεν μπορεί να είναι μικρότερο από '1'", "rate-limits": { "add-limit": "Προσθήκη ορίου", "and-also-less-than": "και επίσης μικρότερο από", @@ -6003,7 +6570,9 @@ "default-agg-interval": "Προεπιλεγμένο διάστημα ομαδοποίησης", "edit-intervals-list-hint": "Μπορεί να καθοριστεί λίστα διαθέσιμων διαστημάτων.", "edit-grouping-intervals-list-hint": "Δυνατότητα διαμόρφωσης λίστας και προεπιλογής διαστημάτων ομαδοποίησης.", - "all": "Όλα" + "all": "Όλα", + "save-current-settings-as-default": "Αποθήκευση τρεχουσών ρυθμίσεων ως προεπιλεγμένου χρονικού παραθύρου", + "hide-option-from-end-users": "Απόκρυψη επιλογής από τους τελικούς χρήστες" }, "tooltip": { "trigger": "Ενεργοποιητής", @@ -6657,7 +7226,8 @@ "export-relations": "Εξαγωγή σχέσεων", "export-attributes": "Εξαγωγή ιδιοτήτων", "export-credentials": "Εξαγωγή διαπιστευτηρίων", - "export-calculated-fields": "Εξαγωγή υπολογιζόμενων πεδίων", + "export-calculated-fields": "Εξαγωγή υπολογιζόμενων πεδίων \nκαι κανόνων συναγερμού", + "export-alarm-rules": "Εξαγωγή κανόνων συναγερμού", "entity-versions": "Εκδόσεις οντοτήτων", "versions": "Εκδόσεις", "created-time": "Ώρα δημιουργίας", @@ -6675,6 +7245,7 @@ "load-attributes": "Φόρτωση ιδιοτήτων", "load-credentials": "Φόρτωση διαπιστευτηρίων", "load-calculated-fields": "Φόρτωση υπολογιζόμενων πεδίων", + "load-alarm-rules": "Φόρτωση κανόνων συναγερμού", "compare-with-current": "Σύγκριση με την τρέχουσα", "diff-entity-with-version": "Διαφορά με την έκδοση οντότητας '{{versionName}}'", "previous-difference": "Προηγούμενη διαφορά", @@ -6884,7 +7455,23 @@ "scan-qr-code": "Σάρωση κωδικού QR", "make-phone-call": "Πραγματοποίηση τηλεφωνικής κλήσης", "get-location": "Λήψη τοποθεσίας τηλεφώνου", - "take-screenshot": "Λήψη στιγμιότυπου οθόνης" + "take-screenshot": "Λήψη στιγμιότυπου οθόνης", + "handle-provision-success-function": "Συνάρτηση χειρισμού επιτυχίας παροχής", + "get-location-function": "Συνάρτηση λήψης τοποθεσίας", + "process-launch-result-function": "Συνάρτηση επεξεργασίας αποτελέσματος εκκίνησης", + "get-phone-number-function": "Συνάρτηση λήψης αριθμού τηλεφώνου", + "process-image-function": "Συνάρτηση επεξεργασίας εικόνας", + "process-qr-code-function": "Συνάρτηση επεξεργασίας κωδικού QR", + "process-location-function": "Συνάρτηση επεξεργασίας τοποθεσίας", + "handle-empty-result-function": "Συνάρτηση χειρισμού κενού αποτελέσματος", + "handle-error-function": "Συνάρτηση χειρισμού σφάλματος", + "handle-non-mobile-fallback-function": "Συνάρτηση εναλλακτικής διαχείρισης για μη κινητά", + "save-to-gallery": "Αποθήκευση στη 'Συλλογή εικόνων'", + "provision-type": "Τύπος παροχής", + "auto": "Αυτόματο", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" }, "custom-action-function": "Συνάρτηση προσαρμοσμένης ενέργειας", "custom-pretty-function": "Συνάρτηση προσαρμοσμένης ενέργειας (με πρότυπο HTML)", @@ -6893,7 +7480,8 @@ "marker": "Δείκτης", "polygon": "Πολύγωνο", "rectangle": "Ορθογώνιο", - "circle": "Κύκλος" + "circle": "Κύκλος", + "polyline": "Πολυγραμμή" }, "place-map-item": "Τοποθέτηση στοιχείου χάρτη", "map-item-tooltip": { @@ -6905,7 +7493,9 @@ "continue-draw-polygon": "Συνέχεια σχεδίασης πολυγώνου", "finish-draw-polygon": "Ολοκλήρωση σχεδίασης πολυγώνου", "start-draw-circle": "Έναρξη σχεδίασης κύκλου", - "finish-draw-circle": "Ολοκλήρωση σχεδίασης κύκλου" + "finish-draw-circle": "Ολοκλήρωση σχεδίασης κύκλου", + "start-draw-polyline": "Έναρξη σχεδίασης πολυγραμμής", + "finish-draw-polyline": "Ολοκλήρωση σχεδίασης πολυγραμμής" } }, "widgets-bundle": { @@ -7471,10 +8061,18 @@ "update-animation-delay": "Καθυστέρηση κίνησης κατά την ενημέρωση" }, "chart-axis": { + "limit": "Όριο", + "source": "Πηγή", + "key-value": "Κλειδί / Τιμή", + "value-required": "Απαιτείται τιμή.", + "entity-key-required": "Απαιτείται κλειδί οντότητας.", + "key-required": "Απαιτείται κλειδί.", + "scale-limits": "Όρια κλίμακας", + "scale-appearance": "Εμφάνιση κλίμακας", "scale": "Κλίμακα", - "scale-min": "ελάχιστο", - "scale-max": "μέγιστο", - "scale-auto": "Αυτόματα" + "scale-min": "ελάχ.", + "scale-max": "μέγ.", + "scale-auto": "Αυτόματο" }, "bar": { "show-border": "Εμφάνιση περιγράμματος", @@ -8015,7 +8613,10 @@ "add-radio-option": "Προσθήκη επιλογής κουμπιού", "radio-label-position": "Θέση ετικέτας", "radio-label-position-before": "Πριν", - "radio-label-position-after": "Μετά" + "radio-label-position-after": "Μετά", + "save-image": "Αποθήκευση εικόνας", + "save-to-gallery": "Αυτόματη αποθήκευση των ληφθεισών εικόνων στη συλλογή εικόνων", + "public-image": "Κάνει την εικόνα διαθέσιμη σε οποιονδήποτε μη εξουσιοδοτημένο χρήστη" }, "invalid-qr-code-text": "Μη έγκυρο κείμενο εισόδου για τον QR κώδικα. Η είσοδος πρέπει να είναι τύπου string", "qr-code": { @@ -8310,7 +8911,8 @@ "trips": "Διαδρομές", "markers": "Δείκτες", "polygons": "Πολύγωνα", - "circles": "Κύκλοι" + "circles": "Κύκλοι", + "polylines": "Πολυγραμμές" }, "data-layer": { "source": "Πηγή", @@ -8507,11 +9109,30 @@ "finish-circle-hint-with-entity": "Κύκλος για '{{entityName}}': κάντε κλικ για ολοκλήρωση και αποθήκευση", "finish-circle-hint": "Κύκλος: κάντε κλικ για ολοκλήρωση σχεδίασης" }, - "select-entity": "Επιλέξτε οντότητα", - "select-entity-hint": "Υπόδειξη: μετά την επιλογή κάντε κλικ στον χάρτη για ορισμό θέσης" + "polyline": { + "polyline-key": "Κλειδί πολυγραμμής", + "polyline-key-required": "Απαιτείται κλειδί πολυγραμμής", + "no-polylines": "Δεν έχουν ρυθμιστεί πολυγραμμές", + "add-polylines": "Προσθήκη πολυγραμμής", + "polyline-configuration": "Διαμόρφωση πολυγραμμής", + "remove-polyline": "Αφαίρεση πολυγραμμής", + "edit": "Επεξεργασία πολυγραμμής", + "cut": "Αποκοπή περιοχής πολυγραμμής", + "rotate": "Περιστροφή πολυγραμμής", + "remove-polyline-for": "Αφαίρεση πολυγραμμής για '{{entityName}}'", + "draw-polyline": "Σχεδίαση πολυγραμμής", + "polyline-place-first-point-hint-with-entity": "Πολυγραμμή για '{{entityName}}': κάντε κλικ για να τοποθετήσετε το πρώτο σημείο", + "polyline-place-first-point-hint": "Πολυγραμμή: κάντε κλικ για να τοποθετήσετε το πρώτο σημείο", + "finish-polyline-hint-with-entity": "Πολυγραμμή για '{{entityName}}': κάντε κλικ για να ολοκληρώσετε τη σχεδίαση", + "finish-polyline-hint": "Πολυγραμμή: κάντε κλικ για να ολοκληρώσετε τη σχεδίαση", + "polyline-place-first-point-cut-hint": "Κάντε κλικ για να τοποθετήσετε το πρώτο σημείο", + "finish-polyline-cut-hint": "Κάντε κλικ στον πρώτο δείκτη για να ολοκληρώσετε και να αποθηκεύσετε" + }, + "select-entity": "Επιλογή οντότητας", + "select-entity-hint": "Υπόδειξη: μετά την επιλογή, κάντε κλικ στον χάρτη για να ορίσετε θέση" }, - "select-entity": "Επιλέξτε οντότητα", - "select-entity-hint": "Υπόδειξη: μετά την επιλογή κάντε κλικ στον χάρτη για ορισμό θέσης", + "select-entity": "Επιλογή οντότητας", + "select-entity-hint": "Υπόδειξη: μετά την επιλογή, κάντε κλικ στον χάρτη για να ορίσετε θέση", "tooltips": { "placeMarker": "Κάντε κλικ για τοποθέτηση οντότητας '{{entityName}}'", "firstVertex": "Πολύγωνο για '{{entityName}}': κάντε κλικ για τοποθέτηση πρώτου σημείου", @@ -8948,6 +9569,7 @@ "show-empty-space-hidden-action": "Εμφάνιση κενού χώρου αντί κουμπιού σε κρυφό κελί", "dont-reserve-space-hidden-action": "Χωρίς κράτηση χώρου για κρυφές ενέργειες", "display-timestamp": "Χρονική σήμανση", + "timestamp-column-name": "Χρονοσφραγίδα", "display-pagination": "Εμφάνιση σελιδοποίησης", "default-page-size": "Προεπιλεγμένο μέγεθος σελίδας", "page-step-settings": "Ρυθμίσεις βήματος σελίδας", @@ -9009,7 +9631,9 @@ "alarm-column-error": "Πρέπει να καθοριστεί τουλάχιστον μία στήλη συναγερμών", "table-tabs": "Καρτέλες πίνακα", "show-cell-actions-menu-mobile": "Εμφάνιση μενού ενεργειών κελιού σε κινητά", - "disable-sorting": "Απενεργοποίηση ταξινόμησης" + "disable-sorting": "Απενεργοποίηση ταξινόμησης", + "sort-by": "Ταξινόμηση καρτελών κατά", + "sort-timestamp-option": "Χρόνος δημιουργίας" }, "latest-chart": { "total": "Σύνολο", @@ -9501,11 +10125,28 @@ "content": "

Δημιουργώντας πίνακες ελέγχου τελικού χρήστη, ένας πελάτης μπορεί να δει μόνο τις δικές του συσκευές. Τα δεδομένα άλλων πελατών θα είναι κρυφά.

Ακολουθήστε την τεκμηρίωση για το πώς να το κάνετε:

" } } + }, + "api-usage": { + "api-usage": "Χρήση API", + "label": "Ετικέτα", + "state-name": "Όνομα κατάστασης", + "status": "Κατάσταση", + "status-required": "Απαιτείται κατάσταση.", + "limit": "Μέγιστο όριο", + "limit-required": "Απαιτείται μέγιστο όριο.", + "current-number": "Τρέχων αριθμός", + "current-number-required": "Απαιτείται τρέχων αριθμός.", + "add-key": "Προσθήκη κλειδιού", + "no-key": "Χωρίς κλειδί", + "delete-key": "Διαγραφή κλειδιού", + "target-dashboard-state": "Κατάσταση πίνακα ελέγχου στόχος", + "go-to-main-state": "Μετάβαση στην προεπιλεγμένη προβολή" } }, "icon": { "icon": "Εικονίδιο", "icons": "Εικονίδια", + "custom": "Προσαρμοσμένο", "select-icon": "Επιλέξτε εικονίδιο", "material-icons": "Material εικονίδια", "show-all": "Εμφάνιση όλων των εικονιδίων", @@ -9546,6 +10187,7 @@ "items-per-page-separator": "από" }, "language": { + "auto": "Auto", "language": "Γλώσσα" } } \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 6506f0ca58..b1a3996e3c 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -810,7 +810,7 @@ "no-assets-matching": "No assets matching '{{entity}}' were found.", "asset-required": "Asset is required.", "name-starts-with": "Asset name expression", - "help-text": "Use '%' according to need: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Use '%' according to need: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "import": "Import assets", "asset-file": "Asset file", "label": "Label", @@ -1102,6 +1102,7 @@ "expression": "Expression", "no-found": "No calculated fields found", "list": "{ count, plural, =1 {One calculated field} other {List of # calculated fields} }", + "add": "Add calculated field", "selected-fields": "{ count, plural, =1 {1 calculated field} other {# calculated fields} } selected", "type": { "simple": "Simple", @@ -2131,7 +2132,7 @@ "remove-alias": "Remove device alias", "add-alias": "Add device alias", "name-starts-with": "Device name expression", - "help-text": "Use '%' according to need: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Use '%' according to need: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Device list", "use-device-name-filter": "Use filter", "device-list-empty": "No devices selected.", @@ -2401,7 +2402,7 @@ "search": "Search asset profiles", "selected-asset-profiles": "{ count, plural, =1 {1 asset profile} other {# asset profiles} } selected", "no-asset-profiles-matching": "No asset profile matching '{{entity}}' were found.", - "asset-profile-required": "Asset profile is required", + "asset-profile-required": "Asset profile is required.", "idCopiedMessage": "Asset profile Id has been copied to clipboard", "set-default": "Make asset profile default", "delete": "Delete asset profile", @@ -2446,7 +2447,7 @@ "search": "Search device profiles", "selected-device-profiles": "{ count, plural, =1 {1 device profile} other {# device profiles} } selected", "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", - "device-profile-required": "Device profile is required", + "device-profile-required": "Device profile is required.", "idCopiedMessage": "Device profile Id has been copied to clipboard", "set-default": "Make device profile default", "delete": "Delete device profile", @@ -3048,7 +3049,7 @@ "no-entities-text": "No entities found", "no-entity-types-matching": "No entity types matching '{{entityType}}' were found.", "name-starts-with": "Name expression", - "help-text": "Use '%' according to need: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Use '%' according to need: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Use filter", "entity-list-empty": "No entities selected.", "entity-type-list-required": "At least one entity type should be selected.", @@ -3251,7 +3252,7 @@ "remove-alias": "Remove entity view alias", "add-alias": "Add entity view alias", "name-starts-with": "Entity View name expression", - "help-text": "Use '%' according to need: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Use '%' according to need: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Entity View list", "use-entity-view-name-filter": "Use filter", "entity-view-list-empty": "No entity views selected.", @@ -3609,6 +3610,7 @@ "order-priority": "Display order", "key-filter": "Key filter", "key-filters": "Key filters", + "or-conditions-disabled": "OR conditions between key filters are disabled by the system administrator.", "key-name": "Key name", "key-name-required": "Key name is required.", "key-type": { @@ -5164,7 +5166,17 @@ "unset-auto-assign-to-edge-title": "Are you sure you do not want to assign the edge rule chain '{{ruleChainName}}' to edge(s) on creation?", "unset-auto-assign-to-edge-text": "After the confirmation the edge rule chain will no longer be automatically assigned to edge(s) on creation.", "unassign-rulechain-title": "Are you sure you want to unassign the rulechain '{{ruleChainName}}'?", - "unassign-rulechains": "Unassign rulechains" + "unassign-rulechains": "Unassign rulechains", + "add-note": "Add note", + "paste-note": "Paste note", + "note": "Note", + "edit-note": "Edit note", + "note-content": "Markdown/HTML content", + "note-details": "Note details", + "note-background-color": "Background color", + "note-border": "Border", + "note-apply-default-markdown-style": "Apply default markdown style", + "note-custom-css": "Note content CSS" }, "rulenode": { "rule-node-events": "Rule node events", @@ -5509,10 +5521,11 @@ "endpoint-url-pattern": "Endpoint URL pattern", "endpoint-url-pattern-required": "Endpoint URL pattern is required", "request-method": "Request method", - "use-simple-client-http-factory": "Use simple client HTTP factory", "ignore-request-body": "Without request body", + "request-body-template": "Request body template", + "request-body-template-hint": "Use ${metadataKey} for metadata, $[messageKey] for message values. If empty, the message payload is used as the request body. For non-JSON payloads, enable 'Parse to plain text'.", "parse-to-plain-text": "Parse to plain text", - "parse-to-plain-text-hint": "If selected, request body message payload will be transformed from JSON string to plain text, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", + "parse-to-plain-text-hint": "If selected, the request body (message payload or body template result) will be sent as a plain text string instead of being parsed as JSON, e.g. msg = \"Hello,\\t\"world\"\" will be parsed to Hello, \"world\"", "read-timeout": "Read timeout in millis", "read-timeout-hint": "The value of 0 means an infinite timeout", "max-parallel-requests-count": "Max number of parallel requests", @@ -5534,6 +5547,7 @@ "bootstrap-servers": "Bootstrap servers", "bootstrap-servers-required": "Bootstrap servers value is required", "other-properties": "Other properties", + "other-properties-empty": "No other properties configured", "key": "Key", "key-required": "Key is required", "retries": "Automatically retry times if fails", @@ -5571,6 +5585,7 @@ "handshake-timeout-ms": "Handshake timeout (ms)", "min-handshake-timeout-ms-message": "Only 0 ms minimum value is allowed.", "client-properties": "Client properties", + "client-properties-empty": "No client properties configured", "queue-url-pattern": "Queue URL pattern", "queue-url-pattern-required": "Queue URL pattern is required", "delay-seconds": "Delay (seconds)", @@ -5588,6 +5603,7 @@ "pubsub-topic-name": "Topic name", "pubsub-topic-name-required": "Topic name is required", "message-attributes": "Message attributes", + "message-attributes-empty": "No message attributes configured", "message-attributes-hint": "Use ${metadataKey} for value from metadata, $[messageKey] for value from message body in name/value fields", "connect-timeout": "Connection timeout (sec)", "connect-timeout-required": "Connection timeout is required.", @@ -5665,7 +5681,6 @@ "proxy-port-range": "Proxy port should be in a range from 1 to 65535.", "proxy-user": "Proxy user", "proxy-password": "Proxy password", - "proxy-scheme": "Proxy scheme", "numbers-to-template": "Phone Numbers To Template", "numbers-to-template-required": "Phone Numbers To Template is required", "numbers-to-template-hint": "Comma separated Phone Numbers, use ${metadataKey} for value from metadata, $[messageKey] for value from message body", @@ -5949,6 +5964,17 @@ "tell-failure-aws-lambda-hint": "Rule node forces failure of message processing if AWS Lambda function execution raises exception.", "basic-mode": "Basic", "advanced-mode": "Advanced", + "url-hint": "Enter the request URL. Ensure special characters are percent-encoded.", + "query-params": "Query parameters", + "query-params-hint": "Additional query parameters to include in the request URL. Both names and values support templatization and are strictly (RFC 3986) encoded after template resolution.", + "query-params-empty": "No query parameters configured", + "query-params-name": "Name", + "query-params-value": "Value", + "query-params-name-required": "Name is required", + "query-params-value-required": "Value is required", + "headers-empty": "No headers configured", + "request-url": "Request URL", + "url": "URL", "save-time-series": { "processing-settings": "Processing settings", "processing-settings-hint": "Define how incoming messages are processed. Basic processing settings allow you to select preconfigured strategies, while Advanced settings allow you to select individual processing strategies for each action.", @@ -7481,7 +7507,7 @@ "handle-empty-result-function": "Handle empty result function", "handle-error-function": "Handle error function", "handle-non-mobile-fallback-function": "Handle Non-Mobile fallback function", - "save-to-gallery": "Save to gallery", + "save-to-gallery": "Save to 'Image gallery'", "provision-type": "Provision type", "auto": "Auto", "wi-fi": "Wi-Fi", @@ -8906,7 +8932,18 @@ }, "custom": { "title": "Custom", - "tile-url": "Tile URL" + "tile-url": "Tile URL", + "style-url": "Style URL", + "vector-tiles": "Vector tiles", + "vector-tiles-hint": "Enable when using a style URL from providers like OpenFreeMap or MapTiler.", + "attribution": "Copyright text", + "attribution-hint": "Displayed at the bottom of the map. Required by most tile providers." + }, + "openfreemap": { + "title": "OpenFreeMap", + "liberty": "Liberty", + "bright": "Bright", + "positron": "Positron" } }, "credentials": { @@ -10139,6 +10176,18 @@ } } }, + "html-container": { + "java-script": "JavaScript", + "js-function": "JavaScript function", + "html": "HTML", + "angular-html-template": "Angular HTML template", + "css": "CSS", + "container-type": "Container type", + "type-plain": "Plain HTML", + "type-angular": "Angular", + "resources": "Resources", + "no-resources": "No resources configured" + }, "api-usage": { "api-usage": "API usage", "label": "Label", diff --git a/ui-ngx/src/assets/locale/locale.constant-es_ES.json b/ui-ngx/src/assets/locale/locale.constant-es_ES.json index 75ba33e6d3..c919107272 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -78,6 +78,7 @@ "show-more": "Mostrar más", "dont-show-again": "No mostrar de nuevo", "see-documentation": "Ver documentación", + "see-debug-events": "Ver eventos de depuración", "clear": "Limpiar", "upload": "Subir", "delete-anyway": "Eliminar de todos modos", @@ -485,6 +486,7 @@ "2fa": { "2fa": "Autenticación de dos factores", "available-providers": "Proveedores disponibles", + "available-providers-required": "Debe configurarse al menos un proveedor de 2FA.", "issuer-name": "Nombre del emisor", "issuer-name-required": "Se requiere el nombre del emisor.", "max-verification-failures-before-user-lockout": "Máximo de fallos de verificación antes de bloquear al usuario", @@ -513,7 +515,9 @@ "verification-message-template-required": "Se requiere la plantilla del mensaje de verificación.", "within-time": "Dentro del tiempo (seg)", "within-time-pattern": "El tiempo debe ser un número entero positivo.", - "within-time-required": "Se requiere el tiempo." + "within-time-required": "Se requiere el tiempo.", + "force-2fa": "Aplicar autenticación de dos factores", + "enforce-for": "Aplicar para" }, "jwt": { "security-settings": "Configuración de seguridad JWT", @@ -545,16 +549,11 @@ "slack-settings": "Configuración de Slack", "mobile-settings": "Configuración móvil", "firebase-service-account-file": "Archivo JSON de credenciales de cuenta de servicio de Firebase", - "select-firebase-service-account-file": "Arrastra y suelta tu archivo de credenciales de cuenta de servicio de Firebase o ", - "trendz": "Trendz", - "trendz-settings": "Configuración de Trendz", - "trendz-url": "URL de Trendz", - "trendz-url-required": "La URL de Trendz es obligatoria", - "trendz-api-key": "Clave API de Trendz", - "trendz-enable": "Habilitar Trendz" + "select-firebase-service-account-file": "Arrastra y suelta tu archivo de credenciales de cuenta de servicio de Firebase o " }, "alarm": { "alarm": "Alarma", + "alarm-list": "Lista de alarmas", "alarms": "Alarmas", "all-alarms": "Todas las alarmas", "select-alarm": "Seleccionar alarma", @@ -655,7 +654,16 @@ "alarm-type": "Tipo de alarma", "enter-alarm-type": "Introducir tipo de alarma", "no-alarm-types-matching": "No se encontraron tipos de alarma que coincidan con '{{entitySubtype}}'.", - "alarm-type-list-empty": "No se han seleccionado tipos de alarma." + "alarm-type-list-empty": "No se han seleccionado tipos de alarma.", + "system-comments": { + "acked-by-user": "La alarma fue reconocida por el usuario {{userName}}", + "cleared-by-user": "La alarma fue borrada por el usuario {{userName}}", + "assigned-to-user": "La alarma fue asignada por el usuario {{userName}} al usuario {{assigneeName}}", + "unassigned-to-user": "La alarma fue desasignada por el usuario {{userName}}", + "unassigned-from-deleted-user": "La alarma fue desasignada porque el usuario {{userName}} fue eliminado", + "comment-deleted": "El usuario {{userName}} eliminó su comentario", + "severity-changed": "La gravedad de la alarma se actualizó de {{oldSeverity}} a {{newSeverity}}" + } }, "alarm-activity": { "add": "Agregar un comentario...", @@ -760,6 +768,7 @@ "name-max-length": "El nombre debe tener menos de 256 caracteres", "label-max-length": "La etiqueta debe tener menos de 256 caracteres", "description": "Descripción", + "description-required": "La descripción es obligatoria.", "type": "Tipo", "type-required": "Se requiere el tipo.", "details": "Detalles", @@ -794,7 +803,7 @@ "no-assets-matching": "No se encontraron activos que coincidan con '{{entity}}'.", "asset-required": "Se requiere un activo", "name-starts-with": "Expresión del nombre del activo", - "help-text": "Usa '%' según sea necesario: '%nombre_activo_contiene%', '%nombre_activo_termina', 'activo_empieza_con'.", + "help-text": "Usa '%' según sea necesario: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Buscar activos", "import": "Importar activos", "asset-file": "Archivo de activos", @@ -873,6 +882,9 @@ "alarms-created-monthly-activity": "Actividad mensual de alarmas creadas", "data-points": "Puntos de datos", "data-points-storage-days": "Días de almacenamiento de puntos de datos", + "data-points-storage-days-hourly-activity": "Actividad horaria de días de almacenamiento de puntos de datos", + "data-points-storage-days-daily-activity": "Actividad diaria de días de almacenamiento de puntos de datos", + "data-points-storage-days-monthly-activity": "Actividad mensual de días de almacenamiento de puntos de datos", "device-api": "API de dispositivo", "email": "Correo electrónico", "email-messages": "Mensajes de correo", @@ -898,14 +910,15 @@ "processing-timeouts": "Tiempos de espera de procesamiento de ${entityName}", "rule-chain": "Cadena de reglas", "rule-engine": "Motor de reglas", - "rule-engine-daily-activity": "Actividad diaria del motor de reglas", "rule-engine-executions": "Ejecuciones del motor de reglas", "rule-engine-hourly-activity": "Actividad por hora del motor de reglas", + "rule-engine-daily-activity": "Actividad diaria del motor de reglas", "rule-engine-monthly-activity": "Actividad mensual del motor de reglas", "rule-engine-statistics": "Estadísticas del motor de reglas", "rule-node": "Nodo de regla", "sms": "SMS", "sms-messages": "Mensajes SMS", + "sms-messages-hourly-activity": "Actividad horaria de mensajes SMS", "sms-messages-daily-activity": "Actividad diaria de mensajes SMS", "sms-messages-monthly-activity": "Actividad mensual de mensajes SMS", "successful": "${entityName} exitoso", @@ -915,13 +928,40 @@ "telemetry-persistence-hourly-activity": "Actividad por hora de persistencia de telemetría", "telemetry-persistence-monthly-activity": "Actividad mensual de persistencia de telemetría", "transport": "Transporte", + "transport-msg-hourly-activity": "Actividad horaria de mensajes de transporte", + "transport-msg-daily-activity": "Actividad diaria de mensajes de transporte", + "transport-msg-monthly-activity": "Actividad mensual de mensajes de transporte", "transport-daily-activity": "Actividad diaria de transporte", "transport-data-points": "Puntos de datos de transporte", - "transport-hourly-activity": "Actividad por hora de transporte", - "transport-messages": "Mensajes de transporte", - "transport-monthly-activity": "Actividad mensual de transporte", + "transport-data-points-hourly-activity": "Actividad horaria de puntos de datos de transporte", + "transport-data-points-daily-activity": "Actividad diaria de puntos de datos de transporte", + "transport-data-points-monthly-activity": "Actividad mensual de puntos de datos de transporte", "view-details": "Ver detalles", - "view-statistics": "Ver estadísticas" + "view-statistics": "Ver estadísticas", + "transport-messages": "Mensajes de transporte", + "transport-messages-hourly-activity": "Actividad horaria de mensajes de transporte", + "transport-data-point-hourly-activity": "Actividad horaria de puntos de datos de transporte", + "javascript-function-executions": "Ejecuciones de funciones JavaScript", + "javascript-function-executions-hourly-activity": "Actividad horaria de ejecuciones de funciones JavaScript", + "javascript-function-executions-daily-activity": "Actividad diaria de ejecuciones de funciones JavaScript", + "javascript-function-executions-monthly-activity": "Actividad mensual de ejecuciones de funciones JavaScript", + "tbel-function-executions": "Ejecuciones de funciones TBEL", + "tbel-function-executions-hourly-activity": "Actividad horaria de ejecuciones de funciones TBEL", + "tbel-function-executions-daily-activity": "Actividad diaria de ejecuciones de funciones TBEL", + "tbel-function-executions-monthly-activity": "Actividad mensual de ejecuciones de funciones TBEL", + "created-reports": "Reportes creados", + "created-reports-hourly-activity": "Actividad horaria de reportes creados", + "created-reports-daily-activity": "Actividad diaria de reportes creados", + "created-reports-monthly-activity": "Actividad mensual de reportes creados", + "emails": "Emails", + "emails-hourly-activity": "Actividad horaria de Emails", + "emails-daily-activity": "Actividad diaria de Emails", + "emails-monthly-activity": "Actividad mensual de Emails", + "status": { + "enabled": "Activado", + "disabled": "Desactivado", + "warning": "Advertencia" + } }, "api-limit": { "cassandra-write-queries-core": "Consultas de escritura en Cassandra desde la API REST", @@ -946,6 +986,40 @@ "edge-uplink-messages": "Mensajes uplink de edge", "edge-uplink-messages-per-edge": "Mensajes uplink de edge por instancia de edge" }, + "api-key": { + "api-key": "Clave de API", + "api-keys": "Claves de API", + "delete-api-key-title": "¿Estás seguro de que quieres eliminar la clave de API '{{name}}'?", + "delete-api-key-text": "Ten cuidado, tras la confirmación la clave será irrecuperable.", + "delete-api-keys-title": "¿Estás seguro de que quieres eliminar { count, plural, =1 {1 clave de API} other {# claves de API} }?", + "delete-api-keys-text": "Ten cuidado, tras la confirmación todas las claves seleccionadas serán irrecuperables.", + "expiration-date": "Fecha de caducidad", + "date": "fecha", + "description": "Descripción", + "disable": "Desactivar", + "edit-description": "Editar descripción", + "enable": "Activar clave de API ", + "expiration-time": "Hora de caducidad", + "expiration-time-never": "Nunca", + "expiration-time-custom": "Personalizado", + "generate": "Generar", + "generate-title": "Generar clave de API", + "generate-text": "Nota: la clave de API hereda los permisos del usuario para el que se crea.", + "generated-api-key-title": "Clave de API generada. ¡Vamos a comprobar la conectividad!", + "generated-api-key-copy": "Asegúrate de copiar y guardar tu clave de API ahora, ya que no podrás verla de nuevo.", + "generated-api-key-command": "Usa las siguientes instrucciones para comprobar la conectividad. Como resultado, deberías recibir la información del usuario actual:", + "generated-api-key-insecure-url": "Ejecutar comandos a través de una conexión HTTP insegura enviará tu clave de API sin cifrar, haciéndola vulnerable a la interceptación.", + "list": "{ count, plural, =1 {Una clave de API} other {Lista de # claves de API} }", + "manage": "Gestionar", + "manage-api-keys": "Gestionar claves de API", + "no-found": "No se han encontrado claves de API", + "selected-api-keys": "{ count, plural, =1 {1 clave de API} other {# claves de API} } seleccionadas", + "search": "Buscar claves de API", + "status": "Estado", + "status-active": "Activa", + "status-inactive": "Inactiva", + "status-expired": "Caducada" + }, "audit-log": { "audit": "Auditoría", "audit-logs": "Registros de auditoría", @@ -999,7 +1073,11 @@ "type-provision-failure": "El aprovisionamiento del dispositivo falló", "type-timeseries-updated": "Telemetría actualizada", "type-timeseries-deleted": "Telemetría eliminada", - "type-sms-sent": "SMS enviado" + "type-sms-sent": "SMS enviado", + "any-type": "Cualquier tipo", + "audit-log-filter-title": "Filtro de registro de auditoría", + "filter-title": "Filtrar", + "filter-types": "Tipos de registro de auditoría" }, "debug-settings": { "label": "Configuración de depuración", @@ -1017,15 +1095,29 @@ "expression": "Expresión", "no-found": "No se encontraron campos calculados", "list": "{ count, plural, =1 {Un campo calculado} other {Lista de # campos calculados} }", + "add": "Añadir campo calculado", "selected-fields": "{ count, plural, =1 {1 campo calculado} other {# campos calculados} } seleccionado(s)", "type": { "simple": "Simple", - "script": "Script" + "simple-hint": "Cálculo aritmético simple basado en argumentos de entrada.", + "script": "Script", + "script-hint": "Cálculo sobre los argumentos definidos mediante un script TBEL.", + "geofencing": "Geovallado", + "geofencing-hint": "Evaluación de la posición GPS de la entidad y de las transiciones respecto a los grupos de zonas de geovallado configurados.", + "propagation": "Propagación", + "propagation-hint": "Propagación de datos a entidades padre o hijas según la dirección y el tipo de relación.", + "related-entities-aggregation": "Agregación de entidades relacionadas", + "related-entities-aggregation-hint": "Agregación de los datos más recientes de entidades relacionadas.", + "time-series-data-aggregation": "Agregación de datos de series temporales", + "time-series-data-aggregation-hint": "Agregación de datos históricos de una entidad actual." }, + "preview": "Vista previa", "arguments": "Argumentos", "decimals-by-default": "Decimales por defecto", "debugging": "Depuración de campos calculados", + "calculated-field-details": "Detalles del campo calculado", "argument-name": "Nombre del argumento", + "name": "Nombre", "datasource": "Origen de datos", "add-argument": "Agregar argumento", "test-script-function": "Probar función de script", @@ -1037,8 +1129,9 @@ "argument-asset": "Activo", "argument-customer": "Customer", "argument-tenant": "Tenant actual", + "argument-owner": "Propietario actual", + "argument-relation-query": "Entidades relacionadas", "argument-type": "Tipo de argumento", - "see-debug-events": "Ver eventos de depuración", "attribute": "Atributo", "copy-argument-name": "Copiar nombre del argumento", "timeseries-key": "Clave de serie temporal", @@ -1051,12 +1144,14 @@ "shared-attributes": "Atributos compartidos", "attribute-key": "Clave del atributo", "default-value": "Valor por defecto", + "default-value-required": "El valor predeterminado es obligatorio.", "limit": "Máx. valores", "time-window": "Ventana de tiempo", "customer-name": "Nombre del customer", "asset-name": "Nombre del activo", "timeseries": "Serie temporal", "output": "Salida", + "output-hint": "Define cómo se procesa la salida.", "create": "Crear nuevo campo calculado", "file": "Archivo de campo calculado", "invalid-file-error": "Formato de archivo inválido. Asegúrate de que el archivo sea un JSON válido.", @@ -1070,8 +1165,174 @@ "delete-multiple-text": "Ten cuidado, después de la confirmación todos los campos calculados seleccionados serán eliminados y todos los datos relacionados serán irrecuperables.", "test-with-this-message": "Probar con este mensaje", "use-latest-timestamp": "Usar la última marca de tiempo", + "entity-coordinates": "Coordenadas de la entidad", + "latitude-time-series-key": "Clave de serie temporal de latitud", + "latitude-time-series-key-required": "La clave de serie temporal de latitud es obligatoria.", + "longitude-time-series-key": "Clave de serie temporal de longitud", + "longitude-time-series-key-required": "La clave de serie temporal de longitud es obligatoria.", + "geofencing-zone-groups": "Grupos de zonas de geovallado", + "geofencing-zone-groups-settings": "Configuración de grupos de zonas de geovallado", + "target-zone": "Zona objetivo", + "perimeter-key": "Clave de perímetro", + "report-strategy": "Estrategia de informe", + "no-zone-configured": "Se requiere al menos una zona.", + "no-zone-configured-required": "Debe configurarse al menos un grupo de zonas.", + "add-zone-group": "Añadir grupo de zonas", + "report-transition-event-only": "Solo eventos de transición", + "report-presence-status-only": "Solo estado de presencia", + "report-transition-event-and-presence": "Estado de presencia y eventos de transición", + "perimeter-attribute-key": "Clave de atributo de perímetro", + "perimeter-attribute-key-required": "La clave de atributo de perímetro es obligatoria.", + "perimeter-attribute-key-pattern": "La clave de atributo de perímetro no es válida.", + "entity-zone-relationship": "Ruta desde la entidad hasta las zonas", + "direction": "Dirección de la relación", + "direction-from": "De la entidad a la zona", + "direction-to": "De la zona a la entidad", + "relation-type": "Tipo de relación", + "create-relation-with-matched-zones": "Crear relaciones para la entidad de origen con las zonas coincidentes", + "relation-level": "Nivel de relación", + "fetch-last-available-level": "Obtener solo el último nivel disponible", + "zone-group-refresh-interval": "Intervalo de actualización de grupos de zonas", + "copy-zone-group-name": "Copiar nombre del grupo de zonas", + "open-details-page": "Abrir página de detalles de la entidad", + "level": "Nivel", + "direction-level": "Dirección", + "direction-up": "Arriba", + "direction-up-parent": "Arriba hasta el padre", + "direction-down": "Abajo", + "direction-down-child": "Abajo hasta el hijo", + "add-level": "Añadir nivel", + "delete-level": "Eliminar nivel", + "no-level": "No hay ningún nivel configurado", + "levels-required": "Debe configurarse al menos un nivel.", + "max-allowed-levels-error": "El nivel de relación supera el máximo permitido.", + "propagation-path-related-entities": "Ruta de propagación a entidades relacionadas", + "propagate-type": { + "arguments-only": "Solo argumentos", + "expression-result": "Resultado del cálculo" + }, + "script": "Script", + "data-propagate": "Datos a propagar", + "output-key": "Clave de salida", + "copy-output-key": "Copiar clave de salida", + "aggregation-path-related-entities": "Ruta de agregación a entidades relacionadas", + "deduplication-interval": "Intervalo de deduplicación", + "deduplication-interval-min": "El intervalo de deduplicación debe ser de al menos {{ sec }} segundos.", + "deduplication-interval-hint": "Tiempo mínimo entre agregaciones de telemetría.", + "deduplication-interval-required": "El intervalo de deduplicación es obligatorio.", + "calculated-field-filter-title": "Filtro de campo calculado", + "filter-title": "Filtrar", + "calculated-field-types": "Tipos de campo calculado", + "events": "Eventos", + "any-type": "Cualquier tipo", + "metrics": { + "metrics": "Métricas", + "metrics-empty": "Debe configurarse al menos una métrica.", + "metric-name": "Nombre de métrica", + "metric-name-required": "El nombre de la métrica es obligatorio.", + "metric-name-pattern": "El nombre de la métrica no es válido.", + "metric-name-duplicate": "Ya existe una métrica con ese nombre.", + "metric-name-max-length": "El nombre de la métrica debe tener menos de 256 caracteres.", + "metric-name-forbidden": "El nombre de la métrica está reservado y no se puede usar.", + "copy-metric-name": "Copiar nombre de la métrica", + "argument-name": "Nombre del argumento", + "aggregation": "Agregación", + "aggregation-type": { + "avg": "Media", + "min": "Mínimo", + "max": "Máximo", + "sum": "Suma", + "count": "Recuento", + "count-unique": "Recuento de únicos" + }, + "filtered": "Filtrado", + "value-source": "Origen del valor", + "value-source-hint": "Define cómo se obtiene el valor para la agregación.", + "value-source-type": { + "key": "Clave", + "function": "Función" + }, + "no-metrics-configured": "Se requiere al menos una métrica.", + "add-metric": "Añadir métrica", + "max-metrics": "Se ha alcanzado el número máximo de métricas.", + "metric-settings": "Configuración de la métrica", + "filter": "Filtro", + "filter-hint": "Permite filtrar entidades durante la agregación. La función de filtro debe devolver un valor booleano y puede usar todos los argumentos configurados." + }, + "output-strategy": { + "strategy": "Estrategia", + "process-right-away": "Procesar de inmediato", + "process-rule-chains": "Procesar mediante cadenas de reglas", + "save-time-series": "Guardar en series temporales", + "save-database": "Guardar en base de datos", + "save-latest-values": "Guardar en valores más recientes", + "send-web-sockets": "Enviar a WebSockets", + "save-calculated-fields": "Enviar a campos calculados", + "update-attribute-only-on-value-change": "Actualizar atributo solo cuando cambie el valor", + "send-attributes-updated-notification": "Enviar notificación de atributos actualizados", + "ttl": "TTL personalizado", + "ttl-required": "El TTL es obligatorio", + "ttl-min": "Solo se permite un TTL mínimo de 0", + "processing-parameters": "Parámetros de procesamiento", + "hint": { + "strategy": "Controla si el resultado se procesa inmediatamente o se envía a una cadena de reglas para un procesamiento adicional.", + "processing-options": "Opciones de procesamiento", + "update-attribute-only-on-value-change": "Actualiza el atributo en cada mensaje entrante, independientemente de si el valor ha cambiado. Esto incrementa el uso de la API y reduce el rendimiento.", + "update-attribute-only-on-value-change-enabled": "Actualiza el atributo solo cuando cambia el valor. Si el valor no cambia, no se actualizan las marcas de tiempo y no se envían notificaciones.", + "send-attributes-updated-notification": "Envía un evento Attributes Updated a la cadena de reglas predeterminada.", + "save-time-series": "Guarda datos de series temporales en la tabla ts_kv de la base de datos.", + "save-database": "Guarda datos de atributos en la base de datos.", + "save-latest-values": "Actualiza los datos de series temporales en la tabla ts_kv_latest de la base de datos si el nuevo valor es más reciente.", + "send-web-sockets-attribute": "Notifica a las suscripciones de WebSocket sobre las actualizaciones de los datos de atributos.", + "send-web-sockets-time-series": "Notifica a las suscripciones de WebSocket sobre las actualizaciones de los datos de series temporales.", + "save-calculated-fields-attribute": "Notifica a los campos calculados sobre las actualizaciones de los datos de atributos.", + "save-calculated-fields-time-series": "Notifica a los campos calculados sobre las actualizaciones de los datos de series temporales.", + "ttl": "Define el periodo de retención de los datos de series temporales. Si está desactivado, se usa el TTL del perfil de Tenant." + } + }, + "aggregate-interval-type": "Tipo de intervalo de agregación", + "aggregate-interval-value": "Valor del intervalo de agregación", + "aggregate-interval-value-required": "El valor del intervalo de agregación es obligatorio.", + "aggregate-interval-value-min": "El valor del intervalo de agregación debe ser de al menos { sec, plural, =0 {0 segundo} =1 {1 segundo} other {# segundos} }.", + "aggregate-interval-value-step-multiple-of": "El valor del intervalo de agregación debe ser un divisor o un múltiplo de 1 día.", + "aggregate-period": { + "hour": "Hora", + "day": "Día", + "week": "Semana (lun - dom)", + "week-sun-sat": "Semana (dom - sáb)", + "month": "Mes", + "quarter": "Trimestre", + "year": "Año", + "custom": "Personalizado" + }, + "aggregate-period-hint-offset": "Tu intervalo de agregación será: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "Tu intervalo de agregación será: {{ interval }} y así sucesivamente.", + "entity-aggregation": { + "argument-hint": "Los datos se obtendrán de la entidad actual.", + "argument-title-hint": "Define los argumentos de entrada utilizados para la agregación.", + "argument-setting-hint": "La telemetría más reciente es el único tipo de argumento disponible para este campo calculado.", + "aggregation-interval": "Intervalo de agregación", + "aggregation-interval-hint": "Define la frecuencia con la que se realiza la agregación. Ejemplo: cada 1 hora agrega datos a las 00:00, 01:00, 02:00, etc. Los resultados de la agregación se almacenan con la marca de tiempo correspondiente al inicio del intervalo de agregación.", + "apply-offset": "Aplicar desfase al intervalo de agregación", + "apply-offset-hint": "Define cuánto desplazar el inicio de cada periodo de agregación (p. ej., +10 minutos - 00:10, 01:10).", + "offset-value": "Valor de desfase", + "offset-value-required": "El valor de desfase es obligatorio.", + "offset-value-min": "El valor de desfase debe ser un entero positivo.", + "offset-value-max": "El valor de desfase debe ser inferior al valor del intervalo de agregación.", + "wait-delay": "Aplicar tiempo de espera para telemetría retrasada", + "wait-delay-hint": "Define cuánto tiempo esperar la telemetría retrasada después de que termine el intervalo. Si llega esa telemetría, se recalculará el resultado para ese intervalo.", + "duration": "Duración", + "duration-required": "La duración es obligatoria.", + "duration-min": "La duración debe ser de al menos 1 minuto.", + "duration-hint": "Cuánto tiempo esperar los datos retrasados después de que termine el intervalo.", + "produce-intermediate-result": "Generar resultado intermedio", + "produce-intermediate-result-hint": "Calcula métricas durante el intervalo actual para generar un resultado intermedio. Las actualizaciones no se producen con más frecuencia que una vez cada {{ time }}." + }, "hint": { - "arguments-simple-with-rolling": "Un campo calculado de tipo simple no debe contener claves con tipo de serie temporal con agregación.", + "arguments-simple-with-rolling": "Un campo calculado de tipo simple no debe contener claves con el tipo de serie temporal móvil.", + "arguments-propagate-arguments-with-rolling": "El tipo 'Serie temporal móvil' es incompatible con la propagación 'Solo argumentos'.", + "arguments-propagate-argument-entity-type": "El tipo de entidad es incompatible con la propagación 'Solo argumentos'.", + "arguments-propagate-argument-must-current-entity": "Debe configurarse al menos un argumento con el tipo de entidad de origen 'Entidad actual'.", "arguments-empty": "Los argumentos no deben estar vacíos.", "expression-required": "Se requiere una expresión.", "expression-invalid": "La expresión no es válida.", @@ -1081,12 +1342,218 @@ "argument-name-duplicate": "Ya existe un argumento con ese nombre.", "argument-name-max-length": "El nombre del argumento debe tener menos de 256 caracteres.", "argument-name-forbidden": "El nombre del argumento está reservado y no puede utilizarse.", + "output-key-required": "La clave de salida es obligatoria.", + "output-key-pattern": "La clave de salida no es válida.", + "output-key-duplicate": "Ya existe una clave con ese nombre.", + "output-key-max-length": "La clave de salida debe tener menos de 256 caracteres.", + "output-key-forbidden": "La clave de salida está reservada y no se puede usar.", + "entity-type-required": "El tipo de entidad es obligatorio", + "name-required": "El nombre es obligatorio.", + "name-pattern": "El nombre no es válido.", + "name-duplicate": "Ya existe un campo calculado con ese nombre.", + "name-max-length": "El nombre debe tener menos de 256 caracteres.", + "name-forbidden": "El nombre está reservado y no se puede usar.", "argument-type-required": "Se requiere el tipo de argumento.", "max-args": "Se ha alcanzado el número máximo de argumentos.", "decimals-range": "Los decimales por defecto deben ser un número entre 0 y 15.", "expression": "La expresión por defecto muestra cómo transformar una temperatura de Fahrenheit a Celsius.", "arguments-entity-not-found": "No se encontró la entidad de destino del argumento.", - "use-latest-timestamp": "Si está habilitado, el valor calculado se almacenará utilizando la marca de tiempo más reciente de la telemetría de los argumentos, en lugar del tiempo del servidor." + "use-latest-timestamp": "Si está activado, el valor calculado se guardará usando la marca de tiempo más reciente de la telemetría de los argumentos, en lugar de la hora del servidor.", + "entity-coordinates": "Especifica las claves de serie temporal que proporcionan las coordenadas GPS de la entidad (latitud y longitud).", + "geofencing-zone-groups": "Define uno o varios grupos de zonas de geovallado para comprobar (p. ej., 'allowedZones', 'restrictedZones'). Cada grupo debe tener un nombre único, que se usa como prefijo para las claves de telemetría de salida del campo calculado.", + "perimeter-attribute-key": "Establece la clave de atributo que contiene la definición del perímetro de la zona de geovallado. El perímetro siempre se toma de los atributos del lado del servidor de la entidad de zona.", + "report-strategy": "El estado de presencia indica si la entidad está actualmente DENTRO o FUERA del grupo de zonas. Los eventos de transición indican cuándo la entidad ENTRÓ o SALIÓ del grupo de zonas.", + "create-relation-with-matched-zones": "Crea y mantiene automáticamente relaciones entre la entidad y las zonas en las que se encuentra actualmente. Las relaciones se eliminan cuando la entidad sale de una zona y se crean cuando entra en una nueva.", + "relation-type-required": "El tipo de relación es obligatorio.", + "relation-level-required": "El nivel de relación es obligatorio.", + "relation-level-min": "El valor mínimo del nivel de relación es 1.", + "relation-level-max": "El valor máximo del nivel de relación es {{max}}.", + "geofencing-empty": "Debe configurarse al menos un grupo de zonas.", + "geofencing-entity-not-found": "No se ha encontrado la entidad objetivo de geovallado.", + "max-geofencing-zone": "Se ha alcanzado el número máximo de zonas de geovallado.", + "zone-group-refresh-interval": "Define con qué frecuencia se actualizan los grupos de zonas configurados mediante entidades relacionadas.", + "zone-group-refresh-interval-required": "El intervalo de actualización de grupos de zonas es obligatorio.", + "zone-group-refresh-interval-min": "El intervalo de actualización del grupo de zonas debe ser de al menos {{ min }} segundos.", + "propagation-path-related-entities": "Define una ruta directa de un solo nivel a una entidad relacionada según la dirección seleccionada y el tipo de relación. Solo se admiten relaciones entre entidades de dispositivo, activo, cliente y tenant. El máximo de entidades resueltas por la ruta de relación es {{ max }}.", + "data-propagate": "Define los datos que se propagarán desde los argumentos configurados a continuación. 'Solo argumentos' usa los datos recuperados directamente, mientras que 'Resultado del cálculo' calcula un nuevo valor a partir de esos datos.", + "aggregation-path-related-entities": "Define una ruta de agregación de un solo nivel mediante relaciones directas con entidades padre o hijas, según la dirección y el tipo de relación. Solo se admiten relaciones entre entidades de dispositivo, activo, cliente y tenant. El máximo de entidades resueltas por la ruta de relación es {{ max }}.", + "arguments-aggregation": "Define los argumentos de entrada utilizados para el filtrado y la agregación.", + "setting-arguments-aggregation": "Los datos se obtendrán de las entidades relacionadas configuradas en la ruta de agregación.", + "metrics": "Define las métricas agregadas en función de los argumentos configurados.", + "entity-aggregation-metrics": "Define las métricas agregadas en función de los argumentos configurados a lo largo de los intervalos de tiempo especificados.", + "import-invalid-calculated-field-type": "No se puede importar el campo calculado: la estructura del campo calculado no es válida.", + "simple-expression-title": "Expresión aritmética que define cómo se calcula el valor calculado.", + "script-title": "Script TBEL que define la lógica de cálculo y los valores de salida.", + "simple-arguments": "Expresión aritmética que define cómo se calcula el valor calculado.", + "script-arguments": "Define los argumentos de entrada disponibles para el script." + } + }, + "alarm-rule": { + "alarm-rules-tab": "Reglas de alarma", + "alarm-rule": "Regla de alarma", + "alarm-rules": "Reglas de alarma", + "alarm-rules-old": "Antiguas", + "alarm-rules-actual": "Actuales", + "severities": "Gravedades", + "cleared": "Condición de borrado", + "delete-title": "¿Estás seguro de que quieres eliminar la regla de alarma '{{title}}'?", + "delete-text": "Ten cuidado, tras la confirmación la regla de alarma y todos los datos relacionados serán irrecuperables.", + "delete-multiple-title": "¿Estás seguro de que quieres eliminar { count, plural, =1 {1 regla de alarma} other {# reglas de alarma} }?", + "delete-multiple-text": "Ten cuidado, tras la confirmación se eliminarán todas las reglas de alarma seleccionadas y todos los datos relacionados serán irrecuperables.", + "create": "Crear nueva regla de alarma", + "add": "Añadir regla de alarma", + "copy": "Copiar configuración de la regla de alarma", + "details": "Detalles de la regla de alarma", + "no-found": "No se han encontrado reglas de alarma", + "list": "{ count, plural, =1 {Una regla de alarma} other {Lista de # reglas de alarma} }", + "selected-fields": "{ count, plural, =1 {1 regla de alarma} other {# reglas de alarma} } seleccionadas", + "import": "Importar regla de alarma", + "file": "Archivo de regla de alarma", + "export": "Exportar regla de alarma", + "export-failed-error": "No se puede exportar la regla de alarma: {{error}}", + "entity-type": "Tipo de entidad", + "entity-type-required": "El tipo de entidad es obligatorio.", + "alarm-type": "Tipo de alarma", + "alarm-type-hint": "Identificador único (p. ej., HighTempAlarm) dentro del ámbito del originador de la alarma (Dispositivo, Activo, etc.) para evitar conflictos.", + "alarm-type-required": "El tipo de alarma es obligatorio.", + "alarm-type-pattern": "El tipo de alarma no es válido.", + "alarm-type-max-length": "El tipo de alarma debe tener menos de 256 caracteres.", + "clear-alarm": "Borrar alarma", + "value-argument": "Argumento", + "value-argument-required": "El argumento es obligatorio.", + "static-settings": "Configuración estática", + "configuration": "Configuración", + "static-schedule": "Estática", + "dynamic-schedule": "Dinámica", + "operation-and": "Y", + "operation-or": "O", + "condition-during": "Durante {{during}}", + "condition-during-dynamic": "Durante \"{{ attribute }}\"", + "condition-repeat-times": "Se repite { count, plural, =1 {1 vez} other {# veces} }", + "condition-repeat-times-dynamic": "Se repite \"{{ attribute }}\" veces", + "filter-preview": "Vista previa del filtro", + "condition-settings": "Configuración de la condición", + "static": "Estática", + "dynamic": "Dinámica", + "argument-filters": "Filtros de argumentos", + "argument-name": "Nombre del argumento", + "value-type": "Tipo de valor", + "general": "General", + "filters": "Filtros", + "date-time-hint": "El argumento debe estar en milisegundos desde epoch. Ejemplo: 1698839340000 equivale a 2023-11-01 12:49:00 UTC.", + "operation": "Operación", + "value-source": "Origen del valor", + "value": "Valor", + "ignore-case": "Ignorar mayúsculas y minúsculas", + "condition": "Condición", + "script": "Script", + "add-filter": "Añadir filtro de argumentos", + "edit-filter": "Filtro de argumentos", + "remove-filter": "Eliminar filtro de argumentos", + "no-filter": "Se requiere al menos un filtro.", + "conditions": { + "simple": "Simple", + "duration": "Duración", + "repeating": "Repetición" + }, + "schedule-title": "Programación", + "edit-schedule": "Editar programación de alarma", + "schedule-type": "Tipo de programador", + "schedule-type-required": "El tipo de programador es obligatorio.", + "schedule": { + "any-time": "Activa todo el tiempo", + "specific-time": "Activa en un horario específico", + "custom": "Personalizado" + }, + "schedule-day": { + "monday": "Lunes", + "tuesday": "Martes", + "wednesday": "Miércoles", + "thursday": "Jueves", + "friday": "Viernes", + "saturday": "Sábado", + "sunday": "Domingo" + }, + "schedule-days": "Días", + "schedule-time": "Hora", + "schedule-time-from": "Desde", + "schedule-time-to": "Hasta", + "schedule-days-of-week-required": "Debe seleccionarse al menos un día de la semana.", + "tbel": "TBEL", + "expression-type": { + "simple": "Simple", + "script": "Script" + }, + "operation-type": { + "and": "Y", + "or": "O" + }, + "filter-predicate-type": { + "string": "Cadena", + "numeric": "Numérico", + "boolean": "Booleano", + "complex": "Complejo" + }, + "alarm-rule-additional-info": "Información adicional", + "edit-alarm-rule-additional-info": "Editar información adicional", + "alarm-rule-additional-info-placeholder": "Indica aquí tus comentarios y ajustes para mostrarlos en Detalles de la alarma, en Información adicional", + "alarm-rule-additional-info-hint": "Sugerencia: usa ${Nombre del argumento} para sustituir los valores de los argumentos que se usan en la condición de la regla de alarma.", + "alarm-rule-additional-info-icon-hint": "Usa el nombre del argumento para sustituir los valores de los argumentos que se usan en la condición de la regla de alarma.", + "alarm-rule-mobile-dashboard": "Tablero móvil", + "alarm-rule-mobile-dashboard-hint": "Lo usa la aplicación móvil como tablero de detalles de la alarma.", + "alarm-rule-no-mobile-dashboard": "No se ha seleccionado ningún tablero", + "alarm-rule-condition": "Condición de la regla de alarma", + "enter-alarm-rule-condition-prompt": "Añadir condición", + "enter-alarm-rule-clear-condition-prompt": "Añadir condición de borrado", + "edit-alarm-rule-condition": "Condición de alarma", + "condition-type": "Tipo de condición", + "condition-type-hint": "Las opciones \"Duración\" y \"Repetición\" no están disponibles cuando se usa la operación \"Missing for\" en el filtro.", + "select-alarm-severity": "Seleccionar gravedad de la alarma", + "add-create-alarm-rule-prompt": "Se requiere al menos una condición de activación.", + "add-create-alarm-rule": "Añadir condición de activación", + "add-clear-alarm-rule": "Añadir condición de borrado", + "condition-duration": "Duración de la condición", + "condition-duration-value": "Valor de duración", + "condition-duration-time-unit": "Unidad de tiempo", + "condition-duration-value-range": "El valor de duración debe estar en un rango de 1 a 2147483647.", + "condition-duration-value-pattern": "El valor de duración debe ser un número entero.", + "condition-duration-value-required": "El valor de duración es obligatorio.", + "condition-duration-time-unit-required": "La unidad de tiempo es obligatoria.", + "condition-repeating-value": "Número de eventos", + "condition-repeating-value-hint": "La actualización de cualquier argumento de la regla de alarma se contará como un evento", + "condition-repeating-value-range": "El número de eventos debe estar en un rango de 1 a 2147483647.", + "condition-repeating-value-pattern": "El número de eventos debe ser un número entero.", + "condition-repeating-value-required": "El número de eventos es obligatorio.", + "create-conditions": "Condiciones de activación", + "clear-condition": "Condición de borrado", + "no-clear-alarm-rule": "No hay ninguna condición de borrado configurada.", + "advanced-settings": "Configuración avanzada", + "propagate-alarm": "Propagar alarma a entidades relacionadas", + "alarm-rule-relation-types-list": "Tipos de relación", + "alarm-rule-relation-types-list-hint": "Define los tipos de relación para filtrar las entidades relacionadas. Si no se establece, la alarma se propagará a todas las entidades relacionadas.", + "propagate-alarm-to-owner": "Propagar alarma al propietario de la entidad (Cliente o Tenant)", + "propagate-alarm-to-tenant": "Propagar alarma al Tenant", + "alarm-rule-filter-title": "Filtro de regla de alarma", + "filter-title": "Filtrar", + "debugging": "Depuración de regla de alarma", + "any-type": "Cualquier tipo", + "enter-alarm-rule-type": "Introducir tipo de alarma", + "no-alarm-rule-types-matching": "No se han encontrado tipos de alarma que coincidan con '{{entitySubtype}}'.", + "alarm-rule-type-list-empty": "No se ha seleccionado ningún tipo de alarma.", + "alarm-rule-type-list": "Lista de tipos de alarma", + "alarm-rule-entity-list": "Lista de entidades", + "missing-for": "sin datos durante", + "time-unit": "Unidad", + "mode": "Modo", + "type": "Tipo", + "value-required": "El valor es obligatorio.", + "min-value": "El valor debe ser 1 o mayor.", + "argument-in-use": "El argumento se usa como argumento general.", + "import-invalid-alarm-rule-type": "No se puede importar la regla de alarma: la estructura de la regla de alarma no es válida.", + "no-filter-preview": "No se ha especificado ningún filtro", + "filter-operation": { + "and": "Y", + "or": "O" } }, "ai-models": { @@ -1111,13 +1578,15 @@ "mistral-ai": "Mistral AI", "anthropic": "Anthropic", "amazon-bedrock": "Amazon Bedrock", - "github-models": "Modelos de GitHub" + "github-models": "GitHub Models", + "ollama": "Ollama" }, "name-required": "Se requiere un nombre.", "name-max-length": "El nombre debe tener 255 caracteres o menos.", "provider": "Proveedor", - "api-key": "Clave API", - "api-key-required": "Se requiere la clave API.", + "api-key": "Clave de API", + "api-key-required": "La clave de API es obligatoria.", + "api-key-open-ai-required": "La clave de API es obligatoria cuando se usa la API oficial de OpenAI.", "project-id": "ID del proyecto", "project-id-required": "Se requiere el ID del proyecto", "location": "Ubicación", @@ -1153,18 +1622,35 @@ "presence-penalty-hint": "Aplica una penalización fija a la probabilidad de un token si ya ha aparecido en el texto.", "frequency-penalty": "Penalización por frecuencia", "frequency-penalty-hint": "Aplica una penalización a la probabilidad de un token que aumenta en función de su frecuencia en el texto.", - "max-output-tokens": "Número máximo de tokens de salida", - "max-output-tokens-min": "Debe ser mayor que 0.", - "max-output-tokens-hint": "Define el número máximo de tokens que el modelo puede generar en una sola respuesta.", + "max-output-tokens": "Máximo de tokens de salida", + "max-output-tokens-hint": "Establece el número máximo de tokens que el \nmodelo puede generar en una única respuesta.", + "context-length": "Longitud del contexto", + "context-length-hint": "Define el tamaño de la ventana de contexto en tokens. Este valor establece el límite total de memoria del modelo, incluyendo tanto la entrada del usuario como la respuesta generada.", "endpoint": "Endpoint", - "endpoint-required": "Se requiere el endpoint.", + "endpoint-required": "El endpoint es obligatorio.", + "baseurl": "URL base", + "baseurl-required": "La URL base es obligatoria.", "service-version": "Versión del servicio", "check-connectivity": "Verificar conectividad", "check-connectivity-success": "La solicitud de prueba fue exitosa", "check-connectivity-failed": "La solicitud de prueba falló", "no-model-matching": "No se encontraron modelos que coincidan con '{{entity}}'.", "model-required": "Se requiere un modelo.", - "no-model-text": "No se encontraron modelos." + "no-model-text": "No se encontraron modelos.", + "authentication": "Autenticación", + "authentication-basic-hint": "Usa la autenticación HTTP Basic estándar. El nombre de usuario y la contraseña se combinarán, se codificarán en Base64 y se enviarán en una cabecera \"Authorization\" con cada solicitud al servidor Ollama.", + "authentication-token-hint": "Usa la autenticación mediante token Bearer. El token proporcionado se enviará directamente en una cabecera \"Authorization\" con cada solicitud al servidor Ollama.", + "authentication-type": { + "none": "Ninguna", + "basic": "Basic", + "token": "Token" + }, + "username": "Nombre de usuario", + "username-required": "El nombre de usuario es obligatorio.", + "password": "Contraseña", + "password-required": "La contraseña es obligatoria.", + "token": "Token", + "token-required": "El token es obligatorio." }, "confirm-on-exit": { "message": "Tienes cambios sin guardar. ¿Estás seguro de que deseas salir de esta página?", @@ -1174,6 +1660,7 @@ "contact": { "country": "País", "country-required": "Se requiere el país.", + "country-object-required": "Selecciona un país válido de la lista.", "city": "Ciudad", "state": "Estado / Provincia", "postal-code": "Código postal", @@ -1210,6 +1697,8 @@ "documentation": "Documentación", "time-left": "{{time}} restante", "output": "Salida", + "sort-asc": "Ascendente", + "sort-desc": "Descendente", "suffix": { "s": "s", "ms": "ms" @@ -1346,6 +1835,8 @@ "mobile-order": "Orden del tablero en la aplicación móvil", "mobile-hide": "Ocultar tablero en la aplicación móvil", "update-image": "Actualizar imagen del tablero", + "update-new-version": "Subir nueva versión", + "upload-file-to-update": "Subir archivo para actualizar", "take-screenshot": "Tomar captura de pantalla", "select-widget-title": "Seleccionar widget", "select-widget-value": "{{title}}: seleccionar widget", @@ -1634,7 +2125,7 @@ "remove-alias": "Eliminar alias de dispositivo", "add-alias": "Agregar alias de dispositivo", "name-starts-with": "Expresión del nombre del dispositivo", - "help-text": "Usa '%' según sea necesario: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Usa '%' según sea necesario: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Lista de dispositivos", "use-device-name-filter": "Usar filtro", "device-list-empty": "No se seleccionaron dispositivos.", @@ -1714,6 +2205,8 @@ "bootstrap-tab": "Cliente Bootstrap", "bootstrap-server": "Servidor Bootstrap", "lwm2m-server": "Servidor LwM2M", + "client-reboot": "Disparador de actualización de registro", + "bootstrap-reboot": "Disparador de solicitud de arranque", "client-publicKey-or-id": "Clave pública o ID del cliente", "client-publicKey-or-id-required": "Se requiere la clave pública o el ID del cliente.", "client-publicKey-or-id-tooltip-psk": "El identificador PSK es arbitrario hasta 128 bytes, conforme a la norma [RFC7925].\nDebe convertirse a cadena y luego a octetos en UTF-8.", @@ -1761,7 +2254,6 @@ "unable-delete-device-alias-text": "No se puede eliminar el alias del dispositivo '{{deviceAlias}}' porque lo utilizan los siguientes widget(s):
{{widgetsList}}", "is-gateway": "Es un gateway", "overwrite-activity-time": "Sobrescribir tiempo de actividad del dispositivo conectado", - "device-filter": "Filtro de dispositivo", "device-filter-title": "Filtro de dispositivo", "filter-title": "Filtro", "device-state": "Estado del dispositivo", @@ -2215,6 +2707,7 @@ "short-id-required": "Se requiere el ID corto del servidor.", "short-id-range": "El ID corto del servidor debe estar en un rango de {{ min }} a {{ max }}.", "short-id-pattern": "El ID corto del servidor debe ser un número entero positivo.", + "short-id-pattern-bs": "El ID corto del servidor solo debe ser null", "lifetime": "Tiempo de vida del registro del cliente", "lifetime-required": "Se requiere el tiempo de vida del registro del cliente.", "lifetime-pattern": "El tiempo de vida del registro debe ser un número entero positivo.", @@ -2237,7 +2730,7 @@ "tqs": "TQS: conexiones TCP y SMS activas; TCP en modo en cola, SMS en modo estándar (no admitido desde LWM2M 1.1)", "sq": "SQ: Conexión SMS en modo en cola (no admitido desde LWM2M 1.1)" }, - "binding-tooltip": "Esta es la lista en el recurso \"binding\" del objeto del servidor LwM2M - /1/x/7.\nIndica los modos de asociación admitidos en el Cliente LwM2M.\nEste valor DEBERÍA ser igual al valor en el recurso “Supported Binding and Modes” en el Objeto de Dispositivo (/3/0/16).\nAunque se admiten múltiples transportes, solo uno puede usarse durante toda la sesión de transporte.\nPor ejemplo, si se admiten UDP y SMS, el Cliente y el Servidor LwM2M pueden elegir comunicarse a través de UDP o SMS durante toda la sesión de transporte.", + "binding-tooltip": "Esta es la lista en el recurso \"binding\" del objeto de servidor LwM2M - /1/x/7.\nIndica los modos de vinculación admitidos en el cliente LwM2M.\nEste valor DEBERÍA ser el mismo que el valor del recurso \"Supported Binding and Modes\" en el objeto de dispositivo (/3/0/16).\nAunque se admiten varios transportes, solo se puede usar un enlace de transporte durante toda la sesión de transporte.\nPor ejemplo, cuando se admiten tanto UDP como SMS, el cliente LwM2M y el servidor LwM2M pueden elegir comunicarse por UDP o por SMS durante toda la sesión de transporte.", "bootstrap-server": "Servidor Bootstrap", "lwm2m-server": "Servidor LwM2M", "include-bootstrap-server": "Incluir actualizaciones del servidor Bootstrap", @@ -2303,13 +2796,15 @@ }, "observe-strategy": { "observe-strategy": "Estrategia de observación", - "single": "Individual", + "single": "Única", "single-description": "Una solicitud Observe por recurso (mayor precisión, más tráfico de red)", - "composite-all": "Compuesta - todos", + "composite-all": "Compuesta para todo", "composite-all-description": "Todos los recursos se observan con una única solicitud Composite Observe (más eficiente, menos flexible)", "composite-by-object": "Compuesta por objetos", "composite-by-object-description": "Los recursos se agrupan por tipo de objeto y se observan usando solicitudes Composite Observe separadas (enfoque equilibrado)" - } + }, + "init-attr-tel-as-obs-strategy": "Inicializar atributos y telemetría usando la estrategia Observe", + "init-attr-tel-as-obs-strategy-hint": "Si es false: los atributos y la telemetría se inicializan leyendo sus valores uno a uno.\\nSi es true: los atributos y la telemetría se inicializan suscribiéndose a sus valores usando la estrategia Observe." }, "snmp": { "add-communication-config": "Agregar configuración de comunicación", @@ -2545,7 +3040,7 @@ "no-entities-text": "No se encontraron entidades", "no-entity-types-matching": "No se encontraron tipos de entidad que coincidan con '{{entityType}}'.", "name-starts-with": "Expresión de nombre", - "help-text": "Use '%' según sea necesario: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Usa '%' según sea necesario: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Usar filtro", "entity-list-empty": "No hay entidades seleccionadas.", "entity-type-list-required": "Se debe seleccionar al menos un tipo de entidad.", @@ -2625,6 +3120,8 @@ "type-rulenodes": "Nodos de regla", "list-of-rulenodes": "{ count, plural, =1 {Un nodo de regla} other {Lista de # nodos de regla} }", "rulenode-name-starts-with": "Nodos de regla cuyos nombres comienzan con '{{prefix}}'", + "type-api-key": "Clave de API", + "type-api-keys": "Claves de API", "type-current-customer": "Customer actual", "type-current-tenant": "Tenant actual", "type-current-user": "Usuario actual", @@ -2646,6 +3143,7 @@ "details": "Detalles de la entidad", "no-entities-prompt": "No se encontraron entidades", "no-data": "No hay datos para mostrar", + "show-all-columns": "Mostrar todo", "columns-to-display": "Columnas para mostrar", "type-api-usage-state": "Estado de uso de API", "type-edge": "Edge", @@ -2691,7 +3189,15 @@ "list-of-mobile-apps": "{ count, plural, =1 {Una aplicación móvil} other {Lista de # aplicaciones móviles} }", "type-mobile-app-bundle": "Paquete móvil", "type-mobile-app-bundles": "Paquetes móviles", - "list-of-mobile-app-bundles": "{ count, plural, =1 {Un paquete móvil} other {Lista de # paquetes móviles} }" + "list-of-mobile-app-bundles": "{ count, plural, =1 {Un paquete móvil} other {Lista de # paquetes móviles} }", + "limit-reached": "Límite alcanzado", + "limit-reached-text": "Has alcanzado el límite de {{ entities }}. Para añadir más, pide a tu administrador del sistema que aumente tu límite de {{ entity }}.", + "request-limit-increase": "Solicitar aumento del límite", + "request-sysadmin-text": "¿Eres el administrador del sistema?", + "login-here": "Iniciar sesión aquí", + "to-increase-limit": "para aumentar el límite.", + "increase-limit-request-sent-title": "Hemos enviado una solicitud automática a tu administrador del sistema para aumentar el límite", + "increase-limit-request-sent-text": "Deja algo de tiempo para que revisen la solicitud y actualicen la configuración. Puede que tengas que actualizar esta página para ver los cambios." }, "entity-field": { "created-time": "Hora de creación", @@ -2737,7 +3243,7 @@ "remove-alias": "Eliminar alias de vista de entidad", "add-alias": "Agregar alias de vista de entidad", "name-starts-with": "Expresión del nombre de la vista de entidad", - "help-text": "Use '%' según sea necesario: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Usa '%' según sea necesario: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Lista de vistas de entidad", "use-entity-view-name-filter": "Usar filtro", "entity-view-list-empty": "No se seleccionaron vistas de entidad.", @@ -3768,6 +4274,7 @@ "two-factor-authentication": "Autenticación de dos factores", "passwords-mismatch-error": "¡Las contraseñas ingresadas deben coincidir!", "password-again": "Repetir contraseña", + "sign-in": "Por favor inicia sesión", "username": "Nombre de usuario (correo electrónico)", "remember-me": "Recordarme", "forgot-password": "¿Olvidaste tu contraseña?", @@ -3778,7 +4285,8 @@ "password-link-sent-message": "Enlace de restablecimiento enviado", "email": "Correo electrónico", "invalid-email-format": "Formato de correo electrónico inválido.", - "login-with": "Iniciar sesión con {{name}}", + "sign-in-with": "Iniciar sesión con {{name}}", + "sign-in-to-your-account": "Iniciar sesión en tu cuenta", "or": "o", "error": "Error de inicio de sesión", "verify-your-identity": "Verifica tu identidad", @@ -3797,7 +4305,51 @@ "activation-link-expired": "El enlace de activación ha expirado", "activation-link-expired-message": "El enlace para activar tu perfil ha expirado. Puedes regresar a la página de inicio de sesión para recibir un nuevo correo.", "reset-password-link-expired": "El enlace para restablecer la contraseña ha expirado", - "reset-password-link-expired-message": "El enlace para restablecer tu contraseña ha expirado. Puedes regresar a la página de inicio de sesión para recibir un nuevo correo." + "reset-password-link-expired-message": "El enlace para restablecer tu contraseña ha expirado. Puedes regresar a la página de inicio de sesión para recibir un nuevo correo.", + "two-fa": "Autenticación de dos factores", + "two-fa-required": "La autenticación de dos factores es obligatoria", + "set-up-verification-method": "Configura un método de verificación para continuar", + "set-up-verification-method-login": "Configura un método de verificación o inicia sesión", + "enable-authenticator-app": "Activar aplicación de autenticación", + "enable-authenticator-app-description": "Introduce el código de seguridad de tu aplicación de autenticación", + "enable-authenticator-sms": "Activar autenticación por SMS", + "enable-authenticator-sms-description": "Introduce el código de 6 dígitos que acabamos de enviar a ", + "enable-authenticator-email": "Activar autenticación por Email", + "enable-authenticator-email-description": "Se ha enviado un código de seguridad a tu dirección de email en ", + "enter-key-manually": "o introduce manualmente esta clave de 32 dígitos:", + "continue": "Continuar", + "confirm": "Confirmar", + "authenticator-app-success": "Aplicación de autenticación activada correctamente", + "authenticator-app-success-description": "La próxima vez que inicies sesión, tendrás que proporcionar un código de autenticación de dos factores", + "authenticator-sms-success": "Autenticación por SMS activada correctamente", + "authenticator-sms-success-description": "La próxima vez que inicies sesión, se te pedirá que introduzcas el código de seguridad que se enviará al número de teléfono", + "authenticator-email-success": "Autenticación por Email activada correctamente", + "authenticator-email-success-description": "La próxima vez que inicies sesión, se te pedirá que introduzcas el código de seguridad que se enviará a tu dirección de email", + "authenticator-backup-code-success": "Código de respaldo activado correctamente", + "authenticator-backup-code-success-description": "La próxima vez que inicies sesión, se te pedirá que introduzcas el código de seguridad o que uses uno de los códigos de respaldo.", + "add-verification-method": "Añadir método de verificación", + "get-backup-code": "Obtener código de respaldo", + "copy-key": "Copiar clave", + "send-code": "Enviar código", + "email-label": "Email", + "email-description": "Introduce un email para usarlo como autenticador.", + "sms-description": "Introduce un número de teléfono para usarlo como autenticador.", + "backup-code-description": "Imprime los códigos para tenerlos a mano cuando necesites usarlos para iniciar sesión en tu cuenta. Puedes usar cada código de respaldo una sola vez.", + "backup-code-warn": "Una vez que salgas de esta página, estos códigos no podrán mostrarse de nuevo. Guárdalos de forma segura usando las opciones de abajo.", + "download-txt": "Descargar (txt)", + "print": "Imprimir", + "verification-code": "Código de 6 dígitos", + "verification-code-invalid": "Formato de código de verificación no válido", + "verification-code-incorrect": "El código de verificación es incorrecto", + "verification-code-many-request": "Demasiadas solicitudes para comprobar el código de verificación", + "scan-qr-code": "Escanea este código QR con tu aplicación de verificación", + "phone-input": { + "phone-input-label": "Número de teléfono", + "phone-input-required": "El número de teléfono es obligatorio", + "phone-input-validation": "El número de teléfono no es válido o no es posible", + "phone-input-pattern": "Número de teléfono no válido. Debe estar en formato E.164, p. ej. {{phoneNumber}}", + "phone-input-hint": "Número de teléfono en formato E.164, p. ej. {{phoneNumber}}" + } }, "mobile": { "add-application": "Agregar aplicación", @@ -4165,7 +4717,8 @@ "api-usage-limit": "Límite de uso de API", "device-activity": "Actividad del dispositivo", "entities-limit": "Límite de entidades", - "entity-action": "Acción sobre entidad", + "entities-limit-increase-request": "Solicitud de aumento del límite de entidades", + "entity-action": "Acción de entidad", "general": "General", "rule-engine-lifecycle-event": "Evento del ciclo de vida del motor de reglas", "rule-node": "Nodo de regla", @@ -4382,6 +4935,12 @@ "at-least": "Al menos:", "character": "{ count, plural, =1 {1 carácter} other {# caracteres} }", "digit": "{ count, plural, =1 {1 dígito} other {# dígitos} }", + "password-tooltip-min-length": "Al menos {{minimumLength}} caracteres", + "password-tooltip-max-length": "Como máximo {{maximumLength}} caracteres", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} carácter en mayúscula", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} carácter en minúscula", + "password-tooltip-digit": "{{minimumDigits}} número", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} carácter especial", "incorrect-password-try-again": "Contraseña incorrecta. Inténtalo de nuevo", "lowercase-letter": "{ count, plural, =1 {1 letra minúscula} other {# letras minúsculas} }", "new-passwords-not-match": "La nueva contraseña no coincide", @@ -4440,7 +4999,8 @@ "additional-info": "Información adicional (JSON)", "invalid-additional-info": "No se pudo analizar el JSON de información adicional.", "no-relations-text": "No se encontraron relaciones", - "not": "No" + "not": "No", + "copy-type": "Copiar tipo" }, "resource": { "add": "Agregar recurso", @@ -4477,7 +5037,8 @@ "jks": "JKS", "js-module": "Módulo JS", "lwm2m-model": "Modelo LWM2M", - "pkcs-12": "PKCS #12" + "pkcs-12": "PKCS #12", + "general": "General" }, "resource-sub-type": "Subtipo", "sub-type": { @@ -4485,7 +5046,12 @@ "scada-symbol": "Símbolo Scada", "extension": "Extensión", "module": "Módulo" - } + }, + "resource-is-in-use": "El recurso se usa en otras entidades", + "resources-are-in-use": "Los recursos se usan en otras entidades", + "resource-is-in-use-text": "El recurso '{{title}}' no se eliminó porque lo usan las siguientes entidades:", + "resources-are-in-use-text": "No se han eliminado todos los recursos porque los usan otras entidades.
Puedes ver las entidades referenciadas haciendo clic en el botón Referencias de la fila del recurso correspondiente.
Si aun así quieres eliminar estos recursos, selecciónalos en la tabla de abajo y haz clic en el botón Eliminar seleccionados.", + "delete-resource-in-use-text": "Si aun así quieres eliminar el recurso, haz clic en el botón Eliminar de todos modos." }, "javascript": { "add": "Agregar recurso JavaScript", @@ -5385,7 +5951,7 @@ "time-series": "Serie temporal", "latest": "Últimos valores", "web-sockets": "WebSockets", - "calculated-fields": "Campos calculados" + "calculated-fields-and-alarm-rules": "Campos calculados y reglas de alarma" }, "save-attribute": { "processing-settings": "Configuraciones de procesamiento", @@ -5435,11 +6001,11 @@ "prompt-settings": "Configuración del prompt", "prompt-settings-hint": "El prompt del sistema (opcional) define el rol general y las restricciones de la IA, mientras que el prompt del usuario define la tarea específica a realizar. Ambos campos admiten plantillas.", "system-prompt": "Prompt del sistema", - "system-prompt-max-length": "El prompt del sistema debe tener 500.000 caracteres o menos.", + "system-prompt-max-length": "El prompt del sistema debe tener 10.000 caracteres o menos.", "system-prompt-blank": "El prompt del sistema no debe estar vacío.", "user-prompt": "Prompt del usuario", "user-prompt-required": "Se requiere el prompt del usuario.", - "user-prompt-max-length": "El prompt del usuario debe tener 500.000 caracteres o menos.", + "user-prompt-max-length": "El prompt del usuario debe tener 10.000 caracteres o menos.", "user-prompt-blank": "El prompt del usuario no debe estar vacío.", "response-format": "Formato de respuesta", "response-text": "Texto", @@ -5456,7 +6022,8 @@ "timeout-required": "Se requiere un tiempo de espera", "timeout-validation": "Debe ser de entre 1 segundo y 10 minutos.", "force-acknowledgement": "Forzar acuse de recibo", - "force-acknowledgement-hint": "Si está habilitado, el mensaje entrante se reconoce inmediatamente. La respuesta del modelo se coloca en cola como un mensaje nuevo y separado." + "force-acknowledgement-hint": "Si está habilitado, el mensaje entrante se reconoce inmediatamente. La respuesta del modelo se coloca en cola como un mensaje nuevo y separado.", + "ai-resources": "Recursos de IA" } }, "timezone": { @@ -5579,7 +6146,8 @@ "bad-request-params": "Parámetros de solicitud incorrectos", "item-not-found": "Elemento no encontrado", "too-many-requests": "Demasiadas solicitudes", - "too-many-updates": "Demasiadas actualizaciones" + "too-many-updates": "Demasiadas actualizaciones", + "entities-limit-exceeded": "Se ha superado el límite de entidades" }, "tenant": { "tenant": "Tenant", @@ -5717,6 +6285,27 @@ "max-arguments-per-cf": "Número máximo de argumentos por campo calculado", "max-arguments-per-cf-range": "El número máximo de argumentos por campo calculado no puede ser negativo", "max-arguments-per-cf-required": "Se requiere el número máximo de argumentos por campo calculado", + "max-related-level-per-argument": "Nivel máximo de relación por argumento de 'Entidades relacionadas'", + "max-related-level-per-argument-range": "El número máximo del nivel de relación por argumento de 'Entidades relacionadas' no puede ser inferior a '1'", + "max-related-level-per-argument-required": "El número máximo del nivel de relación por argumento de 'Entidades relacionadas' es obligatorio", + "min-allowed-scheduled-update-interval": "Intervalo mínimo de actualización permitido para argumentos de 'Entidades relacionadas' (segundos)", + "min-allowed-scheduled-update-interval-range": "El número mínimo del intervalo mínimo de actualización permitido no puede ser negativo", + "min-allowed-deduplication-interval": "Intervalo mínimo de deduplicación permitido (segundos)", + "min-allowed-deduplication-interval-range": "El valor del intervalo mínimo de deduplicación permitido no puede ser negativo", + "min-allowed-deduplication-interval-required": "El intervalo mínimo de deduplicación permitido es obligatorio", + "intermediate-aggregation-interval": "Intervalo de agregación intermedia (segundos)", + "intermediate-aggregation-interval-range": "El valor del intervalo de agregación intermedia no puede ser inferior a '1'", + "intermediate-aggregation-interval-required": "El intervalo de agregación intermedia es obligatorio", + "reevaluation-check-interval": "Intervalo de comprobación de reevaluación (segundos)", + "reevaluation-check-interval-range": "El valor del intervalo de comprobación de reevaluación no puede ser inferior a '1'", + "reevaluation-check-interval-required": "El intervalo de comprobación de reevaluación es obligatorio", + "alarms-reevaluation-interval": "Intervalo de reevaluación de alarmas (segundos)", + "alarms-reevaluation-interval-range": "El valor del intervalo de reevaluación de alarmas no puede ser inferior a '1'", + "alarms-reevaluation-interval-required": "El intervalo de reevaluación de alarmas es obligatorio", + "min-allowed-aggregation-interval": "Intervalo mínimo de agregación permitido (segundos)", + "min-allowed-aggregation-interval-range": "El valor del intervalo mínimo de agregación permitido no puede ser negativo", + "min-allowed-aggregation-interval-required": "El intervalo mínimo de agregación permitido es obligatorio", + "min-allowed-scheduled-update-interval-required": "El número mínimo del intervalo mínimo de actualización permitido es obligatorio", "max-state-size": "Tamaño máximo del estado en KB", "max-state-size-range": "El tamaño máximo del estado en KB no puede ser negativo", "max-state-size-required": "Se requiere el tamaño máximo del estado en KB", @@ -5792,6 +6381,10 @@ "ws-limit-max-subscriptions-per-regular-user": "Número máximo de suscripciones por usuario regular", "ws-limit-max-subscriptions-per-public-user": "Número máximo de suscripciones por usuario público", "ws-limit-updates-per-session": "Actualizaciones WS por sesión", + "relation-search-entity-limit": "Límite de entidades en la búsqueda de relaciones", + "relation-search-entity-limit-hint": "Limita el número de entidades resueltas en el último nivel de la ruta de relación. Se aplica a los argumentos de 'Entidades relacionadas' y a los campos de propagación.", + "relation-search-entity-limit-required": "El límite de entidades en la búsqueda de relaciones es obligatorio", + "relation-search-entity-limit-range": "El límite de entidades en la búsqueda de relaciones no puede ser inferior a '1'", "rate-limits": { "add-limit": "Agregar límite", "and-also-less-than": "y también menor que", @@ -5977,7 +6570,9 @@ "default-agg-interval": "Intervalo de agrupación predeterminado", "edit-intervals-list-hint": "Se puede especificar una lista de opciones de intervalo disponibles.", "edit-grouping-intervals-list-hint": "Es posible configurar la lista de intervalos de agrupación y el intervalo de agrupación predeterminado.", - "all": "Todos" + "all": "Todos", + "save-current-settings-as-default": "Guardar la configuración actual como ventana de tiempo predeterminada", + "hide-option-from-end-users": "Ocultar opción a los usuarios finales" }, "tooltip": { "trigger": "Disparador", @@ -5989,6 +6584,7 @@ "show-date-time-interval": "Mostrar intervalo de fecha y hora", "show-date-time-interval-hint": "Mostrar intervalo de fecha y hora según la agregación de datos.", "hide-zero-tooltip-values": "Ocultar valores cero", + "show-stack-total": "Mostrar el valor total en modo apilado", "background-color": "Color de fondo", "background-blur": "Desenfoque de fondo" }, @@ -6630,7 +7226,8 @@ "export-relations": "Exportar relaciones", "export-attributes": "Exportar atributos", "export-credentials": "Exportar credenciales", - "export-calculated-fields": "Exportar campos calculados", + "export-calculated-fields": "Exportar campos calculados \ny reglas de alarma", + "export-alarm-rules": "Exportar reglas de alarma", "entity-versions": "Versiones de entidad", "versions": "Versiones", "created-time": "Fecha de creación", @@ -6648,6 +7245,7 @@ "load-attributes": "Cargar atributos", "load-credentials": "Cargar credenciales", "load-calculated-fields": "Cargar campos calculados", + "load-alarm-rules": "Cargar reglas de alarma", "compare-with-current": "Comparar con actual", "diff-entity-with-version": "Diferencias con versión de entidad '{{versionName}}'", "previous-difference": "Diferencia anterior", @@ -6702,7 +7300,7 @@ "management": "Gestión de widgets", "editor": "Editor de widgets", "confirm-to-exit-editor-html": "Tienes configuraciones del widget sin guardar.
¿Estás seguro de que deseas salir de esta página?", - "widget-type-not-found": "Problema al cargar la configuración del widget.
Probablemente se haya eliminado el tipo de widget asociado.", + "widget-type-not-found": "Problema al cargar la configuración del widget.
Probablemente se haya\n eliminado el tipo de widget asociado.", "widget-type-load-error": "El widget no se cargó debido a los siguientes errores:", "remove": "Eliminar widget", "delete": "Eliminar widget", @@ -6857,7 +7455,23 @@ "scan-qr-code": "Escanear código QR", "make-phone-call": "Hacer llamada telefónica", "get-location": "Obtener ubicación del teléfono", - "take-screenshot": "Tomar captura de pantalla" + "take-screenshot": "Tomar captura de pantalla", + "handle-provision-success-function": "Gestionar función de aprovisionamiento correcto", + "get-location-function": "Obtener función de ubicación", + "process-launch-result-function": "Procesar función de resultado de inicio", + "get-phone-number-function": "Obtener función de número de teléfono", + "process-image-function": "Procesar función de imagen", + "process-qr-code-function": "Procesar función de código QR", + "process-location-function": "Procesar función de ubicación", + "handle-empty-result-function": "Gestionar función de resultado vacío", + "handle-error-function": "Gestionar función de error", + "handle-non-mobile-fallback-function": "Gestionar función de alternativa para no móvil", + "save-to-gallery": "Guardar en la 'Galería de imágenes'", + "provision-type": "Tipo de aprovisionamiento", + "auto": "Automático", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" }, "custom-action-function": "Función de acción personalizada", "custom-pretty-function": "Función de acción personalizada (con plantilla HTML)", @@ -6866,7 +7480,8 @@ "marker": "Marcador", "polygon": "Polígono", "rectangle": "Rectángulo", - "circle": "Círculo" + "circle": "Círculo", + "polyline": "Polilínea" }, "place-map-item": "Colocar elemento en el mapa", "map-item-tooltip": { @@ -6878,7 +7493,9 @@ "continue-draw-polygon": "Continuar dibujo del polígono", "finish-draw-polygon": "Finalizar dibujo del polígono", "start-draw-circle": "Iniciar dibujo del círculo", - "finish-draw-circle": "Finalizar dibujo del círculo" + "finish-draw-circle": "Finalizar dibujo del círculo", + "start-draw-polyline": "Iniciar dibujo de polilínea", + "finish-draw-polyline": "Finalizar dibujo de polilínea" } }, "widgets-bundle": { @@ -7444,10 +8061,18 @@ "update-animation-delay": "Retardo de la animación de actualización" }, "chart-axis": { + "limit": "Límite", + "source": "Fuente", + "key-value": "Clave / Valor", + "value-required": "El valor es obligatorio.", + "entity-key-required": "La clave de entidad es obligatoria.", + "key-required": "La clave es obligatoria.", + "scale-limits": "Límites de escala", + "scale-appearance": "Apariencia de escala", "scale": "Escala", "scale-min": "mín", "scale-max": "máx", - "scale-auto": "Auto" + "scale-auto": "Automático" }, "bar": { "show-border": "Mostrar borde", @@ -7988,7 +8613,10 @@ "add-radio-option": "Agregar opción de radio", "radio-label-position": "Posición de la etiqueta", "radio-label-position-before": "Antes", - "radio-label-position-after": "Después" + "radio-label-position-after": "Después", + "save-image": "Guardar imagen", + "save-to-gallery": "Guardar automáticamente las imágenes capturadas en la Galería de imágenes", + "public-image": "Hace la imagen disponible para cualquier usuario no autorizado" }, "invalid-qr-code-text": "Texto de entrada no válido para el código QR. La entrada debe ser de tipo cadena", "qr-code": { @@ -8283,7 +8911,8 @@ "trips": "Viajes", "markers": "Marcadores", "polygons": "Polígonos", - "circles": "Círculos" + "circles": "Círculos", + "polylines": "Polilíneas" }, "data-layer": { "source": "Fuente", @@ -8480,8 +9109,27 @@ "finish-circle-hint-with-entity": "Círculo para '{{entityName}}': haz clic para finalizar y guardar el círculo", "finish-circle-hint": "Círculo: haz clic para finalizar el dibujo" }, + "polyline": { + "polyline-key": "Clave de polilínea", + "polyline-key-required": "Clave de polilínea obligatoria", + "no-polylines": "No hay polilíneas configuradas", + "add-polylines": "Añadir polilínea", + "polyline-configuration": "Configuración de polilínea", + "remove-polyline": "Eliminar polilínea", + "edit": "Editar polilínea", + "cut": "Cortar área de polilínea", + "rotate": "Rotar polilínea", + "remove-polyline-for": "Eliminar polilínea para '{{entityName}}'", + "draw-polyline": "Dibujar polilínea", + "polyline-place-first-point-hint-with-entity": "Polilínea para '{{entityName}}': haz clic para colocar el primer punto", + "polyline-place-first-point-hint": "Polilínea: haz clic para colocar el primer punto", + "finish-polyline-hint-with-entity": "Polilínea para '{{entityName}}': haz clic para terminar de dibujar", + "finish-polyline-hint": "Polilínea: haz clic para terminar de dibujar", + "polyline-place-first-point-cut-hint": "Haz clic para colocar el primer punto", + "finish-polyline-cut-hint": "Haz clic en el primer marcador para finalizar y guardar" + }, "select-entity": "Seleccionar entidad", - "select-entity-hint": "Sugerencia: después de seleccionar, haz clic en el mapa para establecer la posición" + "select-entity-hint": "Sugerencia: después de la selección haz clic en el mapa para establecer la posición" }, "select-entity": "Seleccionar entidad", "select-entity-hint": "Sugerencia: después de seleccionar, haz clic en el mapa para establecer la posición", @@ -8921,6 +9569,7 @@ "show-empty-space-hidden-action": "Mostrar espacio vacío en lugar del botón de acción oculto", "dont-reserve-space-hidden-action": "No reservar espacio para botones de acción ocultos", "display-timestamp": "Marca de tiempo", + "timestamp-column-name": "Marca de tiempo", "display-pagination": "Mostrar paginación", "default-page-size": "Tamaño de página predeterminado", "page-step-settings": "Configuraciones de pasos de página", @@ -8982,7 +9631,9 @@ "alarm-column-error": "Debe especificarse al menos una columna de alarma", "table-tabs": "Pestañas de la tabla", "show-cell-actions-menu-mobile": "Mostrar menú desplegable de acciones de celda en modo móvil", - "disable-sorting": "Deshabilitar ordenamiento" + "disable-sorting": "Deshabilitar ordenamiento", + "sort-by": "Ordenar pestañas por", + "sort-timestamp-option": "Hora de creación" }, "latest-chart": { "total": "Total", @@ -9474,11 +10125,28 @@ "content": "

Al crear tableros para el usuario final, un usuario customer solo podrá ver sus propios dispositivos y los datos de otro customer estarán ocultos.

Sigue la documentación para saber cómo hacerlo:

" } } + }, + "api-usage": { + "api-usage": "Uso de la API", + "label": "Etiqueta", + "state-name": "Nombre del estado", + "status": "Estado", + "status-required": "El estado es obligatorio.", + "limit": "Límite máximo", + "limit-required": "El límite máximo es obligatorio.", + "current-number": "Número actual", + "current-number-required": "El número actual es obligatorio.", + "add-key": "Añadir clave", + "no-key": "Sin clave", + "delete-key": "Eliminar clave", + "target-dashboard-state": "Estado del tablero objetivo", + "go-to-main-state": "Ir a la vista predeterminada" } }, "icon": { "icon": "Icono", "icons": "Iconos", + "custom": "Personalizado", "select-icon": "Seleccionar icono", "material-icons": "Iconos de Material", "show-all": "Mostrar todos los iconos", @@ -9519,6 +10187,7 @@ "items-per-page-separator": "de" }, "language": { + "auto": "Auto", "language": "Idioma" } -} +} \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json index 989b252cc2..15fa9b5586 100644 --- a/ui-ngx/src/assets/locale/locale.constant-fr_FR.json +++ b/ui-ngx/src/assets/locale/locale.constant-fr_FR.json @@ -803,7 +803,7 @@ "no-assets-matching": "Aucun actif correspondant à '{{entity}}' trouvé.", "asset-required": "L'actif est requis", "name-starts-with": "Expression du nom de l'actif", - "help-text": "Utilisez '%' selon le besoin : '%nom_actif_contient%', '%nom_actif_termine', 'actif_commence_par'.", + "help-text": "Utilisez '%' selon vos besoins : '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Rechercher des actifs", "import": "Importer des actifs", "asset-file": "Fichier d'actifs", @@ -910,9 +910,9 @@ "processing-timeouts": "Expirations de traitement ${entityName}", "rule-chain": "Chaîne de règles", "rule-engine": "Moteur de règles", - "rule-engine-daily-activity": "Activité quotidienne du moteur de règles", "rule-engine-executions": "Exécutions du moteur de règles", "rule-engine-hourly-activity": "Activité horaire du moteur de règles", + "rule-engine-daily-activity": "Activité quotidienne du moteur de règles", "rule-engine-monthly-activity": "Activité mensuelle du moteur de règles", "rule-engine-statistics": "Statistiques du moteur de règles", "rule-node": "Nœud de règle", @@ -1095,6 +1095,7 @@ "expression": "Expression", "no-found": "Aucun champ calculé trouvé", "list": "{ count, plural, =1 {Un champ calculé} other {Liste de # champs calculés} }", + "add": "Ajouter un champ calculé", "selected-fields": "{ count, plural, =1 {1 champ calculé} other {# champs calculés} } sélectionné(s)", "type": { "simple": "Simple", @@ -2124,7 +2125,7 @@ "remove-alias": "Supprimer l'alias de l'appareil", "add-alias": "Ajouter un alias d'appareil", "name-starts-with": "Expression du nom de l'appareil", - "help-text": "Utilisez '%' selon le besoin : '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Utilisez '%' selon vos besoins : '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Liste des appareils", "use-device-name-filter": "Utiliser un filtre", "device-list-empty": "Aucun appareil sélectionné.", @@ -3039,7 +3040,7 @@ "no-entities-text": "Aucune entité trouvée", "no-entity-types-matching": "Aucun type d'entité correspondant à '{{entityType}}' n'a été trouvé.", "name-starts-with": "Expression du nom", - "help-text": "Utilisez '%' selon le besoin : '%nom_entité_contient%', '%nom_entité_se_termine', 'entité_commence_par'.", + "help-text": "Utilisez '%' selon vos besoins : '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Utiliser le filtre", "entity-list-empty": "Aucune entité sélectionnée.", "entity-type-list-required": "Au moins un type d'entité doit être sélectionné.", @@ -3242,7 +3243,7 @@ "remove-alias": "Supprimer l'alias de la vue d'entité", "add-alias": "Ajouter un alias de vue d'entité", "name-starts-with": "Expression du nom de la vue d'entité", - "help-text": "Utilisez '%' selon le besoin : '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Utilisez '%' selon vos besoins : '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Liste des vues d'entité", "use-entity-view-name-filter": "Utiliser un filtre", "entity-view-list-empty": "Aucune vue d'entité sélectionnée.", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "Gérer la fonction de résultat vide", "handle-error-function": "Gérer la fonction d’erreur", "handle-non-mobile-fallback-function": "Gérer la fonction de repli non mobile", - "save-to-gallery": "Enregistrer dans la galerie", + "save-to-gallery": "Enregistrer dans la 'Galerie d'images'", "provision-type": "Type de provisionnement", "auto": "Auto", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-hi_IN.json b/ui-ngx/src/assets/locale/locale.constant-hi_IN.json index 864a229e5d..36b77be8c6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-hi_IN.json +++ b/ui-ngx/src/assets/locale/locale.constant-hi_IN.json @@ -78,6 +78,7 @@ "show-more": "और दिखाएँ", "dont-show-again": "दोबारा न दिखाएँ", "see-documentation": "दस्तावेज़ देखें", + "see-debug-events": "डिबग घटनाएँ देखें", "clear": "साफ़ करें", "upload": "अपलोड करें", "delete-anyway": "फिर भी हटाएँ", @@ -485,6 +486,7 @@ "2fa": { "2fa": "दो-कारक प्रमाणीकरण", "available-providers": "उपलब्ध प्रदाता", + "available-providers-required": "कम से कम एक 2FA प्रदाता को कॉन्फ़िगर किया जाना चाहिए।", "issuer-name": "जारीकर्ता नाम", "issuer-name-required": "जारीकर्ता नाम आवश्यक है।", "max-verification-failures-before-user-lockout": "उपयोगकर्ता लॉक होने से पहले अधिकतम सत्यापन विफलताएँ", @@ -513,7 +515,9 @@ "verification-message-template-required": "सत्यापन संदेश टेम्पलेट आवश्यक है।", "within-time": "समय के भीतर", "within-time-pattern": "समय धनात्मक पूर्णांक होना चाहिए।", - "within-time-required": "समय आवश्यक है।" + "within-time-required": "समय आवश्यक है।", + "force-2fa": "दो-कारक प्रमाणीकरण लागू करें", + "enforce-for": "के लिए लागू करें" }, "jwt": { "security-settings": "JWT सुरक्षा सेटिंग्स", @@ -545,16 +549,11 @@ "slack-settings": "Slack सेटिंग्स", "mobile-settings": "मोबाइल सेटिंग्स", "firebase-service-account-file": "Firebase सर्विस अकाउंट क्रेडेंशियल्स JSON फ़ाइल", - "select-firebase-service-account-file": "अपनी Firebase सर्विस अकाउंट क्रेडेंशियल्स फ़ाइल को ड्रैग और ड्रॉप करें या ", - "trendz": "Trendz", - "trendz-settings": "Trendz सेटिंग्स", - "trendz-url": "Trendz URL", - "trendz-url-required": "Trendz URL आवश्यक है", - "trendz-api-key": "Trendz API की", - "trendz-enable": "Trendz सक्षम करें" + "select-firebase-service-account-file": "अपनी Firebase सर्विस अकाउंट क्रेडेंशियल्स फ़ाइल को ड्रैग और ड्रॉप करें या " }, "alarm": { "alarm": "अलार्म", + "alarm-list": "अलार्म सूची", "alarms": "अलार्म", "all-alarms": "सभी अलार्म", "select-alarm": "अलार्म चुनें", @@ -655,7 +654,16 @@ "alarm-type": "अलार्म प्रकार", "enter-alarm-type": "अलार्म प्रकार दर्ज करें", "no-alarm-types-matching": "'{{entitySubtype}}' से मेल खाता कोई अलार्म प्रकार नहीं मिला।", - "alarm-type-list-empty": "कोई अलार्म प्रकार चुना नहीं गया है।" + "alarm-type-list-empty": "कोई अलार्म प्रकार चुना नहीं गया है।", + "system-comments": { + "acked-by-user": "अलार्म को उपयोगकर्ता {{userName}} द्वारा स्वीकार किया गया", + "cleared-by-user": "अलार्म को उपयोगकर्ता {{userName}} द्वारा क्लियर किया गया", + "assigned-to-user": "अलार्म को उपयोगकर्ता {{userName}} द्वारा उपयोगकर्ता {{assigneeName}} को असाइन किया गया", + "unassigned-to-user": "अलार्म को उपयोगकर्ता {{userName}} द्वारा असाइन हटाया गया", + "unassigned-from-deleted-user": "अलार्म को असाइन हटाया गया क्योंकि उपयोगकर्ता {{userName}} को हटा दिया गया था", + "comment-deleted": "उपयोगकर्ता {{userName}} ने अपनी टिप्पणी हटा दी", + "severity-changed": "अलार्म की गंभीरता को {{oldSeverity}} से {{newSeverity}} में अपडेट किया गया" + } }, "alarm-activity": { "add": "टिप्पणी जोड़ें...", @@ -760,6 +768,7 @@ "name-max-length": "नाम 256 अक्षरों से कम होना चाहिए", "label-max-length": "लेबल 256 अक्षरों से कम होना चाहिए", "description": "विवरण", + "description-required": "विवरण आवश्यक है।", "type": "प्रकार", "type-required": "प्रकार आवश्यक है।", "details": "विवरण", @@ -794,7 +803,7 @@ "no-assets-matching": "'{{entity}}' से मेल खाता कोई एसेट नहीं मिला।", "asset-required": "एसेट आवश्यक है।", "name-starts-with": "एसेट नाम अभिव्यक्ति", - "help-text": "ज़रूरत के अनुसार '%' का उपयोग करें: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "आवश्यकतानुसार '%' का उपयोग करें: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "एसेट खोजें", "import": "एसेट आयात करें", "asset-file": "एसेट फ़ाइल", @@ -873,6 +882,9 @@ "alarms-created-monthly-activity": "बनाए गए अलार्म की मासिक गतिविधि", "data-points": "डेटा पॉइंट", "data-points-storage-days": "डेटा पॉइंट भंडारण (दिनों में)", + "data-points-storage-days-hourly-activity": "डेटा पॉइंट्स भंडारण दिनों की प्रति घंटा सक्रियता", + "data-points-storage-days-daily-activity": "डेटा पॉइंट्स भंडारण दिनों की दैनिक सक्रियता", + "data-points-storage-days-monthly-activity": "डेटा पॉइंट्स भंडारण दिनों की मासिक सक्रियता", "device-api": "डिवाइस API", "email": "ईमेल", "email-messages": "ईमेल संदेश", @@ -898,14 +910,15 @@ "processing-timeouts": "${entityName} प्रोसेसिंग टाइमआउट", "rule-chain": "रूल चेन", "rule-engine": "रूल इंजन", - "rule-engine-daily-activity": "रूल इंजन की दैनिक सक्रियता", "rule-engine-executions": "रूल इंजन निष्पादन", "rule-engine-hourly-activity": "रूल इंजन की प्रति घंटा सक्रियता", + "rule-engine-daily-activity": "रूल इंजन की दैनिक सक्रियता", "rule-engine-monthly-activity": "रूल इंजन की मासिक सक्रियता", "rule-engine-statistics": "रूल इंजन सांख्यिकी", "rule-node": "रूल नोड", "sms": "एसएमएस", "sms-messages": "एसएमएस संदेश", + "sms-messages-hourly-activity": "एसएमएस संदेशों की प्रति घंटा सक्रियता", "sms-messages-daily-activity": "एसएमएस संदेशों की दैनिक सक्रियता", "sms-messages-monthly-activity": "एसएमएस संदेशों की मासिक सक्रियता", "successful": "${entityName} सफल", @@ -915,11 +928,40 @@ "telemetry-persistence-hourly-activity": "टेलीमेट्री परसिस्टेंस प्रति घंटा गतिविधि", "telemetry-persistence-monthly-activity": "टेलीमेट्री परसिस्टेंस मासिक गतिविधि", "transport": "ट्रांसपोर्ट", + "transport-msg-hourly-activity": "ट्रांसपोर्ट संदेशों की प्रति घंटा सक्रियता", + "transport-msg-daily-activity": "ट्रांसपोर्ट संदेशों की दैनिक सक्रियता", + "transport-msg-monthly-activity": "ट्रांसपोर्ट संदेशों की मासिक सक्रियता", "transport-daily-activity": "ट्रांसपोर्ट की दैनिक सक्रियता", "transport-data-points": "ट्रांसपोर्ट डेटा पॉइंट्स", - "transport-messages": "ट्रांसपोर्ट संदेश", + "transport-data-points-hourly-activity": "ट्रांसपोर्ट डेटा पॉइंट्स की प्रति घंटा सक्रियता", + "transport-data-points-daily-activity": "ट्रांसपोर्ट डेटा पॉइंट्स की दैनिक सक्रियता", + "transport-data-points-monthly-activity": "ट्रांसपोर्ट डेटा पॉइंट्स की मासिक सक्रियता", "view-details": "विवरण देखें", - "view-statistics": "सांख्यिकी देखें" + "view-statistics": "सांख्यिकी देखें", + "transport-messages": "ट्रांसपोर्ट संदेश", + "transport-messages-hourly-activity": "ट्रांसपोर्ट संदेशों की प्रति घंटा सक्रियता", + "transport-data-point-hourly-activity": "ट्रांसपोर्ट डेटा पॉइंट की प्रति घंटा सक्रियता", + "javascript-function-executions": "JavaScript फ़ंक्शन निष्पादन", + "javascript-function-executions-hourly-activity": "JavaScript फ़ंक्शन निष्पादन की प्रति घंटा सक्रियता", + "javascript-function-executions-daily-activity": "JavaScript फ़ंक्शन निष्पादन की दैनिक सक्रियता", + "javascript-function-executions-monthly-activity": "JavaScript फ़ंक्शन निष्पादन की मासिक सक्रियता", + "tbel-function-executions": "TBEL फ़ंक्शन निष्पादन", + "tbel-function-executions-hourly-activity": "TBEL फ़ंक्शन निष्पादन की प्रति घंटा सक्रियता", + "tbel-function-executions-daily-activity": "TBEL फ़ंक्शन निष्पादन की दैनिक सक्रियता", + "tbel-function-executions-monthly-activity": "TBEL फ़ंक्शन निष्पादन की मासिक सक्रियता", + "created-reports": "बनाए गए रिपोर्ट्स", + "created-reports-hourly-activity": "बनाए गए रिपोर्ट्स की प्रति घंटा सक्रियता", + "created-reports-daily-activity": "बनाए गए रिपोर्ट्स की दैनिक सक्रियता", + "created-reports-monthly-activity": "बनाए गए रिपोर्ट्स की मासिक सक्रियता", + "emails": "ईमेल", + "emails-hourly-activity": "ईमेल की प्रति घंटा सक्रियता", + "emails-daily-activity": "ईमेल की दैनिक सक्रियता", + "emails-monthly-activity": "ईमेल की मासिक सक्रियता", + "status": { + "enabled": "सक्षम", + "disabled": "निष्क्रिय", + "warning": "चेतावनी" + } }, "api-limit": { "cassandra-write-queries-core": "Rest API Cassandra लिखने की क्वेरीज़", @@ -944,6 +986,40 @@ "edge-uplink-messages": "Edge uplink संदेश", "edge-uplink-messages-per-edge": "प्रति Edge uplink संदेश" }, + "api-key": { + "api-key": "API कुंजी", + "api-keys": "API कुंजियाँ", + "delete-api-key-title": "क्या आप वाकई API कुंजी '{{name}}' को हटाना चाहते हैं?", + "delete-api-key-text": "सावधान रहें, पुष्टि के बाद कुंजी पुनः प्राप्त नहीं की जा सकेगी।", + "delete-api-keys-title": "क्या आप वाकई { count, plural, =1 {1 API कुंजी} other {# API कुंजियाँ} } हटाना चाहते हैं?", + "delete-api-keys-text": "सावधान रहें, पुष्टि के बाद सभी चयनित कुंजियाँ पुनः प्राप्त नहीं की जा सकेंगी।", + "expiration-date": "समाप्ति तिथि", + "date": "तिथि", + "description": "विवरण", + "disable": "निष्क्रिय करें", + "edit-description": "विवरण संपादित करें", + "enable": "API कुंजी सक्षम करें", + "expiration-time": "समाप्ति समय", + "expiration-time-never": "कभी नहीं", + "expiration-time-custom": "कस्टम", + "generate": "जनरेट करें", + "generate-title": "API कुंजी जनरेट करें", + "generate-text": "ध्यान दें: API कुंजी उस उपयोगकर्ता की अनुमति प्राप्त करती है जिसके लिए यह बनाई गई है।", + "generated-api-key-title": "API कुंजी जनरेट की गई। चलिए कनेक्टिविटी जांचते हैं!", + "generated-api-key-copy": "सुनिश्चित करें कि आप अपनी API कुंजी को अब कॉपी और सेव कर लें क्योंकि आप इसे फिर से नहीं देख पाएंगे।", + "generated-api-key-command": "कनेक्टिविटी जांचने के लिए निम्नलिखित निर्देशों का पालन करें। इसके परिणामस्वरूप, आपको वर्तमान उपयोगकर्ता जानकारी प्राप्त होनी चाहिए:", + "generated-api-key-insecure-url": "असुरक्षित HTTP कनेक्शन पर कमांड निष्पादित करने से आपकी API कुंजी बिना एन्क्रिप्ट के भेजी जाएगी, जिससे यह इंटरसेप्शन के लिए संवेदनशील हो जाएगी।", + "list": "{ count, plural, =1 {एक API कुंजी} other {# API कुंजियाँ} }", + "manage": "प्रबंधित करें", + "manage-api-keys": "API कुंजियों का प्रबंधन करें", + "no-found": "कोई API कुंजी नहीं मिली", + "selected-api-keys": "{ count, plural, =1 {1 API कुंजी} other {# API कुंजियाँ} } चयनित", + "search": "API कुंजियाँ खोजें", + "status": "स्थिति", + "status-active": "सक्रिय", + "status-inactive": "निष्क्रिय", + "status-expired": "समाप्त" + }, "audit-log": { "audit": "ऑडिट", "audit-logs": "ऑडिट लॉग", @@ -997,7 +1073,11 @@ "type-provision-failure": "डिवाइस प्रोविज़निंग विफल रही", "type-timeseries-updated": "टेलीमेट्री अपडेट की गई", "type-timeseries-deleted": "टेलीमेट्री हटाई गई", - "type-sms-sent": "SMS भेजा गया" + "type-sms-sent": "SMS भेजा गया", + "any-type": "कोई भी प्रकार", + "audit-log-filter-title": "ऑडिट लॉग फ़िल्टर", + "filter-title": "फ़िल्टर", + "filter-types": "ऑडिट लॉग प्रकार" }, "debug-settings": { "label": "डिबग कॉन्फ़िगरेशन", @@ -1015,15 +1095,29 @@ "expression": "व्यंजक", "no-found": "कोई कैलक्युलेटेड फ़ील्ड नहीं मिला", "list": "{ count, plural, =1 {एक कैलक्युलेटेड फ़ील्ड} other {# कैलक्युलेटेड फ़ील्ड्स की सूची} }", + "add": "कैलक्युलेटेड फ़ील्ड जोड़ें", "selected-fields": "{ count, plural, =1 {1 कैलक्युलेटेड फ़ील्ड} other {# कैलक्युलेटेड फ़ील्ड्स} } चुना गया", "type": { - "simple": "सिंपल", - "script": "स्क्रिप्ट" + "simple": "साधारण", + "simple-hint": "इनपुट तर्कों के आधार पर सरल अंकगणितीय गणना।", + "script": "स्क्रिप्ट", + "script-hint": "निर्धारित तर्कों पर TBEL स्क्रिप्ट का उपयोग करके गणना।", + "geofencing": "जियोफेंसिंग", + "geofencing-hint": "निर्धारित जियोफेंसिंग क्षेत्र समूहों के खिलाफ एंटिटी GPS स्थिति और संक्रमणों का मूल्यांकन।", + "propagation": "प्रसार", + "propagation-hint": "रिलेशन दिशा और प्रकार के आधार पर डेटा का पैरेंट या चाइल्ड एंटिटीज़ में प्रसार।", + "related-entities-aggregation": "संबंधित एंटिटीज़ का संकलन", + "related-entities-aggregation-hint": "संबंधित एंटिटीज़ से नवीनतम डेटा का संकलन।", + "time-series-data-aggregation": "समय श्रृंखला डेटा संकलन", + "time-series-data-aggregation-hint": "वर्तमान एंटिटी से ऐतिहासिक डेटा का संकलन।" }, + "preview": "पूर्वावलोकन", "arguments": "आर्गुमेंट्स", "decimals-by-default": "डिफ़ॉल्ट दशमलव", "debugging": "कैलक्युलेटेड फ़ील्ड डिबगिंग", + "calculated-field-details": "कैलक्युलेटेड फ़ील्ड विवरण", "argument-name": "आर्गुमेंट नाम", + "name": "नाम", "datasource": "डेटा स्रोत", "add-argument": "आर्गुमेंट जोड़ें", "test-script-function": "स्क्रिप्ट फ़ंक्शन टेस्ट करें", @@ -1036,6 +1130,7 @@ "argument-customer": "कस्टमर", "argument-tenant": "करेंट टेनेंट", "argument-owner": "करेंट ओनर", + "argument-relation-query": "संबंधित एंटिटीज़", "argument-type": "आर्गुमेंट प्रकार", "attribute": "विशेषता", "copy-argument-name": "आर्गुमेंट नाम कॉपी करें", @@ -1049,12 +1144,14 @@ "shared-attributes": "शेयर्ड विशेषताएँ", "attribute-key": "विशेषता कुंजी", "default-value": "डिफ़ॉल्ट मान", + "default-value-required": "डिफ़ॉल्ट मान आवश्यक है।", "limit": "अधिकतम मान", "time-window": "टाइम विंडो", "customer-name": "कस्टमर नाम", "asset-name": "एसेट नाम", "timeseries": "टाइम सीरीज़", "output": "आउटपुट", + "output-hint": "परिभाषित करता है कि आउटपुट को कैसे संसाधित किया जाता है।", "create": "नई कैलक्युलेटेड फ़ील्ड बनाएँ", "file": "कैलक्युलेटेड फ़ील्ड फ़ाइल", "invalid-file-error": "अमान्य फ़ाइल फ़ॉर्मेट। कृपया सुनिश्चित करें कि फ़ाइल एक मान्य JSON फ़ाइल है।", @@ -1068,9 +1165,175 @@ "delete-multiple-text": "सावधान रहें, पुष्टि के बाद सभी चुनी हुई कैलक्युलेटेड फ़ील्ड्स हटा दी जाएँगी और उनसे संबंधित सारा डेटा हमेशा के लिए मिटा दिया जाएगा।", "test-with-this-message": "इस संदेश के साथ परीक्षण करें", "use-latest-timestamp": "नवीनतम टाइमस्टैम्प का उपयोग करें", + "entity-coordinates": "एंटिटी निर्देशांक", + "latitude-time-series-key": "अक्षांश समय श्रृंखला कुंजी", + "latitude-time-series-key-required": "अक्षांश समय श्रृंखला कुंजी आवश्यक है।", + "longitude-time-series-key": "देशांतर समय श्रृंखला कुंजी", + "longitude-time-series-key-required": "देशांतर समय श्रृंखला कुंजी आवश्यक है।", + "geofencing-zone-groups": "जियोफेंसिंग क्षेत्र समूह", + "geofencing-zone-groups-settings": "जियोफेंसिंग क्षेत्र समूह सेटिंग्स", + "target-zone": "लक्ष्य क्षेत्र", + "perimeter-key": "परिधि कुंजी", + "report-strategy": "रिपोर्ट रणनीति", + "no-zone-configured": "कम से कम एक क्षेत्र आवश्यक है।", + "no-zone-configured-required": "कम से कम एक क्षेत्र समूह कॉन्फ़िगर किया जाना चाहिए।", + "add-zone-group": "क्षेत्र समूह जोड़ें", + "report-transition-event-only": "केवल संक्रमण घटनाएँ", + "report-presence-status-only": "केवल उपस्थिति स्थिति", + "report-transition-event-and-presence": "उपस्थिति स्थिति और संक्रमण घटनाएँ", + "perimeter-attribute-key": "परिधि विशेषता कुंजी", + "perimeter-attribute-key-required": "परिधि विशेषता कुंजी आवश्यक है।", + "perimeter-attribute-key-pattern": "परिधि विशेषता कुंजी अमान्य है।", + "entity-zone-relationship": "एंटिटी से क्षेत्रों तक पथ", + "direction": "रिलेशन दिशा", + "direction-from": "एंटिटी से क्षेत्र तक", + "direction-to": "क्षेत्र से एंटिटी तक", + "relation-type": "रिलेशन प्रकार", + "create-relation-with-matched-zones": "मैच किए गए क्षेत्रों के साथ स्रोत एंटिटी के लिए रिलेशन बनाएं", + "relation-level": "रिलेशन स्तर", + "fetch-last-available-level": "केवल अंतिम उपलब्ध स्तर प्राप्त करें", + "zone-group-refresh-interval": "क्षेत्र समूह रिफ्रेश अंतराल", + "copy-zone-group-name": "क्षेत्र समूह का नाम कॉपी करें", + "open-details-page": "एंटिटी विवरण पृष्ठ खोलें", + "level": "स्तर", + "direction-level": "दिशा", + "direction-up": "ऊपर", + "direction-up-parent": "माता-पिता तक ऊपर", + "direction-down": "नीचे", + "direction-down-child": "बच्चे तक नीचे", + "add-level": "स्तर जोड़ें", + "delete-level": "स्तर हटाएं", + "no-level": "कोई स्तर कॉन्फ़िगर नहीं किया गया", + "levels-required": "कम से कम एक स्तर कॉन्फ़िगर किया जाना चाहिए।", + "max-allowed-levels-error": "रिलेशन स्तर अधिकतम अनुमत सीमा से अधिक है।", + "propagation-path-related-entities": "संबंधित एंटिटीज़ तक प्रसार पथ", + "propagate-type": { + "arguments-only": "केवल तर्क", + "expression-result": "गणना परिणाम" + }, + "script": "स्क्रिप्ट", + "data-propagate": "प्रसारित करने के लिए डेटा", + "output-key": "आउटपुट कुंजी", + "copy-output-key": "आउटपुट कुंजी कॉपी करें", + "aggregation-path-related-entities": "संबंधित एंटिटीज़ तक संकलन पथ", + "deduplication-interval": "डेडुप्लिकेशन अंतराल", + "deduplication-interval-min": "डेडुप्लिकेशन अंतराल कम से कम {{ sec }} सेकंड होना चाहिए।", + "deduplication-interval-hint": "टेलीमेट्री संकलनों के बीच न्यूनतम समय।", + "deduplication-interval-required": "डेडुप्लिकेशन अंतराल आवश्यक है।", + "calculated-field-filter-title": "कैलक्युलेटेड फ़ील्ड फ़िल्टर", + "filter-title": "फ़िल्टर", + "calculated-field-types": "कैलक्युलेटेड फ़ील्ड प्रकार", + "events": "घटनाएँ", + "any-type": "कोई भी प्रकार", + "metrics": { + "metrics": "मेट्रिक्स", + "metrics-empty": "कम से कम एक मेट्रिक को कॉन्फ़िगर किया जाना चाहिए।", + "metric-name": "मेट्रिक नाम", + "metric-name-required": "मेट्रिक नाम आवश्यक है।", + "metric-name-pattern": "मेट्रिक नाम अमान्य है।", + "metric-name-duplicate": "इस नाम वाला मेट्रिक पहले से मौजूद है।", + "metric-name-max-length": "मेट्रिक नाम 256 अक्षरों से कम होना चाहिए।", + "metric-name-forbidden": "मेट्रिक नाम आरक्षित है और इसे उपयोग नहीं किया जा सकता।", + "copy-metric-name": "मेट्रिक नाम कॉपी करें", + "argument-name": "तर्क नाम", + "aggregation": "संकलन", + "aggregation-type": { + "avg": "औसत", + "min": "न्यूनतम", + "max": "अधिकतम", + "sum": "योग", + "count": "गणना", + "count-unique": "विशिष्ट गणना" + }, + "filtered": "फ़िल्टर किया गया", + "value-source": "मान स्रोत", + "value-source-hint": "परिभाषित करता है कि संकलन के लिए मान कैसे प्राप्त किया जाता है।", + "value-source-type": { + "key": "कुंजी", + "function": "फ़ंक्शन" + }, + "no-metrics-configured": "कम से कम एक मेट्रिक आवश्यक है।", + "add-metric": "मेट्रिक जोड़ें", + "max-metrics": "अधिकतम मेट्रिक्स संख्या पहुँच गई।", + "metric-settings": "मेट्रिक सेटिंग्स", + "filter": "फ़िल्टर", + "filter-hint": "संकलन के दौरान एंटिटीज़ को फ़िल्टर करने के लिए फ़िल्टरिंग सक्षम करता है। फ़िल्टर फ़ंक्शन को एक बूलियन मान लौटाना चाहिए और यह सभी कॉन्फ़िगर किए गए तर्कों का उपयोग कर सकता है।" + }, + "output-strategy": { + "strategy": "रणनीति", + "process-right-away": "तत्काल प्रक्रिया करें", + "process-rule-chains": "रूल चेन के माध्यम से प्रक्रिया करें", + "save-time-series": "समय श्रृंखला में सहेजें", + "save-database": "डेटाबेस में सहेजें", + "save-latest-values": "नवीनतम मानों में सहेजें", + "send-web-sockets": "WebSockets पर भेजें", + "save-calculated-fields": "कैलक्युलेटेड फ़ील्ड्स में भेजें", + "update-attribute-only-on-value-change": "मान में परिवर्तन होने पर ही गुण अपडेट करें", + "send-attributes-updated-notification": "गुण अपडेट सूचना भेजें", + "ttl": "कस्टम TTL", + "ttl-required": "TTL आवश्यक है", + "ttl-min": "केवल 0 न्यूनतम TTL अनुमत है", + "processing-parameters": "प्रसंस्करण पैरामीटर", + "hint": { + "strategy": "नियंत्रित करता है कि परिणाम तुरंत संसाधित किया जाए या अतिरिक्त प्रसंस्करण के लिए रूल चेन को भेजा जाए।", + "processing-options": "प्रसंस्करण विकल्प", + "update-attribute-only-on-value-change": "प्रत्येक आने वाले संदेश पर गुण को अपडेट करता है, चाहे मान में परिवर्तन हुआ हो या नहीं। यह API उपयोग बढ़ाता है और प्रदर्शन को कम करता है।", + "update-attribute-only-on-value-change-enabled": "गुण को केवल तब अपडेट करें जब मान में परिवर्तन हो। यदि मान अपरिवर्तित रहता है, तो समय-चिह्न अपडेट नहीं होते और सूचनाएँ नहीं भेजी जातीं।", + "send-attributes-updated-notification": "गुण अपडेट इवेंट को डिफ़ॉल्ट रूल चेन को भेजता है।", + "save-time-series": "समय श्रृंखला डेटा को डेटाबेस में ts_kv तालिका में सहेजता है।", + "save-database": "गुण डेटा को डेटाबेस में सहेजता है।", + "save-latest-values": "यदि नया मान नवीनतम है तो समय श्रृंखला डेटा को डेटाबेस में ts_kv_latest तालिका में अपडेट करता है।", + "send-web-sockets-attribute": "WebSocket सब्सक्रिप्शंस को गुण डेटा के अपडेट के बारे में सूचित करता है।", + "send-web-sockets-time-series": "WebSocket सब्सक्रिप्शंस को समय श्रृंखला डेटा के अपडेट के बारे में सूचित करता है।", + "save-calculated-fields-attribute": "कैलक्युलेटेड फ़ील्ड्स को गुण डेटा के अपडेट के बारे में सूचित करता है।", + "save-calculated-fields-time-series": "कैलक्युलेटेड फ़ील्ड्स को समय श्रृंखला डेटा के अपडेट के बारे में सूचित करता है।", + "ttl": "समय श्रृंखला डेटा के लिए प्रतिधारण अवधि को परिभाषित करता है। यदि अक्षम है, तो टेनेन्ट प्रोफ़ाइल TTL का उपयोग किया जाता है।" + } + }, + "aggregate-interval-type": "संकलन अंतराल प्रकार", + "aggregate-interval-value": "संकलन अंतराल मान", + "aggregate-interval-value-required": "संकलन अंतराल मान आवश्यक है।", + "aggregate-interval-value-min": "संकलन अंतराल मान कम से कम { sec, plural, =0 {0 सेकंड} =1 {1 सेकंड} other {# सेकंड} } होना चाहिए।", + "aggregate-interval-value-step-multiple-of": "संकलन अंतराल मान 1 दिन का भाजक या गुणज होना चाहिए।", + "aggregate-period": { + "hour": "घंटा", + "day": "दिन", + "week": "सप्ताह (सोम - रविवार)", + "week-sun-sat": "सप्ताह (रविवार - शनिवार)", + "month": "महीना", + "quarter": "तिमाही", + "year": "वर्ष", + "custom": "कस्टम" + }, + "aggregate-period-hint-offset": "आपका संकलन अंतराल होगा: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "आपका संकलन अंतराल होगा: {{ interval }} और इसी तरह।", + "entity-aggregation": { + "argument-hint": "डेटा वर्तमान एंटिटी से फ़ेच किया जाएगा।", + "argument-title-hint": "संकलन के लिए उपयोग होने वाले इनपुट तर्कों को परिभाषित करता है।", + "argument-setting-hint": "इस कैल्क्युलेटेड फ़ील्ड के लिए 'नवीनतम टेलीमेट्री' ही एकमात्र उपलब्ध तर्क प्रकार है।", + "aggregation-interval": "संकलन अंतराल", + "aggregation-interval-hint": "यह परिभाषित करता है कि संकलन कितनी बार किया जाता है। उदाहरण: हर 1 घंटे पर डेटा 00:00, 01:00, 02:00, आदि पर संकलित होता है। संकलन परिणाम उस टाइमस्टैम्प के साथ स्टोर किए जाते हैं जो संकलन अंतराल की शुरुआत के अनुरूप होता है।", + "apply-offset": "संकलन अंतराल पर ऑफ़सेट लागू करें", + "apply-offset-hint": "यह परिभाषित करता है कि प्रत्येक संकलन अवधि की शुरुआत को कितना शिफ़्ट करना है (उदा., +10 मिनट - 00:10, 01:10)।", + "offset-value": "ऑफ़सेट मान", + "offset-value-required": "ऑफ़सेट मान आवश्यक है।", + "offset-value-min": "ऑफ़सेट मान एक धनात्मक पूर्णांक होना चाहिए।", + "offset-value-max": "ऑफ़सेट मान संकलन अंतराल मान से कम होना चाहिए।", + "wait-delay": "देरी से आने वाली टेलीमेट्री के लिए await टाइमआउट लागू करें", + "wait-delay-hint": "यह परिभाषित करता है कि अंतराल समाप्त होने के बाद देरी से आने वाली टेलीमेट्री के लिए कितनी देर प्रतीक्षा करनी है। यदि ऐसी टेलीमेट्री आती है, तो उस अंतराल के परिणाम की पुनर्गणना की जाएगी।", + "duration": "अवधि", + "duration-required": "अवधि आवश्यक है।", + "duration-min": "अवधि कम से कम 1 मिनट होनी चाहिए।", + "duration-hint": "अंतराल समाप्त होने के बाद देरी से आने वाले डेटा के लिए कितनी देर प्रतीक्षा करनी है।", + "produce-intermediate-result": "मध्यवर्ती परिणाम तैयार करें", + "produce-intermediate-result-hint": "वर्तमान अंतराल के दौरान मेट्रिक्स की गणना करके मध्यवर्ती परिणाम तैयार करता है। अपडेट {{ time }} में एक बार से अधिक बार नहीं होंगे।" + }, "hint": { - "arguments-simple-with-rolling": "सिंपल टाइप की कैलक्युलेटेड फ़ील्ड में 'टाइम सीरीज़ रोलिंग' टाइप की कीज़ नहीं होनी चाहिए।", - "arguments-empty": "आर्गुमेंट्स खाली नहीं होने चाहिए।", + "arguments-simple-with-rolling": "'Simple' प्रकार के कैल्क्युलेटेड फ़ील्ड में 'Time series rolling' प्रकार वाली कुंजियाँ नहीं होनी चाहिए।", + "arguments-propagate-arguments-with-rolling": "'Time series rolling' प्रकार 'Arguments only' प्रोपेगेशन के साथ संगत नहीं है।", + "arguments-propagate-argument-entity-type": "एंटिटी टाइप 'Arguments only' प्रोपेगेशन के साथ संगत नहीं है।", + "arguments-propagate-argument-must-current-entity": "कम से कम एक तर्क को 'Current entity' सोर्स एंटिटी टाइप के साथ कॉन्फ़िगर किया जाना चाहिए।", + "arguments-empty": "कम से कम एक तर्क निर्दिष्ट किया जाना चाहिए।", "expression-required": "एक्सप्रेशन आवश्यक है।", "expression-invalid": "एक्सप्रेशन अमान्य है।", "expression-max-length": "एक्सप्रेशन की लंबाई 255 अक्षरों से कम होनी चाहिए।", @@ -1079,12 +1342,218 @@ "argument-name-duplicate": "ऐसे नाम वाला आर्गुमेंट पहले से मौजूद है।", "argument-name-max-length": "आर्गुमेंट नाम 256 अक्षरों से कम होना चाहिए।", "argument-name-forbidden": "यह आर्गुमेंट नाम आरक्षित है और इसका उपयोग नहीं किया जा सकता।", + "output-key-required": "आउटपुट कुंजी आवश्यक है।", + "output-key-pattern": "आउटपुट कुंजी अमान्य है।", + "output-key-duplicate": "इस नाम की कुंजी पहले से मौजूद है।", + "output-key-max-length": "आउटपुट कुंजी 256 अक्षरों से कम होनी चाहिए।", + "output-key-forbidden": "आउटपुट कुंजी आरक्षित है और इसका उपयोग नहीं किया जा सकता।", + "entity-type-required": "एंटिटी प्रकार आवश्यक है।", + "name-required": "नाम आवश्यक है।", + "name-pattern": "नाम अमान्य है।", + "name-duplicate": "इस नाम वाला नाम पहले से मौजूद है।", + "name-max-length": "नाम 256 अक्षरों से कम होना चाहिए।", + "name-forbidden": "नाम आरक्षित है और इसका उपयोग नहीं किया जा सकता।", "argument-type-required": "आर्गुमेंट टाइप आवश्यक है।", "max-args": "आर्गुमेंट्स की अधिकतम संख्या पूरी हो चुकी है।", "decimals-range": "डिफ़ॉल्ट दशमलव 0 से 15 के बीच की संख्या होनी चाहिए।", "expression": "डिफ़ॉल्ट एक्सप्रेशन दिखाता है कि तापमान को फ़ारेनहाइट से सेल्सियस में कैसे बदला जाए।", "arguments-entity-not-found": "आर्गुमेंट की लक्षित एंटिटी नहीं मिली।", - "use-latest-timestamp": "अगर सक्षम किया गया, तो कैलक्युलेटेड मान को सर्वर समय की बजाय आर्गुमेंट्स की टेलीमेट्री के नवीनतम टाइमस्टैम्प के साथ सेव किया जाएगा।" + "use-latest-timestamp": "अगर सक्षम किया गया, तो कैलक्युलेटेड मान को सर्वर समय की बजाय आर्गुमेंट्स की टेलीमेट्री के नवीनतम टाइमस्टैम्प के साथ सेव किया जाएगा।", + "entity-coordinates": "एंटिटी GPS निर्देशांक (अक्षांश और देशांतर) प्रदान करने वाली समय श्रृंखला कुंजियों को निर्दिष्ट करें।", + "geofencing-zone-groups": "एक या अधिक जियोफेंसिंग जोन समूहों को परिभाषित करें जिन्हें चेक किया जाना चाहिए (उदाहरण: 'allowedZones', 'restrictedZones')। प्रत्येक समूह का एक अद्वितीय नाम होना चाहिए, जिसका उपयोग कैल्क्युलेटेड फ़ील्ड आउटपुट टेलीमेट्री कुंजियों के लिए प्रीफिक्स के रूप में किया जाता है।", + "perimeter-attribute-key": "वह गुण कुंजी सेट करें जो जियोफेंसिंग जोन की परिधि परिभाषा को शामिल करती है। परिधि हमेशा जोन एंटिटी के सर्वर-साइड गुणों से ली जाती है।", + "report-strategy": "उपस्थिति स्थिति रिपोर्ट करती है कि एंटिटी वर्तमान में जोन समूह के अंदर या बाहर है। संक्रमण घटनाएँ रिपोर्ट करती हैं जब एंटिटी जोन समूह में प्रवेश करती है या छोड़ देती है।", + "create-relation-with-matched-zones": "स्वचालित रूप से एंटिटी और उन क्षेत्रों के बीच संबंध बनाएं जिनमें वह वर्तमान में स्थित है। संबंध तब हटाए जाते हैं जब एंटिटी एक क्षेत्र छोड़ देती है और नए क्षेत्र में प्रवेश करते समय बनाए जाते हैं।", + "relation-type-required": "रिलेशन प्रकार आवश्यक है।", + "relation-level-required": "रिलेशन स्तर आवश्यक है।", + "relation-level-min": "न्यूनतम रिलेशन स्तर मान 1 है।", + "relation-level-max": "अधिकतम रिलेशन स्तर मान {{max}} है।", + "geofencing-empty": "कम से कम एक जोन समूह को कॉन्फ़िगर किया जाना चाहिए।", + "geofencing-entity-not-found": "जियोफेंसिंग लक्ष्य एंटिटी नहीं मिली।", + "max-geofencing-zone": "जियोफेंसिंग क्षेत्रों की अधिकतम संख्या प्राप्त हो गई है।", + "zone-group-refresh-interval": "परिभाषित करता है कि संबंधित एंटिटीज़ के माध्यम से कॉन्फ़िगर किए गए जोन समूहों को कितनी बार ताजगी की आवश्यकता है।", + "zone-group-refresh-interval-required": "जोन समूह रिफ्रेश अंतराल आवश्यक है।", + "zone-group-refresh-interval-min": "जोन समूह रिफ्रेश अंतराल कम से कम {{ min }} सेकंड होना चाहिए।", + "propagation-path-related-entities": "चयनित दिशा और रिलेशन प्रकार के आधार पर संबंधित एंटिटी तक एक सीधा, एकल-स्तरीय पथ परिभाषित करता है। केवल डिवाइस, एसेट, कस्टमर और टेनेन्ट एंटिटी के बीच के रिलेशन को समर्थन दिया जाता है। रिलेशन पथ द्वारा अधिकतम एंटिटीज़ {{ max }} हैं।", + "data-propagate": "परिभाषित करता है कि किस डेटा को नीचे कॉन्फ़िगर किए गए तर्कों से प्रसारित किया जाएगा। 'Arguments only' डेटा को सीधे प्राप्त करता है, जबकि 'Expression result' उस डेटा से नया मान गणना करता है।", + "aggregation-path-related-entities": "दिशा और रिलेशन प्रकार के आधार पर पैरेंट या चाइल्ड एंटिटीज़ के साथ सीधे रिलेशन के माध्यम से एकल-स्तरीय संकलन पथ परिभाषित करता है। केवल डिवाइस, एसेट, कस्टमर और टेनेन्ट एंटिटी के बीच के रिलेशन को समर्थन दिया जाता है। रिलेशन पथ द्वारा अधिकतम एंटिटीज़ {{ max }} हैं।", + "arguments-aggregation": "फ़िल्टरिंग और संकलन के लिए उपयोग किए गए इनपुट तर्कों को परिभाषित करता है।", + "setting-arguments-aggregation": "डेटा संबंधित एंटिटीज़ से प्राप्त किया जाएगा जो संकलन पथ में कॉन्फ़िगर की गई हैं।", + "metrics": "कॉन्फ़िगर किए गए तर्कों के आधार पर संकलित मेट्रिक्स को परिभाषित करता है।", + "entity-aggregation-metrics": "निर्धारित समय अंतरालों पर कॉन्फ़िगर किए गए तर्कों के आधार पर संकलित मेट्रिक्स को परिभाषित करता है।", + "import-invalid-calculated-field-type": "कैलक्युलेटेड फ़ील्ड आयात करने में असमर्थ: अमान्य कैलक्युलेटेड फ़ील्ड संरचना।", + "simple-expression-title": "अंकगणितीय अभिव्यक्ति जो परिभाषित करती है कि कैल्क्युलेटेड मान कैसे गणना किया जाता है।", + "script-title": "TBEL स्क्रिप्ट जो गणना लॉजिक और आउटपुट मानों को परिभाषित करती है।", + "simple-arguments": "अंकगणितीय अभिव्यक्ति जो परिभाषित करती है कि कैल्क्युलेटेड मान कैसे गणना किया जाता है।", + "script-arguments": "स्क्रिप्ट के लिए उपलब्ध इनपुट तर्कों को परिभाषित करता है।" + } + }, + "alarm-rule": { + "alarm-rules-tab": "अलार्म नियम", + "alarm-rule": "अलार्म नियम", + "alarm-rules": "अलार्म नियम", + "alarm-rules-old": "पुराना", + "alarm-rules-actual": "वर्तमान", + "severities": "गंभीरता", + "cleared": "क्लियर स्थिति", + "delete-title": "क्या आप वाकई अलार्म नियम '{{title}}' को हटाना चाहते हैं?", + "delete-text": "सावधान रहें, पुष्टि के बाद अलार्म नियम और सभी संबंधित डेटा पुनः प्राप्त नहीं किए जा सकेंगे।", + "delete-multiple-title": "क्या आप वाकई { count, plural, =1 {1 अलार्म नियम} other {# अलार्म नियम} } हटाना चाहते हैं?", + "delete-multiple-text": "सावधान रहें, पुष्टि के बाद सभी चयनित अलार्म नियम हटा दिए जाएंगे और सभी संबंधित डेटा पुनः प्राप्त नहीं किए जा सकेंगे।", + "create": "नया अलार्म नियम बनाएं", + "add": "अलार्म नियम जोड़ें", + "copy": "अलार्म नियम कॉन्फ़िगरेशन कॉपी करें", + "details": "अलार्म नियम विवरण", + "no-found": "कोई अलार्म नियम नहीं मिला", + "list": "{ count, plural, =1 {एक अलार्म नियम} other {# अलार्म नियम} }", + "selected-fields": "{ count, plural, =1 {1 अलार्म नियम} other {# अलार्म नियम} } चयनित", + "import": "अलार्म नियम आयात करें", + "file": "अलार्म नियम फ़ाइल", + "export": "अलार्म नियम निर्यात करें", + "export-failed-error": "अलार्म नियम निर्यात करने में असमर्थ: {{error}}", + "entity-type": "एंटिटी प्रकार", + "entity-type-required": "एंटिटी प्रकार आवश्यक है।", + "alarm-type": "अलार्म प्रकार", + "alarm-type-hint": "अलार्म उत्पत्ति (डिवाइस, एसेट, आदि) के दायरे में अद्वितीय पहचानकर्ता (जैसे, HighTempAlarm) ताकि संघर्षों से बचा जा सके।", + "alarm-type-required": "अलार्म प्रकार आवश्यक है।", + "alarm-type-pattern": "अलार्म प्रकार अमान्य है।", + "alarm-type-max-length": "अलार्म प्रकार 256 अक्षरों से कम होना चाहिए।", + "clear-alarm": "अलार्म को क्लियर करें", + "value-argument": "तर्क", + "value-argument-required": "तर्क आवश्यक है।", + "static-settings": "स्थैतिक सेटिंग्स", + "configuration": "कॉन्फ़िगरेशन", + "static-schedule": "स्थैतिक", + "dynamic-schedule": "डायनेमिक", + "operation-and": "AND", + "operation-or": "OR", + "condition-during": "{{during}} के दौरान", + "condition-during-dynamic": "\"{{attribute}}\" के दौरान", + "condition-repeat-times": "{ count, plural, =1 {1 बार} other {# बार} } दोहराता है", + "condition-repeat-times-dynamic": "\"{{attribute}}\" बार दोहराता है", + "filter-preview": "फ़िल्टर पूर्वावलोकन", + "condition-settings": "स्थिति सेटिंग्स", + "static": "स्थैतिक", + "dynamic": "डायनेमिक", + "argument-filters": "तर्क फ़िल्टर", + "argument-name": "तर्क नाम", + "value-type": "मान प्रकार", + "general": "सामान्य", + "filters": "फ़िल्टर", + "date-time-hint": "तर्क को एपोक मिलिसेकंड में होना चाहिए। उदाहरण: 1698839340000 = 2023-11-01 12:49:00 UTC।", + "operation": "ऑपरेशन", + "value-source": "मान स्रोत", + "value": "मान", + "ignore-case": "अक्षरमाला का ध्यान न दें", + "condition": "स्थिति", + "script": "स्क्रिप्ट", + "add-filter": "तर्क फ़िल्टर जोड़ें", + "edit-filter": "तर्क फ़िल्टर संपादित करें", + "remove-filter": "तर्क फ़िल्टर हटाएँ", + "no-filter": "कम से कम एक फ़िल्टर आवश्यक है।", + "conditions": { + "simple": "साधारण", + "duration": "अवधि", + "repeating": "दोहराना" + }, + "schedule-title": "अनुसूची", + "edit-schedule": "अलार्म अनुसूची संपादित करें", + "schedule-type": "अनुसूचक प्रकार", + "schedule-type-required": "अनुसूचक प्रकार आवश्यक है।", + "schedule": { + "any-time": "हमेशा सक्रिय", + "specific-time": "विशिष्ट समय पर सक्रिय", + "custom": "कस्टम" + }, + "schedule-day": { + "monday": "सोमवार", + "tuesday": "मंगलवार", + "wednesday": "बुधवार", + "thursday": "गुरुवार", + "friday": "शुक्रवार", + "saturday": "शनिवार", + "sunday": "रविवार" + }, + "schedule-days": "दिन", + "schedule-time": "समय", + "schedule-time-from": "से", + "schedule-time-to": "तक", + "schedule-days-of-week-required": "कम से कम एक दिन को सप्ताह के लिए चुना जाना चाहिए।", + "tbel": "TBEL", + "expression-type": { + "simple": "साधारण", + "script": "स्क्रिप्ट" + }, + "operation-type": { + "and": "और", + "or": "या" + }, + "filter-predicate-type": { + "string": "स्ट्रिंग", + "numeric": "संख्यात्मक", + "boolean": "बूलियन", + "complex": "जटिल" + }, + "alarm-rule-additional-info": "अतिरिक्त जानकारी", + "edit-alarm-rule-additional-info": "अतिरिक्त जानकारी संपादित करें", + "alarm-rule-additional-info-placeholder": "कृपया अपनी टिप्पणियाँ और समायोजन यहां प्रदान करें ताकि उन्हें अलार्म विवरण में अतिरिक्त जानकारी के तहत दिखाया जा सके।", + "alarm-rule-additional-info-hint": "संकेत: अलार्म नियम स्थिति में उपयोग किए गए तर्कों के मानों को प्रतिस्थापित करने के लिए ${तर्क नाम} का उपयोग करें।", + "alarm-rule-additional-info-icon-hint": "अलार्म नियम स्थिति में उपयोग किए गए तर्कों के मानों को प्रतिस्थापित करने के लिए तर्क नाम का उपयोग करें।", + "alarm-rule-mobile-dashboard": "मोबाइल डैशबोर्ड", + "alarm-rule-mobile-dashboard-hint": "मोबाइल एप्लिकेशन द्वारा अलार्म विवरण डैशबोर्ड के रूप में उपयोग किया जाता है।", + "alarm-rule-no-mobile-dashboard": "कोई डैशबोर्ड चयनित नहीं है", + "alarm-rule-condition": "अलार्म नियम स्थिति", + "enter-alarm-rule-condition-prompt": "स्थिति जोड़ें", + "enter-alarm-rule-clear-condition-prompt": "साफ़ करने की स्थिति जोड़ें", + "edit-alarm-rule-condition": "अलार्म स्थिति", + "condition-type": "स्थिति प्रकार", + "condition-type-hint": "\"अवधि\" और \"दोहराना\" विकल्प तब उपलब्ध नहीं होते जब \"नहीं मिल रहा है\" ऑपरेशन फ़िल्टर में उपयोग किया जाता है।", + "select-alarm-severity": "अलार्म गंभीरता चुनें", + "add-create-alarm-rule-prompt": "कम से कम एक ट्रिगर स्थिति आवश्यक है।", + "add-create-alarm-rule": "ट्रिगर स्थिति जोड़ें", + "add-clear-alarm-rule": "साफ़ करने की स्थिति जोड़ें", + "condition-duration": "स्थिति अवधि", + "condition-duration-value": "अवधि मान", + "condition-duration-time-unit": "समय इकाई", + "condition-duration-value-range": "अवधि मान को 1 से 2147483647 के बीच होना चाहिए।", + "condition-duration-value-pattern": "अवधि मान पूर्णांक होना चाहिए।", + "condition-duration-value-required": "अवधि मान आवश्यक है।", + "condition-duration-time-unit-required": "समय इकाई आवश्यक है।", + "condition-repeating-value": "घटनाओं की गिनती", + "condition-repeating-value-hint": "अलार्म नियम तर्क के किसी भी अपडेट को घटना के रूप में गिना जाएगा", + "condition-repeating-value-range": "घटनाओं की गिनती 1 से 2147483647 के बीच होनी चाहिए।", + "condition-repeating-value-pattern": "घटनाओं की गिनती पूर्णांक होनी चाहिए।", + "condition-repeating-value-required": "घटनाओं की गिनती आवश्यक है।", + "create-conditions": "ट्रिगर स्थितियाँ", + "clear-condition": "साफ़ करने की स्थिति", + "no-clear-alarm-rule": "कोई साफ़ करने की स्थिति कॉन्फ़िगर नहीं की गई है।", + "advanced-settings": "उन्नत सेटिंग्स", + "propagate-alarm": "अलार्म को संबंधित एंटिटीज़ में प्रसारित करें", + "alarm-rule-relation-types-list": "रिलेशन प्रकार", + "alarm-rule-relation-types-list-hint": "संबंधित एंटिटीज़ को फ़िल्टर करने के लिए रिलेशन प्रकारों को परिभाषित करता है। यदि सेट नहीं किया गया हो, तो अलार्म सभी संबंधित एंटिटीज़ में प्रसारित किया जाएगा।", + "propagate-alarm-to-owner": "अलार्म को एंटिटी मालिक (कस्टमर या टेनेन्ट) को प्रसारित करें", + "propagate-alarm-to-tenant": "अलार्म को टेनेन्ट को प्रसारित करें", + "alarm-rule-filter-title": "अलार्म नियम फ़िल्टर", + "filter-title": "फ़िल्टर", + "debugging": "अलार्म नियम डिबगिंग", + "any-type": "कोई भी प्रकार", + "enter-alarm-rule-type": "अलार्म प्रकार दर्ज करें", + "no-alarm-rule-types-matching": "कोई अलार्म प्रकार '{{entitySubtype}}' से मेल नहीं खाता पाया गया।", + "alarm-rule-type-list-empty": "कोई अलार्म प्रकार चयनित नहीं है।", + "alarm-rule-type-list": "अलार्म प्रकार सूची", + "alarm-rule-entity-list": "एंटिटी सूची", + "missing-for": "नहीं मिल रहा है", + "time-unit": "इकाई", + "mode": "मोड", + "type": "प्रकार", + "value-required": "मान आवश्यक है।", + "min-value": "मान 1 या उससे अधिक होना चाहिए।", + "argument-in-use": "तर्क को सामान्य तर्क के रूप में उपयोग किया जा रहा है।", + "import-invalid-alarm-rule-type": "अलार्म नियम आयात करने में असमर्थ: अमान्य अलार्म नियम संरचना।", + "no-filter-preview": "कोई फ़िल्टर निर्दिष्ट नहीं किया गया", + "filter-operation": { + "and": "और", + "or": "या" } }, "ai-models": { @@ -1191,6 +1660,7 @@ "contact": { "country": "देश", "country-required": "देश आवश्यक है।", + "country-object-required": "कृपया सूची से एक मान्य देश चुनें।", "city": "शहर", "state": "राज्य / प्रांत", "postal-code": "ज़िप / डाक कोड", @@ -1227,6 +1697,8 @@ "documentation": "प्रलेखन", "time-left": "{{time}} बाकी", "output": "आउटपुट", + "sort-asc": "आरोही", + "sort-desc": "अवरोही", "suffix": { "s": "s", "ms": "ms" @@ -1363,6 +1835,8 @@ "mobile-order": "मोबाइल एप्लिकेशन में डैशबोर्ड क्रम", "mobile-hide": "मोबाइल एप्लिकेशन में डैशबोर्ड छिपाएँ", "update-image": "डैशबोर्ड छवि अपडेट करें", + "update-new-version": "नया संस्करण अपलोड करें", + "upload-file-to-update": "अपडेट करने के लिए फ़ाइल अपलोड करें", "take-screenshot": "स्क्रीनशॉट लें", "select-widget-title": "विजेट चुनें", "select-widget-value": "{{title}}: विजेट चुनें", @@ -1651,7 +2125,7 @@ "remove-alias": "डिवाइस उपनाम हटाएँ", "add-alias": "डिवाइस उपनाम जोड़ें", "name-starts-with": "डिवाइस नाम अभिव्यक्ति", - "help-text": "आवश्यकता अनुसार '%' का उपयोग करें: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "आवश्यकतानुसार '%' का उपयोग करें: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "डिवाइस सूची", "use-device-name-filter": "फ़िल्टर का उपयोग करें", "device-list-empty": "कोई डिवाइस चयनित नहीं है।", @@ -1731,6 +2205,8 @@ "bootstrap-tab": "Bootstrap क्लाइंट", "bootstrap-server": "Bootstrap सर्वर", "lwm2m-server": "LwM2M सर्वर", + "client-reboot": "रजिस्ट्रेशन अपडेट ट्रिगर", + "bootstrap-reboot": "बूटस्ट्रैप-रिक्वेस्ट ट्रिगर", "client-publicKey-or-id": "क्लाइंट सार्वजनिक कुंजी या आईडी", "client-publicKey-or-id-required": "क्लाइंट सार्वजनिक कुंजी या आईडी आवश्यक है।", "client-publicKey-or-id-tooltip-psk": "PSK पहचानकर्ता (identifier) एक मनमाना PSK पहचानकर्ता होता है जिसकी लंबाई अधिकतम 128 बाइट तक हो सकती है, जैसा कि मानक [RFC7925] में वर्णित है।\nPSK पहचानकर्ता को पहले एक कैरेक्टर स्ट्रिंग में बदला जाना चाहिए और फिर UTF-8 का उपयोग करके ऑक्टेट्स में एनकोड किया जाना चाहिए।", @@ -2231,6 +2707,7 @@ "short-id-required": "शॉर्ट सर्वर Id आवश्यक है।", "short-id-range": "शॉर्ट सर्वर Id {{ min }} से {{ max }} की रेंज में होना चाहिए।", "short-id-pattern": "शॉर्ट सर्वर Id एक धनात्मक पूर्णांक होना चाहिए।", + "short-id-pattern-bs": "संक्षिप्त सर्वर ID केवल null होनी चाहिए", "lifetime": "क्लाइंट रजिस्ट्रेशन लाइफ़टाइम", "lifetime-required": "क्लाइंट रजिस्ट्रेशन लाइफ़टाइम आवश्यक है।", "lifetime-pattern": "क्लाइंट रजिस्ट्रेशन लाइफ़टाइम एक धनात्मक पूर्णांक होना चाहिए।", @@ -2325,7 +2802,9 @@ "composite-all-description": "सभी रिसोर्स एक ही कंपोज़िट Observe अनुरोध से ऑब्ज़र्व किए जाते हैं (ज़्यादा प्रभावी, कम लचीलापन)", "composite-by-object": "ऑब्जेक्ट के अनुसार कंपोज़िट", "composite-by-object-description": "रिसोर्स को ऑब्जेक्ट प्रकार के अनुसार समूहित करके अलग-अलग कंपोज़िट Observe अनुरोधों से ऑब्ज़र्व किया जाता है (संतुलित तरीका)" - } + }, + "init-attr-tel-as-obs-strategy": "गुण और टेलीमेट्री को Observe रणनीति का उपयोग करके प्रारंभ करें", + "init-attr-tel-as-obs-strategy-hint": "यदि गलत - गुण और टेलीमेट्री को उनके मानों को एक-एक करके पढ़कर प्रारंभ किया जाता है।\\nयदि सही - गुण और टेलीमेट्री को उनके मानों को Observe रणनीति का उपयोग करके सब्सक्राइब करके प्रारंभ किया जाता है।" }, "snmp": { "add-communication-config": "कम्युनिकेशन कॉन्फ़िगरेशन जोड़ें", @@ -2561,7 +3040,7 @@ "no-entities-text": "कोई एंटिटी नहीं मिली.", "no-entity-types-matching": "'{{entityType}}' से मेल खाने वाले कोई एंटिटी प्रकार नहीं मिले.", "name-starts-with": "नाम अभिव्यक्ति", - "help-text": "ज़रूरत के अनुसार '%' का उपयोग करें: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "आवश्यकतानुसार '%' का उपयोग करें: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "फ़िल्टर उपयोग करें", "entity-list-empty": "कोई एंटिटी चयनित नहीं है.", "entity-type-list-required": "कम से कम एक एंटिटी प्रकार चुना जाना चाहिए.", @@ -2641,6 +3120,8 @@ "type-rulenodes": "रूल नोड्स", "list-of-rulenodes": "{ count, plural, =1 {एक रूल नोड} other {# रूल नोड्स की सूची} }", "rulenode-name-starts-with": "वे रूल नोड्स जिनके नाम '{{prefix}}' से शुरू होते हैं", + "type-api-key": "API कुंजी", + "type-api-keys": "API कुंजियाँ", "type-current-customer": "वर्तमान कस्टमर", "type-current-tenant": "वर्तमान टेनेंट", "type-current-user": "वर्तमान उपयोगकर्ता", @@ -2662,6 +3143,7 @@ "details": "एंटिटी विवरण", "no-entities-prompt": "कोई एंटिटी नहीं मिली", "no-data": "दिखाने के लिए कोई डेटा नहीं", + "show-all-columns": "सभी दिखाएं", "columns-to-display": "दिखाने वाले कॉलम", "type-api-usage-state": "API उपयोग स्थिति", "type-edge": "Edge", @@ -2707,7 +3189,15 @@ "list-of-mobile-apps": "{ count, plural, =1 {एक मोबाइल एप्लिकेशन} other {# मोबाइल एप्लिकेशन्स की सूची} }", "type-mobile-app-bundle": "मोबाइल बंडल", "type-mobile-app-bundles": "मोबाइल बंडल्स", - "list-of-mobile-app-bundles": "{ count, plural, =1 {एक मोबाइल बंडल} other {# मोबाइल बंडल्स की सूची} }" + "list-of-mobile-app-bundles": "{ count, plural, =1 {एक मोबाइल बंडल} other {# मोबाइल बंडल्स की सूची} }", + "limit-reached": "सीमा तक पहुँच गई", + "limit-reached-text": "आपने {{ entities }} की सीमा तक पहुँच गया है। अधिक जोड़ने के लिए, कृपया अपने सिस्टम व्यवस्थापक से अपनी {{ entity }} सीमा बढ़ाने का अनुरोध करें।", + "request-limit-increase": "सीमा बढ़ाने का अनुरोध करें", + "request-sysadmin-text": "क्या आप सिस्टम व्यवस्थापक हैं?", + "login-here": "यहाँ लॉगिन करें", + "to-increase-limit": "सीमा बढ़ाने के लिए।", + "increase-limit-request-sent-title": "हमने आपकी सिस्टम व्यवस्थापक को सीमा बढ़ाने के लिए एक स्वचालित अनुरोध भेजा है", + "increase-limit-request-sent-text": "कृपया कुछ समय दें ताकि वे अनुरोध की समीक्षा कर सकें और सेटिंग्स अपडेट कर सकें। आपको परिवर्तन देखने के लिए इस पृष्ठ को रिफ्रेश करना पड़ सकता है।" }, "entity-field": { "created-time": "बनाने का समय", @@ -2753,7 +3243,7 @@ "remove-alias": "एंटिटी व्यू उपनाम हटाएँ", "add-alias": "एंटिटी व्यू उपनाम जोड़ें", "name-starts-with": "एंटिटी व्यू नाम अभिव्यक्ति", - "help-text": "ज़रूरत के अनुसार '%' का उपयोग करें: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "आवश्यकतानुसार '%' का उपयोग करें: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "एंटिटी व्यू सूची", "use-entity-view-name-filter": "फ़िल्टर उपयोग करें", "entity-view-list-empty": "कोई एंटिटी व्यू चयनित नहीं है.", @@ -3789,12 +4279,14 @@ "remember-me": "मुझे याद रखें", "forgot-password": "पासवर्ड भूल गए?", "password-reset": "पासवर्ड रीसेट", - "expired-password-reset-message": "आपके क्रेडेंशियल्स की अवधि समाप्त हो गई है! कृपया नया पासवर्ड बनाएँ.", + "expired-password-reset-message": "आपके क्रेडेंशियल्स की अवधि समाप्त हो गई है! \nकृपया नया पासवर्ड बनाएँ.", "new-password": "नया पासवर्ड", "new-password-again": "नया पासवर्ड पुष्टि करें", "password-link-sent-message": "रीसेट लिंक भेज दिया गया है", "email": "ईमेल", "invalid-email-format": "अमान्य ईमेल फ़ॉर्मेट.", + "sign-in-with": "{{name}} के साथ साइन इन करें", + "sign-in-to-your-account": "अपने खाते में साइन इन करें", "or": "या", "error": "लॉगिन त्रुटि", "verify-your-identity": "अपनी पहचान सत्यापित करें", @@ -3813,7 +4305,51 @@ "activation-link-expired": "सक्रियन लिंक की अवधि समाप्त हो गई है", "activation-link-expired-message": "आपके प्रोफ़ाइल को सक्रिय करने वाला लिंक समाप्त हो गया है। आप नया ईमेल प्राप्त करने के लिए लॉगिन पेज पर वापस जा सकते हैं.", "reset-password-link-expired": "पासवर्ड रीसेट लिंक की अवधि समाप्त हो गई है", - "reset-password-link-expired-message": "आपके पासवर्ड को रीसेट करने वाला लिंक समाप्त हो गया है। आप नया ईमेल प्राप्त करने के लिए लॉगिन पेज पर वापस जा सकते हैं." + "reset-password-link-expired-message": "आपके पासवर्ड को रीसेट करने वाला लिंक समाप्त हो गया है। आप नया ईमेल प्राप्त करने के लिए लॉगिन पेज पर वापस जा सकते हैं.", + "two-fa": "दो-कारक प्रमाणीकरण", + "two-fa-required": "दो-कारक प्रमाणीकरण आवश्यक है", + "set-up-verification-method": "जारी रखने के लिए एक सत्यापन विधि सेट करें", + "set-up-verification-method-login": "वेरिफिकेशन विधि सेट करें या लॉगिन करें", + "enable-authenticator-app": "ऑथेंटिकेटर ऐप सक्षम करें", + "enable-authenticator-app-description": "कृपया अपने ऑथेंटिकेटर ऐप से सुरक्षा कोड दर्ज करें", + "enable-authenticator-sms": "SMS ऑथेंटिकेटर सक्षम करें", + "enable-authenticator-sms-description": "वह 6-अंकों का कोड दर्ज करें जो हमने अभी भेजा है", + "enable-authenticator-email": "ईमेल ऑथेंटिकेटर सक्षम करें", + "enable-authenticator-email-description": "एक सुरक्षा कोड आपके ईमेल पते पर भेजा गया है", + "enter-key-manually": "या इस 32-अंकों की कुंजी को मैन्युअली दर्ज करें:", + "continue": "जारी रखें", + "confirm": "पुष्टि करें", + "authenticator-app-success": "ऑथेंटिकेटर ऐप सफलतापूर्वक सक्षम किया गया", + "authenticator-app-success-description": "अगली बार जब आप साइन इन करेंगे, तो आपको दो-कारक प्रमाणीकरण कोड प्रदान करना होगा", + "authenticator-sms-success": "SMS ऑथेंटिकेटर सफलतापूर्वक सक्षम किया गया", + "authenticator-sms-success-description": "अगली बार जब आप साइन इन करेंगे, तो आपको सुरक्षा कोड दर्ज करने के लिए प्रेरित किया जाएगा जो फोन नंबर पर भेजा जाएगा", + "authenticator-email-success": "ईमेल ऑथेंटिकेटर सफलतापूर्वक सक्षम किया गया", + "authenticator-email-success-description": "अगली बार जब आप साइन इन करेंगे, तो आपको सुरक्षा कोड दर्ज करने के लिए प्रेरित किया जाएगा जो आपके ईमेल पते पर भेजा जाएगा", + "authenticator-backup-code-success": "बैकअप कोड सफलतापूर्वक सक्षम किया गया", + "authenticator-backup-code-success-description": "अगली बार जब आप साइन इन करेंगे, तो आपको सुरक्षा कोड दर्ज करने के लिए या बैकअप कोड में से एक का उपयोग करने के लिए प्रेरित किया जाएगा", + "add-verification-method": "वेरिफिकेशन विधि जोड़ें", + "get-backup-code": "बैकअप कोड प्राप्त करें", + "copy-key": "कुंजी कॉपी करें", + "send-code": "कोड भेजें", + "email-label": "ईमेल", + "email-description": "एक ईमेल दर्ज करें जिसे आप अपना ऑथेंटिकेटर के रूप में उपयोग करेंगे।", + "sms-description": "एक फोन नंबर दर्ज करें जिसे आप अपना ऑथेंटिकेटर के रूप में उपयोग करेंगे।", + "backup-code-description": "कोड को प्रिंट करें ताकि जब आपको इन्हें अपने खाते में लॉगिन करने के लिए उपयोग करने की आवश्यकता हो, तो आपके पास हों। आप प्रत्येक बैकअप कोड का एक बार उपयोग कर सकते हैं।", + "backup-code-warn": "एक बार जब आप इस पृष्ठ को छोड़ देंगे, तो ये कोड फिर से नहीं दिखाए जाएंगे। इन्हें नीचे दिए गए विकल्पों का उपयोग करके सुरक्षित रूप से स्टोर करें।", + "download-txt": "डाउनलोड (txt)", + "print": "प्रिंट करें", + "verification-code": "6-अंकों का कोड", + "verification-code-invalid": "अमान्य सत्यापन कोड प्रारूप", + "verification-code-incorrect": "सत्यापन कोड गलत है", + "verification-code-many-request": "सत्यापन कोड जांचने के लिए बहुत अधिक अनुरोध", + "scan-qr-code": "अपने सत्यापन ऐप से इस QR कोड को स्कैन करें", + "phone-input": { + "phone-input-label": "फोन नंबर", + "phone-input-required": "फोन नंबर आवश्यक है", + "phone-input-validation": "फोन नंबर अमान्य है या संभव नहीं है", + "phone-input-pattern": "अमान्य फोन नंबर। E.164 प्रारूप में होना चाहिए, जैसे {{phoneNumber}}", + "phone-input-hint": "फोन नंबर E.164 प्रारूप में, जैसे {{phoneNumber}}" + } }, "mobile": { "add-application": "एप्लिकेशन जोड़ें", @@ -4181,6 +4717,7 @@ "api-usage-limit": "API उपयोग सीमा", "device-activity": "डिवाइस एक्टिविटी", "entities-limit": "एंटिटीज़ सीमा", + "entities-limit-increase-request": "एंटिटीज़ सीमा बढ़ाने का अनुरोध", "entity-action": "एंटिटी एक्शन", "general": "जनरल", "rule-engine-lifecycle-event": "रूल इंजन लाइफ़साइकल इवेंट", @@ -4398,6 +4935,12 @@ "at-least": "कम से कम:", "character": "{ count, plural, =1 {1 अक्षर} other {# अक्षर} }", "digit": "{ count, plural, =1 {1 अंक} other {# अंक} }", + "password-tooltip-min-length": "कम से कम {{minimumLength}} अक्षर लंबा", + "password-tooltip-max-length": "अधिकतम {{maximumLength}} अक्षर लंबा", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} बड़े अक्षर", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} छोटे अक्षर", + "password-tooltip-digit": "{{minimumDigits}} संख्या", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} विशेष वर्ण", "incorrect-password-try-again": "गलत पासवर्ड. दोबारा कोशिश करें", "lowercase-letter": "{ count, plural, =1 {1 छोटे अक्षर} other {# छोटे अक्षर} }", "new-passwords-not-match": "नए पासवर्ड आपस में मेल नहीं खाते", @@ -4456,7 +4999,8 @@ "additional-info": "अतिरिक्त जानकारी (JSON)", "invalid-additional-info": "अतिरिक्त जानकारी JSON को पार्स करने में असमर्थ.", "no-relations-text": "कोई रिलेशन नहीं मिला", - "not": "नहीं" + "not": "नहीं", + "copy-type": "कॉपी प्रकार" }, "resource": { "add": "संसाधन जोड़ें", @@ -5406,7 +5950,8 @@ }, "time-series": "टाइम सीरीज़", "latest": "नवीनतम मान", - "web-sockets": "WebSockets" + "web-sockets": "WebSockets", + "calculated-fields-and-alarm-rules": "कैल्क्युलेटेड फ़ील्ड्स और अलार्म नियम" }, "save-attribute": { "processing-settings": "प्रोसेसिंग सेटिंग्स", @@ -5601,7 +6146,8 @@ "bad-request-params": "खराब रिक्वेस्ट पैरामीटर्स", "item-not-found": "आइटम नहीं मिला", "too-many-requests": "बहुत अधिक रिक्वेस्ट", - "too-many-updates": "बहुत अधिक अपडेट्स" + "too-many-updates": "बहुत अधिक अपडेट्स", + "entities-limit-exceeded": "एंटिटीज़ की सीमा पार हो गई है" }, "tenant": { "tenant": "टेनेंट", @@ -5739,6 +6285,27 @@ "max-arguments-per-cf": "प्रति कैलक्युलेटेड फ़ील्ड आर्ग्युमेंट्स की अधिकतम संख्या", "max-arguments-per-cf-range": "प्रति कैलक्युलेटेड फ़ील्ड आर्ग्युमेंट्स की अधिकतम संख्या नकारात्मक नहीं हो सकती", "max-arguments-per-cf-required": "प्रति कैलक्युलेटेड फ़ील्ड आर्ग्युमेंट्स की अधिकतम संख्या आवश्यक है", + "max-related-level-per-argument": "'संबंधित एंटिटीज़' तर्क के प्रति अधिकतम रिलेशन स्तर", + "max-related-level-per-argument-range": "'संबंधित एंटिटीज़' तर्क के प्रति रिलेशन स्तर की अधिकतम संख्या '1' से कम नहीं हो सकती", + "max-related-level-per-argument-required": "'संबंधित एंटिटीज़' तर्क के प्रति रिलेशन स्तर की अधिकतम संख्या आवश्यक है", + "min-allowed-scheduled-update-interval": "'संबंधित एंटिटीज़' तर्कों के लिए न्यूनतम अनुमत अपडेट अंतराल (सेकंड)", + "min-allowed-scheduled-update-interval-range": "न्यूनतम अनुमत अपडेट अंतराल का मान नकारात्मक नहीं हो सकता", + "min-allowed-deduplication-interval": "न्यूनतम अनुमत डेडुप्लिकेशन अंतराल (सेकंड)", + "min-allowed-deduplication-interval-range": "न्यूनतम अनुमत डेडुप्लिकेशन अंतराल का मान नकारात्मक नहीं हो सकता", + "min-allowed-deduplication-interval-required": "न्यूनतम अनुमत डेडुप्लिकेशन अंतराल आवश्यक है", + "intermediate-aggregation-interval": "मध्यवर्ती संकलन अंतराल (सेकंड)", + "intermediate-aggregation-interval-range": "मध्यवर्ती संकलन अंतराल का मान '1' से कम नहीं हो सकता", + "intermediate-aggregation-interval-required": "मध्यवर्ती संकलन अंतराल आवश्यक है", + "reevaluation-check-interval": "पुनः मूल्यांकन चेक अंतराल (सेकंड)", + "reevaluation-check-interval-range": "पुनः मूल्यांकन चेक अंतराल का मान '1' से कम नहीं हो सकता", + "reevaluation-check-interval-required": "पुनः मूल्यांकन चेक अंतराल आवश्यक है", + "alarms-reevaluation-interval": "अलार्म पुनः मूल्यांकन अंतराल (सेकंड)", + "alarms-reevaluation-interval-range": "अलार्म पुनः मूल्यांकन अंतराल का मान '1' से कम नहीं हो सकता", + "alarms-reevaluation-interval-required": "अलार्म पुनः मूल्यांकन अंतराल आवश्यक है", + "min-allowed-aggregation-interval": "न्यूनतम अनुमत संकलन अंतराल (सेकंड)", + "min-allowed-aggregation-interval-range": "न्यूनतम अनुमत संकलन अंतराल का मान नकारात्मक नहीं हो सकता", + "min-allowed-aggregation-interval-required": "न्यूनतम अनुमत संकलन अंतराल आवश्यक है", + "min-allowed-scheduled-update-interval-required": "न्यूनतम अनुमत अपडेट अंतराल का न्यूनतम मान आवश्यक है", "max-state-size": "स्टेट का अधिकतम आकार (KB में)", "max-state-size-range": "स्टेट का अधिकतम आकार (KB में) नकारात्मक नहीं हो सकता", "max-state-size-required": "स्टेट का अधिकतम आकार (KB में) आवश्यक है", @@ -5814,6 +6381,10 @@ "ws-limit-max-subscriptions-per-regular-user": "प्रति सामान्य उपयोगकर्ता सब्स्क्रिप्शन्स की अधिकतम संख्या", "ws-limit-max-subscriptions-per-public-user": "प्रति सार्वजनिक उपयोगकर्ता सब्स्क्रिप्शन्स की अधिकतम संख्या", "ws-limit-updates-per-session": "प्रति सत्र WS अपडेट्स", + "relation-search-entity-limit": "रिलेशन खोज एंटिटी सीमा", + "relation-search-entity-limit-hint": "रिलेशन पथ के अंतिम स्तर पर हल की गई एंटिटीज़ की संख्या को सीमित करता है। यह 'संबंधित एंटिटीज़' तर्कों और प्रसार फ़ील्ड्स पर लागू होता है।", + "relation-search-entity-limit-required": "रिलेशन खोज एंटिटी सीमा आवश्यक है", + "relation-search-entity-limit-range": "रिलेशन खोज एंटिटी सीमा '1' से कम नहीं हो सकती", "rate-limits": { "add-limit": "लिमिट जोड़ें", "and-also-less-than": "और साथ ही इससे कम", @@ -5999,7 +6570,9 @@ "default-agg-interval": "डिफ़ॉल्ट ग्रुपिंग अंतराल", "edit-intervals-list-hint": "उपलब्ध अंतराल विकल्पों की सूची निर्दिष्ट की जा सकती है।", "edit-grouping-intervals-list-hint": "ग्रुपिंग अंतरालों की सूची और डिफ़ॉल्ट ग्रुपिंग अंतराल को कॉन्फ़िगर करना संभव है।", - "all": "सभी" + "all": "सभी", + "save-current-settings-as-default": "वर्तमान सेटिंग्स को डिफ़ॉल्ट समय विंडो के रूप में सहेजें", + "hide-option-from-end-users": "एंड-यूज़र्स से विकल्प छुपाएं" }, "tooltip": { "trigger": "ट्रिगर", @@ -6654,6 +7227,7 @@ "export-attributes": "विशेषताएँ एक्सपोर्ट करें", "export-credentials": "क्रेडेंशियल्स एक्सपोर्ट करें", "export-calculated-fields": "कैलक्युलेटेड फ़ील्ड्स एक्सपोर्ट करें", + "export-alarm-rules": "अलार्म नियमों का निर्यात करें", "entity-versions": "एंटिटी वर्ज़न", "versions": "वर्ज़न", "created-time": "बनाने का समय", @@ -6671,6 +7245,7 @@ "load-attributes": "विशेषताएँ लोड करें", "load-credentials": "क्रेडेंशियल्स लोड करें", "load-calculated-fields": "कैलक्युलेटेड फ़ील्ड्स लोड करें", + "load-alarm-rules": "अलार्म नियम लोड करें", "compare-with-current": "वर्तमान से तुलना करें", "diff-entity-with-version": "एंटिटी वर्ज़न '{{versionName}}' से तुलना", "previous-difference": "पिछला अंतर", @@ -6880,7 +7455,23 @@ "scan-qr-code": "QR कोड स्कैन करें", "make-phone-call": "फ़ोन कॉल करें", "get-location": "फ़ोन लोकेशन प्राप्त करें", - "take-screenshot": "स्क्रीनशॉट लें" + "take-screenshot": "स्क्रीनशॉट लें", + "handle-provision-success-function": "प्रोविजन सफलता फ़ंक्शन को हैंडल करें", + "get-location-function": "स्थान प्राप्त करें फ़ंक्शन", + "process-launch-result-function": "लॉन्च परिणाम फ़ंक्शन को प्रोसेस करें", + "get-phone-number-function": "फोन नंबर प्राप्त करें फ़ंक्शन", + "process-image-function": "छवि प्रोसेस फ़ंक्शन", + "process-qr-code-function": "QR कोड प्रोसेस फ़ंक्शन", + "process-location-function": "स्थान प्रोसेस फ़ंक्शन", + "handle-empty-result-function": "खाली परिणाम फ़ंक्शन को हैंडल करें", + "handle-error-function": "त्रुटि फ़ंक्शन को हैंडल करें", + "handle-non-mobile-fallback-function": "नॉन-मोबाइल फ़ॉलबैक फ़ंक्शन को हैंडल करें", + "save-to-gallery": "'इमेज गैलरी' में सहेजें", + "provision-type": "प्रोविजन प्रकार", + "auto": "स्वचालित", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "सॉफ्ट AP" }, "custom-action-function": "कस्टम एक्शन फ़ंक्शन", "custom-pretty-function": "कस्टम एक्शन (HTML टेम्पलेट सहित) फ़ंक्शन", @@ -6889,7 +7480,8 @@ "marker": "मार्कर", "polygon": "बहुभुज", "rectangle": "आयत", - "circle": "वृत्त" + "circle": "वृत्त", + "polyline": "पॉलीलाइन" }, "place-map-item": "मैप आइटम रखें", "map-item-tooltip": { @@ -6901,7 +7493,9 @@ "continue-draw-polygon": "बहुभुज बनाना जारी रखें", "finish-draw-polygon": "बहुभुज बनाना समाप्त करें", "start-draw-circle": "वृत्त बनाना शुरू करें", - "finish-draw-circle": "वृत्त बनाना समाप्त करें" + "finish-draw-circle": "वृत्त बनाना समाप्त करें", + "start-draw-polyline": "पॉलीलाइन ड्रॉ करना शुरू करें", + "finish-draw-polyline": "पॉलीलाइन ड्रॉ करना समाप्त करें" } }, "widgets-bundle": { @@ -7467,10 +8061,18 @@ "update-animation-delay": "अपडेट एनीमेशन विलंब" }, "chart-axis": { + "limit": "सीमा", + "source": "स्रोत", + "key-value": "कुंजी / मान", + "value-required": "मान आवश्यक है।", + "entity-key-required": "एंटिटी कुंजी आवश्यक है।", + "key-required": "कुंजी आवश्यक है।", + "scale-limits": "स्केल सीमाएँ", + "scale-appearance": "स्केल का रूप", "scale": "स्केल", "scale-min": "न्यूनतम", "scale-max": "अधिकतम", - "scale-auto": "स्वतः" + "scale-auto": "स्वचालित" }, "bar": { "show-border": "बॉर्डर दिखाएँ", @@ -8011,7 +8613,10 @@ "add-radio-option": "रेडियो विकल्प जोड़ें", "radio-label-position": "लेबल स्थिति", "radio-label-position-before": "पहले", - "radio-label-position-after": "बाद में" + "radio-label-position-after": "बाद में", + "save-image": "छवि सहेजें", + "save-to-gallery": "स्वचालित रूप से कैप्चर की गई छवियाँ इमेज गैलरी में स्टोर करें", + "public-image": "छवि को किसी भी अनधिकृत उपयोगकर्ता के लिए उपलब्ध बनाता है" }, "invalid-qr-code-text": "QR कोड के लिए इनपुट टेक्स्ट अमान्य है। इनपुट का प्रकार स्ट्रिंग होना चाहिए", "qr-code": { @@ -8306,7 +8911,8 @@ "trips": "ट्रिप्स", "markers": "मार्कर्स", "polygons": "पॉलीगॉन्स", - "circles": "सर्कल्स" + "circles": "सर्कल्स", + "polylines": "पॉलीलाइन्स" }, "data-layer": { "source": "सोर्स", @@ -8503,11 +9109,30 @@ "finish-circle-hint-with-entity": "'{{entityName}}' के लिए सर्कल: समाप्त करने और सहेजने के लिए क्लिक करें", "finish-circle-hint": "सर्कल: ड्रॉइंग समाप्त करने के लिए क्लिक करें" }, + "polyline": { + "polyline-key": "पॉलीलाइन कुंजी", + "polyline-key-required": "पॉलीलाइन कुंजी आवश्यक है", + "no-polylines": "कोई पॉलीलाइन्स कॉन्फ़िगर नहीं की गई हैं", + "add-polylines": "पॉलीलाइन जोड़ें", + "polyline-configuration": "पॉलीलाइन कॉन्फ़िगरेशन", + "remove-polyline": "पॉलीलाइन हटाएं", + "edit": "पॉलीलाइन संपादित करें", + "cut": "पॉलीलाइन क्षेत्र काटें", + "rotate": "पॉलीलाइन घुमाएं", + "remove-polyline-for": "'{{entityName}}' के लिए पॉलीलाइन हटाएं", + "draw-polyline": "पॉलीलाइन ड्रॉ करें", + "polyline-place-first-point-hint-with-entity": "'{{entityName}}' के लिए पॉलीलाइन: पहले बिंदु को रखने के लिए क्लिक करें", + "polyline-place-first-point-hint": "पॉलीलाइन: पहले बिंदु को रखने के लिए क्लिक करें", + "finish-polyline-hint-with-entity": "'{{entityName}}' के लिए पॉलीलाइन: ड्रॉइंग समाप्त करने के लिए क्लिक करें", + "finish-polyline-hint": "पॉलीलाइन: ड्रॉइंग समाप्त करने के लिए क्लिक करें", + "polyline-place-first-point-cut-hint": "पहला बिंदु रखने के लिए क्लिक करें", + "finish-polyline-cut-hint": "पहले मार्कर को क्लिक करें ताकि समाप्त किया जा सके और सहेजा जा सके" + }, "select-entity": "एंटिटी चुनें", - "select-entity-hint": "संकेत: चयन के बाद स्थिति सेट करने के लिए मैप पर क्लिक करें" + "select-entity-hint": "संकेत: चयन के बाद स्थिति सेट करने के लिए मानचित्र पर क्लिक करें" }, "select-entity": "एंटिटी चुनें", - "select-entity-hint": "संकेत: चयन के बाद स्थिति सेट करने के लिए मैप पर क्लिक करें", + "select-entity-hint": "संकेत: चयन के बाद स्थिति सेट करने के लिए मानचित्र पर क्लिक करें", "tooltips": { "placeMarker": "'{{entityName}}' एंटिटी को रखने के लिए क्लिक करें", "firstVertex": "'{{entityName}}' के लिए पॉलीगॉन: पहला बिंदु रखने के लिए क्लिक करें", @@ -8944,6 +9569,7 @@ "show-empty-space-hidden-action": "छिपे हुए सेल बटन क्रिया की जगह खाली स्थान दिखाएँ", "dont-reserve-space-hidden-action": "छिपे हुए एक्शन बटनों के लिए स्थान आरक्षित न करें", "display-timestamp": "टाइमस्टैम्प", + "timestamp-column-name": "टाइमस्टैम्प", "display-pagination": "पेजिनेशन दिखाएँ", "default-page-size": "डिफ़ॉल्ट पृष्ठ आकार", "page-step-settings": "पृष्ठ स्टेप सेटिंग्स", @@ -9005,7 +9631,9 @@ "alarm-column-error": "कम से कम एक अलार्म कॉलम निर्दिष्ट होना चाहिए", "table-tabs": "टेबल टैब्स", "show-cell-actions-menu-mobile": "मोबाइल मोड में सेल एक्शन ड्रॉपडाउन मेनू दिखाएँ", - "disable-sorting": "सॉर्टिंग अक्षम करें" + "disable-sorting": "सॉर्टिंग अक्षम करें", + "sort-by": "टैब्स को इस आधार पर क्रमबद्ध करें", + "sort-timestamp-option": "बनाने का समय" }, "latest-chart": { "total": "कुल", @@ -9497,11 +10125,28 @@ "content": "

एंड-यूज़र डैशबोर्ड बनाए जाने पर, कस्टमर उपयोगकर्ता केवल अपने डिवाइस देख सकेगा, और अन्य कस्टमर का डेटा छुपा रहेगा।

इसे कैसे करना है, इसके लिए डॉक्यूमेंटेशन देखें:

" } } + }, + "api-usage": { + "api-usage": "API उपयोग", + "label": "लेबल", + "state-name": "राज्य नाम", + "status": "स्थिति", + "status-required": "स्थिति आवश्यक है।", + "limit": "अधिकतम सीमा", + "limit-required": "अधिकतम सीमा आवश्यक है।", + "current-number": "वर्तमान संख्या", + "current-number-required": "वर्तमान संख्या आवश्यक है।", + "add-key": "कुंजी जोड़ें", + "no-key": "कोई कुंजी नहीं", + "delete-key": "कुंजी हटाएं", + "target-dashboard-state": "लक्ष्य डैशबोर्ड स्थिति", + "go-to-main-state": "डिफ़ॉल्ट दृश्य पर जाएं" } }, "icon": { "icon": "आइकन", "icons": "आइकन्स", + "custom": "कस्टम", "select-icon": "आइकन चुनें", "material-icons": "मटेरियल आइकन्स", "show-all": "सभी आइकन्स दिखाएँ", @@ -9542,6 +10187,7 @@ "items-per-page-separator": "में से" }, "language": { + "auto": "Auto", "language": "भाषा" } } \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-it_IT.json b/ui-ngx/src/assets/locale/locale.constant-it_IT.json index ab07b5ec48..00de105518 100644 --- a/ui-ngx/src/assets/locale/locale.constant-it_IT.json +++ b/ui-ngx/src/assets/locale/locale.constant-it_IT.json @@ -803,7 +803,7 @@ "no-assets-matching": "Nessun asset corrispondente a '{{entity}}' trovato.", "asset-required": "L'asset è obbligatorio", "name-starts-with": "Espressione nome asset", - "help-text": "Usa '%' secondo necessità: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Usa '%' secondo necessità: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Cerca asset", "import": "Importa asset", "asset-file": "File asset", @@ -910,9 +910,9 @@ "processing-timeouts": "Timeout di elaborazione ${entityName}", "rule-chain": "Catena di regole", "rule-engine": "Motore di regole", - "rule-engine-daily-activity": "Attività giornaliera motore di regole", "rule-engine-executions": "Esecuzioni motore di regole", "rule-engine-hourly-activity": "Attività oraria motore di regole", + "rule-engine-daily-activity": "Attività giornaliera motore di regole", "rule-engine-monthly-activity": "Attività mensile motore di regole", "rule-engine-statistics": "Statistiche motore di regole", "rule-node": "Nodo regola", @@ -1095,6 +1095,7 @@ "expression": "Espressione", "no-found": "Nessun campo calcolato trovato", "list": "{ count, plural, =1 {Un campo calcolato} other {Elenco di # campi calcolati} }", + "add": "Aggiungi campo calcolato", "selected-fields": "{ count, plural, =1 {1 campo calcolato} other {# campi calcolati} } selezionato/i", "type": { "simple": "Semplice", @@ -2124,7 +2125,7 @@ "remove-alias": "Rimuovi alias del dispositivo", "add-alias": "Aggiungi alias del dispositivo", "name-starts-with": "Espressione nome dispositivo", - "help-text": "Usa '%' secondo necessità: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Usa '%' secondo necessità: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Elenco dispositivi", "use-device-name-filter": "Usa filtro", "device-list-empty": "Nessun dispositivo selezionato.", @@ -3039,7 +3040,7 @@ "no-entities-text": "Nessuna entità trovata", "no-entity-types-matching": "Nessun tipo entità corrispondente a '{{entityType}}' trovato.", "name-starts-with": "Espressione nome", - "help-text": "Usa '%' secondo necessità: '%nome_entità_contiene%', '%nome_entità_finisce', 'entità_inizia_con'.", + "help-text": "Usa '%' secondo necessità: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Usa filtro", "entity-list-empty": "Nessuna entità selezionata.", "entity-type-list-required": "È richiesto almeno un tipo di entità.", @@ -3242,7 +3243,7 @@ "remove-alias": "Rimuovi alias vista entità", "add-alias": "Aggiungi alias vista entità", "name-starts-with": "Espressione nome vista entità", - "help-text": "Usa '%' se necessario: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Usa '%' secondo necessità: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Elenco viste entità", "use-entity-view-name-filter": "Usa filtro", "entity-view-list-empty": "Nessuna vista entità selezionata.", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "Funzione per gestire il risultato vuoto", "handle-error-function": "Funzione per gestire l'errore", "handle-non-mobile-fallback-function": "Funzione per gestire il fallback Non-Mobile", - "save-to-gallery": "Salva nella galleria", + "save-to-gallery": "Salva nella 'Galleria Immagini'", "provision-type": "Tipo di provision", "auto": "Auto", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json index d4726ac3e9..84345c6216 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ja_JP.json +++ b/ui-ngx/src/assets/locale/locale.constant-ja_JP.json @@ -803,7 +803,7 @@ "no-assets-matching": "'{{entity}}'に一致するアセットが見つかりません。", "asset-required": "アセットは必須です", "name-starts-with": "アセット名の式", - "help-text": "必要に応じて'%'を使用してください: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'。", + "help-text": "必要に応じて'%'を使用してください: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "アセットを検索", "import": "アセットをインポート", "asset-file": "アセットファイル", @@ -910,9 +910,9 @@ "processing-timeouts": "${entityName} 処理タイムアウト", "rule-chain": "ルールチェーン", "rule-engine": "ルールエンジン", - "rule-engine-daily-activity": "ルールエンジンの日別アクティビティ", "rule-engine-executions": "ルールエンジン実行", "rule-engine-hourly-activity": "ルールエンジンの時間別アクティビティ", + "rule-engine-daily-activity": "ルールエンジンの日別アクティビティ", "rule-engine-monthly-activity": "ルールエンジンの月別アクティビティ", "rule-engine-statistics": "ルールエンジン統計", "rule-node": "ルールノード", @@ -1095,6 +1095,7 @@ "expression": "式", "no-found": "計算フィールドが見つかりません", "list": "{ count, plural, =1 {計算フィールド} other {計算フィールド一覧(# 件)} }", + "add": "計算フィールドを追加", "selected-fields": "{ count, plural, =1 {1 件の計算フィールド} other {# 件の計算フィールド} } を選択", "type": { "simple": "シンプル", @@ -2124,7 +2125,7 @@ "remove-alias": "デバイスエイリアスを削除", "add-alias": "デバイスエイリアスを追加", "name-starts-with": "デバイス名式", - "help-text": "必要に応じて'%'を使用してください: '%device_name_contains%', '%device_name_ends', 'device_starts_with'。", + "help-text": "必要に応じて'%'を使用してください: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "デバイス一覧", "use-device-name-filter": "フィルターを使用", "device-list-empty": "デバイスが選択されていません。", @@ -3039,7 +3040,7 @@ "no-entities-text": "エンティティは見つかりません", "no-entity-types-matching": "エンティティタイプ '{{entityType}}' に一致するエンティティタイプは見つかりませんでした。", "name-starts-with": "名前の表現", - "help-text": "必要に応じて '%' を使用します: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "必要に応じて'%'を使用してください: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "フィルターを使用", "entity-list-empty": "エンティティが選択されていません。", "entity-type-list-required": "少なくとも1つのエンティティタイプを選択する必要があります。", @@ -3242,7 +3243,7 @@ "remove-alias": "エンティティビューのエイリアスを削除", "add-alias": "エンティティビューのエイリアスを追加", "name-starts-with": "エンティティビュー名の式", - "help-text": "必要に応じて '%' を使用してください:'%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'。", + "help-text": "必要に応じて'%'を使用してください: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "エンティティビュー一覧", "use-entity-view-name-filter": "フィルターを使用", "entity-view-list-empty": "エンティティビューが選択されていません。", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "空結果処理関数", "handle-error-function": "エラー処理関数", "handle-non-mobile-fallback-function": "非モバイルフォールバック処理関数", - "save-to-gallery": "ギャラリーに保存", + "save-to-gallery": "'画像ギャラリー'に保存", "provision-type": "プロビジョニングタイプ", "auto": "自動", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json index 787d26ee2e..0351963ed9 100644 --- a/ui-ngx/src/assets/locale/locale.constant-ko_KR.json +++ b/ui-ngx/src/assets/locale/locale.constant-ko_KR.json @@ -10,11 +10,16 @@ "permission-denied": "권한이 없습니다", "permission-denied-text": "이 작업을 수행할 권한이 없습니다!" }, + "account": { + "account": "계정", + "notification-settings": "알림 설정" + }, "action": { "activate": "활성화", "suspend": "비활성화", "save": "저장", "saveAs": "다른 이름으로 저장", + "move": "이동", "cancel": "취소", "ok": "확인", "delete": "삭제", @@ -35,6 +40,7 @@ "edit-mode": "수정 모드", "enter-edit-mode": "수정 모드 진입", "decline-changes": "변경사항 포기", + "decline": "취소", "close": "닫기", "back": "뒤로", "run": "실행", @@ -55,10 +61,29 @@ "continue": "계속", "discard-changes": "변경 취소", "download": "다운로드", + "next": "다음", "next-with-label": "다음: {{label}}", "read-more": "더보기", "hide": "숨기기", - "done": "완료" + "test": "테스트", + "done": "완료", + "print": "인쇄", + "restore": "복원", + "confirm": "확인", + "more": "더보기", + "less": "줄이기", + "skip": "건너뛰기", + "send": "보내기", + "reset": "초기화", + "show-more": "더보기", + "dont-show-again": "다시 표시하지 않음", + "see-documentation": "문서 보기", + "see-debug-events": "디버그 이벤트 보기", + "clear": "지우기", + "upload": "업로드", + "delete-anyway": "무시하고 삭제", + "delete-selected": "선택 삭제", + "set": "설정" }, "aggregation": { "aggregation": "집합", @@ -73,8 +98,11 @@ "none": "없음" }, "admin": { + "settings": "설정", "general": "일반", "general-settings": "일반 설정", + "home-settings": "홈 설정", + "home": "홈", "outgoing-mail": "메일 전송", "outgoing-mail-settings": "메일 전송 설정", "system-settings": "시스템 설정", @@ -83,6 +111,23 @@ "base-url-required": "기본 URL을 입력해야 합니다.", "prohibit-different-url": "클라이언트 요청 헤더로부터 호스트 이름 사용을 제한", "prohibit-different-url-hint": "이 설정은 프로덕션 환경에서 활성화되어야 합니다. 비활성화되면 보안 문제가 발생할 수 있습니다.", + "device-connectivity": { + "device-connectivity": "장치 연결", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPS", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "호스트 또는 포트 필드가 비어 있으면 기본 프로토콜 값이 사용됩니다.", + "host": "호스트", + "port": "포트", + "port-pattern": "포트는 양의 정수여야 합니다.", + "port-range": "포트를 1~65535 범위 내에서 입력하세요." + }, "mail-from": "보내는 사람", "mail-from-required": "보내는 사람을 입력해야 합니다.", "smtp-protocol": "SMTP 프로토콜", @@ -95,7 +140,7 @@ "timeout-required": "제한시간이 입력되지 않았습니다.", "timeout-invalid": "제한시간이 올바르게 입력되지 않았습니다.", "enable-tls": "TLS 사용", - "tls-version" : "TLS 버전", + "tls-version": "TLS 버전", "enable-proxy": "프록시 사용", "proxy-host": "프록시 호스트", "proxy-host-required": "프록시 호스트를 입력하세요.", @@ -104,6 +149,7 @@ "proxy-port-range": "프록시 포트를 1~65535 범위 내에서 입력하세요.", "proxy-user": "프록시 사용자", "proxy-password": "프록시 비밀번호", + "change-password": "비밀번호 변경", "send-test-mail": "테스트 메일 보내기", "sms-provider": "SMS 제공자", "sms-provider-settings": "SMS 제공자 설정", @@ -111,18 +157,21 @@ "sms-provider-type-required": "SMS 제공자 유형을 입력하세요.", "sms-provider-type-aws-sns": "Amazon SNS", "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", "aws-access-key-id": "AWS 액세스 키 ID", "aws-access-key-id-required": "AWS 액세스 ID를 입력하세요.", "aws-secret-access-key": "AWS 보안 액세스 키", "aws-secret-access-key-required": "AWS 보안 액세스 키를 입력하세요", "aws-region": "AWS 지역", "aws-region-required": "AWS 지역을 입력하세요", - "number-from": "Phone Number From", - "number-from-required": "Phone Number From을(를) 입력하세요.", - "number-to": "Phone Number To", - "number-to-required": "Phone Number To을(를) 입력하세요.", - "phone-number-hint": "Phone Number in E.164 format, ex. +19995550123", - "phone-number-pattern": "Invalid phone number. Should be in E.164 format, ex. +19995550123.", + "number-from": "발신 전화번호", + "number-from-required": "발신 전화번호를 입력하세요.", + "number-to": "수신 전화번호", + "number-to-required": "수신 전화번호를 입력하세요.", + "phone-number-hint": "E.164 형식의 전화번호 (예: +821012345678)", + "phone-number-hint-twilio": "전화번호는 E.164 형식/전화번호 SID/메시징 서비스 SID이어야 합니다. 예: +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "잘못된 전화번호 형식입니다. E.164 형식을 사용하세요 (예: +821012345678).", + "phone-number-pattern-twilio": "잘못된 전화번호입니다. E.164 형식/전화번호 SID/메시징 서비스 SID이어야 합니다. 예: +19995550123/PNXXX/MGXXX", "sms-message": "SMS 메시지", "sms-message-required": "SMS 메시지를 입력하세요.", "sms-message-max-length": "SMS 메시지는 1600자를 넘을 수 없습니다.", @@ -137,6 +186,9 @@ "minimum-password-length": "최소 비밀번호 길이", "minimum-password-length-required": "최소 비밀번호 길이를 입력하세요", "minimum-password-length-range": "최소 비밀번호 길이를 5~50 범위 내에서 입력하세요", + "maximum-password-length": "최대 비밀번호 길이", + "maximum-password-length-min": "최대 비밀번호 길이는 최소 6자 이상이어야 합니다.", + "maximum-password-length-less-min": "최대 비밀번호 길이는 최소 길이보다 커야 합니다.", "minimum-uppercase-letters": "최소 대문자 수", "minimum-uppercase-letters-range": "최소 대문자 수는 음수가 될 수 없습니다", "minimum-lowercase-letters": "최소 소문자 수", @@ -149,83 +201,372 @@ "password-expiration-period-days-range": "비밀번호 유효 주기는 음수가 될 수 없습니다", "password-reuse-frequency-days": "비밀번호 재사용 주기(일)", "password-reuse-frequency-days-range": "비밀번호 재사용 주기는 음수가 될 수 없습니다", + "allow-whitespace": "공백 문자 허용", + "force-reset-password-if-no-valid": "유효하지 않은 경우 비밀번호 재설정 강제", + "force-reset-password-if-no-valid-hint": "이 기능을 활성화할 때 주의하십시오. 유효하지 않은 비밀번호를 사용하는 사용자는 이메일을 통해 비밀번호를 재설정해야 합니다.", "general-policy": "일반 정책", "max-failed-login-attempts": "계정이 잠기기 전까지 허용되는 최대 로그인 실패 시도 수", "minimum-max-failed-login-attempts-range": "최대 로그인 실패 시도 수는 음수가 될 수 없습니다", "user-lockout-notification-email": "계정이 잠길 경우 이메일로 안내 발송", + "user-activation-token-ttl": "사용자 활성화 링크 TTL (시간)", + "user-activation-token-ttl-range": "사용자 활성화 링크 TTL은 1~24시간 범위여야 합니다.", + "password-reset-token-ttl": "비밀번호 재설정 링크 TTL (시간)", + "password-reset-token-ttl-range": "비밀번호 재설정 링크 TTL은 1~24시간 범위여야 합니다.", + "mobile-secret-key-length": "모바일 비밀 키 길이", + "mobile-secret-key-length-range": "모바일 비밀 키 길이는 양수여야 합니다.", "domain-name": "도메인 이름", "domain-name-unique": "도메인 이름과 프로토콜은 중복되지 않아야 합니다.", + "domain-name-max-length": "도메인 이름은 256자 미만이어야 합니다.", "error-verification-url": "도메인 이름은 '/'이나 ':'와 같은 문자를 포함할 수 없습니다. 예: thingsboard.io", + "connection-settings": "연결 설정", "oauth2": { - "access-token-uri": "액세스 토큰 URI", - "access-token-uri-required": "Access token URI를 입력하세요.", - "activate-user": "사용자 활성화", - "add-domain": "도메인 추가", - "delete-domain": "도메인 삭제", - "add-provider": "제공자 추가", - "delete-provider": "제공자 삭제", - "allow-user-creation": "사용자 생성 허용", - "always-fullscreen": "항상 최대화면으로", - "authorization-uri": "Authorization URI", - "authorization-uri-required": "Authorization URI를 입력하세요.", - "client-authentication-method": "클라이언트 authentication 방법", - "client-id": "Client ID", - "client-id-required": "Client ID를 입력하세요.", - "client-secret": "Client secret", - "client-secret-required": "Client secret을(를) 입력하세요.", - "custom-setting": "사용자 정의 설정", - "customer-name-pattern": "Customer name pattern", - "default-dashboard-name": "Default dashboard name", - "delete-domain-text": "Be careful, after the confirmation a domain and all provider data will be unavailable.", - "delete-domain-title": "Are you sure you want to delete settings the domain '{{domainName}}'?", - "delete-registration-text": "Be careful, after the confirmation a provider data will be unavailable.", - "delete-registration-title": "Are you sure you want to delete the provider '{{name}}'?", - "email-attribute-key": "Email 속성 키", - "email-attribute-key-required": "Email attribute key을(를) 입력하세요.", - "first-name-attribute-key": "이름 속성 키", - "general": "일반", - "jwk-set-uri": "JSON Web Key URI", - "last-name-attribute-key": "Last name attribute key", - "login-button-icon": "Login button icon", - "login-button-label": "Provider label", - "login-button-label-placeholder": "Login with $(Provider label)", - "login-button-label-required": "Label을(를) 입력하세요.", - "login-provider": "로그인 제공자", - "mapper": "Mapper", - "new-domain": "새로운 도메인", - "oauth2": "OAuth2", - "redirect-uri-template": "Redirect URI 탬플릿", - "copy-redirect-uri": "리디렉션 URI 복사", - "registration-id": "Registration ID", - "registration-id-required": "Registration ID을(를) 입력하세요.", - "registration-id-unique": "Registration ID는 시스템 상에서 중복될 수 없습니다.", - "scope": "Scope", - "scope-required": "Scope을(를) 입력하세요.", - "tenant-name-pattern": "테넌트 이름 규칙", - "tenant-name-pattern-required": "테넌트 이름 규칙을 입력하세요.", - "tenant-name-strategy": "Tenant name strategy", - "type": "Mapper type", - "uri-pattern-error": "유효하지 않은 URI 형식입니다.", - "url": "URL", - "url-pattern": "유효하지 않은 URL 형식입니다.", - "url-required": "URL을(를) 입력하세요.", - "user-info-uri": "User info URI", - "user-info-uri-required": "사용자 정보 URI을(를) 입력하세요.", - "user-name-attribute-name": "사용자 이름 속성 키", - "user-name-attribute-name-required": "사용자 이름 속성 키를 입력하세요", - "protocol": "Protocol", - "domain-schema-http": "HTTP", - "domain-schema-https": "HTTPS", - "domain-schema-mixed": "HTTP+HTTPS", - "enable": "OAuth2 설정 사용" - } + "access-token-uri": "액세스 토큰 URI", + "access-token-uri-required": "액세스 토큰 URI를 입력하세요.", + "activate-user": "사용자 활성화", + "add-domain": "도메인 추가", + "delete-domain": "도메인 삭제", + "add-provider": "제공자 추가", + "delete-provider": "제공자 삭제", + "allow-user-creation": "사용자 생성 허용", + "always-fullscreen": "항상 최대화면으로", + "authorization-uri": "인증 URI", + "authorization-uri-required": "인증 URI를 입력하세요.", + "add-client": "OAuth 2.0 클라이언트 추가", + "client-details": "OAuth 2.0 클라이언트 세부 정보", + "client": "OAuth 2.0 클라이언트", + "clients": "OAuth 2.0 클라이언트", + "no-oauth2-clients": "OAuth 2.0 클라이언트가 없습니다", + "search-oauth2-clients": "OAuth 2.0 클라이언트 검색", + "delete-client-title": "OAuth 2.0 클라이언트 '{{clientName}}'을(를) 삭제하시겠습니까?", + "delete-client-text": "주의: 확인 후 클라이언트와 모든 관련 데이터를 사용할 수 없게 됩니다.", + "delete-mobile-app-title": "모바일 애플리케이션 '{{applicationName}}'을(를) 삭제하시겠습니까?", + "delete-mobile-app-text": "주의: 확인 후 모바일 애플리케이션과 모든 관련 데이터를 사용할 수 없게 됩니다.", + "title": "제목", + "client-title-required": "제목은 필수 항목입니다", + "client-title-max-length": "제목은 100자 미만이어야 합니다.", + "advanced-settings": "고급 설정", + "domain-details": "도메인 세부 정보", + "no-domains": "도메인이 없습니다", + "search-domains": "도메인 검색", + "mobile-app-details": "모바일 애플리케이션 세부 정보", + "add-mobile-app": "모바일 애플리케이션 추가", + "no-mobile-apps": "모바일 애플리케이션이 없습니다", + "search-mobile-apps": "모바일 애플리케이션 검색", + "send-token": "토큰 전송", + "create-new": "새로 만들기", + "client-authentication-method": "클라이언트 인증 방법", + "client-id": "클라이언트 ID", + "client-id-required": "클라이언트 ID를 입력하세요.", + "client-id-max-length": "클라이언트 ID는 256자 미만이어야 합니다.", + "client-secret": "클라이언트 비밀키", + "client-secret-required": "클라이언트 비밀키를 입력하세요.", + "client-secret-max-length": "클라이언트 비밀키는 2049자 미만이어야 합니다.", + "custom-setting": "사용자 정의 설정", + "customer-name-pattern": "고객 이름 패턴", + "customer-name-pattern-max-length": "고객 이름 패턴은 256자 미만이어야 합니다.", + "default-dashboard-name": "기본 대시보드 이름", + "default-dashboard-name-max-length": "기본 대시보드 이름은 256자 미만이어야 합니다.", + "delete-domain-text": "주의: 확인 후 도메인과 모든 공급자 데이터를 사용할 수 없게 됩니다.", + "delete-domain-title": "'{{domainName}}' 도메인을 삭제하시겠습니까?", + "delete-registration-text": "주의: 확인 후 공급자 데이터를 사용할 수 없게 됩니다.", + "delete-registration-title": "'{{name}}' 제공자를 삭제하시겠습니까?", + "email-attribute-key": "이메일 속성 키", + "email-attribute-key-required": "이메일 속성 키를 입력하세요.", + "email-attribute-key-max-length": "이메일 속성 키는 32자 미만이어야 합니다.", + "first-name-attribute-key": "이름 속성 키", + "first-name-attribute-key-max-length": "이름 속성 키는 32자 미만이어야 합니다.", + "general": "일반", + "jwk-set-uri": "JSON 웹 키 URI", + "last-name-attribute-key": "성 속성 키", + "last-name-attribute-key-max-length": "성 속성 키는 32자 미만이어야 합니다.", + "login-button-icon": "로그인 버튼 아이콘", + "login-button-label": "제공자 레이블", + "login-button-label-placeholder": "$(제공자 레이블)로 로그인", + "login-button-label-required": "제공자 레이블을 입력하세요.", + "login-provider": "로그인 제공자", + "mapper": "매퍼", + "new-domain": "새로운 도메인", + "oauth2": "OAuth2", + "password-max-length": "비밀번호는 256자 미만이어야 합니다.", + "redirect-uri-template": "리디렉션 URI 탬플릿", + "copy-redirect-uri": "리디렉션 URI 복사", + "registration-id": "등록 ID", + "registration-id-required": "등록 ID를 입력하세요.", + "registration-id-unique": "등록 ID는 시스템 상에서 중복될 수 없습니다.", + "scope": "범위", + "scope-required": "범위를 입력하세요.", + "tenant-name-pattern": "테넌트 이름 규칙", + "tenant-name-pattern-required": "테넌트 이름 규칙을 입력하세요.", + "tenant-name-pattern-max-length": "테넌트 이름 패턴은 256자 미만이어야 합니다.", + "tenant-name-strategy": "테넌트 이름 전략", + "type": "매퍼 유형", + "uri-pattern-error": "유효하지 않은 URI 형식입니다.", + "url": "URL", + "url-pattern": "유효하지 않은 URL 형식입니다.", + "url-required": "URL을(를) 입력하세요.", + "url-max-length": "URL은 256자 미만이어야 합니다.", + "user-info-uri": "사용자 정보 URI", + "user-info-uri-required": "사용자 정보 URI을(를) 입력하세요.", + "username-max-length": "사용자 이름은 256자 미만이어야 합니다.", + "user-name-attribute-name": "사용자 이름 속성 키", + "user-name-attribute-name-required": "사용자 이름 속성 키를 입력하세요", + "protocol": "프로토콜", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "OAuth2 설정 사용", + "disable": "OAuth 2.0 설정 비활성화", + "edge": "엣지에 전파", + "edge-enable": "엣지 전파 활성화", + "edge-disable": "엣지 전파 비활성화", + "domains": "도메인", + "mobile-apps": "모바일 애플리케이션", + "mobile-package": "애플리케이션 패키지", + "mobile-package-placeholder": "예: my.example.app", + "mobile-package-hint": "Android: 고유한 애플리케이션 ID. iOS: 제품 번들 식별자.", + "mobile-package-unique": "애플리케이션 패키지는 고유해야 합니다.", + "mobile-package-required": "애플리케이션 패키지가 필요합니다.", + "mobile-package-max-length": "애플리케이션 패키지는 256자 미만이어야 합니다", + "mobile-package-spaces": "애플리케이션 패키지에는 공백이 포함되지 않아야 합니다.", + "mobile-app-secret": "애플리케이션 비밀키", + "mobile-app-secret-hint": "최소 512비트 데이터를 나타내는 Base64 인코딩 문자열.", + "mobile-app-secret-required": "애플리케이션 비밀키는 필수 항목입니다.", + "mobile-app-secret-min-length": "애플리케이션 비밀키는 최소 512비트여야 합니다.", + "mobile-app-secret-base64": "애플리케이션 비밀키는 Base64 형식이어야 합니다.", + "invalid-mobile-app-secret": "애플리케이션 비밀키는 영숫자만 포함해야 하며", + "copy-mobile-app-secret": "애플리케이션 비밀키 복사", + "delete-mobile-app": "애플리케이션 정보 삭제", + "providers": "제공자", + "platform-web": "웹", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "모든 플랫폼", + "smtp-provider": "SMTP 제공자", + "allowed-platforms": "허용된 플랫폼", + "authentication": "인증", + "basic": "기본", + "provider": "제공자", + "redirect-url": "리디렉션 URI", + "domain-name": "도메인 이름", + "domain-name-required": "도메인 이름은 필수 항목입니다.", + "redirect-url-template": "리디렉션 URI 탬플릿", + "microsoft-tenant-id": "디렉터리 (테넌트) ID", + "microsoft-tenant-id-required": "디렉터리 (테넌트) ID는 필수 항목입니다.", + "token-uri": "토큰 URI", + "token-uri-required": "토큰 URI는 필수 항목입니다.", + "redirect-uri": "리디렉션 URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "사용자 지정", + "generate-access-token": "액세스 토큰 생성", + "update-access-token": "액세스 토큰 업데이트", + "access-token-status": "액세스 토큰 상태:", + "token-status-generated": "생성됨", + "token-status-not-generated": "생성되지 않음" + }, + "smpp-provider": { + "smpp-version": "SMPP 버전", + "smpp-host": "SMPP 호스트", + "smpp-host-required": "SMPP 호스트는 필수 항목입니다.", + "smpp-port": "SMPP 포트", + "smpp-port-required": "SMPP 포트는 필수 항목입니다.", + "system-id": "시스템 ID", + "system-id-required": "시스템 ID는 필수 항목입니다.", + "password": "비밀번호", + "password-required": "비밀번호는 필수 항목입니다.", + "type-settings": "유형 설정", + "source-settings": "소스 설정", + "destination-settings": "대상 설정", + "additional-settings": "추가 설정", + "system-type": "시스템 유형", + "bind-type": "바인드 유형", + "service-type": "서비스 유형", + "source-address": "소스 주소", + "source-ton": "소스 TON", + "source-npi": "소스 NPI", + "destination-ton": "대상 TON (번호 유형)", + "destination-npi": "대상 NPI (번호 계획 식별)", + "address-range": "주소 범위", + "coding-scheme": "코딩 체계", + "bind-type-tx": "송신기", + "bind-type-rx": "수신기", + "bind-type-trx": "송수신기", + "ton-unknown": "알 수 없음", + "ton-international": "국제", + "ton-national": "국내", + "ton-network-specific": "네트워크 특정", + "ton-subscriber-number": "가입자 번호", + "ton-alphanumeric": "영숫자", + "ton-abbreviated": "약어", + "npi-unknown": "0 - 알 수 없음", + "npi-isdn": "1 - ISDN/전화 번호 계획 (E163/E164)", + "npi-data-numbering-plan": "3 - 데이터 번호 계획 (X.121)", + "npi-telex-numbering-plan": "4 - 텔렉스 번호 계획 (F.69)", + "npi-land-mobile": "6 - 육상 이동통신 (E.212)", + "npi-national-numbering-plan": "8 - 국가 번호 계획", + "npi-private-numbering-plan": "9 - 사설 번호 계획", + "npi-ermes-numbering-plan": "10 - ERMES 번호 계획 (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - 인터넷 (IP)", + "npi-wap-client-id": "18 - WAP 클라이언트 ID (WAP 포럼에서 정의 예정)", + "scheme-smsc": "0 - SMSC 기본 알파벳 (단문 및 장문 코드용 ASCII, 무료용 GSM)", + "scheme-ia5": "1 - IA5 (단문 및 장문 코드용 ASCII, 무료용 Latin 9 (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - 옥텟 미지정 (8비트 바이너리)", + "scheme-latin-1": "3 - 라틴 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - 옥텟 미지정 (8비트 바이너리)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - 키릴 문자 (ISO-8859-5)", + "scheme-latin-hebrew": "7 - 라틴/히브리어 (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - 픽토그램 인코딩", + "scheme-music-codes": "10 - 음악 코드 (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - 확장 칸지 JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - 한국어 그래픽 문자 세트 (KS C 5601/KS X 1001)" + }, + "queue-select-name": "큐 이름 선택", + "queue-name": "이름", + "queue-name-required": "큐 이름은 필수 항목입니다!", + "queues": "큐", + "queue-partitions": "파티션", + "queue-submit-strategy": "제출 전략", + "queue-processing-strategy": "처리 전략", + "queue-configuration": "큐 구성", + "repository-settings": "저장소 설정", + "repository": "저장소", + "repository-url": "저장소 URL", + "repository-url-required": "저장소 URL은 필수 항목입니다.", + "default-branch": "기본 브랜치 이름", + "repository-read-only": "읽기 전용", + "show-merge-commits": "병합 커밋 표시", + "authentication-settings": "인증 설정", + "auth-method": "인증 방법", + "auth-method-username-password": "비밀번호 / 액세스 토큰", + "auth-method-username-password-hint": "GitHub 사용자는 액세스 토큰을 사용해야 합니다", + "auth-method-private-key": "개인 키", + "password-access-token": "비밀번호 / 액세스 토큰", + "change-password-access-token": "비밀번호 / 액세스 토큰 변경", + "private-key": "개인 키", + "drop-private-key-file-or": "개인 키 파일을 끌어다 놓거나", + "passphrase": "암호 문구", + "enter-passphrase": "암호 문구 입력", + "change-passphrase": "암호 문구 변경", + "check-access": "액세스 확인", + "check-repository-access-success": "저장소 액세스가 성공적으로 확인되었습니다!", + "delete-repository-settings-title": "저장소 설정을 삭제하시겠습니까?", + "delete-repository-settings-text": "주의: 확인 후 저장소 설정이 제거되고", + "auto-commit-settings": "자동 커밋 설정", + "auto-commit": "자동 커밋", + "auto-commit-entities": "자동 커밋 엔티티", + "no-auto-commit-entities-prompt": "자동 커밋에 구성된 엔티티가 없습니다", + "delete-auto-commit-settings-title": "자동 커밋 설정을 삭제하시겠습니까?", + "delete-auto-commit-settings-text": "주의: 확인 후에는 자동 커밋 설정이 삭제되며, 모든 엔티티의 자동 커밋 기능이 비활성화됩니다.", + "mobile-app": { + "mobile-app": "모바일 앱", + "mobile-app-qr-code-widget-settings": "모바일 앱 QR 코드 위젯 설정", + "applications": "애플리케이션", + "default": "기본값", + "custom": "사용자 지정", + "android": "Android", + "ios": "iOS", + "appearance": "모양", + "appearance-on-home-page": "홈 페이지 모양", + "enabled": "사용", + "disabled": "비활성화됨", + "badges": "배지", + "label": "라벨", + "label-required": "레이블은 필수 항목입니다", + "label-max-length": "레이블은 50자 이하여야 합니다.", + "right": "오른쪽", + "left": "왼쪽", + "set": "설정", + "preview": "미리보기", + "connect-mobile-app": "모바일 앱 연결", + "use-system-settings": "시스템 설정 사용" + }, + "2fa": { + "2fa": "2단계 인증", + "available-providers": "사용 가능한 제공자", + "available-providers-required": "최소 하나의 2FA 제공자를 구성해야 합니다.", + "issuer-name": "발급자 이름", + "issuer-name-required": "발급자 이름은 필수 항목입니다.", + "max-verification-failures-before-user-lockout": "사용자 잠금 전 최대 인증 실패 횟수", + "max-verification-failures-before-user-lockout-pattern": "최대 인증 실패 횟수는 양의 정수여야 합니다.", + "number-of-checking-attempts": "확인 시도 횟수", + "number-of-checking-attempts-pattern": "확인 시도 횟수는 양의 정수여야 합니다.", + "number-of-checking-attempts-required": "확인 시도 횟수는 필수 항목입니다.", + "number-of-codes": "코드 수", + "number-of-codes-pattern": "코드 수는 양의 정수여야 합니다.", + "number-of-codes-required": "코드 수는 필수 항목입니다.", + "provider": "제공자", + "retry-verification-code-period": "인증 코드 재시도 기간", + "retry-verification-code-period-pattern": "최소 기간은 5초입니다.", + "retry-verification-code-period-required": "인증 코드 재시도 기간은 필수 항목입니다.", + "total-allowed-time-for-verification": "인증 총 허용 시간", + "total-allowed-time-for-verification-pattern": "최소 총 허용 시간은 60초입니다.", + "total-allowed-time-for-verification-required": "총 허용 시간은 필수 항목입니다.", + "use-system-two-factor-auth-settings": "시스템 2단계 인증 설정 사용", + "verification-code-check-rate-limit": "인증 코드 확인 속도 제한", + "verification-code-lifetime": "인증 코드 수명", + "verification-code-lifetime-pattern": "인증 코드 수명은 양의 정수여야 합니다.", + "verification-code-lifetime-required": "인증 코드 수명은 필수 항목입니다.", + "verification-message-template": "인증 메시지 템플릿", + "verification-limitations": "인증 제한", + "verification-message-template-pattern": "인증 메시지에는 ${code} 패턴이 포함되어야 합니다.", + "verification-message-template-required": "인증 메시지 템플릿은 필수 항목입니다.", + "within-time": "시간 내", + "within-time-pattern": "시간은 양의 정수여야 합니다.", + "within-time-required": "시간은 필수 항목입니다.", + "force-2fa": "2단계 인증 강제", + "enforce-for": "강제 적용 대상" + }, + "jwt": { + "security-settings": "JWT 보안 설정", + "issuer-name": "발급자 이름", + "issuer-name-required": "발급자 이름은 필수 항목입니다.", + "signings-key": "서명 키", + "signings-key-hint": "최소 512비트 데이터를 나타내는 Base64 인코딩 문자열.", + "signings-key-required": "서명 키는 필수 항목입니다.", + "signings-key-min-length": "서명 키는 최소 512비트여야 합니다.", + "signings-key-base64": "서명 키는 Base64 형식이어야 합니다.", + "expiration-time": "토큰 만료 시간 (초)", + "expiration-time-required": "토큰 만료 시간은 필수 항목입니다.", + "expiration-time-max": "최대 허용 시간은 2147483647초 (68년)입니다.", + "expiration-time-min": "최소 시간은 60초 (1분)입니다.", + "refresh-expiration-time": "새로고침 토큰 만료 시간 (초)", + "refresh-expiration-time-required": "새로고침 토큰 만료 시간은 필수 항목입니다.", + "refresh-expiration-time-max": "최대 허용 시간은 2147483647초 (68년)입니다.", + "refresh-expiration-time-min": "최소 시간은 900초 (15분)입니다.", + "refresh-expiration-time-less-token": "새로고침 토큰 시간은 토큰 시간보다 커야 합니다.", + "generate-key": "키 생성", + "info-header": "모든 사용자가 다시 로그인해야 합니다", + "info-message": "JWT 서명 키 변경 시 발급된 모든 토큰이 무효화됩니다. 모든 사용자가" + }, + "resources": "리소스", + "notifications": "알림", + "notifications-settings": "알림 설정", + "slack-api-token": "Slack API 토큰", + "slack": "Slack", + "slack-settings": "Slack 설정", + "mobile-settings": "모바일 설정", + "firebase-service-account-file": "Firebase 서비스 계정 자격 증명 JSON 파일", + "select-firebase-service-account-file": "Firebase 서비스 계정 자격 증명 파일을 끌어다 놓거나", + "trendz": "Trendz", + "trendz-settings": "Trendz 설정", + "trendz-url": "Trendz URL", + "trendz-url-required": "Trendz URL은 필수 항목입니다", + "trendz-api-key": "Trendz API 키", + "trendz-enable": "Trendz 사용" }, "alarm": { "alarm": "알람", + "alarm-list": "알람 목록", "alarms": "알람", + "all-alarms": "모든 알람", "select-alarm": "알람 선택", "no-alarms-matching": "'{{entity}}'에 대한 알람이 존재하지 않습니다.", "alarm-required": "알람이 필요합니다", + "alarm-filter": "알람 필터", + "filter": "필터", "alarm-status": "알람 상태", "alarm-status-list": "알람 상태 목록", "any-status": "모든 상태", @@ -249,12 +590,26 @@ "originator": "만든 사람", "originator-type": "만든 사람 유형", "details": "상세 내역", + "originator-label": "발생자 라벨", + "assign": "할당", + "assignments": "할당", + "assignee": "담당자", + "assignee-id": "담당자 ID", + "assignee-first-name": "담당자 이름", + "assignee-last-name": "담당자 성", + "assignee-email": "담당자 이메일", + "unassigned": "할당 해제됨", + "user-deleted": "사용자 삭제됨", + "assignee-not-set": "모두", "status": "상태", "alarm-details": "알람 상세", "start-time": "시작 시각", + "assign-time": "할당 시간", "end-time": "마지막 시각", "ack-time": "확인 시간", "clear-time": "해제 시간", + "duration": "기간", + "alarm-severity": "알람 심각도", "alarm-severity-list": "알람 심각도 목록", "any-severity": "모든 심각도", "severity-critical": "심각한", @@ -264,6 +619,7 @@ "severity-indeterminate": "중간", "acknowledge": "확인", "clear": "지우기", + "delete": "삭제", "search": "알람 검색", "selected-alarms": "{ count, plural, =1 {1 개 알람} other {# 개 알람} }이 선택됨", "no-data": "표시할 데이터가 없습니다", @@ -274,67 +630,116 @@ "aknowledge-alarms-text": "{ count, plural, =1 {1 개 알람} other {# 개 알람} }의 알람을 수용하시겠습니까?", "aknowledge-alarm-title": "알람 확인", "aknowledge-alarm-text": "알람을 확인하시겠습니까??", + "selected-alarms-are-acknowledged": "선택된 알람은 이미 확인되었습니다", "clear-alarms-title": "{ count, plural, =1 {1 개 알람} other {# 개 알람} } 해제", "clear-alarms-text": "{ count, plural, =1 {1 개 알람} other {# 개 알람} }을 해제하시겠습니까?", "clear-alarm-title": "알람 해제", "clear-alarm-text": "알람을 해제하시겠습니까?", + "delete-alarms-title": "{ count, plural, =1 {1개 알람} other {#개 알람} } 삭제", + "delete-alarms-text": "{ count, plural, =1 {1개 알람} other {#개 알람} }을(를) 삭제하시겠습니까?", + "selected-alarms-are-cleared": "선택된 알람은 이미 해제되었습니다", "alarm-status-filter": "알람 상태 필터", - "alarm-filter": "알람 필터", + "alarm-filter-title": "알람 필터", + "assigned": "할당됨", + "filter-title": "필터", "max-count-load": "불러올 최대 알람 수를 0~무제한 범위 내에서 입력하세요", "max-count-load-required": "불러올 최대 알람 수를 입력하세요.", "max-count-load-error-min": "최소 값은 0입니다.", - "fetch-size": "Fetch size", - "fetch-size-required": "Fetch size를 입력하세요.", + "fetch-size": "페치 크기", + "fetch-size-required": "페치 크기를 입력하세요.", "fetch-size-error-min": "최소 값은 10입니다.", + "alarm-types": "알람 유형", "alarm-type-list": "알람 유형 목록", "any-type": "모든 유형", - "search-propagated-alarms": "전파된 알람 검색" + "assigned-to-current-user": "현재 사용자에게 할당됨", + "assigned-to-me": "나에게 할당됨", + "search-propagated-alarms": "전파된 알람 검색", + "comments": "알람 댓글", + "show-more": "더보기", + "additional-info": "추가 정보", + "alarm-type": "알람 유형", + "enter-alarm-type": "알람 유형 입력", + "no-alarm-types-matching": "'{{entitySubtype}}'과 일치하는 알람 유형이 없습니다.", + "alarm-type-list-empty": "선택된 알람 유형이 없습니다.", + "system-comments": { + "acked-by-user": "사용자 {{userName}}이(가) 알람을 확인했습니다", + "cleared-by-user": "사용자 {{userName}}이(가) 알람을 해제했습니다", + "assigned-to-user": "사용자 {{userName}}이(가) 알람을 사용자 {{assigneeName}}에게 할당했습니다", + "unassigned-to-user": "사용자 {{userName}}이(가) 알람 할당을 해제했습니다", + "unassigned-from-deleted-user": "사용자 {{userName}}이(가) 삭제되어 알람 할당이 해제되었습니다", + "comment-deleted": "사용자 {{userName}}이(가) 자신의 댓글을 삭제했습니다", + "severity-changed": "알람 심각도가 {{oldSeverity}}에서 {{newSeverity}}(으)로 업데이트되었습니다" + } + }, + "alarm-activity": { + "add": "댓글 추가...", + "alarm-comment": "알람 댓글", + "comments": "댓글", + "delete-alarm-comment": "이 댓글을 삭제하시겠습니까?", + "refresh": "새로고침", + "oldest-first": "오래된 순", + "newest-first": "최신 순", + "activity": "활동", + "export": "CSV로 내보내기", + "author": "작성자", + "created-date": "생성 날짜", + "edited-date": "수정 날짜", + "text": "텍스트", + "system": "시스템" }, "alias": { - "add": "별칭 추가", - "edit": "별칭 편집", - "name": "별칭", - "name-required": "별칭을 입력하세요.", - "duplicate-alias": "동일한 별칭이 이미 존재합니다.", - "filter-type-single-entity": "단일 개체", - "filter-type-entity-list": "개체 목록", - "filter-type-entity-name": "개체 이름", - "filter-type-state-entity": "대시보드 상태로 부터의 개체", - "filter-type-state-entity-description": "개체 taken from dashboard state parameters", + "add": "별명 추가", + "edit": "별명 편집", + "name": "별명", + "name-required": "별명을 입력하세요.", + "duplicate-alias": "동일한 별명이 이미 존재합니다.", + "filter-type-single-entity": "단일 엔티티", + "filter-type-entity-list": "엔티티 목록", + "filter-type-entity-name": "엔티티 이름", + "filter-type-entity-type": "엔티티 유형", + "filter-type-state-entity": "대시보드 상태로 부터의 엔티티", + "filter-type-state-entity-description": "대시보드 상태 파라미터에서 가져온 엔티티", "filter-type-asset-type": "자산 유형", "filter-type-asset-type-description": "'{{assetTypes}}' 유형의 자산", "filter-type-asset-type-and-name-description": "이름이 '{{prefix}}'로 시작되는 '{{assetTypes}}' 유형의 자산", "filter-type-device-type": "장치 유형", "filter-type-device-type-description": "'{{deviceTypes}}' 유형의 장치", "filter-type-device-type-and-name-description": "이름이 '{{prefix}}'로 시작되는 '{{deviceTypes}}' 유형의 장치", - "filter-type-entity-view-type": "Entity View type", - "filter-type-entity-view-type-description": "Entity Views of type '{{entityViewTypes}}'", - "filter-type-entity-view-type-and-name-description": "Entity Views of type '{{entityViewTypes}}' and with name starting with '{{prefix}}'", + "filter-type-entity-view-type": "엔티티 뷰 유형", + "filter-type-entity-view-type-description": "유형 '{{entityViewTypes}}'의 엔티티 뷰", + "filter-type-entity-view-type-and-name-description": "유형이 '{{entityViewTypes}}'이고 이름이 '{{prefix}}'로 시작하는 엔티티 뷰", + "filter-type-edge-type": "엣지 유형", + "filter-type-edge-type-description": "유형 '{{edgeTypes}}'의 엣지", + "filter-type-edge-type-and-name-description": "유형 '{{edgeTypes}}'이고 이름이 '{{prefix}}'로 시작하는 엣지", "filter-type-relations-query": "관계 질의", - "filter-type-relations-query-description": "{{entities}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-relations-query-description": "{{rootEntity}} {{direction}} {{relationType}} 관계가 있는 {{entities}}", "filter-type-asset-search-query": "자산 검색 질의", - "filter-type-asset-search-query-description": "Assets with types {{assetTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-asset-search-query-description": "{{rootEntity}} {{direction}} {{relationType}} 관계가 있는 유형 {{assetTypes}}의 자산", "filter-type-device-search-query": "장치 검색 질의", - "filter-type-device-search-query-description": "Devices with types {{deviceTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", - "filter-type-entity-view-search-query": "개체 보기 검색 질의", - "filter-type-entity-view-search-query-description": "Entity views with types {{entityViewTypes}} that have {{relationType}} relation {{direction}} {{rootEntity}}", + "filter-type-device-search-query-description": "{{rootEntity}} {{direction}} {{relationType}} 관계가 있는 유형 {{deviceTypes}}의 장치", + "filter-type-entity-view-search-query": "엔티티 뷰 검색 질의", + "filter-type-entity-view-search-query-description": "{{rootEntity}} {{direction}} {{relationType}} 관계가 있는 유형 {{entityViewTypes}}의 엔티티 뷰", "filter-type-apiUsageState": "Api 사용량 상태", - "entity-filter": "개체 필터", - "resolve-multiple": "Resolve as multiple entities", + "filter-type-edge-search-query": "엣지 검색 쿼리", + "filter-type-edge-search-query-description": "유형 {{edgeTypes}}이(가) {{relationType}} 관계 {{direction}}을(를) 가진 엣지", + "entity-filter": "엔티티 필터", + "resolve-multiple": "여러 엔티티로 확인", + "resolve-multiple-hint": "필터링된 모든 엔티티의 데이터를 동시에 렌더링하도록 활성화합니다.\n비활성화할 경우, 위젯에는 선택된 엔티티의 데이터만 표시됩니다.", "filter-type": "필터 유형", "filter-type-required": "필터 유형을 입력하세요.", - "entity-filter-no-entity-matched": "지정된 필터에 해당되는 개체가 없습니다.", - "no-entity-filter-specified": "개체 필터가 지정되지 않음", - "root-state-entity": "루트로 대시보드 상태 개체를 사용", - "last-level-relation": "Fetch last level relation only", - "root-entity": "루트 개체", - "state-entity-parameter-name": "상태 개체 파라미터 이름", - "default-state-entity": "기본 상태 개체", + "entity-filter-no-entity-matched": "지정된 필터에 해당되는 엔티티가 없습니다.", + "no-entity-filter-specified": "엔티티 필터가 지정되지 않음", + "root-state-entity": "루트로 대시보드 상태 엔티티를 사용", + "last-level-relation": "마지막 수준 관계만 가져오기", + "root-entity": "루트 엔티티", + "state-entity-parameter-name": "상태 엔티티 파라미터 이름", + "default-state-entity": "기본 상태 엔티티", "default-entity-parameter-name": "기본값", + "query-options": "쿼리 옵션", "max-relation-level": "최대 관계 수준", "unlimited-level": "제한 수준", - "state-entity": "대시보드 상태 개체", - "all-entities": "모든 개체", + "state-entity": "대시보드 상태 엔티티", + "all-entities": "모든 엔티티", "any-relation": "전체" }, "asset": { @@ -343,16 +748,17 @@ "management": "자산 관리", "view-assets": "자산 보기", "add": "자산 추가", - "assign-to-customer": "커스터머에게 자산 할당", - "assign-asset-to-customer": "자산을 커스터머에게 할당", - "assign-asset-to-customer-text": "커스터머에게 할당할 자산을 선택하세요", + "asset-type-max-length": "자산 유형은 256자 미만이어야 합니다.", + "assign-to-customer": "고객에게 자산 할당", + "assign-asset-to-customer": "자산을 고객에게 할당", + "assign-asset-to-customer-text": "고객에게 할당할 자산을 선택하세요", "no-assets-text": "아무 자산도 없습니다", - "assign-to-customer-text": "자산을 할당할 커스터머를 선택하세요", + "assign-to-customer-text": "자산을 할당할 고객를 선택하세요", "public": "공개", - "assignedToCustomer": "할당된 커스터머", + "assignedToCustomer": "할당된 고객", "make-public": "자산을 공개로 설정", "make-private": "자산을 비공개로 설정", - "unassign-from-customer": "커스터머 할당 해제", + "unassign-from-customer": "고객 할당 해제", "delete": "자산 삭제", "asset-public": "공개된 자산", "asset-type": "자산 유형", @@ -365,7 +771,10 @@ "asset-types": "자산 유형", "name": "이름", "name-required": "이름을 입력하세요.", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "label-max-length": "레이블은 256자 미만이어야 합니다.", "description": "설명", + "description-required": "설명은 필수 항목입니다.", "type": "유형", "type-required": "유형일 입력하세요.", "details": "상세 내역", @@ -373,10 +782,12 @@ "add-asset-text": "새로운 자산 추가", "asset-details": "자산 상세 내역", "assign-assets": "자산 할당", - "assign-assets-text": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 커스터머에게 할당", + "assign-assets-text": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 고객에게 할당", + "assign-asset-to-edge-title": "엣지에 자산 할당", + "assign-asset-to-edge-text": "엣지에 할당할 자산을 선택하세요", "delete-assets": "자산 삭제", "unassign-assets": "자산 할당 해제", - "unassign-assets-action-title": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 커스터머로부터 할당 해제", + "unassign-assets-action-title": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 고객로부터 할당 해제", "assign-new-asset": "새로운 자산 할당", "delete-asset-title": "자산 '{{assetName}}'을(를) 삭제하시겠습니까?", "delete-asset-text": "자산 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", @@ -388,37 +799,54 @@ "make-private-asset-title": "자산 '{{assetName}}'을(를) 비공개로 변경하시겠습니까?", "make-private-asset-text": "자산 및 관련 데이터가 비공개로 전환되면 다른 이들이 접근할 수 없게 됩니다.", "unassign-asset-title": "자산 '{{assetName}}'을 할당 해제 하시겠습니까?", - "unassign-asset-text": "자산이 할당 해제되면 커스터머가 접근할 수 없게 됩니다.", + "unassign-asset-text": "자산이 할당 해제되면 고객가 접근할 수 없게 됩니다.", "unassign-asset": "자산 할당 해제", "unassign-assets-title": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 할당 해제 하시겠습니까?", - "unassign-assets-text": "자산이 할당 해제되면 커스터머가 접근할 수 없게 됩니다.", + "unassign-assets-text": "자산이 할당 해제되면 고객가 접근할 수 없게 됩니다.", + "unassign-assets-from-edge": "엣지에서 자산 할당 해제", "copyId": "자산 ID 복사", "idCopiedMessage": "자산 ID가 클립보드로 복사되었습니다", "select-asset": "자산 선택", "no-assets-matching": "'{{entity}}'와 일치하는 자산이 없습니다.", "asset-required": "자산을 입력하세요", "name-starts-with": "다음으로 시작되는 자산: ", + "help-text": "필요에 따라 '%'를 사용하세요: '%asset_name_contains%', '%asset_name_ends', 'asset_s", "import": "자산 불러오기", "asset-file": "자산 파일", + "label": "라벨", "search": "자산 검색", - "selected-assets": "{ count, plural, =1 {1 개 자산} other {# 개 자산} } 선택됨", - "label": "라벨" + "assign-asset-to-edge": "엣지에 자산 할당", + "unassign-asset-from-edge": "자산 할당 해제", + "unassign-asset-from-edge-title": "자산 '{{assetName}}'을 할당 해제 하시겠습니까?", + "unassign-asset-from-edge-text": "확인 후 자산 할당이 해제되고", + "unassign-assets-from-edge-title": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }을 할당 해제 하시겠습니까?", + "unassign-assets-from-edge-text": "확인 후 선택한 모든 자산의 할당이 해제되고", + "selected-assets": "{ count, plural, =1 {1 개 자산} other {# 개 자산} } 선택됨" }, "attribute": { "attributes": "속성", "latest-telemetry": "최근 데이터", + "no-latest-telemetry": "최신 텔레메트리 없음", "attributes-scope": "장치 속성 범위", - "scope-telemetry": "원격 측정", + "scope-telemetry": "텔레메트리", "scope-latest-telemetry": "최근 데이터", "scope-client": "클라이언트 속성", "scope-server": "서버 속성", "scope-shared": "공유 속성", + "scope-client-short": "클라이언트", + "scope-server-short": "서버", + "scope-shared-short": "공유", + "scope-latest-short": "최신", + "scope-any": "전체", "add": "속성 추가", "key": "키", + "key-max-length": "키는 256자 미만이어야 합니다.", "last-update-time": "마지막 수정된 시간", "key-required": "속성 키를 입력하세요.", - "value": "Value", + "value": "값", "value-required": "속성 값을 입력하세요.", + "telemetry-key-required": "텔레메트리 키는 필수 항목입니다", + "telemetry-value-required": "텔레메트리 값은 필수 항목입니다", "delete-attributes-title": "{ count, plural, =1 {속성} other {여러 속성들을} } 삭제하시겠습니까??", "delete-attributes-text": "모든 선택된 속성들이 제거 될 것이므로 주의하십시오.", "delete-attributes": "속성 삭제", @@ -432,63 +860,179 @@ "selected-attributes": "{ count, plural, =1 {1 개 속성} other {# 개 속성} }이 선택됨", "selected-telemetry": "{ count, plural, =1 {1 개의 텔레메트리 단위} other {# 개의 텔레메트리 단위} }가 선택됨", "no-attributes-text": "아무 속성도 찾을 수 없습니다", - "no-telemetry-text": "아무 텔레메트리도 찾을 수 없습니다." + "no-telemetry-text": "아무 텔레메트리도 찾을 수 없습니다.", + "copy-key": "키 복사", + "add-telemetry": "텔레메트리 추가", + "copy-value": "값 복사", + "delete-timeseries": { + "start-time": "시작 시각", + "ends-on": "종료일", + "strategy": "전략", + "delete-strategy": "삭제 전략", + "all-data": "모든 데이터 삭제", + "all-data-except-latest-value": "최신 값을 제외한 모든 데이터 삭제", + "latest-value": "최신 값 삭제", + "all-data-for-time-period": "기간 내 모든 데이터 삭제", + "rewrite-latest-value": "최신 값 다시 쓰기" + } }, "api-usage": { + "api-features": "API 기능", "api-usage": "Api 사용량", + "alarm": "알람", + "alarms-created": "생성된 알람", + "queue-stats": "큐 통계", + "processing-failures-and-timeouts": "처리 실패 및 시간 초과", + "exceptions": "예외", + "alarms-created-daily-activity": "일일 알람 생성 활동", + "alarms-created-hourly-activity": "시간별 알람 생성 활동", + "alarms-created-monthly-activity": "월별 알람 생성 활동", "data-points": "데이터 포인트", "data-points-storage-days": "데이터 포인트 저장 일수", - "email": "Email", + "data-points-storage-days-hourly-activity": "데이터 포인트 저장 일수 시간별 활동", + "data-points-storage-days-daily-activity": "데이터 포인트 저장 일수 일별 활동", + "data-points-storage-days-monthly-activity": "데이터 포인트 저장 일수 월별 활동", + "device-api": "장치 API", + "email": "이메일", "email-messages": "Email 메시지", "email-messages-daily-activity": "일별 Email 메세지 활동", - "email-messages-hourly-activity": "시간별 Email 메시지 활동", "email-messages-monthly-activity": "월별 Email 메시지 활동", - "exceptions": "예외", - "executions": "Executions", + "executions": "실행", + "scripts": "스크립트", + "scripts-hourly-activity": "시간별 스크립트 활동", + "scripts-daily-activity": "일일 스크립트 활동", + "scripts-monthly-activity": "월별 스크립트 활동", "javascript": "JavaScript", - "javascript-executions": "JavaScript executions", + "javascript-executions": "JavaScript 실행", + "tbel": "TBEL", + "tbel-executions": "TBEL 실행", "latest-error": "최근 오류", "messages": "메시지", - "permanent-failures": "${entityName} Permanent Failures", - "permanent-timeouts": "${entityName} Permanent Timeouts", - "processing-failures": "${entityName} Processing Failures", - "processing-failures-and-timeouts": "Processing Failures and Timeouts", - "processing-timeouts": "${entityName} Processing Timeouts", - "queue-stats": "Queue Stats", + "notifications": "알림", + "notifications-email-sms": "알림 (이메일/SMS)", + "notifications-hourly-activity": "알림 시간별 활동", + "permanent-failures": "${entityName} 영구 실패", + "permanent-timeouts": "${entityName} 영구 시간 초과", + "processing-failures": "${entityName} 처리 실패", + "processing-timeouts": "${entityName} 처리 시간 초과", "rule-chain": "규칙 사슬", "rule-engine": "규칙 엔진", - "rule-engine-daily-activity": "Rule Engine daily activity", - "rule-engine-executions": "Rule Engine executions", - "rule-engine-hourly-activity": "Rule Engine hourly activity", - "rule-engine-monthly-activity": "Rule Engine monthly activity", - "rule-engine-statistics": "Rule Engine Statistics", + "rule-engine-executions": "규칙 엔진 실행", + "rule-engine-hourly-activity": "규칙 엔진 시간별 활동", + "rule-engine-daily-activity": "규칙 엔진 일별 활동", + "rule-engine-monthly-activity": "규칙 엔진 월별 활동", + "rule-engine-statistics": "규칙 엔진 통계", "rule-node": "규칙 노드", "sms": "SMS", "sms-messages": "SMS 메시지", - "sms-messages-daily-activity": "일별 SMS 메시지 활동", "sms-messages-hourly-activity": "시간별 SMS 메시지 활동", + "sms-messages-daily-activity": "일별 SMS 메시지 활동", "sms-messages-monthly-activity": "월별 SMS 메시지 활동", - "successful": "${entityName} Successful", + "successful": "${entityName} 성공", "telemetry": "텔레메트리", - "telemetry-persistence": "Telemetry persistence", - "telemetry-persistence-daily-activity": "Telemetry persistence daily activity", - "telemetry-persistence-hourly-activity": "Telemetry persistence hourly activity", - "telemetry-persistence-monthly-activity": "Telemetry persistence monthly activity", + "telemetry-persistence": "텔레메트리 저장", + "telemetry-persistence-daily-activity": "텔레메트리 저장 일별 활동", + "telemetry-persistence-hourly-activity": "텔레메트리 저장 시간별 활동", + "telemetry-persistence-monthly-activity": "텔레메트리 저장 월별 활동", "transport": "전송", - "transport-daily-activity": "Transport daily activity", - "transport-data-points": "Transport data points", - "transport-hourly-activity": "Transport hourly activity", - "transport-messages": "Transport messages", - "transport-monthly-activity": "Transport monthly activity", + "transport-msg-hourly-activity": "전송 메시지 시간별 활동", + "transport-msg-daily-activity": "전송 메시지 일별 활동", + "transport-msg-monthly-activity": "전송 메시지 월별 활동", + "transport-daily-activity": "전송 일별 활동", + "transport-data-points": "전송 데이터 포인트", + "transport-data-points-hourly-activity": "전송 데이터 포인트 시간별 활동", + "transport-data-points-daily-activity": "전송 데이터 포인트 일별 활동", + "transport-data-points-monthly-activity": "전송 데이터 포인트 월별 활동", "view-details": "상세 내역", - "view-statistics": "통계 보기" + "view-statistics": "통계 보기", + "transport-messages": "전송 메시지", + "transport-messages-hourly-activity": "전송 메시지 시간별 활동", + "transport-data-point-hourly-activity": "전송 데이터 포인트 시간별 활동", + "javascript-function-executions": "JavaScript 함수 실행", + "javascript-function-executions-hourly-activity": "JavaScript 함수 실행 시간별 활동", + "javascript-function-executions-daily-activity": "JavaScript 함수 실행 일별 활동", + "javascript-function-executions-monthly-activity": "JavaScript 함수 실행 월별 활동", + "tbel-function-executions": "TBEL 함수 실행", + "tbel-function-executions-hourly-activity": "TBEL 함수 실행 시간별 활동", + "tbel-function-executions-daily-activity": "TBEL 함수 실행 일별 활동", + "tbel-function-executions-monthly-activity": "TBEL 함수 실행 월별 활동", + "created-reports": "생성된 보고서", + "created-reports-hourly-activity": "생성된 보고서 시간별 활동", + "created-reports-daily-activity": "생성된 보고서 일별 활동", + "created-reports-monthly-activity": "생성된 보고서 월별 활동", + "emails": "이메일", + "emails-hourly-activity": "이메일 시간별 활동", + "emails-daily-activity": "이메일 일별 활동", + "emails-monthly-activity": "이메일 월별 활동", + "status": { + "enabled": "사용", + "disabled": "비활성화됨", + "warning": "경고" + } + }, + "api-limit": { + "cassandra-write-queries-core": "REST API Cassandra 쓰기 쿼리", + "cassandra-read-queries-core": "REST API 및 WS 텔레메트리 Cassandra 읽기 쿼리", + "cassandra-write-queries-rule-engine": "규칙 엔진 텔레메트리 Cassandra 쓰기 쿼리", + "cassandra-read-queries-rule-engine": "규칙 엔진 텔레메트리 Cassandra 읽기 쿼리", + "cassandra-write-queries-monolith": "Monolith 텔레메트리 Cassandra 쓰기 쿼리", + "cassandra-read-queries-monolith": "Monolith 텔레메트리 Cassandra 읽기 쿼리", + "entity-version-creation": "엔티티 버전 생성", + "entity-version-load": "엔티티 버전 불러오기", + "notification-requests": "알림 요청", + "notification-requests-per-rule": "규칙당 알림 요청", + "rest-api-requests": "REST API 요청", + "rest-api-requests-per-customer": "고객당 REST API 요청", + "transport-messages": "전송 메시지", + "transport-messages-per-device": "장치당 전송 메시지", + "transport-messages-per-gateway": "게이트웨이당 전송 메시지", + "transport-messages-per-gateway-device": "게이트웨이 장치당 전송 메시지", + "ws-updates-per-session": "세션당 WS 업데이트", + "edge-events": "엣지 이벤트", + "edge-events-per-edge": "엣지당 엣지 이벤트", + "edge-uplink-messages": "엣지 업링크 메시지", + "edge-uplink-messages-per-edge": "엣지당 업링크 메시지" + }, + "api-key": { + "api-key": "API 키", + "api-keys": "API 키", + "delete-api-key-title": "API 키 '{{name}}'을(를) 삭제하시겠습니까?", + "delete-api-key-text": "주의: 확인 후 키를 복구할 수 없습니다.", + "delete-api-keys-title": "{ count, plural, =1 {1개 API 키} other {#개 API 키} }를 삭제하시겠습니까?", + "delete-api-keys-text": "주의: 확인 후 선택한 모든 키를 복구할 수 없게 됩니다.", + "expiration-date": "만료 날짜", + "date": "날짜", + "description": "설명", + "disable": "비활성화", + "edit-description": "설명 수정", + "enable": "API 키 사용", + "expiration-time": "만료 시간", + "expiration-time-never": "없음", + "expiration-time-custom": "사용자 지정", + "generate": "생성", + "generate-title": "API 키 생성", + "generate-text": "참고: API 키는 생성된 사용자의 권한을 상속받습니다.", + "generated-api-key-title": "API 키가 생성되었습니다. 연결을 확인하세요!", + "generated-api-key-copy": "지금 API 키를 복사하여 저장하세요. 다시 볼 수 없습니다.", + "generated-api-key-command": "다음 지침에 따라 연결을 확인하세요. 결과적으로", + "generated-api-key-insecure-url": "보안되지 않은 HTTP 연결을 통해 명령을 실행하면 API 키가 암호화되지 않고 전송됩니다.", + "list": "{ count, plural, =1 {1개 API 키} other {#개 API 키 목록} }", + "manage": "관리", + "manage-api-keys": "API 키 관리", + "no-found": "API 키가 없습니다", + "selected-api-keys": "{ count, plural, =1 {1개 API 키} other {#개 API 키} } 선택됨", + "search": "API 키 검색", + "status": "상태", + "status-active": "활성", + "status-inactive": "비활성", + "status-expired": "만료됨" }, "audit-log": { "audit": "감사", "audit-logs": "감사 로그", "timestamp": "타임스탬프", - "entity-type": "개체 유형", - "entity-name": "개체 이름", + "entity-type": "엔티티 유형", + "entity-name": "엔티티 이름", "user": "사용자", "type": "유형", "status": "상태", @@ -498,10 +1042,12 @@ "type-updated": "수정됨", "type-attributes-updated": "속성이 수정되었습니다", "type-attributes-deleted": "속성이 삭제되었습니다", - "type-rpc-call": "RPC call", + "type-rpc-call": "RPC 호출", "type-credentials-updated": "자격 증명이 갱신되었습니다", - "type-assigned-to-customer": "커스터머에게 할당", - "type-unassigned-from-customer": "지정된 커스터머 해제", + "type-assigned-to-customer": "고객에게 할당", + "type-unassigned-from-customer": "지정된 고객 해제", + "type-assigned-to-edge": "엣지에 할당됨", + "type-unassigned-from-edge": "엣지에서 할당 해제됨", "type-activated": "활성", "type-suspended": "일시 중지", "type-credentials-read": "자격 증명 읽기", @@ -511,6 +1057,12 @@ "type-relations-delete": "모든 관계가 삭제되었습니다", "type-alarm-ack": "확인", "type-alarm-clear": "해제", + "type-alarm-delete": "알람 삭제됨", + "type-alarm-assign": "알람 할당됨", + "type-alarm-unassign": "알람 할당 해제", + "type-added-comment": "댓글 추가됨", + "type-updated-comment": "댓글 수정됨", + "type-deleted-comment": "댓글 삭제됨", "type-login": "로그인", "type-logout": "로그아웃", "type-lockout": "잠금", @@ -525,7 +1077,587 @@ "type-assigned-from-tenant": "테넌트로부터 지정", "type-assigned-to-tenant": "테넌트로 지정", "type-provision-success": "장치가 프로비전됨", - "type-provision-failure": "장치 프로비저닝이 실패했습니다" + "type-provision-failure": "장치 프로비저닝이 실패했습니다", + "type-timeseries-updated": "텔레메트리 수정됨", + "type-timeseries-deleted": "텔레메트리 삭제됨", + "type-sms-sent": "SMS 전송됨", + "any-type": "모든 유형", + "audit-log-filter-title": "감사 로그 필터", + "filter-title": "필터", + "filter-types": "감사 로그 유형" + }, + "debug-settings": { + "label": "디버그 설정", + "on-failure": "실패만 (24/7)", + "all-messages": "모든 메시지 ({{time}})", + "failures": "실패", + "entity": "엔티티", + "hint": { + "main-limited": "{{time}}당 최대 {{msg}}개의 {{entity}} 디버그 메시지가 기록됩니다.", + "on-failure": "오류 메시지만 기록합니다.", + "all-messages": "모든 디버그 메시지를 기록합니다." + } + }, + "calculated-fields": { + "expression": "표현식", + "no-found": "계산 필드가 없습니다", + "add": "계산 필드 추가", + "list": "{ count, plural, =1 {1개 계산 필드} other {#개 계산 필드 목록} }", + "selected-fields": "{ count, plural, =1 {1개 계산 필드} other {#개 계산 필드} } 선택됨", + "type": { + "simple": "간단", + "simple-hint": "입력 인수를 기반으로 한 단순 산술 계산.", + "script": "스크립트", + "script-hint": "TBEL 스크립트를 사용하여 정의된 인수에 대한 계산.", + "geofencing": "지오펜싱", + "geofencing-hint": "구성된 지오펜싱 영역 그룹에 대한 엔티티 GPS 위치 및 전환 평가.", + "propagation": "전파", + "propagation-hint": "관계 방향 및 유형에 따라 부모 또는 자식 엔티티로 데이터 전파.", + "related-entities-aggregation": "관련 엔티티 집계", + "related-entities-aggregation-hint": "관련 엔티티의 최신 데이터 집계.", + "time-series-data-aggregation": "시계열 데이터 집계", + "time-series-data-aggregation-hint": "현재 엔티티의 과거 데이터 집계." + }, + "preview": "미리보기", + "arguments": "인수", + "decimals-by-default": "기본 소수 자릿수", + "debugging": "계산 필드 디버깅", + "calculated-field-details": "계산 필드 상세", + "argument-name": "인수 이름", + "name": "이름", + "datasource": "데이터소스", + "add-argument": "인수 추가", + "test-script-function": "스크립트 함수 테스트", + "no-arguments": "최소 하나의 인수가 필요합니다.", + "argument-settings": "인수 설정", + "argument-current": "현재 엔티티", + "argument-current-tenant": "현재 테넌트", + "argument-device": "장치", + "argument-asset": "자산", + "argument-customer": "고객", + "argument-tenant": "현재 테넌트", + "argument-owner": "현재 소유자", + "argument-relation-query": "관련 엔티티", + "argument-type": "인수 유형", + "attribute": "속성", + "copy-argument-name": "인수 이름 복사", + "timeseries-key": "시계열 키", + "device-name": "장치 이름", + "latest-telemetry": "최근 데이터", + "rolling": "시계열 롤링", + "attribute-scope": "속성 범위", + "server-attributes": "서버 속성", + "client-attributes": "클라이언트 속성", + "shared-attributes": "공유 속성", + "attribute-key": "속성 키", + "default-value": "기본값", + "default-value-required": "기본값이 필요합니다.", + "limit": "최대 값", + "time-window": "타임윈도우", + "customer-name": "고객 이름", + "asset-name": "자산 이름", + "timeseries": "시계열", + "output": "출력", + "output-hint": "출력 처리 방법을 정의합니다.", + "create": "새 계산 필드 만들기", + "file": "계산 필드 파일", + "invalid-file-error": "잘못된 파일 형식입니다. 파일이 올바른 JSON 파일인지 확인하세요.", + "import": "계산 필드 가져오기", + "export": "계산 필드 내보내기", + "export-failed-error": "계산 필드를 내보낼 수 없습니다: {{error}}", + "output-type": "출력 유형", + "delete-title": "계산 필드 '{{title}}'을(를) 삭제하시겠습니까?", + "delete-text": "주의: 확인 후 계산 필드와 모든 관련 데이터를", + "delete-multiple-title": "{ count, plural, =1 {1개 계산 필드} other {#개 계산 필드} }를 삭제하시겠습니까?", + "delete-multiple-text": "주의: 확인 후 선택한 모든 계산 필드가 제거되고", + "test-with-this-message": "이 메시지로 테스트", + "use-latest-timestamp": "최신 타임스탬프 사용", + "entity-coordinates": "엔티티 좌표", + "latitude-time-series-key": "위도 시계열 키", + "latitude-time-series-key-required": "위도 시계열 키가 필요합니다.", + "longitude-time-series-key": "경도 시계열 키", + "longitude-time-series-key-required": "경도 시계열 키가 필요합니다.", + "geofencing-zone-groups": "지오펜싱 영역 그룹", + "geofencing-zone-groups-settings": "지오펜싱 영역 그룹 설정", + "target-zone": "대상 영역", + "perimeter-key": "경계 키", + "report-strategy": "보고 전략", + "no-zone-configured": "최소 하나의 영역이 필요합니다.", + "no-zone-configured-required": "최소 하나의 영역 그룹을 구성해야 합니다.", + "add-zone-group": "영역 그룹 추가", + "report-transition-event-only": "전환 이벤트만", + "report-presence-status-only": "존재 상태만", + "report-transition-event-and-presence": "존재 상태 및 전환 이벤트", + "perimeter-attribute-key": "경계 속성 키", + "perimeter-attribute-key-required": "경계 속성 키가 필요합니다.", + "perimeter-attribute-key-pattern": "경계 속성 키가 올바르지 않습니다.", + "entity-zone-relationship": "엔티티에서 영역으로의 경로", + "direction": "관계 방향", + "direction-from": "엔티티에서 영역으로", + "direction-to": "영역에서 엔티티로", + "relation-type": "관계 유형", + "create-relation-with-matched-zones": "일치하는 영역과 소스 엔티티의 관계 생성", + "relation-level": "관계 수준", + "fetch-last-available-level": "마지막 사용 가능한 수준만 가져오기", + "zone-group-refresh-interval": "영역 그룹 새로고침 간격", + "copy-zone-group-name": "영역 그룹 이름 복사", + "open-details-page": "엔티티 상세 페이지 열기", + "level": "수준", + "direction-level": "범례 방향", + "direction-up": "위로", + "direction-up-parent": "부모로 위로", + "direction-down": "아래로", + "direction-down-child": "자식으로 아래로", + "add-level": "수준 추가", + "delete-level": "수준 삭제", + "no-level": "구성된 수준 없음", + "levels-required": "최소 하나의 수준을 구성해야 합니다.", + "max-allowed-levels-error": "관계 수준이 최대 허용값을 초과합니다.", + "propagation-path-related-entities": "관련 엔티티로의 전파 경로", + "propagate-type": { + "arguments-only": "인수만", + "expression-result": "계산 결과" + }, + "script": "스크립트", + "data-propagate": "전파할 데이터", + "output-key": "출력 키", + "copy-output-key": "출력 키 복사", + "aggregation-path-related-entities": "관련 엔티티로의 집계 경로", + "deduplication-interval": "중복 제거 간격", + "deduplication-interval-min": "중복 제거 간격은 최소 {{ sec }}초여야 합니다.", + "deduplication-interval-hint": "텔레메트리 집계 사이의 최소 시간.", + "deduplication-interval-required": "중복 제거 간격이 필요합니다.", + "calculated-field-filter-title": "계산 필드 필터", + "filter-title": "필터", + "calculated-field-types": "계산 필드 유형", + "events": "이벤트", + "any-type": "모든 유형", + "metrics": { + "metrics": "메트릭", + "metrics-empty": "최소 하나의 메트릭을 구성해야 합니다.", + "metric-name": "메트릭 이름", + "metric-name-required": "메트릭 이름이 필요합니다.", + "metric-name-pattern": "메트릭 이름이 올바르지 않습니다.", + "metric-name-duplicate": "같은 이름의 메트릭이 이미 존재합니다.", + "metric-name-max-length": "메트릭 이름은 256자 미만이어야 합니다.", + "metric-name-forbidden": "메트릭 이름은 예약어이므로 사용할 수 없습니다.", + "copy-metric-name": "메트릭 이름 복사", + "argument-name": "인수 이름", + "aggregation": "집합", + "aggregation-type": { + "avg": "평균", + "min": "최소", + "max": "최대", + "sum": "합계", + "count": "숫자", + "count-unique": "고유 개수" + }, + "filtered": "필터됨", + "value-source": "값 소스", + "value-source-hint": "집계에 사용할 값을 얻는 방법을 정의합니다.", + "value-source-type": { + "key": "키", + "function": "함수" + }, + "no-metrics-configured": "최소 하나의 메트릭이 필요합니다.", + "add-metric": "메트릭 추가", + "max-metrics": "최대 메트릭 수에 도달했습니다.", + "metric-settings": "메트릭 설정", + "filter": "필터", + "filter-hint": "집계 중 엔티티 필터링을 활성화합니다. 필터 함수는 부울 값을 반환해야 하며 구성된 모든 인수를 사용할 수 있습니다." + }, + "output-strategy": { + "strategy": "전략", + "process-right-away": "즉시 처리", + "process-rule-chains": "규칙 사슬을 통해 처리", + "save-time-series": "시계열에 저장", + "save-database": "데이터베이스에 저장", + "save-latest-values": "최신 값에 저장", + "send-web-sockets": "WebSocket으로 전송", + "save-calculated-fields": "계산 필드로 전송", + "update-attribute-only-on-value-change": "값 변경 시에만 속성 업데이트", + "send-attributes-updated-notification": "속성 업데이트 알림 전송", + "ttl": "사용자 지정 TTL", + "ttl-required": "TTL이 필요합니다", + "ttl-min": "최소 TTL은 0만 허용됩니다", + "processing-parameters": "처리 매개변수", + "hint": { + "strategy": "결과를 즉시 처리할지 추가 처리를 위해 규칙 사슬로 전송할지 제어합니다.", + "processing-options": "처리 옵션", + "update-attribute-only-on-value-change": "값이 변경되었는지 여부에 관계없이 들어오는 모든 메시지에서 속성을 업데이트합니다. 이는 API 사용량을 증가시키고 성능을 저하시킵니다.", + "update-attribute-only-on-value-change-enabled": "값이 변경될 때만 속성을 업데이트합니다. 값이 변경되지 않으면 타임스탬프가 업데이트되지 않고 알림이 전송되지 않습니다.", + "send-attributes-updated-notification": "기본 규칙 사슬로 속성 업데이트 이벤트를 전송합니다.", + "save-time-series": "시계열 데이터를 데이터베이스의 ts_kv 테이블에 저장합니다.", + "save-database": "속성 데이터를 데이터베이스에 저장합니다.", + "save-latest-values": "새 값이 더 최근인 경우 데이터베이스의 ts_kv_latest 테이블에서 시계열 데이터를 업데이트합니다.", + "send-web-sockets-attribute": "속성 데이터 업데이트에 대해 WebSocket 구독에 알립니다.", + "send-web-sockets-time-series": "시계열 데이터 업데이트에 대해 WebSocket 구독에 알립니다.", + "save-calculated-fields-attribute": "속성 데이터 업데이트에 대해 계산 필드에 알립니다.", + "save-calculated-fields-time-series": "시계열 데이터 업데이트에 대해 계산 필드에 알립니다.", + "ttl": "시계열 데이터의 보존 기간을 정의합니다. 비활성화하면 테넌트 프로필 TTL이 사용됩니다." + } + }, + "aggregate-interval-type": "집계 간격 유형", + "aggregate-interval-value": "집계 간격 값", + "aggregate-interval-value-required": "집계 간격 값이 필요합니다.", + "aggregate-interval-value-min": "집계 간격 값은 최소 { sec, plural, =0 {0초} =1 {1초} other {#초} }여야 합니다.", + "aggregate-interval-value-step-multiple-of": "집계 간격 값은 1일의 약수 또는 배수여야 합니다.", + "aggregate-period": { + "hour": "시간", + "day": "일", + "week": "주 (월 - 일)", + "week-sun-sat": "주 (일 - 토)", + "month": "달", + "quarter": "분기", + "year": "년", + "custom": "사용자 지정" + }, + "aggregate-period-hint-offset": "집계 간격은 {{ interval }}입니다.", + "aggregate-period-hint-offset-and-so-on": "집계 간격은 {{ interval }} 등입니다.", + "entity-aggregation": { + "argument-hint": "현재 엔티티에서 데이터를 가져옵니다.", + "argument-title-hint": "집계에 사용할 입력 인수를 정의합니다.", + "argument-setting-hint": "최신 텔레메트리는 이 계산 필드에서 사용 가능한 유일한 인수 유형입니다.", + "aggregation-interval": "집계 간격", + "aggregation-interval-hint": "집계 수행 빈도를 정의합니다. 예: 1시간마다 00:00, 01:00, 02:00 등에 데이터를 집계합니다. 집계 결과는 집계 간격의 시작에 해당하는 타임스탬프와 함께 저장됩니다.", + "apply-offset": "집계 간격에 오프셋 적용", + "apply-offset-hint": "각 집계 기간의 시작을 얼마나 이동할지 정의합니다 (예: +10분 - 00:10, 01:10).", + "offset-value": "오프셋 값", + "offset-value-required": "오프셋 값이 필요합니다.", + "offset-value-min": "오프셋 값은 양의 정수여야 합니다.", + "offset-value-max": "오프셋 값은 집계 간격 값보다 작아야 합니다.", + "wait-delay": "지연된 텔레메트리에 대한 대기 시간 초과 적용", + "wait-delay-hint": "간격이 끝난 후 지연된 텔레메트리를 얼마나 기다릴지 정의합니다. 이러한 텔레메트리가 도착하면 해당 간격의 결과가 다시 계산됩니다.", + "duration": "기간", + "duration-required": "지속 시간이 필요합니다.", + "duration-min": "지속 시간은 최소 1분이어야 합니다.", + "duration-hint": "간격이 끝난 후 지연된 데이터를 얼마나 기다릴지.", + "produce-intermediate-result": "중간 결과 생성", + "produce-intermediate-result-hint": "현재 간격 동안 지표를 계산하여 중간 결과를 생성합니다." + }, + "hint": { + "arguments-simple-with-rolling": "단순 유형 계산 필드에는 시계열 롤링 유형의 키가 포함되지 않아야 합니다.", + "arguments-propagate-arguments-with-rolling": "'시계열 롤링' 유형은 '인수만' 전파와 호환되지 않습니다.", + "arguments-propagate-argument-entity-type": "엔티티 유형은 '인수만' 전파와 호환되지 않습니다.", + "arguments-propagate-argument-must-current-entity": "최소 하나의 인수가 '현재 엔티티' 소스 엔티티로 구성되어야 합니다.", + "arguments-empty": "최소 하나의 인수를 지정해야 합니다.", + "expression-required": "표현식이 필요합니다.", + "expression-invalid": "표현식이 올바르지 않습니다", + "expression-max-length": "표현식 길이는 255자 미만이어야 합니다.", + "argument-name-required": "인수 이름이 필요합니다.", + "argument-name-pattern": "인수 이름이 올바르지 않습니다.", + "argument-name-duplicate": "같은 이름의 인수가 이미 존재합니다.", + "argument-name-max-length": "인수 이름은 256자 미만이어야 합니다.", + "argument-name-forbidden": "인수 이름은 예약어이므로 사용할 수 없습니다.", + "output-key-required": "출력 키가 필요합니다.", + "output-key-pattern": "출력 키가 올바르지 않습니다.", + "output-key-duplicate": "같은 이름의 키가 이미 존재합니다.", + "output-key-max-length": "출력 키는 256자 미만이어야 합니다.", + "output-key-forbidden": "출력 키는 예약어이므로 사용할 수 없습니다.", + "entity-type-required": "엔티티 유형이 필요합니다", + "name-required": "이름을 입력하세요.", + "name-pattern": "이름이 올바르지 않습니다.", + "name-duplicate": "같은 이름의 이름이 이미 존재합니다.", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "name-forbidden": "이름은 예약어이므로 사용할 수 없습니다.", + "argument-type-required": "인수 유형이 필요합니다.", + "max-args": "최대 인수 수에 도달했습니다.", + "decimals-range": "기본 소수 자릿수는 0~15 사이의 숫자여야 합니다.", + "expression": "기본 식은 온도를 화씨에서 섭씨로 변환하는 방법을 보여줍니다.", + "arguments-entity-not-found": "인수 대상 엔티티를 찾을 수 없습니다.", + "use-latest-timestamp": "사용하면 계산된 값이 가장 최신 타임스탬프를 사용하여 저장됩니다.", + "entity-coordinates": "엔티티 GPS 좌표(위도 및 경도)를 제공하는 시계열 키를 지정하세요.", + "geofencing-zone-groups": "확인할 하나 이상의 지오펜싱 영역 그룹을 정의하세요 (예: 'allowedZones', 'restr", + "perimeter-attribute-key": "지오펜싱 영역 경계 정의가 포함된 속성 키를 설정하세요.", + "report-strategy": "현재 상태 보고서는 엔티티가 영역 내부 또는 외부에 있는지 보고합니다.", + "create-relation-with-matched-zones": "엔티티와 해당 엔티티가 포함된 영역 간의 관계를 자동으로 생성하고 유지 관리합니다.", + "relation-type-required": "관계 유형을 입력하세요.", + "relation-level-required": "관계 수준이 필요합니다.", + "relation-level-min": "최소 관계 수준 값은 1입니다.", + "relation-level-max": "최대 관계 수준 값은 {{max}}입니다.", + "geofencing-empty": "최소 하나의 영역 그룹을 구성해야 합니다.", + "geofencing-entity-not-found": "지오펜싱 대상 엔티티를 찾을 수 없습니다.", + "max-geofencing-zone": "최대 지오펜싱 영역 수에 도달했습니다.", + "zone-group-refresh-interval": "관련 엔티티를 통해 구성된 영역 그룹이 새로고침되는 빈도를 정의합니다.", + "zone-group-refresh-interval-required": "영역 그룹 새로고침 간격이 필요합니다.", + "zone-group-refresh-interval-min": "영역 그룹 새로고침 간격은 최소 {{ min }}초여야 합니다.", + "propagation-path-related-entities": "선택한 방향을 기반으로 관련 엔티티에 대한 직접적인 단일 수준 경로를 정의합니다.", + "data-propagate": "아래 구성된 인수에서 전파할 데이터를 정의합니다. '인수만'", + "aggregation-path-related-entities": "부모 또는 자식과의 직접 관계를 통한 단일 수준 집계 경로를 정의합니다.", + "arguments-aggregation": "필터링 및 집계에 사용되는 입력 인수를 정의합니다.", + "setting-arguments-aggregation": "집계 경로에 구성된 관련 엔티티에서 데이터를 가져옵니다.", + "metrics": "구성된 인수를 기반으로 집계된 지표를 정의합니다.", + "entity-aggregation-metrics": "지정된 시간 동안 구성된 인수를 기반으로 집계된 지표를 정의합니다.", + "import-invalid-calculated-field-type": "계산 필드를 가져올 수 없습니다: 잘못된 계산 필드 구조.", + "simple-expression-title": "계산된 값을 계산하는 방법을 정의하는 산술 식입니다.", + "script-title": "계산 로직과 출력 값을 정의하는 TBEL 스크립트입니다.", + "simple-arguments": "계산된 값을 계산하는 방법을 정의하는 산술 식입니다.", + "script-arguments": "스크립트에서 사용할 수 있는 입력 인수를 정의합니다." + } + }, + "alarm-rule": { + "alarm-rules-tab": "알람 규칙", + "alarm-rule": "알람 규칙", + "alarm-rules": "알람 규칙", + "alarm-rules-old": "이전", + "alarm-rules-actual": "실제", + "severities": "심각도", + "cleared": "해제 조건", + "delete-title": "알람 규칙 '{{title}}'을(를) 삭제하시겠습니까?", + "delete-text": "주의: 확인 후 알람 규칙과 모든 관련 데이터를", + "delete-multiple-title": "{ count, plural, =1 {1개 알람 규칙} other {#개 알람 규칙} }을(를) 삭제하시겠습니까?", + "delete-multiple-text": "주의: 확인 후 선택한 모든 알람 규칙이 제거되고", + "create": "새 알람 규칙 만들기", + "add": "알람 규칙 추가", + "copy": "알람 규칙 구성 복사", + "details": "알람 규칙 상세", + "no-found": "알람 규칙이 없습니다", + "list": "{ count, plural, =1 {1개 알람 규칙} other {#개 알람 규칙 목록} }", + "selected-fields": "{ count, plural, =1 {1개 알람 규칙} other {#개 알람 규칙} } 선택됨", + "import": "알람 규칙 가져오기", + "file": "알람 규칙 파일", + "export": "알람 규칙 내보내기", + "export-failed-error": "알람 규칙을 내보낼 수 없습니다: {{error}}", + "entity-type": "엔티티 유형", + "entity-type-required": "엔티티의 유형을 입력하세요.", + "alarm-type": "알람 유형", + "alarm-type-hint": "충돌을 방지하기 위해 알람 발생자(장치, 자산 등) 범위 내의 고유 식별자(예: HighTempAlarm).", + "alarm-type-required": "알람 유형은 필수 항목입니다.", + "alarm-type-pattern": "알람 유형이 올바르지 않습니다.", + "alarm-type-max-length": "알람 유형은 256자 미만이어야 합니다.", + "clear-alarm": "알람 해제", + "value-argument": "인수", + "value-argument-required": "인수가 필요합니다.", + "static-settings": "정적 설정", + "configuration": "설정", + "static-schedule": "정적", + "dynamic-schedule": "동적", + "operation-and": "그리고", + "operation-or": "또는", + "condition-during": "{{during}} 동안", + "condition-during-dynamic": "\"{{ attribute }}\" 동안", + "condition-repeat-times": "{ count, plural, =1 {1회} other {#회} } 반복", + "condition-repeat-times-dynamic": "\"{{ attribute }}\" 번 반복", + "filter-preview": "필터 미리보기", + "condition-settings": "조건 설정", + "static": "정적", + "dynamic": "동적", + "argument-filters": "인수 필터", + "argument-name": "인수 이름", + "value-type": "값 유형", + "general": "일반", + "filters": "필터", + "date-time-hint": "인수는 에포크 밀리초여야 합니다. 예: 1698839340000은 2023-11-01입니다.", + "operation": "연산", + "value-source": "값 소스", + "value": "값", + "ignore-case": "대소문자 무시", + "condition": "조건", + "script": "스크립트", + "add-filter": "인수 필터 추가", + "edit-filter": "인수 필터", + "remove-filter": "인수 필터 제거", + "no-filter": "최소 하나의 필터가 필요합니다.", + "conditions": { + "simple": "간단", + "duration": "기간", + "repeating": "반복" + }, + "schedule-title": "일정", + "edit-schedule": "알람 일정 편집", + "schedule-type": "스케줄러 유형", + "schedule-type-required": "스케줄러 유형은 필수 항목입니다.", + "schedule": { + "any-time": "항상 활성", + "specific-time": "특정 시간에 활성", + "custom": "사용자 지정" + }, + "schedule-day": { + "monday": "월요일", + "tuesday": "화요일", + "wednesday": "수요일", + "thursday": "목요일", + "friday": "금요일", + "saturday": "토요일", + "sunday": "일요일" + }, + "schedule-days": "요일", + "schedule-time": "시간", + "schedule-time-from": "부터", + "schedule-time-to": "까지", + "schedule-days-of-week-required": "최소 하나의 요일을 선택해야 합니다.", + "tbel": "TBEL", + "expression-type": { + "simple": "간단", + "script": "스크립트" + }, + "operation-type": { + "and": "그리고", + "or": "또는" + }, + "filter-predicate-type": { + "string": "문자열", + "numeric": "숫자", + "boolean": "불리언", + "complex": "복합" + }, + "alarm-rule-additional-info": "추가 정보", + "edit-alarm-rule-additional-info": "추가 정보 수정", + "alarm-rule-additional-info-placeholder": "알람 세부 정보 내에 표시할 설명과 조정 사항을 여기에 입력하세요.", + "alarm-rule-additional-info-hint": "힌트: ${인수 이름{{dashboardTitle}}는 이제 공개되어서 다음 공개 링크를 통해 접근될 수 있습니다:", "public-dashboard-notice": "참고: 관련된 장치에 접근하기 위해서는 장치들 또한 공개되어야 합니다.", "make-private-dashboard-title": "대시보드 '{{dashboardTitle}}'를 비공개로 전환하시겠습니까?", "make-private-dashboard-text": "대시보드는 비공개로 전환되어 다른 이들이 접근할 수 없게 됩니다.", "make-private-dashboard": "대시보드 비공개 전환", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", + "socialshare-text": "'{{dashboardTitle}}' - ThingsBoard 제공", + "socialshare-title": "'{{dashboardTitle}}' - ThingsBoard 제공", "select-dashboard": "대시보드 선택", "no-dashboards-matching": "'{{entity}}'와 일치하는 대시보드가 없습니다.", "dashboard-required": "대시보드를 입력하세요.", @@ -682,12 +1892,26 @@ "background-image": "배경 이미지", "background-size-mode": "배경 사이즈 모드", "no-image": "이미지 없음", + "empty-image": "이미지 없음", "drop-image": "이곳에 이미지를 끌어다놓거나 이곳을 클릭하여 파일을 선택하고 업로드하세요.", + "maximum-upload-file-size": "최대 업로드 파일 크기: {{ size }}", + "cannot-upload-file": "파일을 업로드할 수 없습니다", "settings": "설정", + "move-all-widgets": "모든 위젯 이동", + "move-by": "이동 거리", + "cols": "열", + "rows": "행", + "layout": "레이아웃", + "layout-type-default": "기본값", + "layout-type-scada": "SCADA", + "layout-type-divider": "구분선", + "layout-settings-type": "레이아웃 설정: {{ type }} 브레이크포인트", "columns-count": "열 개수", "columns-count-required": "열 개수를 입력하세요.", "min-columns-count-message": "열 개수를 최소 10 이상으로 입력하세요.", "max-columns-count-message": "열 개수를 최대 100 이하로 입력하세요.", + "min-layout-width": "최소 레이아웃 너비", + "columns-suffix": "열", "widgets-margins": "위젯 사이 여백 크기", "margin-required": "여백 값을 입력하세요.", "min-margin-message": "최소 여백 값은 0보다 커야 합니다.", @@ -700,23 +1924,39 @@ "vertical-margin-required": "가로 여백 값을 입력하세요.", "min-vertical-margin-message": "최소 가로 여백 값은 0보다 커야 합니다.", "max-vertical-margin-message": "최대 가로 여백 값은 50보다 작아야 합니다.", + "apply-outer-margin": "레이아웃 측면에 여백 적용", "autofill-height": "레이아웃 세로 길이 자동 채우기", "mobile-layout": "모바일 레이아웃 설정", "mobile-row-height": "모바일 행 높이, px", "mobile-row-height-required": "모바일 행 높이를 입력하세요.", "min-mobile-row-height-message": "최소 모바일 행 높이 값은 5 픽셀보다 커야 합니다.", "max-mobile-row-height-message": "최대 모바일 행 높이 값은 200 픽셀보다 작아야 합니다.", + "row-height": "행 높이", + "row-height-required": "행 높이 값은 필수 항목입니다.", + "min-row-height-message": "최소 행 높이 값은 5픽셀만 허용됩니다.", + "max-row-height-message": "최대 행 높이 값은 200픽셀만 허용됩니다.", + "display-first-in-mobile-view": "모바일 보기에서 먼저 표시", + "title-settings": "제목 설정", "display-title": "대시보드 제목 표시", - "toolbar-always-open": "도구 상자를 열어두기", "title-color": "제목 색상", + "toolbar-settings": "도구 모음 설정", + "hide-toolbar": "도구 모음 숨기기", + "toolbar-always-open": "도구 상자를 열어두기", "display-dashboards-selection": "대시보드 선택 표시", - "display-entities-selection": "개체 선택 표시", + "display-entities-selection": "엔티티 선택 표시", "display-filters": "필터 표시", "display-dashboard-timewindow": "타임윈도우 표시", "display-dashboard-export": "디스플레이 내보내기", + "display-update-dashboard-image": "대시보드 이미지 업데이트 표시", + "dashboard-logo-settings": "대시보드 로고 설정", + "display-dashboard-logo": "대시보드 전체 화면 모드에서 로고 표시", + "dashboard-logo-image": "대시보드 로고 이미지", + "advanced-settings": "고급 설정", + "dashboard-css": "대시보드 CSS", "import": "대시보드 가져오기", "export": "대시보드 내보내기", "export-failed-error": "대시보드 내보내기를 할 수 없습니다.: {error}", + "export-prompt": "대시보드 이미지 및 리소스 포함", "create-new-dashboard": "대시보드 생성", "dashboard-file": "대시보드 파일", "invalid-dashboard-file-error": "대시보드 가져오기를 할 수 없습니다.: 대시보드 데이터 구조가 잘못되었습니다.", @@ -732,14 +1972,17 @@ "alias-resolution-error-title": "대시보드 별명 구성 오류", "invalid-aliases-config": "일부 별명 필터와 일치하는 장치를 찾을 수 없습니다.
이 문제를 해결하려면 관리자에게 문의하십시오.", "select-devices": "장치 선택", - "assignedToCustomer": "할당된 커스터머", - "assignedToCustomers": "할당된 커스터머", + "assignedToCustomer": "할당된 고객", + "assignedToCustomers": "할당된 고객", "public": "공개", + "copyId": "대시보드 ID 복사", + "idCopiedMessage": "대시보드 ID가 클립보드에 복사되었습니다", "public-link": "공개 링크", "copy-public-link": "공개 링크 복사", "public-link-copied-message": "대시보드 공개 링크가 클립보드로 복사되었습니다", "manage-states": "대시보드 상태 관리", "states": "대시보드 상태", + "states-short": "상태", "search-states": "대시보드 상태 검색", "selected-states": "{ count, plural, =1 {1 개 대시보드 상태} other {# 개 대시보드 상태} } 선택됨", "edit-state": "대시보드 상태 편집", @@ -759,12 +2002,38 @@ "hide-details": "상세 내역 숨기기", "select-state": "대상 상태 선택", "state-controller": "상태 컨트롤러", + "state-controller-default": "정적 (사용 중단)", "search": "대시보드 검색", - "selected-dashboards": "{ count, plural, =1 {1 개 대시보드} other {# 개 대시보드} } 선택됨" + "selected-dashboards": "{ count, plural, =1 {1 개 대시보드} other {# 개 대시보드} } 선택됨", + "home-dashboard": "홈 대시보드", + "home-dashboard-hide-toolbar": "홈 대시보드 도구 모음 숨기기", + "unassign-dashboard-from-edge-text": "확인 후 대시보드 할당이 해제되고", + "unassign-dashboards-from-edge-title": "{ count, plural, =1 {1 개 대시보드} other {# 개 대시보드} }의 할당을 취소하시겠습니까?", + "unassign-dashboards-from-edge-text": "확인 후 선택한 모든 대시보드 할당이 해제되고", + "assign-dashboard-to-edge": "엣지에 대시보드 할당", + "assign-dashboard-to-edge-text": "엣지에 할당할 대시보드를 선택하세요", + "non-existent-dashboard-state-error": "ID가 \"{{ stateId }}\"인 대시보드 상태를 찾을 수 없습니다", + "edit-mode": "수정 모드", + "duplicate-state-action": "상태 복제", + "breakpoint-value": "브레이크포인트 ({{ value }})", + "breakpoints-id": { + "default": "기본값", + "xs": "모바일 (xs)", + "sm": "태블릿 (sm)", + "md": "노트북 (md)", + "lg": "데스크톱 (lg)", + "xl": "데스크톱 (xl)" + }, + "view-format-type-grid": "그리드", + "view-format-type-list": "목록", + "view-format": "보기 형식" }, "datakey": { "settings": "설정", + "general": "일반", "advanced": "고급", + "key": "키", + "keys": "키", "label": "라벨", "color": "색상", "units": "값 뒤에 표기할 특수 기호", @@ -774,25 +2043,64 @@ "configuration": "데이터 키 구성", "timeseries": "시계열", "attributes": "속성", - "entity-field": "개체 필드", + "entity-field": "엔티티 필드", "alarm": "알람 필드", "timeseries-required": "장치 시계열 를 입력하세요.", "timeseries-or-attributes-required": "장치 시계열/속성 를 입력하세요.", - "alarm-fields-timeseries-or-attributes-required": "Alarm fields or entity timeseries/attributes are required.", - "maximum-timeseries-or-attributes": "Maximum { count, plural, =1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", + "alarm-fields-timeseries-or-attributes-required": "알람 필드 또는 엔티티 시계열/속성이 필요합니다.", + "maximum-timeseries-or-attributes": "최대 { count, plural, =1 {1개의 시계열/속성이 허용됩니다.} other {#개의 시계열/속성이 허용됩니다.} }", "alarm-fields-required": "알람 필드를 입력하세요.", "function-types": "함수 유형", + "function-type": "함수 유형", "function-types-required": "함수 유형을 입력하세요.", + "data-keys": "데이터 키", + "data-key": "데이터 키", + "data-keys-required": "데이터 키는 필수 항목입니다.", + "data-key-required": "데이터 키는 필수 항목입니다.", + "alarm-keys": "알람 데이터 키", + "alarm-key": "알람 데이터 키", + "alarm-key-functions": "알람 키 함수", + "alarm-key-function": "알람 키 함수", + "latest-keys": "최신 데이터 키", + "latest-key": "최신 데이터 키", + "latest-key-functions": "최신 키 함수", + "latest-key-function": "최신 키 함수", + "timeseries-keys": "시계열 데이터 키", + "timeseries-key": "시계열 데이터 키", + "timeseries-key-functions": "시계열 키 함수", + "timeseries-key-function": "시계열 키 함수", "maximum-function-types": "최대 { count, plural, =1 {1 개 함수} other {# 개 함수} } 종류만 허용됩니다.", "time-description": "현재 값의 타임스탬프;", "value-description": "현재 값;", "prev-value-description": "이전 함수 호출의 결과;", "time-prev-description": "이전 값의 타임스탬프;", - "prev-orig-value-description": "원래 이전 값;" + "prev-orig-value-description": "원래 이전 값;", + "aggregation": "집합", + "aggregation-type-hint-common": "성능상의 이유로 집계 값 계산은 다음에만 사용할 수 있습니다", + "aggregation-type-none-hint": "최신 값을 가져옵니다.", + "aggregation-type-min-hint": "선택한 시간 창 내의 데이터 포인트 중 최소값을 찾습니다.", + "aggregation-type-max-hint": "선택한 시간 창 내의 데이터 포인트 중 최대값을 찾습니다.", + "aggregation-type-avg-hint": "선택한 시간 창 내의 데이터 포인트 평균을 계산합니다.", + "aggregation-type-sum-hint": "선택한 시간 창 내의 데이터 포인트의 모든 값을 합산합니다.", + "aggregation-type-count-hint": "선택한 시간 창 내의 데이터 포인트 총 수입니다.", + "delta-calculation": "델타 계산", + "enable-delta-calculation": "델타 계산 사용", + "enable-delta-calculation-hint": "사용하면 데이터 키 값이 집계된 값을 기반으로 계산됩니다.", + "delta-calculation-result": "델타 계산 결과", + "delta-calculation-result-previous-value": "이전 값", + "delta-calculation-result-delta-absolute": "델타 (절대값)", + "delta-calculation-result-delta-percent": "델타 (퍼센트)", + "source": "소스", + "latest": "최신", + "latest-value": "최신 값", + "delta": "델타", + "percent": "퍼센트", + "absolute": "절대값" }, "datasource": { "type": "데이터소스 유형", "name": "이름", + "label": "라벨", "add-datasource-prompt": "데이터소스를 추가하세요." }, "details": { @@ -808,6 +2116,7 @@ "management": "장치 관리", "view-devices": "장치 보기", "device-alias": "장치 별명", + "device-type-max-length": "장치 유형은 256자 미만이어야 합니다.", "aliases": "장치 별명", "no-alias-matching": "'{{alias}}' 를 찾을 수 없습니다.", "no-aliases-found": "별명이 없습니다.", @@ -823,51 +2132,101 @@ "remove-alias": "장치 별명 삭제", "add-alias": "장치 별명 추가", "name-starts-with": "시작되는 이름", + "help-text": "필요에 따라 '%'를 사용하세요: '%device_name_contains%', '%device_name_ends', 'devic", "device-list": "장치 리스트", "use-device-name-filter": "필터 사용", "device-list-empty": "선택된 장치가 없습니다.", "device-name-filter-required": "장치 필터 이름을 입력하세요.", "device-name-filter-no-device-matched": "'{{device}}' 로 시작되는 장치를 찾을 수 없습니다.", "add": "장치 추가", - "assign-to-customer": "커스터머에게 할당", - "assign-device-to-customer": "장치를 커스터머에게 할당", - "assign-device-to-customer-text": "커스터머에게 할당할 장치를 선택하십시오.", + "assign-to-customer": "고객에게 할당", + "assign-device-to-customer": "장치를 고객에게 할당", + "assign-device-to-customer-text": "고객에게 할당할 장치를 선택하십시오.", + "assign-device-to-edge-title": "엣지에 장치 할당", + "assign-device-to-edge-text": "엣지에 할당할 장치를 선택하세요", "make-public": "장치를 공개로 전환", "make-private": "장치를 비공개로 전환", "no-devices-text": "장치 없음", - "assign-to-customer-text": "장치에 할당할 커스터머를 선택하세요.", + "assign-to-customer-text": "장치에 할당할 고객을 선택하세요.", "device-details": "장치 상세 정보", "add-device-text": "장치 추가", "credentials": "자격 증명", "manage-credentials": "자격 증명 관리", "delete": "장치 삭제", "assign-devices": "장치 할당", - "assign-devices-text": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }를 커스터머에 할당", + "assign-devices-text": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }를 고객에 할당", "delete-devices": "장치 삭제", - "unassign-from-customer": "커스터머 할당 해제", + "unassign-from-customer": "고객 할당 해제", "unassign-devices": "장치 할당 취소", - "unassign-devices-action-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }를 커스터머에게서 할당 해제", + "unassign-devices-action-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }를 고객에게서 할당 해제", + "unassign-device-from-edge-title": "'{{deviceName}}' 장치 할당을 해제하시겠습니까?", + "unassign-device-from-edge-text": "확인 후 장치 할당이 해제되고", + "unassign-devices-from-edge": "엣지에서 장치 할당 해제", "assign-new-device": "새로운 장치 할당", - "make-public-device-title": "Are you sure you want to make the device '{{deviceName}}' public?", - "make-public-device-text": "After the confirmation the device and all its data will be made public and accessible by others.", - "make-private-device-title": "Are you sure you want to make the device '{{deviceName}}' private?", - "make-private-device-text": "After the confirmation the device and all its data will be made private and won't be accessible by others.", - "view-credentials": "크리덴셜 보기", + "make-public-device-title": "장치 '{{deviceName}}'을(를) 공개하시겠습니까?", + "make-public-device-text": "확인 후 장치와 모든 데이터가 공개되어 다른 사용자가 액세스할 수 있습니다.", + "make-private-device-title": "장치 '{{deviceName}}'을(를) 비공개로 만드시겠습니까?", + "make-private-device-text": "확인 후 장치와 모든 데이터가 비공개되어 다른 사용자가 액세스할 수 없습니다.", + "view-credentials": "자격 증명 보기", "delete-device-title": "'{{deviceName}}' 장치를 삭제하시겠습니까?", "delete-device-text": "장치 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", "delete-devices-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }를 삭제하시겠습니까?", "delete-devices-action-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} } 삭제", "delete-devices-text": "장치 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", "unassign-device-title": "'{{deviceName}}' 장치 할당을 해제하시겠습니까?", - "unassign-device-text": "장치가 할당 해제되고 커스터머는 액세스 할 수 없게됩니다.", + "unassign-device-text": "장치가 할당 해제되고 고객은 액세스 할 수 없게됩니다.", "unassign-device": "장치 할당 취소", "unassign-devices-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }의 할당을 해제하시겠습니까??", - "unassign-devices-text": "선택된 장치가 할당 해제되고 커스터머는 액세스 할 수 없게됩니다.", - "device-credentials": "장치 크리덴셜", - "credentials-type": "크리덴셜 타입", - "access-token": "억세스 토큰", + "unassign-devices-text": "선택된 장치가 할당 해제되고 고객은 액세스 할 수 없게됩니다.", + "device-credentials": "장치 자격 증명", + "loading-device-credentials": "장치 자격 증명 로딩 중...", + "credentials-type": "자격 증명 타입", + "access-token": "액세스 토큰", "access-token-required": "액세스 토큰을 입력하세요.", "access-token-invalid": "액세스 토큰 길이는 1 - 32 자 여야합니다.", + "certificate-pem-format": "PEM 형식의 인증서", + "certificate-pem-format-required": "인증서는 필수 항목입니다.", + "copy-access-token": "액세스 토큰 복사", + "copy-certificate": "인증서 복사", + "copy-client-id": "클라이언트 ID 복사", + "copy-user-name": "사용자 이름 복사", + "copy-password": "비밀번호 복사", + "generate-client-id": "클라이언트 ID 생성", + "generate-user-name": "사용자 이름 생성", + "generate-password": "비밀번호 생성", + "generate-access-token": "액세스 토큰 생성", + "lwm2m-security-config": { + "identity": "클라이언트 ID", + "identity-required": "클라이언트 ID는 필수 항목입니다.", + "identity-tooltip": "PSK 식별자는 [RFC4279]에 설명된 대로 최대 128바이트의 임의 PSK 식별자입니다.", + "client-key": "클라이언트 키", + "client-key-required": "클라이언트 키는 필수 항목입니다.", + "client-key-tooltip-prk": "RPK 공개 키 또는 ID는 표준 [RFC7250]에 있어야 하며 Base64로 인코딩되어야 합니다.", + "client-key-tooltip-psk": "PSK 키는 표준 [RFC4279]에 있어야 하며 HexDec 형식이어야 합니다: 32, 64, 128자", + "endpoint": "엔드포인트 클라이언트 이름", + "endpoint-required": "엔드포인트 클라이언트 이름은 필수 항목입니다.", + "client-public-key": "클라이언트 공개 키", + "client-public-key-hint": "클라이언트 공개 키가 비어 있으면 신뢰할 수 있는 인증서가 사용됩니다.", + "client-public-key-tooltip": "X509 공개 키는 DER 인코딩된 X509v3 형식이어야 하며 EC만 지원합니다.", + "mode": "보안 구성 모드", + "client-tab": "클라이언트 보안 구성", + "client-certificate": "클라이언트 인증서", + "bootstrap-tab": "부트스트랩 클라이언트", + "bootstrap-server": "부트스트랩 서버", + "lwm2m-server": "LwM2M 서버", + "client-reboot": "등록 업데이트 트리거", + "bootstrap-reboot": "부트스트랩 요청 트리거", + "client-publicKey-or-id": "클라이언트 공개 키 또는 ID", + "client-publicKey-or-id-required": "클라이언트 공개 키 또는 ID는 필수 항목입니다.", + "client-publicKey-or-id-tooltip-psk": "PSK 식별자는 [RFC4279]에 설명된 대로 최대 128바이트의 임의 PSK 식별자입니다.", + "client-publicKey-or-id-tooltip-rpk": "RPK 공개 키 또는 ID는 표준 [RFC7250]에 있어야 하며 Base64로 인코딩되어야 합니다.", + "client-publicKey-or-id-tooltip-x509": "X509 공개 키는 DER 인코딩된 X509v3 형식이어야 하며 EC만 지원합니다.", + "client-secret-key": "클라이언트 비밀 키", + "client-secret-key-required": "클라이언트 비밀 키는 필수 항목입니다.", + "client-secret-key-tooltip-psk": "PSK 키는 표준 [RFC4279]에 있어야 하며 HexDec 형식이어야 합니다: 32, 64, 128자", + "client-secret-key-tooltip-prk": "RPK 비밀 키는 PKCS_8 형식(DER 인코딩, 표준 [RFC5958])이어야 합니다.", + "client-secret-key-tooltip-x509": "X509 비밀 키는 PKCS_8 형식(DER 인코딩, 표준 [RFC5958])이어야 합니다." + }, "client-id": "클라이언트 ID", "client-id-pattern": "잘못된 문자가 포함되어 있습니다.", "user-name": "사용자 이름", @@ -883,23 +2242,34 @@ "any-device": "모든 장치", "no-device-types-matching": "'{{entitySubtype}}'에 해당되는 장치 유형을 찾을 수 없습니다.", "device-type-list-empty": "아무 장치 유형도 선택되지 않았습니다.", + "device-profile-type-list-empty": "최소 하나의 장치 프로필을 선택해야 합니다.", "device-types": "장치 유형", "name": "이름", "name-required": "이름을 입력하세요.", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "label-max-length": "레이블은 256자 미만이어야 합니다.", "description": "설명", "label": "라벨", "events": "이벤트", "details": "상세", "copyId": "장치 아이디 복사", "copyAccessToken": "액세스 토큰 복사", - "copy-mqtt-authentication": "MQTT 크리덴셜 복사", + "copy-mqtt-authentication": "MQTT 자격 증명 복사", "idCopiedMessage": "장치 아이디가 클립보드에 복사되었습니다.", - "accessTokenCopiedMessage": "장치 억세스 토큰이 클립보드에 복사되었습니다.", - "mqtt-authentication-copied-message": "장치 MQTT 크리덴셜이 클립보드에 복사되었습니다.", - "assignedToCustomer": "커스터머에 할당됨", + "accessTokenCopiedMessage": "장치 액세스 토큰이 클립보드에 복사되었습니다.", + "mqtt-authentication-copied-message": "장치 MQTT 자격 증명이 클립보드에 복사되었습니다.", + "assignedToCustomer": "고객에 할당됨", "unable-delete-device-alias-title": "장치 별명을 삭제할 수 없습니다.", "unable-delete-device-alias-text": "'{{deviceAlias}}' 장치 별명을 삭제할 수 없습니다. 다음 위젯에서 사용하고 있습니다.
{{widgetsList}}", "is-gateway": "게이트웨이 여부", + "overwrite-activity-time": "연결된 장치의 활동 시간 덮어쓰기", + "device-filter-title": "장치 필터", + "filter-title": "필터", + "device-state": "장치 상태", + "state": "상태", + "any": "전체", + "active": "활성", + "inactive": "비활성", "public": "공개", "device-public": "장치가 공개됨", "select-device": "장치 선택", @@ -911,161 +2281,739 @@ "transport-configuration": "전송 설정", "wizard": { "device-details": "장치 상세 정보" + }, + "unassign-devices-from-edge-title": "{ count, plural, =1 {장치 1 개} other {장치 # 개} }의 할당을 해제하시겠습니까??", + "unassign-devices-from-edge-text": "확인 후 선택한 모든 장치의 할당이 해제되고", + "time": "시간", + "connectivity": { + "check-connectivity": "연결 확인", + "device-created-check-connectivity": "장치가 생성되었습니다. 연결을 확인하세요!", + "loading-check-connectivity-command": "연결 확인 명령 로딩 중...", + "use-following-instructions": "다음 지침을 사용하여 장치 대신 텔레메트리를 전송하세요.", + "execute-following-command": "다음 명령을 실행하세요", + "install-curl-windows": "Windows 10 b17063부터 cURL이 기본적으로 사용할 수 있습니다.", + "install-curl-macos": "Mac OS X 10.2 6C115 (Jaguar)부터 cURL이 기본적으로 사용할 수 있습니다.", + "install-mqtt-windows": "mosquitto_pub을 다운로드, 설치, 설정 및 실행하는 지침을 사용하세요.", + "install-coap-client": "coap-client를 다운로드, 설치, 설정 및 실행하는 지침을 사용하세요.", + "install-necessary-client-tools": "필요한 클라이언트 도구 설치", + "mqtts-x509-command": "다음 문서를 사용하여 인증서로 MQTT를 통해 장치를 연결하세요.", + "coaps-x509-command": "다음 문서를 사용하여 인증서로 CoAP over DTLS를 통해 장치를 연결하세요.", + "snmp-command": "다음 문서를 사용하여 SNMP를 통해 장치를 연결하세요.", + "sparkplug-command": "다음 문서를 사용하여 MQTT Sparkplug를 통해 장치를 연결하세요.", + "lwm2m-command": "다음 문서를 사용하여 LWM2M을 통해 장치를 연결하세요." } }, + "dynamic-form": { + "property": { + "properties": "속성", + "property": "속성", + "id": "ID", + "name": "이름", + "type": "유형", + "type-text": "텍스트", + "type-password": "비밀번호", + "type-textarea": "텍스트 영역", + "type-number": "숫자", + "type-switch": "스위치", + "type-select": "선택", + "type-radios": "라디오 버튼", + "type-datetime": "날짜/시간", + "type-image": "이미지", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "마크다운", + "type-color": "색상", + "type-color-settings": "색상 설정", + "type-font": "글꼴", + "type-units": "단위", + "type-icon": "아이콘", + "type-fieldset": "필드셋", + "type-array": "배열", + "type-html-section": "HTML 섹션", + "group-title": "그룹 제목", + "no-properties": "구성된 속성 없음", + "add-property": "속성 추가", + "property-settings": "속성 설정", + "remove-property": "속성 제거", + "default-value": "기본값", + "value-required": "값 필요", + "number-settings": "숫자 설정", + "min": "최소", + "max": "최대", + "step": "단계", + "selected-options-limit": "선택 옵션 제한", + "advanced-ui-settings": "고급 UI 설정", + "disable-on-property": "속성 비활성화", + "disable-on-property-none": "없음 (필드 항상 활성화)", + "display-condition-function": "표시 조건 함수", + "sub-label": "보조 라벨", + "vertical-divider-after": "다음에 수직 구분선", + "input-field-suffix": "입력 필드 접미사", + "property-row-classes": "속성 행 클래스", + "property-field-classes": "속성 필드 클래스", + "not-unique-property-ids-error": "속성 ID는 고유해야 합니다!", + "enable-multiple-select": "다중 선택 활성화", + "allow-empty-select-option": "빈 옵션 허용", + "select-options": "선택 옵션", + "not-unique-select-option-value-error": "선택 옵션 값은 고유해야 합니다!", + "value": "값", + "label": "라벨", + "add-option": "옵션 추가", + "no-options": "구성된 옵션 없음", + "remove-option": "옵션 제거", + "textarea-rows": "텍스트 영역 행 수", + "help-id": "도움말 ID", + "buttons-direction": "버튼 방향", + "direction-row": "행", + "direction-column": "열", + "radio-button-options": "라디오 버튼 옵션", + "datetime-type": "날짜/시간 필드 유형", + "datetime-type-date": "날짜", + "datetime-type-time": "시간", + "datetime-type-datetime": "날짜/시간", + "enable-clear-button": "지우기 버튼 활성화", + "html-section-settings": "HTML 섹션 설정", + "html-section-classes": "HTML 섹션 클래스", + "html-section-content": "HTML 섹션 내용", + "array-item": "배열 항목", + "item-type": "항목 유형", + "item-name": "항목 이름", + "no-items": "항목 없음", + "support-unit-conversion": "단위 변환 지원" + }, + "clear-form": "양식 지우기", + "clear-form-prompt": "모든 양식 속성을 제거하시겠습니까?", + "import-form": "JSON에서 양식 가져오기", + "export-form": "양식을 JSON으로 내보내기", + "json-file": "JSON 파일", + "json-content": "JSON 내용", + "invalid-form-json-file-error": "JSON에서 양식을 가져올 수 없습니다: 잘못된 양식 JSON 데이터 구조입니다." + }, + "asset-profile": { + "asset-profile": "자산 프로필", + "asset-profiles": "자산 프로필", + "all-asset-profiles": "모두", + "add": "자산 프로필 추가", + "edit": "자산 프로필 수정", + "asset-profile-details": "자산 프로필 상세", + "no-asset-profiles-text": "자산 프로필이 없습니다", + "search": "자산 프로필 검색", + "selected-asset-profiles": "{ count, plural, =1 {1개 자산 프로필} other {#개 자산 프로필} } 선택됨", + "no-asset-profiles-matching": "'{{entity}}'와(과) 일치하는 자산 프로필이 없습니다.", + "asset-profile-required": "자산 프로필이 필요합니다", + "idCopiedMessage": "자산 프로필 ID가 클립보드에 복사되었습니다", + "set-default": "자산 프로필 기본값으로 설정", + "delete": "자산 프로필 삭제", + "copyId": "자산 프로필 ID 복사", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "new-device-profile-name": "자산 프로필 이름", + "new-device-profile-name-required": "자산 프로필 이름이 필요합니다.", + "name": "이름", + "name-required": "이름을 입력하세요.", + "image": "자산 프로필 이미지", + "description": "설명", + "default": "기본값", + "default-rule-chain": "기본 규칙 사슬", + "default-edge-rule-chain": "기본 엣지 규칙 사슬", + "default-edge-rule-chain-hint": "이 자산 프로필의 자산에 대한 들어오는 데이터를 처리하는 규칙 사슬로 엣지에서 사용됩니다", + "mobile-dashboard": "모바일 대시보드", + "mobile-dashboard-hint": "모바일 애플리케이션에서 자산 상세 대시보드로 사용됩니다", + "select-queue-hint": "드롭다운 목록에서 선택하세요.", + "delete-asset-profile-title": "자산 프로필 '{{assetProfileName}}'을(를) 삭제하시겠습니까?", + "delete-asset-profile-text": "주의: 확인 후 자산 프로필과 모든 관련 데이터가", + "delete-asset-profiles-title": "{ count, plural, =1 {1개 자산 프로필} other {#개 자산 프로필} }을(를) 삭제하시겠습니까?", + "delete-asset-profiles-text": "주의: 확인 후 선택한 모든 자산 프로필이 제거되고", + "set-default-asset-profile-title": "자산 프로필 '{{assetProfileName}}'을(를) 기본값으로 설정하시겠습니까?", + "set-default-asset-profile-text": "확인 후 자산 프로필이 기본값으로 표시되고", + "no-asset-profiles-found": "자산 프로필이 없습니다.", + "create-new-asset-profile": "새로 만들기!", + "create-asset-profile": "새 자산 프로필 만들기", + "import": "자산 프로필 가져오기", + "export": "자산 프로필 내보내기", + "export-failed-error": "자산 프로필을 내보낼 수 없습니다: {{error}}", + "asset-profile-file": "자산 프로필 파일", + "invalid-asset-profile-file-error": "자산 프로필을 가져올 수 없습니다: 잘못된 자산 프로필 데이터 구조입니다." + }, "device-profile": { - "device-profile": "장치 프로파일", - "device-profiles": "장치 프로파일", + "device-profile": "장치 프로필", + "device-profiles": "장치 프로필", "all-device-profiles": "모두", - "add": "장치 프로파일 추가", - "edit": "장치 프로파일 편집", - "device-profile-details": "장치 프로파일 상세 정보", - "no-device-profiles-text": "아무 장치 프로파일도 찾을 수 없습니다", - "search": "장치 프로파일 검색", - "selected-device-profiles": "{ count, plural, =1 {1 개 장치 프로파일} other {# 개 장치 프로파일} } 선택됨", - "no-device-profiles-matching": "No device profile matching '{{entity}}' were found.", - "device-profile-required": "장치 프로파일을 입력하세요", - "idCopiedMessage": "Device profile Id has been copied to clipboard", - "set-default": "Make device profile default", - "delete": "Delete device profile", - "copyId": "Copy device profile Id", + "add": "장치 프로필 추가", + "edit": "장치 프로필 편집", + "device-profile-details": "장치 프로필 상세 정보", + "no-device-profiles-text": "아무 장치 프로필도 찾을 수 없습니다", + "search": "장치 프로필 검색", + "selected-device-profiles": "{ count, plural, =1 {1 개 장치 프로필} other {# 개 장치 프로필} } 선택됨", + "no-device-profiles-matching": "'{{entity}}'와(과) 일치하는 장치 프로필을 찾을 수 없습니다.", + "device-profile-required": "장치 프로필을 입력하세요", + "idCopiedMessage": "장치 프로필 ID가 클립보드에 복사되었습니다.", + "set-default": "장치 프로필 기본값으로 설정", + "delete": "장치 프로필 삭제", + "copyId": "장치 프로필 ID 복사", + "name-max-length": "이름은 256자 미만이어야 합니다.", "name": "이름", "name-required": "이름을 입력하세요.", - "type": "프로파일 유형", - "type-required": "프로파일 유형을 입력하세요.", + "type": "프로필 유형", + "type-required": "프로필 유형을 입력하세요.", "type-default": "기본값", + "image": "장치 프로필 이미지", "transport-type": "전송 유형", "transport-type-required": "전송 유형을 입력하세요.", "transport-type-default": "기본값", "transport-type-default-hint": "기본 MQTT, HTTP, CoAP 전송을 지원", "transport-type-mqtt": "MQTT", "transport-type-mqtt-hint": "고급 MQTT 전송 설정을 활성화", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "고급 CoAP 전송 설정을 활성화합니다.", "transport-type-lwm2m": "LWM2M", "transport-type-lwm2m-hint": "LWM2M 전송 유형", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "SNMP 전송 구성을 지정하세요.", + "transport-type-http": "HTTP", "description": "설명", "default": "기본값", - "profile-configuration": "프로파일 설정", + "profile-configuration": "프로필 설정", "transport-configuration": "전송 설정", "default-rule-chain": "기본 규칙 사슬", - "select-queue-hint": "Select from a drop-down list.", - "delete-device-profile-title": "Are you sure you want to delete the device profile '{{deviceProfileName}}'?", - "delete-device-profile-text": "Be careful, after the confirmation the device profile and all related data will become unrecoverable.", - "delete-device-profiles-title": "Are you sure you want to delete { count, plural, =1 {1 개 장치 프로파일} other {# 개 장치 프로파일} }?", - "delete-device-profiles-text": "Be careful, after the confirmation all selected device profiles will be removed and all related data will become unrecoverable.", - "set-default-device-profile-title": "Are you sure you want to make the device profile '{{deviceProfileName}}' default?", - "set-default-device-profile-text": "After the confirmation the device profile will be marked as default and will be used for new devices with no profile specified.", - "no-device-profiles-found": "No device profiles found.", - "create-new-device-profile": "Create a new one!", - "mqtt-device-topic-filters": "MQTT device topic filters", - "mqtt-device-topic-filters-unique": "MQTT device topic filters need to be unique.", - "mqtt-device-payload-type": "MQTT device payload", + "default-edge-rule-chain": "기본 엣지 규칙 사슬", + "default-edge-rule-chain-hint": "엣지에서 이 장치 프로필의 장치에 대한 들어오는 데이터를 처리하는 규칙 체인으로 사용됩니다.", + "mobile-dashboard": "모바일 대시보드", + "mobile-dashboard-hint": "모바일 애플리케이션에서 장치 세부 정보 대시보드로 사용됩니다.", + "select-queue-hint": "드롭다운 목록에서 선택하세요.", + "delete-device-profile-title": "장치 프로필 '{{deviceProfileName}}'을(를) 삭제하시겠습니까?", + "delete-device-profile-text": "주의: 확인 후 장치 프로필과 관련된 모든 데이터(OTA 업데이트 포함)는 복구할 수 없게 됩니다.", + "delete-device-profiles-title": "정말 { count, plural, =1 {1개의 장치 프로필} other {#개의 장치 프로필} }을 삭제하시겠습니까?", + "delete-device-profiles-text": "주의: 확인 후 선택한 모든 장치 프로필이 제거되고 관련된 모든 데이터(OTA 업데이트 포함)는 복구할 수 없게 됩니다.", + "set-default-device-profile-title": "장치 프로필 '{{deviceProfileName}}'을(를) 기본값으로 설정하시겠습니까?", + "set-default-device-profile-text": "확인 후 장치 프로필이 기본값으로 표시되며 프로필이 지정되지 않은 새 장치에 사용됩니다.", + "no-device-profiles-found": "장치 프로필을 찾을 수 없습니다.", + "create-new-device-profile": "새로 만들기!", + "mqtt-device-topic-filters": "MQTT 장치 토픽 필터", + "mqtt-device-topic-filters-unique": "MQTT 장치 토픽 필터는 고유해야 합니다.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) 노드.", + "mqtt-device-topic-filters-spark-plug-hint": "Sparkplug B 페이로드 및 토픽 형식의 EoN 노드에서 연결을 허용합니다.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "속성으로 저장할 SparkPlug 지표.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "장치 속성으로 저장될 SparkPlug 지표의 이름입니다. 다른 모든 지표는", + "mqtt-device-payload-type": "MQTT 장치 페이로드", "mqtt-device-payload-type-json": "JSON", "mqtt-device-payload-type-proto": "Protobuf", - "mqtt-payload-type-required": "Payload type is required.", - "support-level-wildcards": "Single [+] and multi-level [#] wildcards supported.", - "telemetry-topic-filter": "Telemetry topic filter", - "telemetry-topic-filter-required": "Telemetry topic filter is required.", - "attributes-topic-filter": "Attributes publish topic filter", - "attributes-subscribe-topic-filter": "Attributes subscribe topic filter", - "attributes-topic-filter-required": "Attributes publish topic filter is required.", - "attributes-subscribe-topic-filter-required": "Attributes subscribe topic is required", - "telemetry-proto-schema": "Telemetry proto schema", - "telemetry-proto-schema-required": "Telemetry proto schema is required.", - "attributes-proto-schema": "Attributes proto schema", - "attributes-proto-schema-required": "Attributes proto schema is required.", - "rpc-response-topic-filter": "RPC response topic filter", - "rpc-response-topic-filter-required": "RPC response topic filter is required.", - "not-valid-pattern-topic-filter": "Not valid pattern topic filter", - "not-valid-single-character": "Invalid use of a single-level wildcard character", - "not-valid-multi-character": "Invalid use of a multi-level wildcard character", - "single-level-wildcards-hint": "[+] is suitable for any topic filter level. Ex.: v1/devices/+/telemetry or +/devices/+/attributes.", - "multi-level-wildcards-hint": "[#] can replace the topic filter itself and must be the last symbol of the topic. Ex.: # or v1/devices/me/#.", + "mqtt-enable-compatibility-with-json-payload-format": "다른 페이로드 형식과의 호환성 사용.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "사용하면 플랫폼은 기본적으로 Protobuf 페이로드 형식을 사용합니다. 파싱이", + "mqtt-use-json-format-for-default-downlink-topics": "기본 다운링크 토픽에 JSON 형식 사용", + "mqtt-use-json-format-for-default-downlink-topics-hint": "사용하면 플랫폼은 속성 및 RPC를 푸시하는 데 JSON 페이로드 형식을 사용합니다.", + "mqtt-send-ack-on-validation-exception": "PUBLISH 메시지 유효성 검사 실패 시 PUBACK 전송", + "mqtt-send-ack-on-validation-exception-hint": "기본적으로 플랫폼은 메시지 유효성 검사 실패 시 MQTT 세션을 닫습니다.", + "mqtt-protocol-version": "프로토콜 버전", + "snmp-add-mapping": "SNMP 매핑 추가", + "snmp-mapping-not-configured": "OID를 시계열/텔레메트리에 매핑하도록 구성되지 않음", + "snmp-timseries-or-attribute-name": "매핑을 위한 시계열/속성 이름", + "snmp-timseries-or-attribute-type": "매핑을 위한 시계열/속성 유형", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "페이로드 유형은 필수 항목입니다.", + "coap-device-type": "CoAP 장치 유형", + "coap-device-payload-type": "CoAP 장치 페이로드", + "coap-device-type-required": "CoAP 장치 유형은 필수 항목입니다.", + "coap-device-type-default": "기본값", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "단일 레벨 [+] 및 다중 레벨 [#] 와일드카드가 지원됩니다.", + "telemetry-topic-filter": "텔레메트리 토픽 필터", + "telemetry-topic-filter-required": "텔레메트리 토픽 필터는 필수 항목입니다.", + "attributes-topic-filter": "속성 게시 토픽 필터", + "attributes-subscribe-topic-filter": "속성 구독 토픽 필터", + "attributes-topic-filter-required": "속성 게시 토픽 필터는 필수 항목입니다.", + "attributes-subscribe-topic-filter-required": "속성 구독 토픽은 필수 항목입니다.", + "telemetry-proto-schema": "텔레메트리 프로토 스키마", + "telemetry-proto-schema-required": "텔레메트리 프로토 스키마는 필수 항목입니다.", + "attributes-proto-schema": "속성 프로토 스키마", + "attributes-proto-schema-required": "속성 프로토 스키마는 필수 항목입니다.", + "rpc-response-proto-schema": "RPC 응답 프로토 스키마", + "rpc-response-proto-schema-required": "RPC 응답 프로토 스키마는 필수 항목입니다.", + "rpc-response-topic-filter": "RPC 응답 토픽 필터", + "rpc-response-topic-filter-required": "RPC 응답 토픽 필터는 필수 항목입니다.", + "rpc-request-proto-schema": "RPC 요청 프로토 스키마", + "rpc-request-proto-schema-required": "RPC 요청 프로토 스키마는 필수 항목입니다.", + "rpc-request-proto-schema-hint": "RPC 요청 메시지에는 항상 다음 필드가 있어야 합니다: string method = 1; int32 requestId", + "not-valid-pattern-topic-filter": "유효하지 않은 패턴 토픽 필터", + "not-valid-single-character": "단일 수준 와일드카드 문자의 잘못된 사용", + "not-valid-multi-character": "다중 수준 와일드카드 문자의 잘못된 사용", + "single-level-wildcards-hint": "[+]는 모든 토픽 필터 레벨에 적합합니다. 예: v1/devices/+/telemetry 또는 +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#]는 토픽 필터 자체를 대체할 수 있으며 토픽의 마지막 기호여야 합니다. 예: # 또는 v1/devices/me/#.", "alarm-rules": "알람 규칙", - "alarm-rules-with-count": "Alarm rules ({{count}})", - "no-alarm-rules": "No alarm rules configured", - "add-alarm-rule": "Add alarm rule", - "edit-alarm-rule": "Edit alarm rule", - "alarm-type": "Alarm type", - "alarm-type-required": "Alarm type is required.", - "alarm-type-unique": "Alarm type must be unique within the device profile alarm rules.", - "create-alarm-pattern": "Create {{alarmType}} alarm", - "create-alarm-rules": "Create alarm rules", - "no-create-alarm-rules": "No create conditions configured", - "add-create-alarm-rule-prompt": "Please add create alarm rule", - "clear-alarm-rule": "Clear alarm rule", - "no-clear-alarm-rule": "No clear condition configured", - "add-create-alarm-rule": "Add create condition", - "add-clear-alarm-rule": "Add clear condition", - "select-alarm-severity": "Select alarm severity", - "alarm-severity-required": "Alarm severity is required.", - "condition-duration": "Condition duration", - "condition-duration-value": "Duration value", - "condition-duration-time-unit": "Time unit", - "condition-duration-value-range": "Duration value should be in a range from 1 to 2147483647.", - "condition-duration-value-pattern": "Duration value should be integers.", - "condition-duration-value-required": "Duration value is required.", - "condition-duration-time-unit-required": "Time unit is required.", - "advanced-settings": "Advanced settings", - "propagate-alarm": "Propagate alarm", - "alarm-rule-relation-types-list": "Relation types", - "alarm-rule-relation-types-list-hint": "Defines relation types to filter the related entities. If not set, the alarm will be propagated to all related entities.", - "alarm-rule-condition": "Alarm rule condition", - "enter-alarm-rule-condition-prompt": "Please add alarm rule condition", - "edit-alarm-rule-condition": "Edit alarm rule condition", + "alarm-rules-with-count": "알람 규칙 ({{count}})", + "no-alarm-rules": "구성된 알람 규칙 없음", + "add-alarm-rule": "알람 규칙 추가", + "edit-alarm-rule": "알람 규칙 편집", + "alarm-type": "알람 유형", + "alarm-type-required": "알람 유형은 필수 항목입니다.", + "alarm-type-unique": "알람 유형은 장치 프로필 알람 규칙 내에서 고유해야 합니다.", + "alarm-type-max-length": "알람 유형은 256자 미만이어야 합니다.", + "create-alarm-pattern": "{{alarmType}} 알람 생성", + "create-alarm-rules": "알람 규칙 생성", + "no-create-alarm-rules": "구성된 생성 조건 없음", + "add-create-alarm-rule-prompt": "알람 생성 규칙을 추가하세요.", + "clear-alarm-rule": "알람 규칙 지우기", + "no-clear-alarm-rule": "지움 조건이 구성되지 않음", + "add-create-alarm-rule": "생성 조건 추가", + "add-clear-alarm-rule": "지움 조건 추가", + "select-alarm-severity": "알람 심각도 선택", + "alarm-severity-required": "알람 심각도는 필수 항목입니다.", + "condition-duration": "조건 기간", + "condition-duration-value": "기간 값", + "condition-duration-time-unit": "시간 단위", + "condition-duration-value-range": "기간 값은 1에서 2147483647 범위여야 합니다.", + "condition-duration-value-pattern": "기간 값은 정수여야 합니다.", + "condition-duration-value-required": "기간 값은 필수 항목입니다.", + "condition-duration-time-unit-required": "시간 단위는 필수 항목입니다.", + "advanced-settings": "고급 설정", + "alarm-rule-additional-info": "추가 정보", + "edit-alarm-rule-additional-info": "추가 정보 수정", + "alarm-rule-additional-info-placeholder": "알람 세부 정보 내에 표시할 설명과 조정 사항을 여기에 입력하세요.", + "alarm-rule-additional-info-hint": "힌트: ${keyName
누락된 규칙 체인 목록:
{{missingRuleChains}}", + "upgrade-instructions": "업그레이드 지침", + "widget-datasource-error": "이 위젯은 엣지 엔티티 데이터소스만 지원합니다", + "connected": "연결됨", + "disconnected": "연결 해제됨" + }, + "edge-event": { + "type-dashboard": "대시보드", + "type-asset": "자산", + "type-device": "장치", + "type-device-profile": "장치 프로필", + "type-asset-profile": "자산 프로필", + "type-entity-view": "엔티티 뷰", + "type-alarm": "알람", + "type-rule-chain": "규칙 사슬", + "type-rule-chain-metadata": "규칙 사슬 메타데이터", + "type-edge": "엣지", + "type-user": "사용자", + "type-tenant": "테넌트", + "type-tenant-profile": "테넌트 프로필", + "type-customer": "고객", + "type-relation": "관계", + "type-widgets-bundle": "위젯 번들", + "type-widgets-type": "위젯 유형", + "type-admin-settings": "관리자 설정", + "type-ota-package": "OTA 패키지", + "type-queue": "큐", + "action-type-added": "추가됨", + "action-type-deleted": "삭제됨", + "action-type-updated": "수정됨", + "action-type-post-attributes": "속성 게시", + "action-type-attributes-updated": "속성 업데이트됨", + "action-type-attributes-deleted": "속성 삭제됨", + "action-type-timeseries-updated": "시계열 업데이트됨", + "action-type-credentials-updated": "자격 증명 업데이트됨", + "action-type-assigned-to-customer": "고객에게 할당", + "action-type-unassigned-from-customer": "지정된 고객 해제", + "action-type-relation-add-or-update": "관계 추가 또는 업데이트", + "action-type-relation-deleted": "관계 삭제됨", + "action-type-rpc-call": "RPC 호출", + "action-type-alarm-ack": "알람 확인", + "action-type-alarm-clear": "알람 해제", + "action-type-alarm-assigned": "알람 할당됨", + "action-type-alarm-unassigned": "알람 할당 해제", + "action-type-assigned-to-edge": "엣지에 할당됨", + "action-type-unassigned-from-edge": "엣지에서 할당 해제됨", + "action-type-credentials-request": "자격 증명 요청", + "action-type-entity-merge-request": "엔티티 병합 요청" }, "error": { "unable-to-connect": "서버에 연결할 수 없습니다! 인터넷 연결을 확인하십시오.", @@ -1073,58 +3021,72 @@ "unknown-error": "알 수 없는 오류" }, "entity": { - "entity": "개체", - "entities": "개체", - "aliases": "개체 별명", - "entity-alias": "개체 별명", - "unable-delete-entity-alias-title": "개체의 별명을 삭제할 수 없습니다.", - "unable-delete-entity-alias-text": "개체 별명 '{{entityAlias}}'은 다음 위젯에서 사용되고 있어 삭제할 수 없습니다:
{{widgetsList}}", - "duplicate-alias-error": "별명 '{{alias}}'은 이미 사용중 입니다.
대시보드 내에서 개체의 별명은 중복될 수 없습니다.", + "entity": "엔티티", + "entities": "엔티티", + "entities-count": "엔티티 수", + "alarms-count": "알람 수", + "aliases": "엔티티 별명", + "aliases-short": "별명", + "entity-alias": "엔티티 별명", + "unable-delete-entity-alias-title": "엔티티의 별명을 삭제할 수 없습니다.", + "unable-delete-entity-alias-text": "엔티티 별명 '{{entityAlias}}'은 다음 위젯에서 사용되고 있어 삭제할 수 없습니다:
{{widgetsList}}", + "duplicate-alias-error": "별명 '{{alias}}'은 이미 사용중 입니다.
대시보드 내에서 엔티티의 별명은 중복될 수 없습니다.", "missing-entity-filter-error": "별명 '{{alias}}'에 대한 필터가 지정되지 않았습니다.", "configure-alias": "별명 '{{alias}}' 설정", "alias": "별명", - "alias-required": "개체 별명을 입력하세요.", - "remove-alias": "개체 별명 삭제", - "add-alias": "개체 별명 추가", - "entity-list": "개체 목록", - "entity-type": "개체 유형", - "entity-types": "개체 유형", - "entity-type-list": "개체 유형 목록", - "any-entity": "모든 개체", - "enter-entity-type": "개체 유형 입력", - "no-entities-matching": "'{{entity}}'에 해당되는 개체를 찾을 수 없습니다.", - "no-entity-types-matching": "'{{entityType}}'에 해당되는 개체 유형을 찾을 수 없습니다.", + "alias-required": "엔티티 별명을 입력하세요.", + "remove-alias": "엔티티 별명 삭제", + "add-alias": "엔티티 별명 추가", + "edit-alias": "엔티티 별명 편집", + "entity-list": "엔티티 목록", + "entity-type": "엔티티 유형", + "entity-types": "엔티티 유형", + "entity-type-list": "엔티티 유형 목록", + "any-entity": "모든 엔티티", + "add-entity-type": "엔티티 유형 추가", + "enter-entity-type": "엔티티 유형 입력", + "no-entities-matching": "'{{entity}}'에 해당되는 엔티티를 찾을 수 없습니다.", + "no-entities-text": "아무 엔티티도 없습니다", + "no-entity-types-matching": "'{{entityType}}'에 해당되는 엔티티 유형을 찾을 수 없습니다.", "name-starts-with": "다음으로 시작하는 이름", + "help-text": "필요에 따라 '%'를 사용하세요: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "필터 사용", - "entity-list-empty": "아무 개체도 선택되지 않았습니다.", - "entity-name-filter-required": "개체 이름 필터를 입력하세요.", - "entity-name-filter-no-entity-matched": "'{{entity}}'로 시작되는 개체를 찾을 수 없습니다.", + "entity-list-empty": "아무 엔티티도 선택되지 않았습니다.", + "entity-type-list-required": "최소 하나의 엔티티 유형을 선택해야 합니다.", + "entity-name-filter-required": "엔티티 이름 필터를 입력하세요.", + "entity-name-filter-no-entity-matched": "'{{entity}}'로 시작되는 엔티티를 찾을 수 없습니다.", "all-subtypes": "모두", - "select-entities": "선택된 개체", - "no-aliases-found": "아무 개체도 없습니다.", - "no-alias-matching": "개체 '{{alias}}'을(를) 찾을 수 없습니다.", + "select-entities": "선택된 엔티티", + "no-aliases-found": "아무 엔티티도 없습니다.", + "no-alias-matching": "엔티티 '{{alias}}'을(를) 찾을 수 없습니다.", "create-new-alias": "새로 별명 만들기", + "create-new": "새로 만들기", "key": "키", "key-name": "키 이름", "no-keys-found": "아무 키도 찾을 수 없습니다.", "no-key-matching": "'{{key}}'를 찾을 수 없습니다.", "create-new-key": "새로 키 만들기", "type": "유형", - "type-required": "개체의 유형을 입력하세요.", + "type-required": "엔티티의 유형을 입력하세요.", "type-device": "장치", "type-devices": "장치", "list-of-devices": "{ count, plural, =1 {1 개 장치} other {# 개 장치} }", "device-name-starts-with": "이름이 '{{prefix}}'로 시작되는 장치", - "type-device-profile": "Device profile", - "type-device-profiles": "Device profiles", - "list-of-device-profiles": "{ count, plural, =1 {1 개 장치 프로파일} other {# 개 장치 프로파일 목록} }", - "device-profile-name-starts-with": "Device profiles whose names start with '{{prefix}}'", + "type-device-profile": "장치 프로필", + "type-device-profiles": "장치 프로필", + "clear-selected-profiles": "선택한 프로필 지우기", + "list-of-device-profiles": "{ count, plural, =1 {1 개 장치 프로필} other {# 개 장치 프로필 목록} }", + "device-profile-name-starts-with": "이름이 '{{prefix}}'로 시작하는 장치 프로필", + "type-asset-profile": "자산 프로필", + "type-asset-profiles": "자산 프로필", + "list-of-asset-profiles": "{ count, plural, =1 {하나의 자산 프로필} other {#개 자산 프로필 목록} }", + "asset-profile-name-starts-with": "이름이 '{{prefix}}'로 시작하는 자산 프로필", "type-asset": "자산", "type-assets": "자산", "list-of-assets": "{ count, plural, =1 {1 개 자산} other {# 개 자산} }", "asset-name-starts-with": "이름이 '{{prefix}}'로 시작되는 자산", - "type-entity-view": "개체 보기", - "type-entity-views": "개체 보기", + "type-entity-view": "엔티티 뷰", + "type-entity-views": "엔티티 뷰", "list-of-entity-views": "{ count, plural, =1 {1 개 자산 보기} other {# 개 자산 보기} }", "entity-view-name-starts-with": "이름이 '{{prefix}}'로 시작되는 자산 보기", "type-rule": "규칙", @@ -1139,14 +3101,14 @@ "type-tenants": "테넌트", "list-of-tenants": "{ count, plural, =1 {1 개 테넌트} other {# 개 테넌트} }", "tenant-name-starts-with": "이름이 '{{prefix}}'로 시작되는 테넌트", - "type-tenant-profile": "테넌트 프로파일", - "type-tenant-profiles": "테넌트 프로파일", - "list-of-tenant-profiles": "{ count, plural, =1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일 목록} }", - "tenant-profile-name-starts-with": "'이름이 {{prefix}}'로 시작하는 테넌트 프로파일", - "type-customer": "커스터머", - "type-customers": "커스터머", - "list-of-customers": "{ count, plural, =1 {1 개 커스터머} other {# 개 커스터머} }", - "customer-name-starts-with": "이름이 '{{prefix}}'로 시작되는 커스터머", + "type-tenant-profile": "테넌트 프로필", + "type-tenant-profiles": "테넌트 프로필", + "list-of-tenant-profiles": "{ count, plural, =1 {1 개 테넌트 프로필} other {# 개 테넌트 프로필 목록} }", + "tenant-profile-name-starts-with": "'이름이 {{prefix}}'로 시작하는 테넌트 프로필", + "type-customer": "고객", + "type-customers": "고객", + "list-of-customers": "{ count, plural, =1 {1 개 고객} other {# 개 고객} }", + "customer-name-starts-with": "이름이 '{{prefix}}'로 시작되는 고객", "type-user": "사용자", "type-users": "사용자", "list-of-users": "{ count, plural, =1 {1 개 사용자} other {# 개 사용자} }", @@ -1167,19 +3129,84 @@ "type-rulenodes": "규칙 노드", "list-of-rulenodes": "{ count, plural, =1 {1 개 규칙 노드} other {# 개 규칙 노드의 목록} }", "rulenode-name-starts-with": "이름이 '{{prefix}}'로 시작되는 규칙 노트", - "type-current-customer": "현재 커스터머", + "type-api-key": "API 키", + "type-api-keys": "API 키", + "type-current-customer": "현재 고객", "type-current-tenant": "현재 테넌트", "type-current-user": "현재 사용자", "type-current-user-owner": "현재 소유자", - "search": "개체 검색", - "selected-entities": "{ count, plural, =1 {1 개체} other {# 개체} } 선택됨", - "entity-name": "개체 이름", - "entity-label": "개체 라벨", - "details": "개체 상세", - "no-entities-prompt": "아무 개체도 없습니다", + "type-calculated-field": "계산 필드", + "type-calculated-fields": "계산 필드", + "type-ai-model": "AI 모델", + "type-ai-models": "AI 모델", + "type-widgets-bundle": "위젯 번들", + "type-widgets-bundles": "위젯 번들", + "list-of-widgets-bundles": "{ count, plural, =1 {하나의 위젯 번들} other {#개 위젯 번들 목록} }", + "type-widget": "위젯", + "type-widgets": "위젯", + "list-of-widgets": "{ count, plural, =1 {1개 위젯} other {#개 위젯 목록} }", + "search": "엔티티 검색", + "selected-entities": "{ count, plural, =1 {1 엔티티} other {# 엔티티} } 선택됨", + "entity-name": "엔티티 이름", + "entity-label": "엔티티 라벨", + "details": "엔티티 상세", + "no-entities-prompt": "아무 엔티티도 없습니다", "no-data": "표시할 데이터가 없습니다", + "show-all-columns": "모두 표시", "columns-to-display": "표시할 컬럼", - "type-api-usage-state": "Api 사용량 상태" + "type-api-usage-state": "Api 사용량 상태", + "type-edge": "엣지", + "type-edges": "엣지", + "list-of-edges": "{ count, plural, =1 {1개 Edge} other {#개 Edge 목록} }", + "edge-name-starts-with": "이름이 '{{prefix}}'로 시작하는 Edge", + "version-conflict": { + "message": "기존 버전을 덮어쓰거나 변경 사항을 삭제하고 최신 버전을 로드하시겠습니까?", + "link": "이것을 사용하여 {{entityType}}의 버전을 다운로드할 수 있습니다.", + "overwrite": "버전 덮어쓰기", + "discard": "변경 취소" + }, + "type-tb-resource": "리소스", + "type-tb-resources": "리소스", + "list-of-tb-resources": "{ count, plural, =1 {1개 리소스} other {#개 리소스 목록} }", + "type-ota-package": "OTA 패키지", + "type-ota-packages": "OTA 패키지", + "list-of-ota-packages": "{ count, plural, =1 {1개 OTA 패키지} other {#개 OTA 패키지 목록} }", + "type-rpc": "RPC", + "type-queue": "큐", + "type-queue-stats": "큐 통계", + "type-queues-stats": "큐 통계", + "type-notification": "알림", + "type-notification-rule": "알림 규칙", + "type-notification-rules": "알림 규칙", + "list-of-notification-rules": "{ count, plural, =1 {하나의 알림 규칙} other {#개 알림 규칙 목록} }", + "type-notification-target": "알림 수신자", + "type-notification-targets": "알림 수신자", + "list-of-notification-targets": "{ count, plural, =1 {하나의 알림 수신자} other {#개 알림 수신자 목록} }", + "type-notification-request": "알림 요청", + "type-notification-template": "알림 템플릿", + "type-notification-templates": "알림 템플릿", + "list-of-notification-templates": "{ count, plural, =1 {하나의 알림 템플릿} other {#개 알림 템플릿 목록} }", + "link": "링크", + "type-oauth2-client": "OAuth 2.0 클라이언트", + "type-oauth2-clients": "OAuth 2.0 클라이언트", + "list-of-oauth2-clients": "{ count, plural, =1 {하나의 OAuth 2.0 클라이언트} other {#개 OAuth 2.0 클라이언트 목록} }", + "type-domain": "도메인", + "type-domains": "도메인", + "list-of-domains": "{ count, plural, =1 {1개 도메인} other {#개 도메인 목록} }", + "type-mobile-app": "모바일 애플리케이션", + "type-mobile-apps": "모바일 애플리케이션", + "list-of-mobile-apps": "{ count, plural, =1 {하나의 모바일 애플리케이션} other {#개 모바일 애플리케이션 목록} }", + "type-mobile-app-bundle": "모바일 번들", + "type-mobile-app-bundles": "모바일 번들", + "list-of-mobile-app-bundles": "{ count, plural, =1 {하나의 모바일 번들} other {#개 모바일 번들 목록} }", + "limit-reached": "한도 도달", + "limit-reached-text": "{{ entities }}의 한도에 도달했습니다. 더 추가하려면 시스템 관리자에게 {{ entity }} 한도 증가를 요청하세요.", + "request-limit-increase": "한도 증가 요청", + "request-sysadmin-text": "시스템 관리자입니까?", + "login-here": "여기에 로그인", + "to-increase-limit": "하여 한도를 늘리세요.", + "increase-limit-request-sent-title": "시스템 관리자에게 한도 증가를 요청하는 자동 요청을 보냈습니다", + "increase-limit-request-sent-text": "요청을 검토하고 설정을 업데이트할 시간을 기다려 주세요. 변경 사항을 보려면 이 페이지를 새로고침해야 할 수 있습니다." }, "entity-field": { "created-time": "생성 일시", @@ -1187,7 +3214,7 @@ "type": "유형", "first-name": "이름", "last-name": "성", - "email": "Email", + "email": "이메일", "title": "제목", "country": "국가", "state": "주", @@ -1196,88 +3223,94 @@ "address2": "주소 2", "zip": "우편번호", "phone": "전화", - "label": "라벨" + "label": "라벨", + "queue-name": "큐 이름", + "service-id": "서비스 ID", + "owner-name": "소유자 이름", + "owner-type": "소유자 유형" }, "entity-view": { - "entity-view": "개체 보기", - "entity-view-required": "개체 보기를 입력하세요.", - "entity-views": "개체 보기", - "management": "개체 보기 관리", - "view-entity-views": "개체 보기 보기", - "entity-view-alias": "개체 보기 별명", - "aliases": "개체 보기 별명", - "no-alias-matching": "'{{alias}}' not found.", - "no-aliases-found": "No aliases found.", - "no-key-matching": "'{{key}}' not found.", - "no-keys-found": "No keys found.", - "create-new-alias": "Create a new one!", - "create-new-key": "Create a new one!", - "duplicate-alias-error": "Duplicate alias found '{{alias}}'.
Entity View aliases must be unique within the dashboard.", - "configure-alias": "Configure '{{alias}}' alias", - "no-entity-views-matching": "No entity views matching '{{entity}}' were found.", - "public": "Public", - "alias": "Alias", - "alias-required": "Entity View alias is required.", - "remove-alias": "Remove entity view alias", - "add-alias": "Add entity view alias", - "name-starts-with": "Entity View name starts with", - "entity-view-list": "Entity View list", - "use-entity-view-name-filter": "Use filter", - "entity-view-list-empty": "No entity views selected.", - "entity-view-name-filter-required": "Entity view name filter is required.", - "entity-view-name-filter-no-entity-view-matched": "No entity views starting with '{{entityView}}' were found.", - "add": "Add Entity View", - "entity-view-public": "Entity view is public", - "assign-to-customer": "Assign to customer", - "assign-entity-view-to-customer": "Assign Entity View(s) To Customer", - "assign-entity-view-to-customer-text": "Please select the entity views to assign to the customer", - "no-entity-views-text": "No entity views found", - "assign-to-customer-text": "Please select the customer to assign the entity view(s)", - "entity-view-details": "개체 보기 상세 정보", - "add-entity-view-text": "새로운 개체 보기 추가", - "delete": "Delete entity view", - "assign-entity-views": "개체 보기 할당", - "assign-entity-views-text": "{ count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} }를 커스터머에 할당", - "delete-entity-views": "Delete entity views", - "unassign-from-customer": "Unassign from customer", - "unassign-entity-views": "Unassign entity views", - "unassign-entity-views-action-title": "{ count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} }를 커스터머로 부터 할당 해제", - "assign-new-entity-view": "Assign new entity view", - "delete-entity-view-title": "Are you sure you want to delete the entity view '{{entityViewName}}'?", - "delete-entity-view-text": "Be careful, after the confirmation the entity view and all related data will become unrecoverable.", - "delete-entity-views-title": "Are you sure you want to delete { count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} }?", - "delete-entity-views-action-title": "Delete { count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} }", - "delete-entity-views-text": "Be careful, after the confirmation all selected entity views will be removed and all related data will become unrecoverable.", - "unassign-entity-view-title": "Are you sure you want to unassign the entity view '{{entityViewName}}'?", - "unassign-entity-view-text": "After the confirmation the entity view will be unassigned and won't be accessible by the customer.", - "unassign-entity-view": "Unassign entity view", - "unassign-entity-views-title": "Are you sure you want to unassign { count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} }?", - "unassign-entity-views-text": "After the confirmation all selected entity views will be unassigned and won't be accessible by the customer.", - "entity-view-type": "개체 보기 유형", - "entity-view-type-required": "Entity View type is required.", - "select-entity-view-type": "Select entity view type", - "enter-entity-view-type": "Enter entity view type", - "any-entity-view": "Any entity view", - "no-entity-view-types-matching": "No entity view types matching '{{entitySubtype}}' were found.", - "entity-view-type-list-empty": "No entity view types selected.", - "entity-view-types": "Entity View types", - "created-time": "Created time", - "name": "Name", - "name-required": "Name is required.", - "description": "Description", + "entity-view": "엔티티 뷰", + "entity-view-required": "엔티티 뷰를 입력하세요.", + "entity-views": "엔티티 뷰", + "management": "엔티티 뷰 관리", + "view-entity-views": "엔티티 뷰 보기", + "entity-view-alias": "엔티티 뷰 별명", + "aliases": "엔티티 뷰 별명", + "no-alias-matching": "'{{alias}}' 를 찾을 수 없습니다.", + "no-aliases-found": "별명이 없습니다.", + "no-key-matching": "'{{key}}' 를 찾을 수 없습니다.", + "no-keys-found": "Key가 없습니다.", + "create-new-alias": "새로 만들기!", + "create-new-key": "새로 만들기!", + "duplicate-alias-error": "중복 별명 '{{alias}}'이(가) 발견되었습니다.
엔티티 뷰 별명은 대시보드 내에서 고유해야 합니다.", + "configure-alias": "'{{alias}}' 별명 구성", + "no-entity-views-matching": "'{{entity}}'와(과) 일치하는 엔티티 뷰를 찾을 수 없습니다.", + "public": "공개", + "alias": "별명", + "alias-required": "엔티티 뷰 별명은 필수 항목입니다.", + "remove-alias": "엔티티 뷰 별명 제거", + "add-alias": "엔티티 뷰 별명 추가", + "name-starts-with": "엔티티 뷰 이름 표현식", + "help-text": "필요에 따라 '%'를 사용하세요: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", + "entity-view-list": "엔티티 뷰 목록", + "use-entity-view-name-filter": "필터 사용", + "entity-view-list-empty": "선택된 엔티티 뷰 없음.", + "entity-view-name-filter-required": "엔티티 뷰 이름 필터는 필수 항목입니다.", + "entity-view-name-filter-no-entity-view-matched": "'{{entityView}}'로 시작하는 엔티티 뷰를 찾을 수 없습니다.", + "add": "엔티티 뷰 추가", + "entity-view-public": "엔티티 뷰가 공개됨", + "assign-to-customer": "고객에게 자산 할당", + "assign-entity-view-to-customer": "고객에 엔티티 뷰 할당", + "assign-entity-view-to-customer-text": "고객에 할당할 엔티티 뷰를 선택하세요", + "assign-entity-view-to-edge-title": "엣지에 엔티티 뷰 할당", + "no-entity-views-text": "엔티티 뷰를 찾을 수 없음", + "assign-to-customer-text": "엔티티 뷰를 할당할 고객을 선택하세요", + "entity-view-details": "엔티티 뷰 상세 정보", + "add-entity-view-text": "새로운 엔티티 뷰 추가", + "delete": "엔티티 뷰 삭제", + "assign-entity-views": "엔티티 뷰 할당", + "assign-entity-views-text": "{ count, plural, =1 {1 개 엔티티 뷰} other {# 개 엔티티 뷰} }를 고객에 할당", + "delete-entity-views": "엔티티 뷰 삭제", + "unassign-from-customer": "고객 할당 해제", + "unassign-entity-views": "엔티티 뷰 할당 해제", + "unassign-entity-views-action-title": "{ count, plural, =1 {1 개 엔티티 뷰} other {# 개 엔티티 뷰} }를 고객으로 부터 할당 해제", + "assign-new-entity-view": "새 엔티티 뷰 할당", + "delete-entity-view-title": "엔티티 뷰 '{{entityViewName}}'을(를) 삭제하시겠습니까?", + "delete-entity-view-text": "주의: 확인 후 엔티티 뷰와 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-entity-views-title": "정말 { count, plural, =1 {1개의 엔티티 뷰} other {#개의 엔티티 뷰} }를 삭제하시겠습니까?", + "delete-entity-views-action-title": "Delete { count, plural, =1 {1 개 엔티티 뷰} other {# 개 엔티티 뷰} }", + "delete-entity-views-text": "주의: 확인 후 선택한 모든 엔티티 뷰가 제거되고 모든 관련 데이터를 복구할 수 없게 됩니다.", + "unassign-entity-view-title": "엔티티 뷰 '{{entityViewName}}'을(를) 엣지에서 할당 해제하시겠습니까?", + "unassign-entity-view-text": "확인 후 엔티티 뷰가 할당 해제되어 고객이 액세스할 수 없습니다.", + "unassign-entity-view": "엔티티 뷰 할당 해제", + "unassign-entity-views-title": "정말 { count, plural, =1 {1개의 엔티티 뷰} other {#개의 엔티티 뷰} }를 할당 해제하시겠습니까?", + "unassign-entity-views-text": "확인 후 선택한 모든 엔티티 뷰가 할당 해제되어 고객이 액세스할 수 없습니다.", + "entity-view-type": "엔티티 뷰 유형", + "entity-view-type-required": "엔티티 뷰 유형은 필수 항목입니다.", + "select-entity-view-type": "엔티티 뷰 유형 선택", + "enter-entity-view-type": "엔티티 뷰 유형 입력", + "any-entity-view": "모든 엔티티 뷰", + "no-entity-view-types-matching": "'{{entitySubtype}}'와(과) 일치하는 엔티티 뷰 유형을 찾을 수 없습니다.", + "entity-view-type-list-empty": "선택된 엔티티 뷰 유형 없음.", + "entity-view-types": "엔티티 뷰 유형", + "created-time": "생성 일시", + "name": "이름", + "name-required": "이름을 입력하세요.", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "type-max-length": "엔티티 뷰 유형은 256자 미만이어야 합니다.", + "description": "설명", "events": "이벤트", "details": "상세 정보", - "copyId": "개체 보기 ID 복사", - "idCopiedMessage": "개체 보기 ID가 클립보드에 복사되었습니다", - "assignedToCustomer": "할당된 커스터머", - "unable-entity-view-device-alias-title": "개체 보기 별명을 삭제할 수 없습니다", - "unable-entity-view-device-alias-text": "Device alias '{{entityViewAlias}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", - "select-entity-view": "개체 보기 선택", - "make-public": "개체 보기를 공개로 전환", - "make-private": "개체 보기를 비공개로 전환", - "start-date": "시작 날짜", + "copyId": "엔티티 뷰 ID 복사", + "idCopiedMessage": "엔티티 뷰 ID가 클립보드에 복사되었습니다", + "assignedToCustomer": "할당된 고객", + "unable-entity-view-device-alias-title": "엔티티 뷰 별명을 삭제할 수 없습니다", + "unable-entity-view-device-alias-text": "장치 별명 '{{entityViewAlias}}'은(는) 다음 위젯에서 사용 중이므로 삭제할 수 없습니다:
{{widgetsList}}", + "select-entity-view": "엔티티 뷰 선택", + "make-public": "엔티티 뷰를 공개로 전환", + "make-private": "엔티티 뷰를 비공개로 전환", "start-ts": "시작 시간", - "end-date": "종료 날짜", "end-ts": "종료 시간", "date-limits": "날짜 제한", "client-attributes": "클라이언트 속성", @@ -1288,34 +3321,49 @@ "shared-attributes-placeholder": "공유 속성", "server-attributes-placeholder": "서버 속성", "timeseries-placeholder": "시계열", - "target-entity": "대상 개체", + "target-entity": "대상 엔티티", "attributes-propagation": "속성 전파", - "attributes-propagation-hint": "Entity View will automatically copy specified attributes from Target Entity each time you save or update this entity view. For performance reasons target entity attributes are not propagated to entity view on each attribute change. You can enable automatic propagation by configuring \"copy to view\" rule node in your rule chain and linking \"Post attributes\" and \"Attributes Updated\" messages to the new rule node.", + "attributes-propagation-hint": "엔티티 뷰는 이 엔티티 뷰를 저장하거나 업데이트할 때마다 대상 엔티티에서 지정된 속성을 자동으로 복사합니다. 성능상의 이유로 대상 엔티티 속성은 각 속성 변경 시 엔티티 뷰에 전파되지 않습니다. 규칙 체인에서 \"뷰로 복사\" 규칙 노드를 구성하고 \"속성 게시\" 및 \"속성 업데이트됨\" 메시지를 새 규칙 노드에 연결하여 자동 전파를 활성화할 수 있습니다.", "timeseries-data": "시계열 데이터", - "timeseries-data-hint": "Configure timeseries data keys of the target entity that will be accessible to the entity view. This timeseries data is read-only.", - "make-public-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' public?", - "make-public-entity-view-text": "After the confirmation the entity view and all its data will be made public and accessible by others.", - "make-private-entity-view-title": "Are you sure you want to make the entity view '{{entityViewName}}' private?", - "make-private-entity-view-text": "After the confirmation the entity view and all its data will be made private and won't be accessible by others.", - "search": "Search entity views", - "selected-entity-views": "{ count, plural, =1 {1 개 개체 보기} other {# 개 개체 보기} } 선택됨" + "timeseries-data-hint": "엔티티 뷰에서 액세스할 수 있는 대상 엔티티의 시계열 데이터 키를 구성합니다. 이 시계열 데이터는 읽기 전용입니다.", + "search": "엔티티 뷰 검색", + "selected-entity-views": "{ count, plural, =1 {1 개 엔티티 뷰} other {# 개 엔티티 뷰} } 선택됨", + "make-public-entity-view-title": "엔티티 뷰 '{{entityViewName}}'을(를) 공개하시겠습니까?", + "make-public-entity-view-text": "확인 후 엔티티 뷰와 모든 데이터가 공개되어 다른 사용자가 액세스할 수 있습니다.", + "make-private-entity-view-title": "엔티티 뷰 '{{entityViewName}}'을(를) 비공개로 설정하시겠습니까?", + "make-private-entity-view-text": "확인 후 엔티티 뷰와 모든 데이터가 비공개되어 다른 사용자가 액세스할 수 없습니다.", + "assign-entity-view-to-edge": "엣지에 엔티티 뷰 할당", + "assign-entity-view-to-edge-text": "엣지에 할당할 엔티티 뷰를 선택하세요", + "unassign-entity-view-from-edge-title": "엔티티 뷰 '{{entityViewName}}'을(를) 엣지에서 할당 해제하시겠습니까?", + "unassign-entity-view-from-edge-text": "확인 후 엔티티 뷰가 할당 해제되어 엣지에서 액세스할 수 없습니다.", + "unassign-entity-views-from-edge-action-title": "엣지에서 { count, plural, =1 {1개 엔티티 뷰} other {#개 엔티티 뷰} } 할당 해제", + "unassign-entity-view-from-edge": "엔티티 뷰 할당 해제", + "unassign-entity-views-from-edge-title": "정말 { count, plural, =1 {1개의 엔티티 뷰} other {#개의 엔티티 뷰} }를 할당 해제하시겠습니까?", + "unassign-entity-views-from-edge-text": "확인 후 선택한 모든 엔티티 뷰가 할당 해제되어 엣지에서 액세스할 수 없습니다." }, "event": { "event-type": "이벤트 타입", + "events-filter": "이벤트 필터", + "clean-events": "이벤트 지우기", "type-error": "에러", "type-lc-event": "주기적 이벤트", "type-stats": "통계", "type-debug-rule-node": "디버그", "type-debug-rule-chain": "디버그", + "type-debug-calculated-field": "디버그", + "arguments": "인수", + "result": "결과", "no-events-prompt": "이벤트 없음", "error": "에러", "alarm": "알람", "event-time": "이벤트 발생 시간", "server": "서버", - "body": "Body", + "body": "본문", "method": "방법", "type": "유형", + "message": "메시지", "message-id": "메시지 ID", + "copy-message-id": "메시지 ID 복사", "message-type": "메시지 유형", "data-type": "데이터 유형", "relation-type": "관계 유형", @@ -1326,9 +3374,23 @@ "success": "성공", "failed": "실패", "messages-processed": "처리된 메시지", + "max-messages-processed": "처리된 최대 메시지 수", + "min-messages-processed": "처리된 최소 메시지 수", "errors-occurred": "오류가 발생했습니다", + "max-errors-occurred": "발생한 최대 오류 수", + "min-errors-occurred": "발생한 최소 오류 수", + "min-value": "최소 값은 0입니다.", "all-events": "모두", - "entity-type": "개체 유형" + "has-error": "오류 있음", + "entity-id": "엔티티 ID", + "copy-entity-id": "엔티티 ID 복사", + "entity-type": "엔티티 유형", + "clear-filter": "필터 지우기", + "clear-request-title": "모든 이벤트 지우기", + "clear-request-text": "모든 이벤트를 지우시겠습니까?", + "started": "시작됨", + "updated": "수정됨", + "stopped": "정지됨" }, "extension": { "extensions": "확장", @@ -1358,13 +3420,13 @@ "device-name-expression": "장치 이름 표현", "device-type-expression": "장치 유형 표현", "custom": "사용자 정의", - "to-double": "To Double", - "transformer": "Transformer", + "to-double": "실수로 변환", + "transformer": "변환기", "json-required": "Transformer json을(를) 입력하세요.", - "json-parse": "Unable to parse transformer json.", + "json-parse": "변환기 JSON을 파싱할 수 없습니다.", "attributes": "속성", "add-attribute": "속성 추가", - "add-map": "Add mapping element", + "add-map": "매핑 요소 추가", "timeseries": "시계열", "add-timeseries": "시계열 추가", "field-required": "필드를 입력하세요.", @@ -1373,7 +3435,7 @@ "host": "호스트", "port": "포트", "port-range": "포트를 1~65535 범위 내에서 입력하세요.", - "ssl": "Ssl", + "ssl": "SSL", "credentials": "인증", "username": "사용자 이름", "password": "비밀 번호", @@ -1386,23 +3448,23 @@ "cert": "인증 파일 *", "no-file": "아무 파일도 선택되지 않았습니다.", "drop-file": "여기로 파일을 드래그 하거나, 여기를 클릭해서 파일을 선택하세요.", - "mapping": "Mapping", - "topic-filter": "주제 필터", + "mapping": "매핑", + "topic-filter": "토픽 필터", "converter-type": "변환기 유형", - "converter-json": "Json", + "converter-json": "JSON", "json-name-expression": "장치 이름 JSON 표현", - "topic-name-expression": "장치 이름 주제 표현", + "topic-name-expression": "장치 이름 토픽 표현", "json-type-expression": "장치 유형 JSON 표현", - "topic-type-expression": "장치 유형 주제 표현", + "topic-type-expression": "장치 유형 토픽 표현", "attribute-key-expression": "속성 키 표현", "attr-json-key-expression": "속성 키 JSON 표현", - "attr-topic-key-expression": "속성 키 주제 표현", + "attr-topic-key-expression": "속성 키 토픽 표현", "request-id-expression": "요청 ID 표현", "request-id-json-expression": "요청 ID JSON 표현", - "request-id-topic-expression": "요청 ID 주제 표현", - "response-topic-expression": "응답 주제 표현", + "request-id-topic-expression": "요청 ID 토픽 표현", + "response-topic-expression": "응답 토픽 표현", "value-expression": "값 표현", - "topic": "주제", + "topic": "토픽", "timeout": "시간 제한(ms)", "converter-json-required": "JSON 변환기가 필요합니다.", "converter-json-parse": "JSON 변환기를 파싱할 수 없습니다.", @@ -1415,15 +3477,15 @@ "add-attribute-request": "속성 요청 추가", "attribute-updates": "속성 업데이트", "add-attribute-update": "속성 업데이트 추가", - "server-side-rpc": "Server side RPC", - "add-server-side-rpc-request": "Add server-side RPC request", - "device-name-filter": "Device name filter", - "attribute-filter": "Attribute filter", - "method-filter": "Method filter", - "request-topic-expression": "Request topic expression", - "response-timeout": "Response timeout in milliseconds", - "topic-expression": "Topic expression", - "client-scope": "Client scope", + "server-side-rpc": "서버 측 RPC", + "add-server-side-rpc-request": "서버 측 RPC 요청 추가", + "device-name-filter": "장치 이름 필터", + "attribute-filter": "속성 필터", + "method-filter": "메서드 필터", + "request-topic-expression": "요청 토픽 식", + "response-timeout": "응답 시간 초과 (밀리초)", + "topic-expression": "토픽 식", + "client-scope": "클라이언트 범위", "add-device": "장치 추가", "opc-server": "서버", "opc-add-server": "서버 추가", @@ -1432,7 +3494,7 @@ "opc-application-uri": "애플리케이션 URI", "opc-scan-period-in-seconds": "스캔 주기(초)", "opc-security": "보안", - "opc-identity": "Identity", + "opc-identity": "ID", "opc-keystore": "키스톤", "opc-type": "유형", "opc-keystore-type": "유형", @@ -1447,31 +3509,31 @@ "modbus-add-server-prompt": "서버/보조를 추가해주세요", "modbus-transport": "전송", "modbus-tcp-reconnect": "자동으로 재접속", - "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-rtu-over-tcp": "TCP over RTU", "modbus-port-name": "시리얼 포트 이름", "modbus-encoding": "인코딩", - "modbus-parity": "Parity", + "modbus-parity": "패리티", "modbus-baudrate": "전송 속도", "modbus-databits": "데이터 비트", "modbus-stopbits": "정지 비트", "modbus-databits-range": "데이터 비트를 7~8 범위에서 입력하세요.", "modbus-stopbits-range": "정지 비트를 1~2 범위에서 입력하세요.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", + "modbus-unit-id": "유닛 ID", + "modbus-unit-id-range": "유닛 ID는 1에서 247 범위여야 합니다.", "modbus-device-name": "장치 이름", "modbus-poll-period": "풀 주기 (ms)", "modbus-attributes-poll-period": "속성 풀 주기 (ms)", "modbus-timeseries-poll-period": "시계열 풀 주기 (ms)", "modbus-poll-period-range": "풀 주기를 양수로 입력하세요.", "modbus-tag": "태그", - "modbus-function": "Function", + "modbus-function": "함수", "modbus-register-address": "등록 주소", "modbus-register-address-range": "등록 주소를 0~65535 범위에서 입력하세요.", "modbus-register-bit-index": "비트 인덱스", "modbus-register-bit-index-range": "비트 인덱스를 0~15 범위에서 입력하세요.", "modbus-register-count": "등록 카운트", "modbus-register-count-range": "등록 카운트를 양수로 입력하세요.", - "modbus-byte-order": "Byte order", + "modbus-byte-order": "바이트 순서", "sync": { "status": "상태", "sync": "동기화", @@ -1487,91 +3549,110 @@ "file": "확장 파일", "invalid-file-error": "올바르지 않은 확장 파일" }, + "feature": { + "advanced-features": "고급 기능" + }, "filter": { - "add": "Add filter", - "edit": "Edit filter", - "name": "Filter name", - "name-required": "Filter name is required.", - "duplicate-filter": "Filter with same name is already exists.", - "filters": "Filters", - "unable-delete-filter-title": "Unable to delete filter", - "unable-delete-filter-text": "Filter '{{filter}}' can't be deleted as it used by the following widget(s):
{{widgetsList}}", - "duplicate-filter-error": "Duplicate filter found '{{filter}}'.
Filters must be unique within the dashboard.", - "missing-key-filters-error": "Key filters is missing for filter '{{filter}}'.", - "filter": "필터", - "editable": "편집 가능", - "no-filters-found": "아무 필터도 찾을 수 없습니다.", - "no-filter-text": "필터가 특정되지 않았습니다", - "add-filter-prompt": "필터를 추가해주세요", - "no-filter-matching": "필터 '{{filter}}'를 찾을 수 없습니다.", - "create-new-filter": "새로 필터를 만드세요!", - "filter-required": "필터를 입력하세요.", - "operation": { - "operation": "Operation", - "equal": "같음", - "not-equal": "같이 않음", - "starts-with": "다음으로 시작함", - "ends-with": "다음으로 끝남", - "contains": "포함함", - "not-contains": "포함하지 않음", - "greater": "큼", - "less": "작음", - "greater-or-equal": "크거나 같음", - "less-or-equal": "작거나 같음", - "and": "그리고", - "or": "또는" - }, - "ignore-case": "ignore case", - "value": "값", - "remove-filter": "Remove filter", - "preview": "Filter preview", - "no-filters": "No filters configured", - "add-filter": "Add filter", - "add-complex-filter": "Add complex filter", - "add-complex": "Add complex", - "complex-filter": "Complex filter", - "edit-complex-filter": "Edit complex filter", - "edit-filter-user-params": "Edit filter predicate user parameters", - "filter-user-params": "Filter predicate user parameters", - "user-parameters": "User parameters", - "display-label": "Label to display", - "order-priority": "Field order priority", - "key-filter": "Key filter", - "key-filters": "Key filters", - "key-name": "Key name", - "key-name-required": "Key name is required.", - "key-type": { - "key-type": "Key type", - "attribute": "Attribute", - "timeseries": "Timeseries", - "entity-field": "Entity field" - }, - "value-type": { - "value-type": "Value type", - "string": "String", - "numeric": "Numeric", - "boolean": "Boolean", - "date-time": "Datetime" - }, - "value-type-required": "Key value type is required.", - "key-value-type-change-title": "Are you sure you want to change key value type?", - "key-value-type-change-message": "If you confirm new value type all entered key filters will be removed.", - "no-key-filters": "No key filters configured", - "add-key-filter": "Add key filter", - "remove-key-filter": "Remove key filter", - "edit-key-filter": "Edit key filter", - "date": "Date", - "time": "Time", - "current-tenant": "Current tenant", - "current-customer": "Current customer", - "current-user": "Current user", - "current-device": "현재 장치", - "default-value": "기본값", - "dynamic-source-type": "Dynamic source type", - "no-dynamic-value": "No dynamic value", - "source-attribute": "소스 속성", - "switch-to-dynamic-value": "Switch to dynamic value", - "switch-to-default-value": "Switch to default value" + "add": "필터 추가", + "edit": "필터 편집", + "name": "필터 이름", + "name-required": "필터 이름은 필수 항목입니다.", + "duplicate-filter": "같은 이름의 필터가 이미 존재합니다.", + "filters": "필터", + "unable-delete-filter-title": "필터를 삭제할 수 없습니다", + "unable-delete-filter-text": "필터 '{{filter}}'은(는) 다음 위젯에서 사용 중이므로 삭제할 수 없습니다:
{{widgetsList}}", + "duplicate-filter-error": "중복 필터 '{{filter}}'이(가) 발견되었습니다.
필터는 대시보드 내에서 고유해야 합니다.", + "missing-key-filters-error": "'{{filter}}' 필터에 키 필터가 없습니다.", + "filter": "필터", + "editable": "편집 가능", + "editable-hint": "사용자가 대시보드에서 필터 값을 변경할 수 있도록 허용.", + "no-filters-found": "아무 필터도 찾을 수 없습니다.", + "no-filter-text": "필터가 특정되지 않았습니다", + "add-filter-prompt": "필터를 추가해주세요", + "no-filter-matching": "필터 '{{filter}}'를 찾을 수 없습니다.", + "create-new-filter": "새로 필터를 만드세요!", + "create-new": "새로 만들기", + "filter-required": "필터를 입력하세요.", + "operation": { + "operation": "연산", + "equal": "같음", + "not-equal": "같이 않음", + "starts-with": "다음으로 시작함", + "ends-with": "다음으로 끝남", + "contains": "포함함", + "not-contains": "포함하지 않음", + "greater": "큼", + "less": "작음", + "greater-or-equal": "크거나 같음", + "less-or-equal": "작거나 같음", + "and": "그리고", + "or": "또는", + "in": "포함", + "not-in": "미포함" + }, + "ignore-case": "대소문자 무시", + "value": "값", + "remove-filter": "필터 제거", + "duplicate-filter-action": "필터 복제", + "preview": "필터 미리보기", + "no-filters": "구성된 필터 없음", + "add-filter": "필터 추가", + "add-complex-filter": "복합 필터 추가", + "add-complex": "복합 추가", + "complex-filter": "복합 필터", + "edit-complex-filter": "복합 필터 편집", + "edit-filter-user-params": "필터 술어 사용자 매개변수 편집", + "filter-user-params": "필터 술어 사용자 매개변수", + "user-parameters": "사용자 매개변수", + "display-label": "표시할 레이블", + "custom-label": "사용자 지정 레이블", + "custom-label-hint": "필터에 사용자 지정 레이블을 설정하려면 활성화합니다. 비활성화하면 레이블이 자동으로 생성됩니다.", + "order-priority": "표시 순서", + "key-filter": "키 필터", + "key-filters": "키 필터", + "key-name": "키 이름", + "key-name-required": "키 이름은 필수 항목입니다.", + "key-type": { + "key-type": "키 유형", + "attribute": "속성", + "timeseries": "시계열", + "entity-field": "엔티티 필드", + "constant": "상수", + "client-attribute": "클라이언트 속성", + "server-attribute": "서버 속성", + "shared-attribute": "공유된 속성" + }, + "value-type": { + "value-type": "값 유형", + "string": "문자열", + "numeric": "숫자", + "boolean": "불리언", + "date-time": "날짜/시간" + }, + "value-type-required": "키 값 유형은 필수 항목입니다.", + "key-value-type-change-title": "키 값 유형을 변경하시겠습니까?", + "key-value-type-change-message": "확인하면 입력한 모든 키 필터가 제거됩니다.", + "no-key-filters": "구성된 키 필터 없음", + "add-key-filter": "키 필터 추가", + "remove-key-filter": "키 필터 제거", + "edit-key-filter": "키 필터 편집", + "date": "날짜", + "time": "시간", + "current-tenant": "현재 테넌트", + "current-customer": "현재 고객", + "current-user": "현재 사용자", + "current-device": "현재 장치", + "default-value": "기본값", + "default-comma-separated-values": "기본 쉼표로 구분된 값", + "dynamic-source-type": "동적 소스 유형", + "dynamic-value": "동적 값", + "no-dynamic-value": "동적 값 없음", + "source-attribute": "소스 속성", + "switch-to-dynamic-value": "동적 값으로 전환", + "switch-to-default-value": "기본값으로 전환", + "inherit-owner": "소유자로부터 상속", + "source-attribute-not-set": "소스 속성이 설정되지 않은 경우", + "unit": "단위" }, "fullscreen": { "expand": "전체화면으로 확장", @@ -1583,15 +3664,14 @@ "function": "기능" }, "gateway": { - "create-new-gateway": "Create a new gateway", - "create-new-gateway-text": "Are you sure you want create a new gateway with name: '{{gatewayName}}'?", - "gateway": "Gateway", - "gateway-exists": "Device with same name is already exists.", - "gateway-name": "Gateway name", - "gateway-name-required": "Gateway name is required.", - "gateway-saved": "Gateway configuration successfully saved.", - "no-gateway-found": "No gateway found.", - "no-gateway-matching": " '{{item}}' not found." + "gateway-name": "게이트웨이 이름", + "gateway-name-required": "게이트웨이 이름은 필수 항목입니다.", + "gateways": "게이트웨이", + "create-new-gateway": "새 게이트웨이 생성", + "create-new-gateway-text": "이름이 '{{gatewayName}}'인 새 게이트웨이를 생성하시겠습니까?", + "launch-command": "실행 명령", + "no-gateway-found": "게이트웨이를 찾을 수 없습니다.", + "no-gateway-matching": " '{{item}}'을(를) 찾을 수 없습니다." }, "grid": { "delete-item-title": "이 항목을 삭제 하시겠습니까?", @@ -1607,28 +3687,98 @@ "scroll-to-top": "스크롤 맨 위로" }, "help": { - "goto-help-page": "도움" + "goto-help-page": "도움", + "show-help": "도움말 표시" }, "home": { "home": "홈", - "profile": "프로파일", + "profile": "프로필", "logout": "로그아웃", "menu": "메뉴", - "avatar": "Avatar", + "avatar": "아바타", "open-user-menu": "사용자 메뉴 열기" }, + "file-input": { + "browse-file": "파일 찾아보기", + "browse-files": "파일 찾아보기" + }, + "image": { + "gallery": "이미지 갤러리", + "search": "이미지 검색", + "selected-images": "{ count, plural, =1 {1개 이미지} other {#개 이미지} } 선택됨", + "created-time": "생성 일시", + "name": "이름", + "name-required": "이름을 입력하세요.", + "resolution": "해상도", + "size": "크기", + "system": "시스템", + "download-image": "이미지 다운로드", + "export-image": "이미지를 JSON으로 내보내기", + "import-image": "JSON에서 이미지 가져오기", + "upload-image": "이미지 업로드", + "edit-image": "이미지 수정", + "image-details": "이미지 상세", + "no-images": "이미지가 없습니다", + "delete-image": "이미지 삭제", + "delete-image-title": "이미지 '{{imageTitle}}'을(를) 삭제하시겠습니까?", + "delete-image-text": "주의: 확인 후 이미지를 복구할 수 없게 됩니다.", + "delete-images-title": "{ count, plural, =1 {1개 이미지} other {#개 이미지} }를 삭제하시겠습니까?", + "delete-images-text": "주의: 확인 후 선택한 모든 이미지가 제거되고 모든 관련 데이터를 복구할 수 없게 됩니다.", + "list-mode": "목록 보기", + "grid-mode": "그리드 보기", + "image-preview": "이미지 미리보기", + "update-image": "이미지 업데이트", + "export-failed-error": "이미지를 내보낼 수 없습니다: {{error}}", + "image-json-file": "이미지 JSON 파일", + "invalid-image-json-file-error": "JSON에서 이미지를 가져올 수 없습니다: 잘못된 이미지 JSON 데이터 구조입니다.", + "image-is-in-use": "이미지가 다른 엔티티에서 사용 중입니다", + "images-are-in-use": "이미지가 다른 엔티티에서 사용 중입니다", + "image-is-in-use-text": "'{{title}}' 이미지가 다음 엔티티에서 사용 중이므로 삭제되지 않았습니다:", + "images-are-in-use-text": "모든 이미지가 삭제되지 않았습니다. 다른 엔티티에서 사용 중이기 때문입니다.
해당 이미지 행의 참조 버튼을 클릭하여 참조된 엔티티를 볼 수 있습니다.
이 이미지를 계속 삭제하려면 아래 표에서 선택하고 선택 삭제 버튼을 클릭하세요.", + "delete-image-in-use-text": "이미지를 계속 삭제하려면 무조건 삭제 버튼을 클릭하세요.", + "system-entities": "시스템 엔티티:", + "entities": "엔티티:", + "references": "참조", + "include-system-images": "시스템 이미지 포함", + "clear-image": "이미지 지우기", + "no-image": "이미지 없음", + "no-image-selected": "이미지 없음", + "browse-from-gallery": "갤러리에서 찾아보기", + "set-link": "링크 설정", + "image-link": "이미지 링크", + "link": "링크", + "copy-image-link": "이미지 링크 복사", + "embed-image": "이미지 포함", + "embed-to-html": "HTML에 포함", + "embed-to-html-hint": "이 기능은 승인되지 않은 사용자도 링크에 액세스할 수 있게 합니다.", + "embed-to-html-text": "다음 코드 스니펫을 사용하여 일반 HTML 기반 컴포넌트에 이미지를 포함할 수 있습니다.
이러한 컴포넌트에는 HTML 카드 위젯, 셀 콘텐츠 함수 등이 포함됩니다.", + "embed-to-angular-template": "Angular HTML 템플릿에 포함", + "embed-to-angular-template-text": "다음 코드 스니펫을 사용하여 컴포넌트에 사용될 Angular HTML 템플릿에 이미지를 포함할 수 있습니다.
이러한 컴포넌트에는 마크다운 위젯, 위젯 편집기의 HTML 섹션, 사용자 지정 작업 등이 포함됩니다." + }, + "image-input": { + "drop-images-or": "이미지를 끌어다 놓거나", + "drag-and-drop": "끌어다 놓기", + "or": "또는", + "browse": "찾아보기", + "no-images": "선택된 이미지 없음", + "images": "이미지" + }, "import": { "no-file": "선택된 파일이 없습니다.", "drop-file": "업로드 할 JSON 파일을 끌어다 놓거나 여기 클릭하여 파일을 선택하십시오.", + "drop-json-file-or": "JSON 파일을 끌어다 놓거나", "drop-file-csv": "업로드 할 CSV 파일을 끌어다 놓거나 여기 클릭하여 파일을 선택하십시오.", + "drop-file-csv-or": "CSV 파일을 끌어다 놓거나", "column-value": "값", "column-title": "제목", "column-example": "값 데이터 예시", "column-key": "속성/텔레메트리 키", - "csv-delimiter": "CSV delimiter", + "credentials": "자격 증명", + "csv-delimiter": "CSV 구분자", "csv-first-line-header": "첫번째 줄에 컬럼 이름 포함", "csv-update-data": "속성/텔레메트리 업데이트", - "import-csv-number-columns-error": "A file should contain at least two columns", + "details": "상세 내역", + "import-csv-number-columns-error": "파일에는 최소 두 개의 열이 포함되어야 합니다.", "import-csv-invalid-format-error": "유효하지 않은 파일 포맷. Line: '{{line}}'", "column-type": { "name": "이름", @@ -1639,21 +3789,388 @@ "shared-attribute": "공유된 속성", "server-attribute": "서버 속성", "timeseries": "시계열", - "entity-field": "개체 필드", + "entity-field": "엔티티 필드", "access-token": "액세스 토큰", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT 클라이언트 ID", + "user-name": "MQTT 사용자 이름", + "password": "MQTT 비밀번호" + }, + "lwm2m": { + "client-endpoint": "LwM2M 엔드포인트 클라이언트 이름", + "security-config-mode": "LwM2M 보안 구성 모드", + "client-identity": "LwM2M 클라이언트 ID", + "client-key": "LwM2M 클라이언트 키", + "client-cert": "LwM2M 클라이언트 공개 키", + "bootstrap-server-security-mode": "LwM2M 부트스트랩 서버 보안 모드", + "bootstrap-server-secret-key": "LwM2M 부트스트랩 서버 비밀 키", + "bootstrap-server-public-key-id": "LwM2M 부트스트랩 서버 공개 키 또는 ID", + "lwm2m-server-security-mode": "LwM2M 서버 보안 모드", + "lwm2m-server-secret-key": "LwM2M 서버 비밀 키", + "lwm2m-server-public-key-id": "LwM2M 서버 공개 키 또는 ID" + }, + "snmp": { + "host": "SNMP 호스트", + "port": "SNMP 포트", + "version": "SNMP 버전 (v1, v2c 또는 v3)", + "community-string": "SNMP 커뮤니티 문자열" + }, "isgateway": "게이트웨이 여부", - "description": "설정" + "activity-time-from-gateway-device": "게이트웨이 장치의 활동 시간", + "description": "설정", + "routing-key": "엣지 키", + "secret": "엣지 시크릿" }, - "stepper-text":{ + "stepper-text": { "select-file": "파일 선택", "configuration": "설정 불러오기", "column-type": "컬럼 유형 선택", - "creat-entities": "새로운 개체 생성" + "creat-entities": "새로운 엔티티 생성" }, "message": { - "create-entities": "{{count}}개의 새로운 개체사 성공적으로 생성되었습니다.", - "update-entities": "{{count}}개의 개체가 성공적으로 업데이트 되었습니다.", - "error-entities": "{{count}}개의 개체를 생성하는데 오류가 발생했습니다." + "create-entities": "{{count}}개의 새로운 엔티티사 성공적으로 생성되었습니다.", + "update-entities": "{{count}}개의 엔티티가 성공적으로 업데이트 되었습니다.", + "error-entities": "{{count}}개의 엔티티를 생성하는데 오류가 발생했습니다." + } + }, + "scada": { + "symbols": "SCADA 심볼", + "search": "심볼 검색", + "selected-symbols": "{ count, plural, =1 {1개 심볼} other {#개 심볼} } 선택됨", + "download-symbol": "SCADA 심볼 다운로드", + "export-symbol": "SCADA 심볼을 JSON으로 내보내기", + "import-symbol": "JSON에서 SCADA 심볼 가져오기", + "upload-symbol": "SCADA 심볼 업로드", + "update-symbol": "SCADA 심볼 업데이트", + "edit-symbol": "SCADA 심볼 수정", + "symbol-details": "SCADA 심볼 상세", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "심볼이 없습니다", + "show-hidden-elements": "숨겨진 요소 표시", + "hide-hidden-elements": "숨겨진 요소 숨기기", + "delete-symbol": "SCADA 심볼 삭제", + "delete-symbol-title": "SCADA 심볼 '{{imageTitle}}'을(를) 삭제하시겠습니까?", + "delete-symbol-text": "주의: 확인 후 SCADA 심볼을 복구할 수 없게 됩니다.", + "delete-symbols-title": "{ count, plural, =1 {1개 SCADA 심볼} other {#개 SCADA 심볼} }을(를) 삭제하시겠습니까?", + "delete-symbols-text": "주의: 확인 후 선택한 모든 SCADA 심볼이 제거되고 모든 관련 데이터를 복구할 수 없게 됩니다.", + "include-system-symbols": "시스템 심볼 포함", + "symbol-preview": "심볼 미리보기", + "general": "일반", + "tags": "태그", + "properties": "속성", + "title": "제목", + "description": "설명", + "search-tags": "태그 검색", + "widget-size": "위젯 크기", + "cols": "열", + "rows": "행", + "state-render-function": "상태 렌더링 함수", + "preview": "미리보기", + "preview-widget-action-text": "위젯 액션 '{{type}}'이(가) 성공적으로 호출되었습니다!", + "no-symbol": "SCADA 심볼 없음", + "no-symbol-selected": "선택된 SCADA 심볼 없음", + "clear-symbol": "SCADA 심볼 지우기", + "browse-symbol-from-gallery": "갤러리에서 SCADA 심볼 찾아보기", + "zoom-in": "확대", + "zoom-out": "축소", + "create-widget": "위젯 만들기", + "create-widget-from-symbol": "SCADA 심볼로 위젯 만들기", + "hidden": "숨겨짐", + "tag": { + "tag": "태그", + "on-click-action": "클릭 시 액션", + "no-tags": "구성된 태그 없음", + "delete-tag-text": "{{elementType}} 요소에서 태그
{{tag}}을(를) 삭제하시겠습니까?", + "update-tag": "태그 업데이트", + "enter-tag": "태그 입력", + "tag-settings": "태그 설정", + "remove-tag": "태그 제거", + "add-tag": "태그 추가" + }, + "behavior": { + "behavior": "동작", + "id": "ID", + "name": "이름", + "type": "유형", + "no-behaviors": "구성된 동작 없음", + "add-behavior": "동작 추가", + "type-action": "동작", + "type-value": "값", + "type-widget-action": "위젯 액션", + "behavior-settings": "동작 설정", + "remove-behavior": "동작 제거", + "hint": "힌트", + "group-title": "그룹 제목", + "value-type": "값 유형", + "default-value": "기본값", + "true-label": "참 라벨", + "false-label": "거짓 라벨", + "state-label": "상태 라벨", + "default-payload": "기본 페이로드", + "not-unique-behavior-ids-error": "동작 ID는 고유해야 합니다!", + "default-settings": "기본 설정" + }, + "symbol": { + "symbol": "SCADA 심볼", + "fluid-presence": "유체 존재", + "fluid-presence-hint": "파이프에 유체가 있는지 나타냅니다.", + "fluid-present": "유체 있음", + "present": "있음", + "absent": "없음", + "flow-presence": "흐름 존재", + "flow-presence-hint": "파이프에서 유체가 흐르고 있는지 나타냅니다.", + "flow-present": "흐름 있음", + "flow-direction": "흐름 방향", + "flow-direction-hint": "유체 흐름 방향을 나타냅니다.", + "forward": "전방", + "reverse": "역방향", + "flow-animation-speed": "흐름 애니메이션 속도", + "flow-animation-speed-hint": "흐름의 애니메이션 속도를 나타내는 double 값. 1 - 정상 속도, 0 - 애니메이션 없음, < 1 - 느린 애니메이션, > 1 - 빠른 애니메이션.", + "leak": "누출", + "leak-hint": "파이프에 누출이 있는지 나타냅니다.", + "leak-present": "누출 있음", + "fluid-color": "유체 색상", + "pipe-color": "파이프 색상", + "horizontal-pipe": "수평 파이프", + "vertical-pipe": "수직 파이프", + "horizontal-fluid-color": "수평 유체 색상", + "vertical-fluid-color": "수직 유체 색상", + "left-pipe": "왼쪽 파이프", + "right-pipe": "오른쪽 파이프", + "top-pipe": "상단 파이프", + "bottom-pipe": "하단 파이프", + "left-fluid-color": "왼쪽 유체 색상", + "right-fluid-color": "오른쪽 유체 색상", + "top-fluid-color": "상단 유체 색상", + "bottom-fluid-color": "하단 유체 색상", + "display": "디스플레이", + "display-format": "디스플레이 형식", + "value": "값", + "decimals": "소수 자릿수", + "units": "단위", + "flow-meter-value-hint": "유량계 디스플레이에 표시되는 double 값", + "value-hint": "현재 값을 나타내는 double 값", + "running": "실행 중", + "running-hint": "구성 요소가 실행 상태인지 나타냅니다.", + "warning-state": "경고 상태", + "warning": "경고", + "warning-click": "경고 클릭", + "warning-state-hint": "구성 요소가 경고 상태인지 나타냅니다.", + "critical-state": "심각 상태", + "critical": "심각한", + "critical-click": "중요 클릭", + "critical-state-hint": "구성 요소가 심각 상태인지 나타냅니다.", + "critical-state-animation": "심각 상태 애니메이션", + "critical-state-animation-hint": "구성 요소가 심각 상태일 때 깜박이는 애니메이션을 활성화할지 여부.", + "warning-critical-state-animation": "경고/심각 상태 애니메이션", + "warning-critical-state-animation-hint": "구성 요소가 경고 또는 심각 상태일 때 깜박이는 애니메이션을 활성화할지 여부.", + "animation": "애니메이션", + "broken": "손상됨", + "broken-hint": "구성 요소가 손상되었는지 나타냅니다.", + "on-display-click": "디스플레이 클릭 시", + "on-display-click-hint": "사용자가 디스플레이를 클릭할 때 호출되는 액션.", + "pipe": "파이프", + "default-border-color": "기본 테두리 색상", + "active-border-color": "활성 테두리 색상", + "warning-border-color": "경고 테두리 색상", + "critical-border-color": "심각 테두리 색상", + "background-color": "배경색", + "rotation-animation-speed": "회전 애니메이션 속도", + "rotation-animation-speed-hint": "회전 애니메이션 속도를 나타내는 double 값. 1 - 정상 속도, 0 - 애니메이션 없음, < 1 - 느린 애니메이션, > 1 - 빠른 애니메이션.", + "on-click": "클릭 시", + "on-click-hint": "사용자가 구성 요소를 클릭할 때 호출되는 액션.", + "connectors-positions": "커넥터 위치", + "right-connector": "오른쪽 커넥터", + "right-top-connector": "오른쪽 상단 커넥터", + "right-bottom-connector": "오른쪽 하단 커넥터", + "left-connector": "왼쪽 커넥터", + "left-top-connector": "왼쪽 상단 커넥터", + "left-bottom-connector": "왼쪽 하단 커넥터", + "top-left-connector": "상단 왼쪽 커넥터", + "top-right-connector": "상단 오른쪽 커넥터", + "top-connector": "상단 커넥터", + "bottom-connector": "하단 커넥터", + "running-color": "실행 색상", + "stopped-color": "정지 색상", + "stopped": "정지됨", + "warning-color": "경고 색상", + "critical-color": "심각 색상", + "opened": "열림", + "opened-hint": "구성 요소가 열린 상태인지 나타냅니다.", + "open": "열기", + "open-hint": "사용자가 구성 요소를 열기 위해 클릭할 때 호출되는 액션.", + "close": "닫기", + "close-hint": "사용자가 구성 요소를 닫기 위해 클릭할 때 호출되는 액션.", + "close-state-animation": "닫힘 상태 애니메이션", + "close-state-animation-hint": "구성 요소가 닫힌 상태일 때 깜박이는 애니메이션을 활성화할지 여부.", + "opened-color": "열림 색상", + "closed-color": "닫힘 색상", + "opened-rotation-angle": "열림 회전 각도", + "closed-rotation-angle": "닫힘 회전 각도", + "tank-capacity": "탱크 용량", + "tank-capacity-hint": "총 탱크 용량을 나타내는 double 값.", + "current-volume": "현재 볼륨", + "current-volume-hint": "현재 사용 중인 볼륨을 나타내는 double 값.", + "tank-color": "탱크 색상", + "value-box": "값 상자", + "value-text": "값 텍스트", + "scale": "눈금", + "transparent-mode": "투명 모드", + "major-ticks": "주 눈금", + "intervals": "간격", + "major-ticks-color": "주 눈금 색상", + "normal": "정상", + "minor-ticks": "보조 눈금", + "minor-ticks-color": "보조 눈금 색상", + "temperature": "온도", + "temperature-hint": "현재 온도를 나타내는 double 값.", + "update-temperature": "온도 업데이트", + "update-temperature-hint": "사용자가 현재 온도를 변경하기 위해 클릭할 때 호출되는 액션.", + "run": "실행", + "run-hint": "사용자가 구성 요소를 실행하기 위해 클릭할 때 호출되는 액션.", + "stop": "정지", + "stop-hint": "사용자가 구성 요소를 정지하기 위해 클릭할 때 호출되는 액션.", + "temperature-step": "온도 단계 증가", + "heat-pump-color": "히트 펌프 색상", + "power-button-background": "전원 버튼 배경", + "value-box-background": "값 상자 배경", + "value-units": "값 단위", + "enable-units-scale": "눈금에 단위 표시", + "filtration-mode": "필터 모드", + "filtration-mode-hint": "현재 필터 모드를 나타내는 정수 값.", + "filtration-mode-update": "필터 모드 업데이트 상태", + "filtration-mode-update-hint": "사용자가 현재 필터 모드를 변경하기 위해 클릭할 때 호출되는 동작.", + "filter-mode": "필터", + "waste-mode": "폐기", + "backwash-mode": "역세척", + "recirculate-mode": "순환", + "rinse-mode": "헹굼", + "closed-mode": "닫힘", + "sand-filter-color": "모래 필터 색상", + "mode-box-background": "모드 상자 배경", + "border-color": "테두리 색상", + "label-color": "레이블 색상", + "water-leak-hint": "누출 여부를 나타냅니다.", + "default-color": "기본 색상", + "leak-color": "누출 색상", + "full-value": "전체 값", + "full-value-hint": "전체 값을 나타내는 실수 값.", + "label": "라벨", + "icon": "아이콘", + "button-color": "버튼 색상", + "on-label": "'켜짐' 레이블 텍스트", + "off-label": "'꺼짐' 레이블 텍스트", + "arrow-presence": "화살표 표시", + "arrow-presence-hint": "연결기에 화살표가 있는지 나타냅니다.", + "arrow-present": "화살표 있음", + "arrow-direction": "흐름 방향", + "arrow-direction-hint": "흐름 방향을 나타냅니다.", + "flow-animation": "흐름 존재", + "flow-animation-hint": "연결기에 유체가 흐르고 있는지 나타냅니다.", + "flow": "흐름", + "flow-line": "선", + "flow-line-style": "선 스타일", + "flow-style-hint": "완벽한 애니메이션 동기화를 위해 Dash와 Gap 값의 합이 100으로 나누어 떨어지도록 설정하세요.", + "flow-dash-cap": "대시 끝", + "dash-cap-butt": "평면", + "dash-cap-round": "둥글게", + "dash-cap-square": "사각형", + "dash": "대시", + "gap": "간격", + "main-line": "메인 선", + "line": "선", + "line-color": "선 색상", + "arrow-color": "화살표 색상", + "target-value": "목표 값", + "target-value-hint": "눈금의 목표 지점을 나타냅니다.", + "min-max-value": "최소 및 최대 값", + "min-value": "최소", + "max-value": "최대", + "progress-bar": "진행 막대", + "progress-arrow": "진행 화살표", + "warning-scale-color": "경고 눈금 색상", + "critical-scale-color": "중요 눈금 색상", + "scale-color": "스케일 색상", + "target": "대상", + "high-warning-state": "높은 경고 상태", + "show-high-warning-scale": "높은 경고 스케일 표시", + "high-warning-scale": "높은 경고 스케일", + "high-warning-state-hint": "double 값은 높은 경고 범위가 높은 위험 또는 최대값까지임을 나타냅니다.", + "low-warning-state": "낮은 경고 상태", + "show-low-warning-scale": "낮은 경고 스케일 표시", + "low-warning-scale": "낮은 경고 스케일", + "low-warning-state-hint": "double 값은 낮은 경고 범위가 낮은 위험 또는 최소값까지임을 나타냅니다.", + "high-critical-state": "높은 위험 상태", + "show-high-critical-scale": "높은 위험 스케일 표시", + "high-critical-scale": "높은 위험 스케일", + "high-critical-state-hint": "double 값은 높은 위험 범위가 최대값 스케일까지임을 나타냅니다.", + "low-critical-state": "낮은 위험 상태", + "show-low-critical-scale": "낮은 위험 상태 표시", + "low-critical-scale": "낮은 위험 상태", + "low-critical-state-hint": "double 값은 낮은 위험 범위가 최소값 스케일까지임을 나타냅니다.", + "filter-color": "필터 색상", + "colors": "색상", + "indicator-colors": "표시기 색상", + "enabled": "사용", + "disabled": "비활성화됨", + "on": "켜짐", + "off": "꺼짐", + "on-off-state": "켜짐/꺼짐 상태", + "on-off-state-hint": "컴포넌트가 켜짐 또는 꺼짐 상태인지 나타냅니다.", + "on-update-state": "켜짐 업데이트 상태", + "on-update-state-hint": "사용자가 상태를 켜짐으로 업데이트하기 위해 클릭할 때 호출되는 작업입니다.", + "off-update-state": "꺼짐 업데이트 상태", + "off-update-state-hint": "사용자가 상태를 꺼짐으로 업데이트하기 위해 클릭할 때 호출되는 작업입니다.", + "voltage": "전압", + "input-voltage": "입력 전압", + "input-voltage-hint": "double 값은 입력 전압 값을 나타냅니다.", + "output-voltage": "출력 전압", + "output-voltage-hint": "출력 전압 값을 나타내는 실수 값.", + "first-phase-voltage": "1상 전압", + "second-phase-voltage": "2상 전압", + "third-phase-voltage": "3상 전압", + "phase-voltage-hint": "현재 위상의 전압 값을 나타내는 실수 값", + "voltage-hint": "현재 전압을 나타내는 실수 값", + "current-voltage-color": "현재 전압 색상", + "phase-indicator-color": "위상 표시기 색상", + "measured": "측정됨", + "measured-hint": "킬로와트시 단위의 에너지 사용량을 나타내는 실수 값", + "day-rate": "주간 요금", + "night-rate": "야간 요금", + "off-peak-rate": "비성수기 요금", + "peak-rate": "성수기 요금", + "export-rate": "수출 요금", + "operating-mode": "운영 모드", + "bypass-mode": "바이패스", + "operating-mode-hint": "현재 작동 모드를 나타내는 정수 값 (0 - 꺼짐, 1 - 켜짐, 2 - 바이패스)", + "connected": "연결됨", + "connected-hint": "구성 요소가 연결 상태인지 나타냅니다.", + "disconnected": "연결 해제됨", + "indicator": "표시기", + "operation-mode": "운영 모드", + "operation-mode-hint": "인버터가 메인 또는 인버터 모드인지 나타냅니다.", + "operation-mode-indicators-color": "운영 모드 표시기 색상", + "mains-on-mode": "메인 켜짐", + "inverter-on-mode": "인버터 켜짐", + "charging-mode": "충전 모드", + "charging-mode-hint": "현재 충전 모드를 나타내는 정수 값 (1 - 벌크, 2 - 흡수, 3 - 플로트)", + "charging-mode-indicators-color": "충전 모드 표시기 색상", + "inverter-faults": "고장", + "inverter-fault-indicators-color": "고장 표시기 색상", + "overload-fault": "과부하", + "overload-fault-hint": "인버터가 과부하 상태인지 나타냅니다.", + "low-battery-fault": "배터리 부족", + "low-battery-fault-hint": "배터리가 과도하게 방전되었는지 나타냅니다.", + "temperature-fault": "온도", + "temperature-fault-hint": "인버터에 고온이 있는지 나타냅니다.", + "triangle": "삼각형", + "socket": "소켓", + "left-button": "왼쪽 버튼", + "right-button": "오른쪽 버튼", + "alarm-colors": "알람 색상", + "hook-color": "후크 색상" } }, "item": { @@ -1662,8 +4179,25 @@ "js-func": { "no-return-error": "함수는 값을 반환해야 합니다!", "return-type-mismatch": "함수는 '{{type}}' 유형의 값을 반환해야 합니다!", - "tidy": "Tidy", - "mini": "Mini" + "tidy": "깔끔한", + "mini": "미니", + "modules": "모듈", + "remove-module": "모듈 제거", + "no-modules": "구성된 모듈 없음", + "add-module": "모듈 추가", + "module-alias": "별명", + "invalid-module-alias-name": "잘못된 별명 이름", + "module-resource": "JS 모듈 리소스", + "not-unique-module-aliases-error": "모듈 별명은 고유해야 합니다!", + "show-module-info": "모듈 정보 표시", + "show-module-source-code": "모듈 소스 코드 표시", + "module-members": "모듈 멤버", + "module-no-members": "모듈에 내보낸 멤버가 없습니다", + "module-load-error": "모듈 로드 오류", + "source-code": "소스 코드", + "source-code-load-error": "소스 코드 로드 오류", + "no-js-module-text": "JS 모듈을 찾을 수 없음", + "no-js-module-matching": "'{{module}}'와(과) 일치하는 JS 모듈을 찾을 수 없습니다." }, "key-val": { "key": "키", @@ -1674,52 +4208,660 @@ }, "layout": { "layout": "레이아웃", + "layouts": "레이아웃", "manage": "레이아웃 관리", "settings": "레이아웃 설정", "color": "색", - "main": "Main", + "main": "메인", "right": "오른쪽", - "select": "대상 레이아웃 선택" + "left": "왼쪽", + "select": "대상 레이아웃 선택", + "percentage-width": "백분율 너비 (%)", + "fixed-width": "고정 너비 (px)", + "left-width": "왼쪽 열 (%)", + "right-width": "오른쪽 열 (%)", + "pick-fixed-side": "고정 측면: ", + "layout-fixed-width": "고정 너비 (px)", + "value-min-error": "값은 {{min}}{{unit}}보다 커야 합니다.", + "value-max-error": "값은 {{max}}{{unit}}보다 작아야 합니다.", + "layout-fixed-width-required": "고정 너비는 필수 항목입니다.", + "right-width-percentage-required": "오른쪽 백분율은 필수 항목입니다.", + "left-width-percentage-required": "왼쪽 백분율은 필수 항목입니다.", + "divider": "구분선", + "right-side": "오른쪽 레이아웃", + "left-side": "왼쪽 레이아웃", + "add-new-breakpoint": "새 브레이크포인트 추가", + "breakpoint": "브레이크포인트", + "breakpoints": "브레이크포인트", + "copy-from": "복사 원본", + "size": "크기", + "delete-breakpoint-title": "브레이크포인트 '{{name}}'을(를) 삭제하시겠습니까?", + "delete-breakpoint-text": "참고: 확인 후 중단점을 복구할 수 없게 되며 설정이 기본 중단점으로 되돌아갑니다." }, "legend": { "direction": "범례 방향", "position": "범례 위치", - "sort-legend": "범례에 데이터키 정렬", + "show-values": "값 표시", + "min-option": "최소", + "max-option": "최대", + "average-option": "평균", + "total-option": "합계", + "latest-option": "최신", + "sort-legend": "범례에 데이터키 정렬", "show-max": "최대값 표시", "show-min": "최소값 표시", "show-avg": "평균값 표시", "show-total": "총합 표시", + "show-latest": "최신 값 표시", "settings": "범례 설정", "min": "최소", "max": "최대", "avg": "평균", "total": "합계", + "latest": "최신", + "Min": "최소", + "Max": "최대", + "Avg": "평균", + "Total": "합계", + "Latest": "최신", "comparison-time-ago": { + "previousInterval": "(이전 간격)", + "customInterval": "(사용자 지정 간격)", "days": "(일 전)", "weeks": "(주 전)", "months": "(달 전)", "years": "(년 전)" - } + }, + "column-title": "열 제목", + "label": "라벨", + "value": "값" }, "login": { "login": "로그인", "request-password-reset": "비밀번호 재설정", "reset-password": "비밀번호 재설정", "create-password": "비밀번호 생성", + "two-factor-authentication": "2단계 인증", "passwords-mismatch-error": "입력된 비밀번호는 같아야 합니다!", "password-again": "비밀번호 확인", + "sign-in": "로그인", "username": "사용자명 (이메일)", "remember-me": "아이디 저장", "forgot-password": "비밀번호찾기", "password-reset": "비밀번호 재설정", - "expired-password-reset-message": "당신의 크리덴셜이 만료되었습니다! 새로운 비밀번호를 생성하세요.", + "expired-password-reset-message": "당신의 자격 증명이 만료되었습니다! 새로운 비밀번호를 생성하세요.", "new-password": "새 비밀번호", "new-password-again": "새 비밀번호 확인", "password-link-sent-message": "비밀번호 재설정 링크가 성공적으로 전송되었습니다!", "email": "이메일", - "login-with": "{{name}}으로 로그인", + "invalid-email-format": "잘못된 Email 형식입니다.", + "sign-in-with": "{{name}}(으)로 로그인", + "sign-in-to-your-account": "계정에 로그인하세요", "or": "또는", - "error": "로그인 오류" + "error": "로그인 오류", + "verify-your-identity": "신원 확인", + "select-way-to-verify": "확인 방법 선택", + "resend-code": "코드 재전송", + "resend-code-wait": "{ time, plural, =1 {1초} other {#초} } 후에 코드 재전송", + "try-another-way": "다른 방법 시도", + "totp-auth-description": "인증기 앱에서 보안 코드를 입력하세요.", + "totp-auth-placeholder": "코드", + "sms-auth-description": "보안 코드가 {{contact}}의 전화번호로 전송되었습니다.", + "sms-auth-placeholder": "SMS 코드", + "email-auth-description": "보안 코드가 {{contact}}의 이메일 주소로 전송되었습니다.", + "email-auth-placeholder": "이메일 코드", + "backup-code-auth-description": "백업 코드 중 하나를 입력하세요.", + "backup-code-auth-placeholder": "백업 코드", + "activation-link-expired": "활성화 링크가 만료되었습니다", + "activation-link-expired-message": "프로필 활성화 링크가 만료되었습니다. 로그인 페이지로 돌아가서 새 이메일을 받을 수 있습니다.", + "reset-password-link-expired": "비밀번호 재설정 링크가 만료되었습니다", + "reset-password-link-expired-message": "비밀번호 재설정 링크가 만료되었습니다. 로그인 페이지로 돌아가서 새 이메일을 받을 수 있습니다.", + "two-fa": "2단계 인증", + "two-fa-required": "2단계 인증이 필요합니다", + "set-up-verification-method": "계속하려면 확인 방법을 설정하세요", + "set-up-verification-method-login": "확인 방법을 설정하거나 로그인하세요", + "enable-authenticator-app": "인증기 앱 활성화", + "enable-authenticator-app-description": "인증기 앱에서 보안 코드를 입력하세요", + "enable-authenticator-sms": "SMS 인증기 활성화", + "enable-authenticator-sms-description": "방금 전송된 6자리 코드를 입력하세요: ", + "enable-authenticator-email": "이메일 인증기 활성화", + "enable-authenticator-email-description": "보안 코드가 이메일 주소로 전송되었습니다: ", + "enter-key-manually": "또는 이 32자리 키를 직접 입력하세요:", + "continue": "계속", + "confirm": "확인", + "authenticator-app-success": "인증기 앱이 활성화되었습니다", + "authenticator-app-success-description": "다음에 로그인할 때 2단계 인증 코드를 제공해야 합니다", + "authenticator-sms-success": "SMS 인증기가 활성화되었습니다", + "authenticator-sms-success-description": "다음에 로그인할 때 전화번호로 전송될 보안 코드를 입력하라는 메시지가 표시됩니다", + "authenticator-email-success": "이메일 인증기가 활성화되었습니다", + "authenticator-email-success-description": "다음에 로그인할 때 이메일 주소로 전송될 보안 코드를 입력하라는 메시지가 표시됩니다", + "authenticator-backup-code-success": "백업 코드가 활성화되었습니다", + "authenticator-backup-code-success-description": "다음에 로그인할 때 보안 코드를 입력하거나 백업 코드 중 하나를 사용하라는 메시지가 표시됩니다.", + "add-verification-method": "확인 방법 추가", + "get-backup-code": "백업 코드 받기", + "copy-key": "키 복사", + "send-code": "코드 전송", + "email-label": "이메일", + "email-description": "인증기로 사용할 이메일을 입력하세요.", + "sms-description": "인증기로 사용할 전화번호를 입력하세요.", + "backup-code-description": "코드를 인쇄하여 계정에 로그인할 때 필요할 때 사용할 수 있도록 하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.", + "backup-code-warn": "이 페이지를 떠나면 이 코드를 다시 볼 수 없습니다. 아래 옵션을 사용하여 안전하게 보관하세요.", + "download-txt": "다운로드 (txt)", + "print": "인쇄", + "verification-code": "6자리 코드", + "verification-code-invalid": "잘못된 확인 코드 형식입니다.", + "verification-code-incorrect": "확인 코드가 올바르지 않습니다.", + "verification-code-many-request": "확인 코드 확인 요청이 너무 많습니다.", + "scan-qr-code": "인증 앱으로 이 QR 코드를 스캔하세요", + "phone-input": { + "phone-input-label": "전화번호", + "phone-input-required": "전화번호는 필수 항목입니다.", + "phone-input-validation": "전화번호가 유효하지 않거나 불가능합니다.", + "phone-input-pattern": "잘못된 전화번호입니다. E.164 형식을 사용하세요 (예: {{phoneNumber}})", + "phone-input-hint": "E.164 형식의 전화번호 (예: {{phoneNumber}})" + } + }, + "markdown": { + "edit": "수정", + "preview": "미리보기", + "copy-code": "클릭하여 복사", + "copied": "복사됨!" + }, + "mobile": { + "add-application": "애플리케이션 추가", + "app-id": "앱 사이트 연결 ID", + "app-id-required": "앱 사이트 연결 ID가 필요합니다", + "app-id-pattern": "잘못된 앱 사이트 연결 ID 형식입니다.", + "app-store-link": "App Store 링크", + "app-store-link-required": "App Store 링크가 필요합니다", + "application-details": "애플리케이션 상세", + "application-package": "애플리케이션 패키지", + "application-secret": "애플리케이션 시크릿", + "application-secret-required": "애플리케이션 시크릿이 필요합니다", + "application": "애플리케이션", + "applications": "애플리케이션", + "copy-app-id": "앱 ID 복사", + "copy-app-store-link": "App Store 링크 복사", + "copy-application-package": "애플리케이션 패키지 복사", + "copy-application-secret": "애플리케이션 비밀키 복사", + "copy-google-play-link": "Google Play 링크 복사", + "copy-sha256-certificate-fingerprints": "SHA256 인증서 지문 복사", + "delete-application": "애플리케이션 삭제", + "delete-application-button-text": "결과를 이해하며 애플리케이션을 삭제합니다", + "delete-application-text": "이 작업은 취소할 수 없습니다. 애플리케이션이 영구적으로 삭제됩니다.
영구 삭제하지 않으려면 애플리케이션을 임시로 중단할 수 있습니다.
그래도 애플리케이션을 삭제하려면 \"{{phrase}}\"를 입력하여 확인하세요.", + "delete-application-title-short": "애플리케이션 '{{name}}'을(를) 삭제하시겠습니까?", + "delete-application-text-short": "주의: 확인 후 애플리케이션과 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-application-phrase": "애플리케이션 삭제", + "delete-applications-bundle-text": "주의: 확인 후 모바일 번들과 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-applications-bundle-title": "모바일 번들 '{{bundleName}}'을(를) 삭제하시겠습니까?", + "generate-application-secret": "애플리케이션 비밀키 생성", + "google-play-link": "Google Play 링크", + "google-play-link-required": "Google Play 링크는 필수 항목입니다.", + "latest-version": "최신 버전", + "min-version": "최소 버전", + "invalid-version-pattern": "잘못된 버전 형식입니다. major.minor.patch 형식을 사용하세요 (예: 1.0.0).", + "mobile-center": "모바일 센터", + "mobile-package": "애플리케이션 패키지", + "mobile-package-max-length": "애플리케이션 패키지는 256자 미만이어야 합니다", + "mobile-package-required": "애플리케이션 패키지가 필요합니다.", + "mobile-package-pattern": "애플리케이션 패키지 형식이 올바르지 않습니다", + "mobile-package-title": "애플리케이션 제목", + "mobile-package-title-max-length": "애플리케이션 제목은 256자 미만이어야 합니다", + "no-application": "애플리케이션을 찾을 수 없습니다", + "no-bundles": "번들을 찾을 수 없습니다", + "platform-type": "플랫폼 유형", + "search-application": "애플리케이션 검색", + "search-bundles": "번들 검색", + "set": "설정", + "sha256-certificate-fingerprints": "SHA256 인증서 지문", + "sha256-certificate-fingerprints-required": "SHA256 인증서 지문은 필수 항목입니다.", + "sha256-certificate-fingerprints-pattern": "잘못된 SHA256 인증서 지문 형식입니다.", + "show-hidden-pages": "숨겨진 페이지 표시", + "status": "상태", + "status-type": { + "deprecated": "사용 중단됨", + "draft": "초안", + "published": "게시됨", + "suspended": "일시 중지" + }, + "store-information": "스토어 정보", + "version-information": "버전 정보", + "min-version-release-notes": "최소 버전 릴리스 정보", + "latest-version-release-notes": "최신 버전 릴리스 정보", + "bundle": "번들", + "bundles": "번들", + "add-bundle": "번들 추가", + "title": "제목", + "title-required": "제목은 필수 항목입니다", + "title-cannot-contain-only-spaces": "제목은 공백만 포함할 수 없습니다.", + "title-max-length": "제목은 256자 미만이어야 합니다.", + "oauth-clients": "OAuth 2.0 클라이언트", + "android-app": "Android 앱", + "android-application": "Android 애플리케이션", + "ios-app": "iOS 앱", + "ios-application": "iOS 애플리케이션", + "invalid-store-link": "잘못된 스토어 링크입니다.", + "enable-oauth": "OAuth 2.0 활성화", + "enable-self-registration": "셀프 등록 활성화", + "edit-bundle": "번들 편집", + "description": "설명", + "basic-settings": "기본 설정", + "no-application-matching": "'{{entity}}'와(과) 일치하는 애플리케이션을 찾을 수 없습니다.", + "no-bundle-matching": "'{{entity}}'와(과) 일치하는 번들을 찾을 수 없습니다.", + "application-required": "애플리케이션이 필요합니다.", + "bundle-required": "번들이 필요합니다.", + "no-application-text": "애플리케이션을 찾을 수 없습니다", + "no-bundle-text": "번들을 찾을 수 없습니다", + "layout": "레이아웃", + "pages": "페이지", + "hide-all-pages": "모든 페이지 숨기기", + "reset-to-default-pages": "기본 페이지로 재설정", + "add-specific-page": "특정 페이지 추가", + "visible": "표시", + "hidden": "숨김", + "reset-to-page-default": "페이지를 기본값으로 재설정", + "mobile-599": "모바일 (최대 599px)", + "tablet-959": "태블릿 (최대 959px)", + "max-element-number": "최대 요소 수", + "page-name": "페이지 이름", + "page-name-required": "페이지 이름은 필수 항목입니다.", + "page-name-cannot-contain-only-spaces": "페이지 이름은 공백만 포함할 수 없습니다.", + "page-name-max-length": "페이지 이름은 256자 미만이어야 합니다.", + "page-type": "페이지 유형", + "pages-types": { + "dashboard": "대시보드", + "web-view": "웹 보기", + "custom": "사용자 지정" + }, + "url": "URL", + "invalid-url-format": "잘못된 URL 형식입니다.", + "path": "경로", + "invalid-path-format": "잘못된 경로 형식입니다.", + "custom-page": "사용자 지정 페이지", + "edit-page": "페이지 편집", + "edit-custom-page": "사용자 지정 페이지 편집", + "delete-page": "페이지 삭제", + "qr-code-widget": "QR 코드 위젯", + "type-here": "유형 히어로", + "configuration-dialog": "구성 대화상자", + "configuration-app": "구성 앱", + "configuration-step": { + "prepare-environment-title": "개발 환경 준비", + "prepare-environment-text": "Flutter ThingsBoard 모바일 애플리케이션에는 Flutter SDK가 필요합니다. 지침에 따라 Flutter SDK를 설정하세요.", + "get-source-code-title": "앱 소스 코드 얻기", + "get-source-code-text": "GitHub 저장소에서 복제하여 Flutter ThingsBoard 모바일 애플리케이션 소스 코드를 얻을 수 있습니다:", + "configure-app-settings-title": "앱 설정 구성", + "configure-app-settings-text": "구성 파일을 다운로드하여 이전 단계에서 복제한 프로젝트의 루트 디렉토리에 배치하세요.", + "download-file": "파일 다운로드", + "run-app-title": "앱 실행", + "run-app-text": "IDE에 설명된 대로 앱을 실행하세요.\n터미널을 사용하는 경우 다음 명령으로 앱을 실행하세요:", + "more-information": "자세한 정보는 시작하기 문서에서 찾을 수 있습니다.", + "getting-started": "시작하기" + } + }, + "notification": { + "action-button": "액션 버튼", + "action-type": "액션 유형", + "active": "활성", + "add-notification-recipients-group": "알림 수신자 그룹 추가", + "add-notification-template": "알림 템플릿 추가", + "add-recipient": "수신자 추가", + "add-recipients": "수신자 추가", + "add-rule": "규칙 추가", + "add-stage": "단계 추가", + "add-template": "템플릿 추가", + "after": "이후", + "alarm-assignment-trigger-settings": "알람 할당 트리거 설정", + "alarm-comment-trigger-settings": "알람 댓글 트리거 설정", + "alarm-trigger-settings": "알람 트리거 설정", + "all": "모두", + "api-feature-hint": "필드가 비어 있으면 트리거가 모든 API 기능에 적용됩니다.", + "api-usage-trigger-settings": "API 사용 트리거 설정", + "new-platform-version-trigger-settings": "새 플랫폼 버전 트리거 설정", + "rate-limits-trigger-settings": "속도 제한 초과 트리거 설정", + "task-processing-failure-trigger-settings": "작업 처리 실패 트리거 설정", + "resources-shortage-trigger-settings": "리소스 부족 트리거 설정", + "at-least-one-should-be-selected": "하나 이상 선택해야 합니다", + "basic-settings": "기본 설정", + "button-text": "버튼 텍스트", + "button-text-required": "버튼 텍스트가 필요합니다", + "button-text-max-length": "버튼 텍스트는 {{ length }}자 이하여야 합니다", + "compose": "작성", + "conversation": "대화", + "conversation-required": "대화는 필수 항목입니다.", + "copy-notification-template": "알림 템플릿 복사", + "copy-rule": "규칙 복사", + "copy-template": "템플릿 복사", + "create-new": "새로 만들기", + "created": "생성됨", + "customize-messages": "메시지 사용자 지정", + "cpu-threshold": "CPU 임계값", + "delete-notification-text": "주의: 확인 후 알림을 복구할 수 없게 됩니다.", + "delete-notification-title": "알림을 삭제하시겠습니까?", + "delete-notifications-text": "주의: 확인 후 알림을 복구할 수 없게 됩니다.", + "delete-notifications-title": "{ count, plural, =1 {1개 알림} other {#개 알림} }을(를) 삭제하시겠습니까?", + "delete-recipient-text": "주의: 확인 후 수신자를 복구할 수 없게 됩니다.", + "delete-recipient-title": "수신자 '{{recipientName}}'을(를) 삭제하시겠습니까?", + "delete-recipients-text": "주의: 확인 후 수신자를 복구할 수 없게 됩니다.", + "delete-recipients-title": "{ count, plural, =1 {1개 수신자} other {#개 수신자} }를 삭제하시겠습니까?", + "delete-request-text": "주의: 확인 후 요청을 복구할 수 없게 됩니다.", + "delete-request-title": "요청을 삭제하시겠습니까?", + "delete-requests-text": "주의: 확인 후 요청을 복구할 수 없게 됩니다.", + "delete-requests-title": "{ count, plural, =1 {1개 요청} other {#개 요청} }을(를) 삭제하시겠습니까?", + "delete-rule-text": "주의: 확인 후 규칙을 복구할 수 없게 됩니다.", + "delete-rule-title": "규칙 '{{ruleName}}'을(를) 삭제하시겠습니까?", + "delete-rules-text": "주의: 확인 후 규칙을 복구할 수 없게 됩니다.", + "delete-rules-title": "{ count, plural, =1 {1개 규칙} other {#개 규칙} }을(를) 삭제하시겠습니까?", + "delete-template-text": "주의: 확인 후 템플릿을 복구할 수 없게 됩니다.", + "delete-template-title": "템플릿 '{{templateName}}'을(를) 삭제하시겠습니까?", + "delete-templates-text": "주의: 확인 후 템플릿을 복구할 수 없게 됩니다.", + "delete-templates-title": "{ count, plural, =1 {1개 템플릿} other {#개 템플릿} }을(를) 삭제하시겠습니까?", + "deleted": "삭제됨", + "delivery-method": { + "delivery-method": "전달 방법", + "email": "이메일", + "email-preview": "이메일 알림 미리보기", + "slack": "Slack", + "slack-preview": "Slack 알림 미리보기", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Microsoft Teams 알림 미리보기", + "sms": "SMS", + "sms-preview": "SMS 알림 미리보기", + "web": "웹", + "web-preview": "웹 알림 미리보기", + "mobile-app": "모바일 앱", + "mobile-app-preview": "모바일 앱 알림 미리보기" + }, + "delivery-method-not-configure-click": "전달 방법이 구성되지 않았습니다. 클릭하여 설정하세요.", + "delivery-method-not-configure-contact": "전달 방법이 구성되지 않았습니다. 시스템 관리자에게 문의하세요.", + "delivery-methods": "전달 방법", + "description": "설명", + "device-activity-trigger-settings": "장치 활성 트리거 설정", + "device-list-rule-hint": "필드가 비어 있으면 트리거가 모든 장치에 적용됩니다.", + "device-profiles-list-rule-hint": "필드가 비어 있으면 트리거가 모든 장치 프로필에 적용됩니다", + "disabled": "비활성화됨", + "edge-trigger-settings": "Edge 트리거 설정", + "edge-list-rule-hint": "필드가 비어 있으면 트리거가 모든 엣지 인스턴스에 적용됩니다", + "edit-notification-recipients-group": "알림 수신자 그룹 편집", + "edit-notification-template": "알림 템플릿 편집", + "edit-rule": "규칙 편집", + "edit-template": "템플릿 편집", + "enabled": "사용", + "entities-limit-trigger-settings": "엔티티 제한 트리거 설정", + "entity-action-trigger-settings": "엔티티 동작 트리거 설정", + "entity-type": "엔티티 유형", + "escalation-chain": "에스컬레이션 체인", + "failed-send": "전송 실패", + "fails": "{ count, plural, =1 {1회 실패} other {#회 실패} }", + "filter": "필터", + "first-recipient": "첫 번째 수신자", + "inactive": "비활성", + "inbox": "받은편지함", + "notification-inbox": "알림 / 받은편지함", + "input-field-support-templatization": "입력 필드는 템플릿화를 지원합니다.", + "input-fields-support-templatization": "입력 필드는 템플릿화를 지원합니다.", + "link": "링크", + "link-required": "링크는 필수 항목입니다.", + "link-max-length": "링크는 {{ length }}자 이하여야 합니다.", + "link-type": { + "dashboard": "대시보드 열기", + "link": "URL 링크 열기" + }, + "loading-notifications": "알림 로딩 중...", + "management": "알림 관리", + "mark-all-as-read": "모두 읽음으로 표시", + "mark-as-read": "읽음으로 표시", + "message": "메시지", + "message-required": "메시지는 필수 항목입니다.", + "message-max-length": "메시지는 {{ length }}자 이하여야 합니다.", + "name": "이름", + "name-required": "이름은 필수 항목입니다.", + "new-notification": "새 알림", + "no-inbox-notification": "알림을 찾을 수 없음", + "no-notification-request": "알림 요청 없음", + "no-notification-templates": "알림 템플릿을 찾을 수 없음", + "no-notifications-yet": "아직 알림 없음", + "no-recipients-notification": "수신자 알림 없음", + "no-recipients-matching": "'{{entity}}'와(과) 일치하는 수신자를 찾을 수 없습니다.", + "no-recipients-text": "수신자를 찾을 수 없음", + "no-rule": "구성된 규칙 없음", + "no-rules-notification": "규칙 알림 없음", + "no-severity-found": "심각도를 찾을 수 없음", + "no-severity-matching": "'{{severity}}'을(를) 찾을 수 없습니다.", + "no-template-matching": "'{{template}}'와(과) 일치하는 리소스를 찾을 수 없습니다.", + "create-new-template": "새로 만들기!", + "not-found-slack-recipient": "Slack 수신자를 찾을 수 없음", + "notification": "알림", + "notification-center": "알림 센터", + "notification-tap-action": "알림 탭 동작", + "notification-tap-action-hint": "비활성화하면 기본 알람 대시보드가 사용됩니다.", + "notify": "알림", + "notify-again": "다시 알림", + "notify-alarm-action": { + "acknowledged": "확인", + "assigned": "알람 할당됨", + "cleared": "해제", + "created": "알람 생성됨", + "severity-changed": "알람 심각도 변경됨", + "unassigned": "알람 할당 해제" + }, + "notify-on": "알림 설정", + "notify-on-comment-update": "댓글 업데이트 시 알림", + "notify-on-required": "알림 설정은 필수 항목입니다.", + "notify-on-unassign": "할당 해제 시 알림", + "notify-only-user-comments": "사용자 댓글에만 알림", + "only-rule-chain-lifecycle-failures": "규칙 체인 수명 주기 실패만", + "only-rule-node-lifecycle-failures": "규칙 노드 수명 주기 실패만", + "platform-users": "플랫폼 사용자", + "ram-threshold": "RAM 임계값", + "rate-limits": "속도 제한", + "rate-limits-hint": "필드가 비어 있으면 트리거가 모든 속도 제한에 적용됩니다.", + "recipient": "수신자", + "recipient-group": "수신자 그룹", + "recipient-type": { + "affected-tenant-administrators": "영향받는 테넌트 관리자", + "affected-user": "영향받는 사용자", + "all-users": "모든 사용자", + "customer-users": "고객 사용자", + "system-administrators": "시스템 관리자", + "tenant-administrators": "테넌트 관리자", + "user-filters": "사용자 필터", + "user-list": "사용자 목록", + "users-entity-owner": "엔티티 소유자의 사용자" + }, + "recipients": "수신자", + "notification-recipient": "알림 수신자", + "notification-recipient-required": "알림 수신자는 필수 항목입니다.", + "notification-recipients": "알림 / 수신자", + "recipients-count": "{ count, plural, =1 {1명 수신자} other {#명 수신자} }", + "recipients-required": "수신자는 필수 항목입니다.", + "refresh-allow-delivery-method": "전달 방법 새로고침 허용", + "request-search": "요청 검색", + "request-status": { + "processing": "처리 중", + "scheduled": "예약됨", + "sent": "전송됨" + }, + "review": "검토", + "rule": "규칙", + "rule-chain-list-rule-hint": "필드가 비어 있으면 트리거가 모든 규칙 체인에 적용됩니다.", + "rule-engine-events-trigger-settings": "규칙 엔진 이벤트 트리거 설정", + "rule-engine-filter": "규칙 엔진 필터", + "rule-name": "규칙 이름", + "rule-name-required": "이름은 필수 항목입니다.", + "rule-disable": "알림 규칙 비활성화", + "rule-enable": "알림 규칙 활성화", + "rule-node-filter": "규칙 노드 필터", + "rules": "규칙", + "notification-rules": "알림 / 규칙", + "scheduler-later": "나중에 예약", + "search-notification": "알림 검색", + "search-recipients": "수신자 검색", + "search-rules": "규칙 검색", + "search-templates": "템플릿 검색", + "see-documentation": "문서 보기", + "selected-notifications": "{ count, plural, =1 {1개 알림} other {#개 알림} } 선택됨", + "selected-recipients": "{ count, plural, =1 {1명 수신자} other {#명 수신자} } 선택됨", + "selected-requests": "{ count, plural, =1 {1개 요청} other {#개 요청} } 선택됨", + "selected-rules": "{ count, plural, =1 {1개 규칙} other {#개 규칙} } 선택됨", + "selected-template": "{ count, plural, =1 {1개 템플릿} other {#개 템플릿} } 선택됨", + "send-notification": "알림 전송", + "sent": "전송됨", + "setup": "설정", + "notification-sent": "알림 / 전송됨", + "set-entity-from-notification": "알림에서 대시보드 상태로 엔티티 설정", + "slack-chanel-type": "Slack 채널 유형", + "slack-chanel-types": { + "direct": "다이렉트 메시지", + "private-channel": "비공개 채널", + "public-channel": "공개 채널" + }, + "start-from-scratch": "처음부터 시작", + "status": "상태", + "stop-escalation-alarm-status-become": "알람 상태가 다음이 되면 에스컬레이션 중지:", + "storage-threshold": "저장소 임계값", + "subject": "제목", + "subject-required": "제목이 필요합니다", + "subject-max-length": "제목은 {{ length }}자 이하여야 합니다", + "template": "템플릿", + "template-name": "템플릿 이름", + "template-required": "템플릿이 필요합니다", + "template-type": { + "alarm": "알람", + "alarm-assignment": "알람 할당", + "alarm-comment": "알람 댓글", + "api-usage-limit": "API 사용 한도", + "device-activity": "장치 활동", + "entities-limit": "엔티티 한도", + "entities-limit-increase-request": "엔티티 한도 증가 요청", + "entity-action": "엔티티 작업", + "general": "일반", + "rule-engine-lifecycle-event": "규칙 엔진 수명 주기 이벤트", + "rule-node": "규칙 노드", + "new-platform-version": "새 플랫폼 버전", + "rate-limits": "속도 제한 초과", + "edge-communication-failure": "엣지 통신 실패", + "edge-connection": "엣지 연결", + "task-processing-failure": "작업 처리 실패", + "resources-shortage": "리소스 부족" + }, + "templates": "템플릿", + "notification-templates": "알림 / 템플릿", + "tenant-profiles-list-rule-hint": "필드가 비어 있으면 트리거가 모든 테넌트 프로필에 적용됩니다", + "tenants-list-rule-hint": "필드가 비어 있으면 트리거가 모든 테넌트에 적용됩니다", + "threshold": "임계값", + "theme-color": "테마 색상", + "time": "시간", + "track-rule-node-events": "규칙 노드 이벤트 추적", + "trigger": { + "alarm": "알람", + "alarm-assignment": "알람 할당", + "alarm-comment": "알람 댓글", + "api-usage-limit": "API 사용 한도", + "device-activity": "장치 활동", + "entities-limit": "엔티티 한도", + "entity-action": "엔티티 작업", + "rule-engine-lifecycle-event": "규칙 엔진 수명 주기 이벤트", + "new-platform-version": "새 플랫폼 버전", + "rate-limits": "속도 제한 초과", + "edge-connection": "엣지 연결", + "edge-communication-failure": "엣지 통신 실패", + "task-processing-failure": "작업 처리 실패", + "resources-shortage": "리소스 부족", + "trigger": "트리거", + "trigger-required": "트리거가 필요합니다" + }, + "type": "유형", + "unread": "읽지 않음", + "updated": "수정됨", + "use-deprecated-webhook-connectors": "더 이상 사용되지 않는 웹훅 커넥터 사용", + "use-old-api": "이전 API 사용", + "use-template": "템플릿 사용", + "view-all": "모두 보기", + "warning": "경고", + "webhook-url": "웹훅 URL", + "webhook-url-required": "웹훅 URL이 필요합니다", + "workflow-url": "워크플로 URL", + "workflow-url-required": "워크플로 URL이 필요합니다", + "channel-name": "채널 이름", + "channel-name-required": "채널 이름이 필요합니다", + "settings": { + "notification-settings": "알림 설정", + "reset-all": "모든 설정 재설정", + "reset-all-title": "폼을 재설정하시겠습니까?", + "reset-all-text": "확인 후 설정 폼이 기본값으로 재설정되고 저장됩니다.", + "type": "유형", + "enable-all": "모두 활성화", + "disable-all": "모두 비활성화", + "delivery-not-configured": "배달 방법이 구성되지 않음" + } + }, + "ota-update": { + "add": "패키지 추가", + "assign-firmware": "할당된 펌웨어", + "assign-firmware-required": "할당된 펌웨어가 필요합니다", + "assign-software": "할당된 소프트웨어", + "assign-software-required": "할당된 소프트웨어가 필요합니다", + "auto-generate-checksum": "체크섬 자동 생성", + "checksum": "체크섬", + "checksum-hint": "체크섬이 비어 있으면 자동으로 생성됩니다.", + "checksum-algorithm": "체크섬 알고리즘", + "checksum-copied-message": "패키지 체크섬이 클립보드에 복사되었습니다.", + "change-firmware": "펌웨어 변경으로 { count, plural, =1 {1개 장치} other {#개 장치} }가 업데이트될 수 있습니다.", + "change-software": "소프트웨어 변경으로 { count, plural, =1 {1개 장치} other {#개 장치} }가 업데이트될 수 있습니다.", + "change-ota-setting-title": "OTA 설정을 변경하시겠습니까?", + "chose-compatible-device-profile": "업로드된 패키지는 선택한 프로필이 있는 장치에서만 사용할 수 있습니다.", + "chose-firmware-distributed-device": "장치에 배포할 펌웨어를 선택하세요.", + "chose-software-distributed-device": "장치에 배포할 소프트웨어를 선택하세요.", + "content-type": "콘텐츠 유형", + "copy-checksum": "체크섬 복사", + "copy-direct-url": "직접 URL 복사", + "copyId": "패키지 ID 복사", + "copied": "복사됨!", + "delete": "패키지 삭제", + "delete-ota-update-text": "주의: 확인 후 OTA 업데이트를 복구할 수 없게 됩니다.", + "delete-ota-update-title": "OTA 업데이트 '{{title}}'을(를) 삭제하시겠습니까?", + "delete-ota-updates-text": "주의: 확인 후 선택한 모든 OTA 업데이트가 제거됩니다.", + "delete-ota-updates-title": "{ count, plural, =1 {1개 OTA 업데이트} other {#개 OTA 업데이트} }을(를) 삭제하시겠습니까?", + "description": "설명", + "direct-url": "직접 URL", + "direct-url-copied-message": "패키지 직접 URL이 클립보드에 복사되었습니다.", + "direct-url-required": "직접 URL은 필수 항목입니다.", + "download": "패키지 다운로드", + "drop-file": "패키지 파일을 끌어다 놓거나 클릭하여 업로드할 파일을 선택하세요.", + "drop-package-file-or": "패키지 파일을 끌어다 놓거나", + "file-name": "파일 이름", + "file-size": "파일 크기", + "file-size-bytes": "파일 크기 (바이트)", + "idCopiedMessage": "패키지 ID가 클립보드에 복사되었습니다.", + "no-firmware-matching": "'{{entity}}'과(와) 일치하는 호환되는 펌웨어 OTA 업데이트 패키지가 없습니다.", + "no-firmware-text": "프로비저닝된 호환되는 펌웨어 OTA 업데이트 패키지가 없습니다.", + "no-packages-text": "패키지를 찾을 수 없습니다", + "no-software-matching": "'{{entity}}'과(와) 일치하는 호환되는 소프트웨어 OTA 업데이트 패키지가 없습니다.", + "no-software-text": "프로비저닝된 호환되는 소프트웨어 OTA 업데이트 패키지가 없습니다.", + "ota-update": "OTA 업데이트", + "ota-update-details": "OTA 업데이트 세부 정보", + "ota-updates": "OTA 업데이트", + "package-file": "패키지 파일", + "package-type": "패키지 유형", + "packages-repository": "패키지 저장소", + "search": "패키지 검색", + "selected-package": "{ count, plural, =1 {1개 패키지} other {#개 패키지} } 선택됨", + "title": "제목", + "title-required": "제목을 입력하세요.", + "title-max-length": "제목은 256자 미만이어야 합니다.", + "types": { + "firmware": "펌웨어", + "software": "소프트웨어" + }, + "upload-binary-file": "바이너리 파일 업로드", + "use-external-url": "외부 URL 사용", + "version": "버전", + "version-required": "버전은 필수 항목입니다.", + "version-tag": "버전 태그", + "version-tag-hint": "사용자 지정 태그는 장치에서 보고한 패키지 버전과 일치해야 합니다.", + "version-max-length": "버전은 256자 미만이어야 합니다.", + "warning-after-save-no-edit": "패키지가 업로드되면 제목, 버전, 장치 프로필 및 패키지 유형을 수정할 수 없습니다." }, "position": { "top": "상단", @@ -1728,14 +4870,109 @@ "right": "오른쪽" }, "profile": { - "profile": "프로파일", + "profile": "프로필", "last-login-time": "마지막 로그인", "change-password": "비밀번호 변경", - "current-password": "현재 비밀번호" + "current-password": "현재 비밀번호", + "copy-jwt-token": "JWT 토큰 복사", + "jwt-token": "JWT 토큰", + "token-valid-till": "토큰 유효 기간", + "tokenCopiedSuccessMessage": "JWT 토큰이 클립보드에 복사되었습니다.", + "tokenCopiedWarnMessage": "JWT 토큰이 만료되었습니다! 페이지를 새로고침하세요." + }, + "profiles": { + "profiles": "프로필" + }, + "security": { + "security": "보안", + "general-settings": "일반 보안 설정", + "access-token": "액세스 토큰", + "access-token-required": "액세스 토큰이 필요합니다", + "clientId": "클라이언트 ID", + "clientId-required": "클라이언트 ID가 필요합니다", + "username": "사용자명", + "username-required": "사용자 이름은 필수 항목입니다.", + "ca-cert": "CA 인증서", + "2fa": { + "2fa": "2단계 인증", + "2fa-description": "2단계 인증은 무단 액세스로부터 계정을 보호합니다. 로그인할 때 보안 코드를 입력하기만 하면 됩니다.", + "authenticate-with": "다음으로 인증할 수 있습니다:", + "disable-2fa-provider-text": "{{name}}을(를) 비활성화하면 계정 보안이 약화됩니다.", + "disable-2fa-provider-title": "{{name}}을(를) 비활성화하시겠습니까?", + "get-new-code": "새 코드 받기", + "main-2fa-method": "기본 2단계 인증 방법으로 사용", + "dialog": { + "activation-step-description-email": "다음에 로그인할 때 이메일 주소로 전송될 보안 코드를 입력하라는 메시지가 표시됩니다.", + "activation-step-description-sms": "다음에 로그인할 때 전화번호로 전송될 보안 코드를 입력하라는 메시지가 표시됩니다.", + "activation-step-description-totp": "다음에 로그인할 때 2단계 인증 코드를 제공해야 합니다.", + "activation-step-label": "활성화", + "backup-code-description": "코드를 인쇄하여 계정에 로그인할 때 필요할 때 사용할 수 있도록 하세요. 각 백업 코드는 한 번만 사용할 수 있습니다.", + "backup-code-warn": "이 페이지를 떠나면 이 코드를 다시 볼 수 없습니다. 아래 옵션을 사용하여 안전하게 보관하세요.", + "download-txt": "다운로드 (txt)", + "email-step-description": "인증기로 사용할 이메일을 입력하세요.", + "email-step-label": "이메일", + "enable-email-title": "이메일 인증기 활성화", + "enable-sms-title": "SMS 인증기 활성화", + "enable-totp-title": "인증기 앱 활성화", + "enter-verification-code": "여기에 6자리 코드를 입력하세요", + "get-backup-code-title": "백업 코드 받기", + "next": "다음", + "scan-qr-code": "인증 앱으로 이 QR 코드를 스캔하세요", + "send-code": "코드 전송", + "sms-step-description": "인증기로 사용할 전화번호를 입력하세요.", + "sms-step-label": "전화번호", + "success": "성공!", + "totp-step-description-install": "Google Authenticator, Authy 또는 Duo와 같은 앱을 설치할 수 있습니다.", + "totp-step-description-open": "휴대전화에서 인증기 앱을 엽니다.", + "totp-step-label": "앱 받기", + "verification-code": "6자리 코드", + "verification-code-invalid": "잘못된 확인 코드 형식입니다.", + "verification-code-incorrect": "확인 코드가 올바르지 않습니다.", + "verification-code-many-request": "확인 코드 확인 요청이 너무 많습니다.", + "verification-step-description": "{{address}}(으)로 방금 전송된 6자리 코드를 입력하세요", + "verification-step-label": "확인" + }, + "provider": { + "email": "이메일", + "email-description": "이메일 주소로 전송된 보안 코드를 사용하여 인증하세요.", + "email-hint": "인증 코드가 {{ info }}(으)로 이메일로 전송됩니다", + "sms": "SMS", + "sms-description": "휴대전화를 사용하여 인증하세요. 로그인할 때 SMS 메시지로 보안 코드를 보내드립니다.", + "sms-hint": "인증 코드가 {{ info }}(으)로 문자 메시지로 전송됩니다", + "totp": "인증기 앱", + "totp-description": "휴대전화에서 Google Authenticator, Authy 또는 Duo와 같은 앱을 사용하여 인증하세요. 로그인할 보안 코드를 생성합니다.", + "totp-hint": "계정에 인증기 앱이 설정되었습니다", + "backup_code": "백업 코드", + "backup-code-description": "이 인쇄 가능한 일회용 암호는 여행 중처럼 휴대전화를 사용할 수 없을 때 로그인할 수 있도록 해줍니다.", + "backup-code-hint": "현재 {{ info }}개의 일회용 코드가 활성화되어 있습니다." + } + }, + "password-requirement": { + "at-least": "최소:", + "character": "{ count, plural, =1 {1자} other {#자} }", + "digit": "{ count, plural, =1 {1자리 숫자} other {#자리 숫자} }", + "password-tooltip-min-length": "최소 {{minimumLength}}자 이상", + "password-tooltip-max-length": "최대 {{maximumLength}}자 이하", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}}개 대문자", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}}개 소문자", + "password-tooltip-digit": "{{minimumDigits}}개 숫자", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}}개 특수 문자", + "incorrect-password-try-again": "잘못된 비밀번호입니다. 다시 시도하세요.", + "lowercase-letter": "{ count, plural, =1 {1개 소문자} other {#개 소문자} }", + "new-passwords-not-match": "새 비밀번호가 일치하지 않습니다.", + "password-should-not-contain-spaces": "비밀번호에 공백이 포함되지 않아야 합니다.", + "password-not-meet-requirements": "비밀번호가 요구 사항을 충족하지 않습니다.", + "password-requirements": "비밀번호 요구 사항", + "password-should-difference": "새 비밀번호는 현재 비밀번호와 달라야 합니다.", + "special-character": "{ count, plural, =1 {1개 특수 문자} other {#개 특수 문자} }", + "uppercase-letter": "{ count, plural, =1 {1개 대문자} other {#개 대문자} }", + "at-most": "최대:" + } }, "relation": { "relations": "관계", "direction": "방향", + "clear-relation-type": "관계 유형 지우기", "search-direction": { "FROM": "보내는 사람", "TO": "받는 사람" @@ -1748,41 +4985,135 @@ "to-relations": "수신 관계", "selected-relations": "{ count, plural, =1 {1 개 관계} other {# 개 관계} } 선택됨", "type": "유형", - "to-entity-type": "받는 개체 유형", - "to-entity-name": "받는 개체 이름", - "from-entity-type": "보내는 개체 유형", - "from-entity-name": "받는 개체 이름", - "to-entity": "받는 개체", - "from-entity": "보내는 개체", + "to-entity-type": "받는 엔티티 유형", + "to-entity-name": "받는 엔티티 이름", + "from-entity-type": "보내는 엔티티 유형", + "from-entity-name": "받는 엔티티 이름", + "to-entity": "받는 엔티티", + "from-entity": "보내는 엔티티", "delete": "관계 삭제", "relation-type": "관계 유형", "relation-type-required": "관계 유형을 입력하세요.", + "relation-type-max-length": "관계 유형은 256자 미만이어야 합니다.", "any-relation-type": "전체 유형", "add": "관계 추가", "edit": "관계 편집", - "delete-to-relation-title": "개체 '{{entityName}}'을(를) 삭제하시겠습니까?", - "delete-to-relation-text": "주의: 개체 '{{entityName}}'는 현재 개체와 관계가 해제됩니다.", + "delete-to-relation-title": "엔티티 '{{entityName}}'을(를) 삭제하시겠습니까?", + "delete-to-relation-text": "주의: 엔티티 '{{entityName}}'는 현재 엔티티와 관계가 해제됩니다.", "delete-to-relations-title": "{ count, plural, =1 {1 개 관계} other {# 개 관계} }를 삭제하시겠습니까?", - "delete-to-relations-text": "주의: 모든 선택된 관계는 제거되며, 해당되는 개체는 현재의 개체와 관계가 해제됩니다.", - "delete-from-relation-title": "개체 '{{entityName}}'의 관계를 삭제하시겠습니까?", - "delete-from-relation-text": "주의: 현재 개체는 개체 '{{entityName}}'와의 관계가 해제됩니다.", + "delete-to-relations-text": "주의: 모든 선택된 관계는 제거되며, 해당되는 엔티티는 현재의 엔티티와 관계가 해제됩니다.", + "delete-from-relation-title": "엔티티 '{{entityName}}'의 관계를 삭제하시겠습니까?", + "delete-from-relation-text": "주의: 현재 엔티티는 엔티티 '{{entityName}}'와의 관계가 해제됩니다.", "delete-from-relations-title": "{ count, plural, =1 {1 개 관계} other {# 개 관계} }를 삭제하시겠습니까?", - "delete-from-relations-text": "주의: 모든 선택된 관계는 제거되며, 현제 개체는 해당되는 대체와 관계가 해제됩니다.", + "delete-from-relations-text": "주의: 모든 선택된 관계는 제거되며, 현제 엔티티는 해당되는 대체와 관계가 해제됩니다.", "remove-relation-filter": "관계 필터 삭제", + "remove-filter": "필터 제거", "add-relation-filter": "관계 필터 추가", "any-relation": "전체 관계", "relation-filters": "관계 필터", + "relation-filter": "관계 필터", "additional-info": "추가 정보 (JSON)", "invalid-additional-info": "추가적인 정보 JSON을 파싱할 수 없습니다.", - "no-relations-text": "아무 관계도 찾을 수 없습니다" + "no-relations-text": "아무 관계도 찾을 수 없습니다", + "not": "제외", + "copy-type": "유형 복사" + }, + "resource": { + "add": "리소스 추가", + "all-types": "모두", + "copyId": "리소스 ID 복사", + "delete": "리소스 삭제", + "delete-resource-text": "주의: 확인 후 리소스를 복구할 수 없게 됩니다.", + "delete-resource-title": "리소스 '{{resourceTitle}}'을(를) 삭제하시겠습니까?", + "delete-resources-action-title": "{ count, plural, =1 {1개 리소스} other {#개 리소스} } 삭제", + "delete-resources-text": "선택한 리소스는 장치 프로필에서 사용 중이더라도 삭제됩니다.", + "delete-resources-title": "{ count, plural, =1 {1개 리소스} other {#개 리소스} }를 삭제하시겠습니까?", + "download": "리소스 다운로드", + "drop-file": "리소스 파일을 끌어다 놓거나 클릭하여 업로드할 파일을 선택하세요.", + "drop-resource-file-or": "리소스 파일을 끌어다 놓거나", + "empty": "리소스가 비어 있습니다", + "file-name": "파일 이름", + "idCopiedMessage": "리소스 ID가 클립보드에 복사되었습니다.", + "no-resource-matching": "'{{widgetsBundle}}'와(과) 일치하는 리소스를 찾을 수 없습니다.", + "no-resource-text": "리소스를 찾을 수 없습니다", + "open-widgets-bundle": "위젯 번들 열기", + "resource": "리소스", + "resource-file": "리소스 파일", + "resource-files": "리소스 파일", + "resource-library-details": "리소스 세부 정보", + "resource-type": "리소스 유형", + "resources-library": "리소스 라이브러리", + "search": "리소스 검색", + "selected-resources": "{ count, plural, =1 {1개 리소스} other {#개 리소스} } 선택됨", + "system": "시스템", + "title": "제목", + "title-required": "제목을 입력하세요.", + "title-max-length": "제목은 256자 미만이어야 합니다.", + "type": { + "jks": "JKS", + "js-module": "JS 모듈", + "lwm2m-model": "LWM2M 모델", + "pkcs-12": "PKCS #12", + "general": "일반" + }, + "resource-sub-type": "하위 유형", + "sub-type": { + "image": "이미지", + "scada-symbol": "SCADA 심볼", + "extension": "확장", + "module": "모듈" + }, + "resource-is-in-use": "리소스가 다른 엔티티에서 사용 중입니다", + "resources-are-in-use": "리소스가 다른 엔티티에서 사용 중입니다", + "resource-is-in-use-text": "'{{title}}' 리소스가 다음 엔티티에서 사용 중이므로 삭제되지 않았습니다:", + "resources-are-in-use-text": "모든 리소스가 삭제되지 않았습니다. 다른 엔티티에서 사용 중이기 때문입니다.
해당 리소스 행의 참조 버튼을 클릭하여 참조된 엔티티를 볼 수 있습니다.
이 리소스를 계속 삭제하려면 아래 표에서 선택하고 선택 삭제 버튼을 클릭하세요.", + "delete-resource-in-use-text": "리소스를 계속 삭제하려면 무조건 삭제 버튼을 클릭하세요." + }, + "javascript": { + "add": "JavaScript 리소스 추가", + "delete": "JavaScript 리소스 삭제", + "delete-javascript-resource-text": "주의: 확인 후 JavaScript 리소스를 복구할 수 없게 됩니다.", + "delete-javascript-resource-title": "JavaScript 리소스 '{{resourceTitle}}'을(를) 삭제하시겠습니까?", + "delete-javascript-resources-action-title": "JavaScript { count, plural, =1 {1개 리소스} other {#개 리소스} } 삭제", + "delete-javascript-resources-text": "선택한 JavaScript 리소스는 JavaScript 함수에서 사용 중이더라도 삭제됩니다.", + "delete-javascript-resources-title": "JavaScript { count, plural, =1 {1개 리소스} other {#개 리소스} }를 삭제하시겠습니까?", + "delete-javascript-resource-in-use-text": "JavaScript 리소스를 계속 삭제하려면 무조건 삭제 버튼을 클릭하세요.", + "download": "JavaScript 리소스 다운로드", + "upload-from-file": "파일에서 JavaScript 업로드", + "resource-file": "JavaScript 리소스 파일", + "drop-file": "JavaScript 파일을 끌어다 놓거나 클릭하여 업로드할 파일을 선택하세요.", + "drop-resource-file-or": "JavaScript 파일을 끌어다 놓거나", + "javascript-library": "JavaScript 라이브러리", + "javascript-type": "JavaScript 유형", + "javascript-resource-details": "JavaScript 리소스 세부 정보", + "javascript-resource-is-in-use": "JavaScript 리소스가 다른 엔티티에서 사용 중입니다", + "javascript-resources-are-in-use": "JavaScript 리소스가 다른 엔티티에서 사용 중입니다", + "javascript-resource-is-in-use-text": "'{{title}}' JavaScript 리소스가 다음 엔티티에서 사용 중이므로 삭제되지 않았습니다:", + "javascript-resources-are-in-use-text": "모든 JavaScript 리소스가 삭제되지 않았습니다. 다른 엔티티에서 사용 중이기 때문입니다.
해당 리소스 행의 참조 버튼을 클릭하여 참조된 엔티티를 볼 수 있습니다.
이 JavaScript 리소스를 계속 삭제하려면 아래 표에서 선택하고 선택 삭제 버튼을 클릭하세요.", + "search": "JavaScript 리소스 검색", + "selected-javascript-resources": "{ count, plural, =1 {1개 JavaScript 리소스} other {#개 JavaScript 리소스} } 선택됨", + "no-javascript-resource-text": "JavaScript 리소스를 찾을 수 없습니다", + "all-types": "모두", + "module-script": "모듈 스크립트" + }, + "rpc": { + "error": { + "target-device-is-not-set": "대상 장치가 설정되지 않았습니다!", + "invalid-target-entity": "{{entityType}} 엔티티는 RPC 명령을 지원하지 않습니다.", + "failed-to-resolve-target-device": "대상 장치를 확인하지 못했습니다!", + "request-timeout": "요청 시간 초과", + "rpc-http-error": "오류: {{status}} - {{statusText}}" + } }, "rulechain": { "rulechain": "규칙 사슬", + "rulechain-events": "규칙 체인 이벤트", "rulechains": "규칙 사슬", "root": "루트", "delete": "규칙 사슬 삭제", "name": "이름", "name-required": "이름을 입력하세요.", + "name-max-length": "이름은 256자 미만이어야 합니다.", "description": "설명", "add": "규칙 사슬 추가", "set-root": "규칙 사슬 루트로 전환", @@ -1814,27 +5145,53 @@ "debug-mode": "디버그 모드", "search": "규칙 사슬 검색", "selected-rulechains": "{ count, plural, =1 {1 개 규칙 사슬} other {# 개 규칙 사슬} } 선택됨", - "open-rulechain": "규칙 사슬 열기" + "open-rulechain": "규칙 사슬 열기", + "edge-template-root": "템플릿 루트", + "assign-to-edge": "엣지에 할당", + "edge-rulechain": "엣지 규칙 체인", + "unassign-rulechain-from-edge-text": "확인 후 규칙 체인이 할당 해제되어 엣지에서 액세스할 수 없습니다.", + "unassign-rulechains-from-edge-title": "{ count, plural, =1 {1개 규칙 체인} other {#개 규칙 체인} }을(를) 할당 해제하시겠습니까?", + "unassign-rulechains-from-edge-text": "확인 후 선택한 모든 규칙 체인이 할당 해제되어 엣지에서 액세스할 수 없습니다.", + "assign-rulechain-to-edge-title": "Edge에 규칙 체인 할당", + "assign-rulechain-to-edge-text": "Edge에 할당할 규칙 체인을 선택하세요", + "set-edge-template-root-rulechain": "규칙 체인을 Edge 템플릿 루트로 만들기", + "set-edge-template-root-rulechain-title": "규칙 체인 '{{ruleChainName}}'을(를) 엣지 템플릿 루트로 설정하시겠습니까?", + "set-edge-template-root-rulechain-text": "확인 후 규칙 체인이 엣지 템플릿 루트가 되며 새로 생성된 엣지의 루트 규칙 체인이 됩니다.", + "invalid-rulechain-type-error": "규칙 체인을 가져올 수 없습니다: 잘못된 규칙 체인 유형입니다. 예상 유형은 {{expectedRuleChainType}}입니다.", + "set-auto-assign-to-edge": "생성 시 Edge에 규칙 체인 할당", + "set-auto-assign-to-edge-title": "엣지 규칙 체인 '{{ruleChainName}}'을(를) 생성 시 엣지에 할당하시겠습니까?", + "set-auto-assign-to-edge-text": "확인 후 엣지 규칙 체인이 생성 시 엣지에 자동으로 할당됩니다.", + "unset-auto-assign-to-edge": "생성 시 Edge에 규칙 체인 할당 안 함", + "unset-auto-assign-to-edge-title": "엣지 규칙 체인 '{{ruleChainName}}'을(를) 생성 시 엣지에 할당하지 않으시겠습니까?", + "unset-auto-assign-to-edge-text": "확인 후 엣지 규칙 체인이 생성 시 엣지에 더 이상 자동으로 할당되지 않습니다.", + "unassign-rulechain-title": "규칙 체인 '{{ruleChainName}}'의 할당을 해제하시겠습니까?", + "unassign-rulechains": "규칙 체인 할당 해제" }, "rulenode": { + "rule-node-events": "규칙 노드 이벤트", "details": "상세 내역", "events": "이벤트", "search": "노드 검색", "open-node-library": "노드 라이브러리 열기", + "close-node-library": "노드 라이브러리 닫기", "add": "규칙 노드 추가", "name": "이름", "name-required": "이름을 입력하세요.", + "name-max-length": "이름은 256자 미만이어야 합니다.", "type": "유형", + "rule-node-description": "규칙 노드 설명", "delete": "규칙 노드 삭제", "select-all-objects": "모든 노드와 연결을 선택", "deselect-all-objects": "모든 노드와 연결을 선택 해제", "delete-selected-objects": "선택된 노드와 연결을 삭제", "delete-selected": "선택 삭제", + "create-nested-rulechain": "중첩 규칙 체인 생성", "select-all": "모두 선택", "copy-selected": "선택 복사", "deselect-all": "선택 해제", "rulenode-details": "규칙 노드 상세 정보", "debug-mode": "디버그 모드", + "singleton": "싱글톤", "configuration": "설정", "link": "링크", "link-details": "규칙 노드 링크 상세 정보", @@ -1849,45 +5206,964 @@ "no-link-label-matching": "'{{label}}' 을 찾을 수 없습니다.", "create-new-link-label": "새로 링크 라벨 만들기", "type-filter": "필터", - "type-filter-details": "Filter incoming messages with configured conditions", - "type-enrichment": "Enrichment", + "type-filter-details": "구성된 조건으로 들어오는 메시지 필터링", + "type-enrichment": "보강", "type-enrichment-details": "메시지 메타데이터에 추가적인 정보를 추가", - "type-transformation": "Transformation", - "type-transformation-details": "Change Message payload and Metadata", - "type-action": "", + "type-transformation": "변환", + "type-transformation-details": "메시지 페이로드 및 메타데이터 변경", + "type-action": "동작", "type-action-details": "특별한 조치를 수행", "type-external": "외부", - "type-external-details": "Interacts with external system", - "type-rule-chain": "Rule Chain", - "type-rule-chain-details": "Forwards incoming messages to specified Rule Chain", + "type-external-details": "외부 시스템과 상호 작용", + "type-rule-chain": "규칙 사슬", + "type-rule-chain-details": "수신 메시지를 지정된 규칙 체인으로 전달", + "type-flow": "흐름", + "type-flow-details": "메시지 흐름 구성", "type-input": "입력", - "type-input-details": "Logical input of Rule Chain, forwards incoming messages to next related Rule Node", - "type-unknown": "Unknown", - "type-unknown-details": "Unresolved Rule Node", - "directive-is-not-loaded": "Defined configuration directive '{{directiveName}}' is not available.", - "ui-resources-load-error": "Failed to load configuration ui resources.", - "invalid-target-rulechain": "Unable to resolve target rule chain!", - "test-script-function": "Test script function", + "type-input-details": "규칙 체인의 논리적 입력, 수신 메시지를 다음 관련 규칙 노드로 전달", + "type-unknown": "알 수 없음", + "type-unknown-details": "확인되지 않은 규칙 노드", + "directive-is-not-loaded": "정의된 구성 지시문 '{{directiveName}}'을(를) 사용할 수 없습니다.", + "ui-resources-load-error": "구성 UI 리소스를 로드하지 못했습니다.", + "invalid-target-rulechain": "대상 규칙 체인을 확인할 수 없습니다!", + "test-script-function": "스크립트 함수 테스트", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", "message": "메시지", "message-type": "메시지 유형", - "select-message-type": "메시지 유형 선택", + "select-message-type": "메시지 유형 선택", "message-type-required": "메시지 유형을 입력하세요.", "metadata": "메타데이터", "metadata-required": "메타데이터 엔트리를 입력하세요.", "output": "출력", "test": "테스트", - "help": "도움말" + "help": "도움말", + "reset-debug-settings": "모든 노드에서 디버그 설정 재설정", + "test-with-this-message": "이 메시지로 {{test}}", + "queue-hint": "메시지를 다른 큐로 전달할 큐를 선택하세요. 기본적으로 '기본' 큐가 사용됩니다.", + "queue-singleton-hint": "다중 인스턴스 환경에서 메시지 전달을 위한 큐를 선택하세요. 기본적으로 '기본' 큐가 사용됩니다." + }, + "rule-node-config": { + "id": "ID", + "additional-info": "추가 정보", + "advanced-settings": "고급 설정", + "create-entity-if-not-exists": "존재하지 않는 경우 새 엔티티 생성", + "create-entity-if-not-exists-hint": "활성화하면 지정된 매개변수가 있는 새 엔티티가 이미 존재하지 않는 한 생성됩니다. 기존 엔티티는 관계에 그대로 사용됩니다.", + "select-device-connectivity-event": "장치 연결 이벤트 선택", + "entity-name-pattern": "이름 패턴", + "device-name-pattern": "장치 이름", + "asset-name-pattern": "자산 이름", + "entity-view-name-pattern": "엔티티 뷰 이름", + "customer-title-pattern": "고객 제목", + "dashboard-name-pattern": "대시보드 제목", + "user-name-pattern": "사용자 이메일", + "edge-name-pattern": "엣지 이름", + "entity-name-pattern-required": "이름 패턴이 필요합니다", + "entity-name-pattern-hint": "이름 패턴 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "copy-message-type": "메시지 유형 복사", + "entity-type-pattern": "유형 패턴", + "entity-type-pattern-required": "유형 패턴이 필요합니다", + "message-type-value": "메시지 유형 값", + "message-type-value-required": "메시지 유형 값이 필요합니다", + "message-type-value-max-length": "메시지 유형 값은 256자 미만이어야 합니다", + "output-message-type": "출력 메시지 유형", + "entity-cache-expiration": "엔티티 캐시 만료 시간 (초)", + "entity-cache-expiration-hint": "찾은 엔티티 레코드를 저장할 수 있는 최대 시간 간격을 지정합니다. 0 값은 레코드가 만료되지 않음을 의미합니다.", + "entity-cache-expiration-required": "엔티티 캐시 만료 시간이 필요합니다.", + "entity-cache-expiration-range": "엔티티 캐시 만료 시간은 0 이상이어야 합니다.", + "customer-name-pattern": "고객 제목", + "customer-name-pattern-required": "고객 제목이 필요합니다", + "customer-name-pattern-hint": "$[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "create-customer-if-not-exists": "존재하지 않는 경우 새 고객 생성", + "unassign-from-customer": "발신자가 대시보드인 경우 특정 고객에서 할당 해제", + "unassign-from-customer-tooltip": "대시보드만 한 번에 여러 고객에게 할당할 수 있습니다.\n메시지 발신자가 대시보드인 경우 할당을 해제할 고객의 제목을 명시적으로 지정해야 합니다.", + "customer-cache-expiration": "고객 캐시 만료 시간 (초)", + "customer-cache-expiration-hint": "찾은 고객 레코드를 저장할 수 있는 최대 시간 간격을 지정합니다. 0 값은 레코드가 만료되지 않음을 의미합니다.", + "customer-cache-expiration-required": "고객 캐시 만료 시간이 필요합니다.", + "customer-cache-expiration-range": "고객 캐시 만료 시간은 0 이상이어야 합니다.", + "interval-start": "간격 시작", + "interval-end": "간격 종료", + "time-unit": "시간 단위", + "fetch-mode": "가져오기 모드", + "order-by-timestamp": "타임스탬프로 정렬", + "limit": "제한", + "limit-hint": "최소 제한 값은 2, 최대는 1000입니다. 단일 항목을 가져오려면 가져오기 모드 '첫 번째' 또는 '마지막'을 선택하세요.", + "limit-required": "제한이 필요합니다.", + "limit-range": "제한은 2에서 1000 사이여야 합니다.", + "time-unit-milliseconds": "밀리초", + "time-unit-seconds": "초", + "time-unit-minutes": "분", + "time-unit-hours": "시간", + "time-unit-days": "요일", + "time-value-range": "1~2147483647 범위를 허용합니다.", + "start-interval-value-required": "간격 시작은 필수 항목입니다.", + "end-interval-value-required": "간격 종료는 필수 항목입니다.", + "filter": "필터", + "switch": "스위치", + "math-templatization-tooltip": "이 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "add-message-type": "메시지 유형 추가", + "select-message-types-required": "최소 하나의 메시지 유형을 선택해야 합니다.", + "select-message-types": "메시지 유형 선택", + "no-message-types-found": "메시지 유형을 찾을 수 없음", + "no-message-type-matching": "'{{messageType}}'을(를) 찾을 수 없습니다.", + "create-new-message-type": "새로 만들기", + "message-types-required": "메시지 유형이 필요합니다.", + "client-attributes": "클라이언트 속성", + "shared-attributes": "공유 속성", + "server-attributes": "서버 속성", + "attributes-keys": "속성 키", + "attributes-keys-required": "속성 키가 필요합니다", + "attributes-scope": "속성 범위", + "attributes-scope-value": "속성 범위 값", + "attributes-scope-value-copy": "속성 범위 값 복사", + "attributes-scope-hint": "'scope' 메타데이터 키를 사용하여 메시지별로 속성 범위를 동적으로 설정하세요. 제공된 경우 구성에 설정된 범위를 재정의합니다.", + "notify-device": "장치에 알림 강제 전송", + "send-attributes-updated-notification": "속성 업데이트 알림 전송", + "send-attributes-updated-notification-hint": "업데이트된 속성에 대한 알림을 별도의 메시지로 규칙 엔진 큐에 보냅니다.", + "send-attributes-deleted-notification": "속성 삭제 알림 보내기", + "send-attributes-deleted-notification-hint": "삭제된 속성에 대한 알림을 별도의 메시지로 규칙 엔진 큐에 보냅니다.", + "update-attributes-only-on-value-change": "값이 변경된 경우에만 속성 저장", + "update-attributes-only-on-value-change-hint": "값이 변경되었는지 여부에 관계없이 모든 수신 메시지에서 속성을 업데이트합니다. API 사용량이 증가하고 성능이 저하됩니다.", + "update-attributes-only-on-value-change-hint-enabled": "값이 변경된 경우에만 속성을 업데이트합니다. 값이 변경되지 않으면 속성 타임스탬프 업데이트나 속성 변경 알림이 전송되지 않습니다.", + "fetch-credentials-to-metadata": "메타데이터에 자격 증명 가져오기", + "notify-device-on-update-hint": "활성화하면 공유 속성 업데이트에 대한 알림을 장치에 강제로 보냅니다. 비활성화하면 알림 동작이 수신 메시지 메타데이터의 'notifyDevice' 매개변수에 의해 제어됩니다. 알림을 끄려면 메시지 메타데이터에 'notifyDevice' 매개변수가 'false'로 설정되어 있어야 합니다. 다른 모든 경우에는 장치에 알림이 전송됩니다.", + "notify-device-on-delete-hint": "활성화하면 공유 속성 제거에 대한 알림을 장치에 강제로 보냅니다. 비활성화하면 알림 동작이 수신 메시지 메타데이터의 'notifyDevice' 매개변수에 의해 제어됩니다. 알림을 켜려면 메시지 메타데이터에 'notifyDevice' 매개변수가 'true'로 설정되어 있어야 합니다. 다른 모든 경우에는 장치에 알림이 전송되지 않습니다.", + "latest-timeseries": "최신 시계열 데이터 키", + "timeseries-keys": "시계열 키", + "timeseries-keys-required": "최소 하나의 시계열 키를 선택해야 합니다.", + "add-timeseries-key": "시계열 키 추가", + "add-message-field": "메시지 필드 추가", + "relation-search-parameters": "관계 검색 매개변수", + "relation-parameters": "관계 매개변수", + "add-metadata-field": "메타데이터 필드 추가", + "data-keys": "메시지 필드 이름", + "copy-from": "복사 원본", + "data-to-metadata": "데이터를 메타데이터로", + "metadata-to-data": "메타데이터를 데이터로", + "use-regular-expression-hint": "정규 표현식을 사용하여 패턴별로 키를 복사하세요.\n\n팁 & 요령:\n'Enter'를 눌러 필드 이름 입력을 완료하세요.\n'Backspace'를 눌러 필드 이름을 삭제하세요. 여러 필드 이름이 지원됩니다.", + "interval": "간격", + "interval-required": "간격이 필요합니다", + "interval-hint": "중복 제거 간격 (초).", + "interval-min-error": "최소 허용 값은 1입니다", + "max-pending-msgs": "보류 중인 최대 메시지 수", + "max-pending-msgs-hint": "각 고유 중복 제거 ID에 대해 메모리에 저장되는 최대 메시지 수입니다.", + "max-pending-msgs-required": "보류 중인 최대 메시지 수가 필요합니다", + "max-pending-msgs-max-error": "최대 허용 값은 1000입니다", + "max-pending-msgs-min-error": "최소 허용 값은 1입니다", + "max-retries": "최대 재시도 횟수", + "max-retries-required": "최대 재시도 횟수가 필요합니다", + "max-retries-hint": "중복 제거된 메시지를 큐에 푸시하기 위한 최대 재시도 횟수입니다. 재시도 사이에 10초 지연이 사용됩니다", + "max-retries-max-error": "최대 허용 값은 100입니다", + "max-retries-min-error": "최소 허용 값은 0입니다", + "strategy": "전략", + "strategy-required": "전략이 필요합니다", + "strategy-all-hint": "중복 제거 기간 동안 도착한 모든 메시지를 단일 JSON 배열 메시지로 반환합니다. 각 요소는 msg 및 metadata 내부 속성이 있는 엔티티를 나타냅니다.", + "strategy-first-hint": "중복 제거 기간 동안 도착한 첫 번째 메시지를 반환합니다.", + "strategy-last-hint": "중복 제거 기간 동안 도착한 마지막 메시지를 반환합니다.", + "first": "첫 번째", + "last": "과거", + "all": "모두", + "output-msg-type-hint": "중복 제거 결과의 메시지 유형입니다.", + "queue-name-hint": "중복 제거 결과가 게시될 큐 이름입니다.", + "keys": "키", + "keys-required": "키가 필요합니다", + "rename-keys-in": "키 이름 변경 위치", + "data": "데이터", + "message": "메시지", + "metadata": "메타데이터", + "current-key-name": "현재 키 이름", + "key-name-required": "키 이름은 필수 항목입니다.", + "new-key-name": "새 키 이름", + "new-key-name-required": "새 키 이름이 필요합니다", + "metadata-keys": "메타데이터 필드 이름", + "json-path-expression": "JSON 경로 표현식", + "json-path-expression-required": "JSON 경로 표현식이 필요합니다", + "json-path-expression-hint": "JSONPath는 JSON 구조에서 요소 또는 요소 집합의 경로를 지정합니다. '$'는 루트 엔티티 또는 배열을 나타냅니다.", + "relations-query": "관계 질의", + "device-relations-query": "장치 관계 쿼리", + "max-relation-level": "최대 관계 수준", + "max-relation-level-error": "값은 0보다 크거나 지정되지 않아야 합니다.", + "max-relation-level-invalid": "값은 정수여야 합니다.", + "relation-type": "관계 유형", + "relation-type-pattern": "관계 유형 패턴", + "relation-type-pattern-required": "관계 유형 패턴이 필요합니다", + "relation-types-list": "전파할 관계 유형", + "relation-types-list-hint": "전파 관계 유형이 선택되지 않으면 관계 유형별 필터링 없이 알람이 전파됩니다.", + "unlimited-level": "제한 수준", + "latest-telemetry": "최근 데이터", + "add-telemetry-key": "텔레메트리 키 추가", + "delete-from": "삭제 위치", + "use-regular-expression-delete-hint": "정규 표현식을 사용하여 패턴별로 키를 삭제하세요.\n\n팁 & 요령:\n'Enter'를 눌러 필드 이름 입력을 완료하세요.\n'Backspace'를 눌러 필드 이름을 삭제하세요.\n여러 필드 이름이 지원됩니다.", + "fetch-into": "가져오기 위치", + "attr-mapping": "속성 매핑:", + "source-attribute": "소스 속성 키", + "source-attribute-required": "소스 속성 키가 필요합니다.", + "source-telemetry": "소스 텔레메트리 키", + "source-telemetry-required": "소스 텔레메트리 키가 필요합니다.", + "target-key": "대상 키", + "target-key-required": "대상 키가 필요합니다.", + "attr-mapping-required": "최소 하나의 매핑 항목을 지정해야 합니다.", + "fields-mapping": "필드 매핑", + "fields-mapping-hint": "메시지 필드가 $entityId로 설정된 경우 메시지 발신자의 ID가 지정된 테이블 열에 저장됩니다.", + "relations-query-config-direction-suffix": "발신자", + "profile-name": "프로필 이름", + "fetch-circle-parameter-info-from-metadata-hint": "메타데이터 필드 '{{perimeterKeyName}}'은(는) 다음 형식으로 정의되어야 합니다: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "메타데이터 필드 '{{perimeterKeyName}}'은(는) 다음 형식으로 정의되어야 합니다: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "$[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "fields-mapping-required": "최소 하나의 필드 매핑을 지정해야 합니다.", + "at-least-one-field-required": "최소 하나의 입력 필드에 값이 제공되어야 합니다.", + "originator-fields-sv-map-hint": "대상 키 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "sv-map-hint": "대상 키 필드만 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "source-field": "소스 필드", + "source-field-required": "소스 필드가 필요합니다.", + "originator-source": "발신자 소스", + "new-originator": "새 발신자", + "originator-customer": "고객", + "originator-tenant": "테넌트", + "originator-related": "관련 엔티티", + "originator-alarm-originator": "알람 발생자", + "originator-entity": "이름 패턴으로 엔티티 찾기", + "clone-message": "메시지 복제", + "transform": "변환", + "default-ttl": "기본 TTL", + "default-ttl-required": "기본 TTL은 필수 항목입니다.", + "default-ttl-hint": "규칙 노드는 메시지 메타데이터에서 TTL(Time-to-Live) 값을 가져옵니다. 값이 없으면 구성에 지정된 TTL이 기본값이 됩니다. 값이 0으로 설정되면 테넌트 프로필 구성의 TTL이 적용됩니다.", + "default-ttl-zero-hint": "값이 0이면 TTL이 적용되지 않습니다.", + "min-default-ttl-message": "최소 TTL은 0만 허용됩니다.", + "generation-parameters": "생성 매개변수", + "message-count": "생성 메시지 제한 (0 - 무제한)", + "message-count-required": "생성 메시지 제한은 필수 항목입니다.", + "min-message-count-message": "최소 메시지 수는 0만 허용됩니다.", + "period-seconds": "기간 (초)", + "period-seconds-required": "기간은 필수 항목입니다.", + "generation-frequency-seconds": "생성 빈도 (초)", + "generation-frequency-required": "생성 빈도는 필수 항목입니다.", + "min-generation-frequency-message": "최소 1초만 허용됩니다.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "초 단위 기간 패턴 사용", + "use-metadata-period-in-seconds-patterns-hint": "선택하면 규칙 노드는 간격이 초 단위라고 가정하여 메시지 메타데이터 또는 데이터의 초 간격 패턴을 사용합니다.", + "period-in-seconds-pattern": "초 단위 기간 패턴", + "period-in-seconds-pattern-required": "초 단위 기간 패턴은 필수 항목입니다.", + "min-period-seconds-message": "최소 기간은 1초만 허용됩니다.", + "originator": "만든 사람", + "message-body": "메시지 본문", + "message-metadata": "메시지 메타데이터", + "generate": "생성", + "current-rule-node": "현재 규칙 노드", + "current-tenant": "현재 테넌트", + "generator-function": "생성기 함수", + "test-generator-function": "생성기 함수 테스트", + "generator": "생성기", + "test-filter-function": "필터 함수 테스트", + "test-switch-function": "스위치 함수 테스트", + "test-transformer-function": "변환기 함수 테스트", + "transformer": "변환기", + "alarm-create-condition": "알람 생성 조건", + "test-condition-function": "조건 함수 테스트", + "alarm-clear-condition": "알람 해제 조건", + "alarm-details-builder": "알람 상세 빌더", + "test-details-function": "세부 정보 함수 테스트", + "alarm-type": "알람 유형", + "select-entity-types": "엔티티 유형 선택", + "alarm-type-required": "알람 유형은 필수 항목입니다.", + "alarm-severity": "알람 심각도", + "alarm-severity-required": "알람 심각도가 필요합니다", + "alarm-severity-pattern": "알람 심각도 패턴", + "alarm-status-filter": "알람 상태 필터", + "alarm-status-list-empty": "알람 상태 목록이 비어 있습니다", + "no-alarm-status-matching": "일치하는 알람 상태를 찾을 수 없습니다.", + "propagate": "관련 엔티티에 알람 전파", + "propagate-to-owner": "엔티티 소유자(고객 또는 테넌트)에게 알람 전파", + "propagate-to-tenant": "테넌트에게 알람 전파", + "condition": "조건", + "details": "상세 내역", + "to-string": "문자열로 변환", + "test-to-string-function": "문자열 변환 함수 테스트", + "from-template": "부터", + "from-template-required": "보낸 사람은 필수 항목입니다.", + "message-to-metadata": "메시지에서 메타데이터로", + "metadata-to-message": "메타데이터에서 메시지로", + "from-message": "메시지에서", + "from-metadata": "메타데이터에서", + "to-template": "받는 사람", + "to-template-required": "받는 사람 템플릿은 필수 항목입니다.", + "mail-address-list-template-hint": "쉼표로 구분된 주소 목록, 메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요", + "cc-template": "참조", + "bcc-template": "숨은 참조", + "subject-template": "제목", + "subject-template-required": "제목 템플릿은 필수 항목입니다.", + "body-template": "본문", + "body-template-required": "본문 템플릿은 필수 항목입니다.", + "dynamic-mail-body-type": "동적 메일 본문 유형", + "mail-body-type": "메일 본문 유형", + "body-type-template": "본문 유형 템플릿", + "reply-routing-configuration": "응답 라우팅 구성", + "rpc-reply-routing-configuration-hint": "이 구성 매개변수는 응답을 보내기 위해 서비스, 세션 및 요청을 식별하는 데 사용되는 메타데이터 키 이름을 지정합니다.", + "reply-routing-configuration-hint": "이 구성 매개변수는 응답을 보내기 위해 서비스와 요청을 식별하는 데 사용되는 메타데이터 키 이름을 지정합니다.", + "request-id-metadata-attribute": "요청 ID", + "service-id-metadata-attribute": "서비스 ID", + "session-id-metadata-attribute": "세션 ID", + "timeout-sec": "시간 초과 (초)", + "timeout-required": "시간 초과는 필수 항목입니다.", + "min-timeout-message": "최소 시간 초과 값은 0만 허용됩니다.", + "endpoint-url-pattern": "엔드포인트 URL 패턴", + "endpoint-url-pattern-required": "엔드포인트 URL 패턴은 필수 항목입니다.", + "request-method": "요청 방법", + "use-simple-client-http-factory": "간단한 클라이언트 HTTP 팩토리 사용", + "ignore-request-body": "요청 본문 없음", + "parse-to-plain-text": "일반 텍스트로 파싱", + "parse-to-plain-text-hint": "선택하면 요청 본문 메시지 페이로드가 JSON 문자열에서 일반 텍스트로 변환됩니다. 예: msg = \"Hello,\\t\"world\"\"는 Hello, \"world\"로 구문 분석됩니다.", + "read-timeout": "읽기 시간 초과 (밀리초)", + "read-timeout-hint": "0 값은 무한 시간 초과를 의미합니다.", + "max-parallel-requests-count": "최대 병렬 요청 수", + "max-parallel-requests-count-hint": "0 값은 병렬 처리에 제한이 없음을 지정합니다.", + "max-response-size": "최대 응답 크기 (KB)", + "max-response-size-hint": "JSON 또는 XML 페이로드와 같은 HTTP 메시지를 디코딩하거나 인코딩할 때 데이터 버퍼링에 할당된 최대 메모리 양", + "headers": "헤더", + "headers-hint": "헤더/값 필드에서 메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요", + "header": "헤더", + "header-required": "헤더는 필수 항목입니다.", + "value": "값", + "value-required": "값은 필수 항목입니다.", + "topic-pattern": "토픽 패턴", + "key-pattern": "키 패턴", + "key-pattern-hint": "선택 사항. 유효한 파티션 번호가 지정되면 레코드를 보낼 때 사용됩니다. 파티션이 지정되지 않으면 키가 대신 사용됩니다. 둘 다 지정되지 않으면 라운드 로빈 방식으로 파티션이 할당됩니다.", + "topic-pattern-required": "토픽 패턴은 필수 항목입니다.", + "topic": "토픽", + "topic-required": "토픽은 필수 항목입니다.", + "bootstrap-servers": "부트스트랩 서버", + "bootstrap-servers-required": "부트스트랩 서버 값이 필요합니다", + "other-properties": "기타 속성", + "key": "키", + "key-required": "키는 필수 항목입니다.", + "retries": "실패 시 자동 재시도 횟수", + "min-retries-message": "최소 재시도는 0만 허용됩니다.", + "batch-size-bytes": "생성된 배치 크기 (바이트)", + "min-batch-size-bytes-message": "최소 배치 크기는 0만 허용됩니다.", + "linger-ms": "로컬 버퍼링 시간 (ms)", + "min-linger-ms-message": "최소 0ms만 허용됩니다.", + "buffer-memory-bytes": "클라이언트 버퍼 최대 크기 (바이트)", + "min-buffer-memory-message": "최소 버퍼 크기는 0만 허용됩니다.", + "memory-buffer-size-range": "메모리 버퍼 크기는 0에서 {{max}} KB 사이여야 합니다.", + "acks": "확인 수", + "topic-arn-pattern": "토픽 ARN 패턴", + "topic-arn-pattern-required": "토픽 ARN 패턴은 필수 항목입니다.", + "aws-access-key-id": "AWS 액세스 키 ID", + "aws-access-key-id-required": "AWS 액세스 ID를 입력하세요.", + "aws-secret-access-key": "AWS 보안 액세스 키", + "aws-secret-access-key-required": "AWS 보안 액세스 키를 입력하세요", + "aws-region": "AWS 지역", + "aws-region-required": "AWS 지역을 입력하세요", + "exchange-name-pattern": "교환 이름 패턴", + "routing-key-pattern": "라우팅 키 패턴", + "message-properties": "메시지 속성", + "host": "호스트", + "host-required": "호스트는 필수 항목입니다.", + "port": "포트", + "port-required": "포트는 필수 항목입니다.", + "port-range": "포트를 1~65535 범위 내에서 입력하세요.", + "virtual-host": "가상 호스트", + "username": "사용자명", + "password": "비밀번호", + "automatic-recovery": "자동 복구", + "connection-timeout-ms": "연결 시간 초과 (ms)", + "min-connection-timeout-ms-message": "최소 0ms만 허용됩니다.", + "handshake-timeout-ms": "핸드셰이크 시간 초과 (ms)", + "min-handshake-timeout-ms-message": "최소 0ms만 허용됩니다.", + "client-properties": "클라이언트 속성", + "queue-url-pattern": "큐 URL 패턴", + "queue-url-pattern-required": "큐 URL 패턴은 필수 항목입니다.", + "delay-seconds": "지연 (초)", + "min-delay-seconds-message": "최소 0초만 허용됩니다.", + "max-delay-seconds-message": "최대 900초만 허용됩니다.", + "name": "이름", + "name-required": "이름은 필수 항목입니다.", + "queue-type": "큐 유형", + "sqs-queue-standard": "표준", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "GCP 프로젝트 ID", + "gcp-project-id-required": "GCP 프로젝트 ID는 필수 항목입니다.", + "gcp-service-account-key": "GCP 서비스 계정 키 파일", + "gcp-service-account-key-required": "GCP 서비스 계정 키 파일은 필수 항목입니다.", + "pubsub-topic-name": "토픽 이름", + "pubsub-topic-name-required": "토픽 이름은 필수 항목입니다.", + "message-attributes": "메시지 속성", + "message-attributes-hint": "이름/값 필드에서 메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요", + "connect-timeout": "연결 시간 초과 (초)", + "connect-timeout-required": "연결 시간 초과는 필수 항목입니다.", + "connect-timeout-range": "연결 시간 초과는 1에서 200 범위여야 합니다.", + "client-id": "클라이언트 ID", + "client-id-hint": "선택 사항. 자동 생성된 클라이언트 ID를 사용하려면 비워 두세요. 클라이언트 ID를 지정할 때 주의하세요. 대부분의 MQTT 브로커는 동일한 클라이언트 ID로 여러 연결을 허용하지 않습니다. 이러한 브로커에 연결하려면 MQTT 클라이언트 ID가 고유해야 합니다. 플랫폼이 마이크로 서비스 모드로 실행 중인 경우 각 마이크로 서비스에서 규칙 노드의 복사본이 시작됩니다. 이로 인해 동일한 ID를 가진 여러 MQTT 클라이언트가 자동으로 생성되어 규칙 노드에 오류가 발생할 수 있습니다. 이러한 오류를 방지하려면 아래의 \"서비스 ID를 클라이언트 ID에 접미사로 추가\" 옵션을 활성화하세요.", + "append-client-id-suffix": "클라이언트 ID에 서비스 ID를 접미사로 추가", + "client-id-suffix-hint": "선택 사항. \"클라이언트 ID\"가 명시적으로 지정된 경우에 적용됩니다. 선택하면 서비스 ID가 클라이언트 ID에 접미사로 추가됩니다. 플랫폼이 마이크로 서비스 모드로 실행 중일 때 오류를 방지하는 데 도움이 됩니다.", + "device-id": "장치 ID", + "device-id-required": "장치 ID는 필수 항목입니다.", + "clean-session": "클린 세션", + "enable-ssl": "SSL 활성화", + "credentials": "자격 증명", + "credentials-type": "자격 증명 타입", + "credentials-type-required": "자격 증명 유형은 필수 항목입니다.", + "credentials-anonymous": "익명", + "credentials-basic": "기본", + "credentials-pem": "PEM", + "credentials-pem-hint": "최소 서버 CA 인증서 파일 또는 클라이언트 인증서와 클라이언트 개인 키 파일 쌍이 필요합니다", + "credentials-sas": "공유 액세스 서명", + "sas-key": "SAS 키", + "sas-key-required": "SAS 키는 필수 항목입니다.", + "hostname": "호스트 이름", + "hostname-required": "호스트 이름은 필수 항목입니다.", + "azure-ca-cert": "CA 인증서 파일", + "username-required": "사용자 이름이 필요합니다.", + "password-required": "비밀번호가 필요합니다.", + "ca-cert": "서버 CA 인증서 파일", + "private-key": "클라이언트 개인 키 파일", + "cert": "클라이언트 인증서 파일", + "no-file": "아무 파일도 선택되지 않았습니다.", + "drop-file": "여기로 파일을 드래그 하거나, 여기를 클릭해서 파일을 선택하세요.", + "private-key-password": "개인 키 비밀번호", + "use-system-smtp-settings": "시스템 SMTP 설정 사용", + "use-metadata-dynamic-interval": "동적 간격 사용", + "metadata-dynamic-interval-hint": "간격 시작 및 종료 입력 필드는 템플릿을 지원합니다. 대체된 템플릿 값은 밀리초로 설정되어야 합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "use-metadata-interval-patterns-hint": "선택하면 규칙 노드는 간격이 밀리초라고 가정하여 메시지 메타데이터 또는 데이터의 시작 및 종료 간격 패턴을 사용합니다.", + "use-message-alarm-data": "메시지 알람 데이터 사용", + "overwrite-alarm-details": "알람 세부 정보 덮어쓰기", + "use-alarm-severity-pattern": "알람 심각도 패턴 사용", + "check-all-keys": "모든 지정된 필드가 있는지 확인", + "check-all-keys-hint": "선택하면 지정된 모든 키가 메시지 데이터와 메타데이터에 있는지 확인합니다.", + "check-relation-to-specific-entity": "특정 엔티티와의 관계 확인", + "check-relation-to-specific-entity-tooltip": "활성화하면 특정 엔티티와의 관계 존재 여부를 확인하고, 그렇지 않으면 모든 엔티티와의 관계 존재 여부를 확인합니다. 두 경우 모두 관계 조회는 구성된 방향과 유형을 기반으로 합니다.", + "check-relation-hint": "방향과 관계 유형을 기반으로 특정 엔티티 또는 모든 엔티티와의 관계 존재 여부를 확인합니다.", + "delete-relation-with-specific-entity": "특정 엔티티와의 관계 삭제", + "delete-relation-with-specific-entity-hint": "활성화하면 하나의 특정 엔티티와의 관계만 삭제됩니다. 그렇지 않으면 일치하는 모든 엔티티와의 관계가 제거됩니다.", + "delete-relation-hint": "수신 메시지의 발신자에서 지정된 엔티티 또는 엔티티 목록으로의 관계를 방향과 유형에 따라 삭제합니다.", + "remove-current-relations": "현재 관계 제거", + "remove-current-relations-hint": "수신 메시지의 발신자에서 방향과 유형에 따라 현재 관계를 제거합니다.", + "change-originator-to-related-entity": "발신자를 관련 엔티티로 변경", + "change-originator-to-related-entity-hint": "제출된 메시지를 다른 엔티티의 메시지로 처리하는 데 사용됩니다.", + "start-interval": "간격 시작", + "end-interval": "간격 종료", + "start-interval-required": "간격 시작은 필수 항목입니다.", + "end-interval-required": "간격 종료는 필수 항목입니다.", + "smtp-protocol": "프로토콜", + "smtp-host": "SMTP 호스트", + "smtp-host-required": "SMTP 호스트를 입력해야 합니다.", + "smtp-port": "SMTP 포트", + "smtp-port-required": "SMTP 포트를 입력해야 합니다.", + "smtp-port-range": "SMTP 포트는 1에서 65535 범위여야 합니다.", + "timeout-msec": "시간 초과 (ms)", + "min-timeout-msec-message": "최소 0ms만 허용됩니다.", + "enter-username": "사용자명을 입력하세요.", + "enter-password": "비밀번호를 입력하세요.", + "enable-tls": "TLS 사용", + "tls-version": "TLS 버전", + "enable-proxy": "프록시 사용", + "use-system-proxy-properties": "시스템 프록시 속성 사용", + "proxy-host": "프록시 호스트", + "proxy-host-required": "프록시 호스트를 입력하세요.", + "proxy-port": "프록시 포트", + "proxy-port-required": "프록시 포트를 입력하세요.", + "proxy-port-range": "프록시 포트를 1~65535 범위 내에서 입력하세요.", + "proxy-user": "프록시 사용자", + "proxy-password": "프록시 비밀번호", + "proxy-scheme": "프록시 스키마", + "numbers-to-template": "받는 사람 전화번호 템플릿", + "numbers-to-template-required": "받는 사람 전화번호 템플릿은 필수 항목입니다.", + "numbers-to-template-hint": "쉼표로 구분된 전화번호, 메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요", + "sms-message-template": "SMS 메시지 템플릿", + "sms-message-template-required": "SMS 메시지 템플릿은 필수 항목입니다.", + "use-system-sms-settings": "시스템 SMS 제공자 설정 사용", + "min-period-0-seconds-message": "최소 기간은 0초만 허용됩니다.", + "max-pending-messages": "최대 대기 메시지", + "max-pending-messages-required": "최대 대기 메시지는 필수 항목입니다.", + "max-pending-messages-range": "최대 대기 메시지는 1에서 100000 범위여야 합니다.", + "originator-types-filter": "발신자 유형 필터", + "interval-seconds": "간격 (초)", + "interval-seconds-required": "간격은 필수 항목입니다.", + "int-range": "값은 최대 정수 제한(2147483648)을 초과하지 않아야 합니다.", + "min-interval-seconds-message": "최소 간격은 1초만 허용됩니다.", + "output-timeseries-key-prefix": "출력 시계열 키 접두사", + "output-timeseries-key-prefix-required": "출력 시계열 키 접두사는 필수 항목입니다.", + "separator-hint": "\"Enter\"를 눌러 필드 입력을 완료하세요.", + "select-details": "세부 정보 선택", + "entity-details-id": "ID", + "entity-details-title": "제목", + "entity-details-country": "국가", + "entity-details-state": "주", + "entity-details-city": "시", + "entity-details-zip": "우편번호", + "entity-details-address": "주소", + "entity-details-address2": "상세 주소", + "entity-details-additional_info": "추가 정보", + "entity-details-phone": "전화번호", + "entity-details-email": "이메일", + "email-sender": "이메일 보낸 사람", + "fields-to-check": "확인할 필드", + "add-detail": "상세 추가", + "check-all-keys-tooltip": "활성화하면 수신 메시지와 해당 메타데이터 내의 메시지 및 메타데이터 필드 이름에 나열된 모든 필드의 존재 여부를 확인합니다.", + "fields-to-check-hint": "\"Enter\"를 눌러 필드 이름 입력을 완료하세요. 여러 필드 이름이 지원됩니다.", + "entity-details-list-empty": "최소 하나의 세부 정보를 선택해야 합니다.", + "alarm-status": "알람 상태", + "alarm-required": "최소 하나의 알람 상태를 선택해야 합니다.", + "no-entity-details-matching": "일치하는 엔티티 세부 정보를 찾을 수 없습니다.", + "custom-table-name": "사용자 지정 테이블 이름", + "custom-table-name-required": "테이블 이름이 필요합니다", + "custom-table-hint": "테이블은 Cassandra 클러스터에 생성되어야 하며 일반 TB 테이블에 데이터가 삽입되지 않도록 이름이 'cs_tb_' 접두사로 시작해야 합니다. 여기에 'cs_tb_' 접두사 없이 테이블 이름을 입력하세요.", + "message-field": "메시지 필드", + "message-field-required": "메시지 필드가 필요합니다.", + "table-col": "테이블 열", + "table-col-required": "테이블 열이 필요합니다.", + "latitude-field-name": "위도 필드 이름", + "longitude-field-name": "경도 필드 이름", + "latitude-field-name-required": "위도 필드 이름이 필요합니다.", + "longitude-field-name-required": "경도 필드 이름이 필요합니다.", + "fetch-perimeter-info-from-metadata": "메타데이터에서 경계 정보 가져오기", + "fetch-perimeter-info-from-metadata-tooltip": "경계 유형이 '다각형'으로 설정된 경우 메타데이터 필드 '{{perimeterKeyName}}'의 값이 추가 구문 분석 없이 경계 정의로 설정됩니다. 그렇지 않고 경계 유형이 '원'으로 설정된 경우 '{{perimeterKeyName}}' 메타데이터 필드의 값이 구문 분석되어 원 경계 정의에 사용되는 'latitude', 'longitude', 'radius', 'radiusUnit' 필드가 추출됩니다.", + "perimeter-key-name": "경계 키 이름", + "perimeter-key-name-hint": "경계 정보를 포함하는 메타데이터 필드 이름입니다.", + "perimeter-key-name-required": "경계 키 이름이 필요합니다.", + "perimeter-circle": "원", + "perimeter-polygon": "다각형", + "perimeter-type": "경계 유형", + "circle-center-latitude": "중심 위도", + "circle-center-latitude-required": "중심 위도가 필요합니다.", + "circle-center-longitude": "중심 경도", + "circle-center-longitude-required": "중심 경도가 필요합니다.", + "range-unit-meter": "미터", + "range-unit-kilometer": "킬로미터", + "range-unit-foot": "피트", + "range-unit-mile": "마일", + "range-unit-nautical-mile": "해리", + "range-units": "범위 단위", + "range-units-required": "범위 단위가 필요합니다.", + "range": "범위", + "range-required": "범위가 필요합니다.", + "polygon-definition": "다각형 정의", + "polygon-definition-required": "다각형 정의가 필요합니다.", + "polygon-definition-hint": "다각형을 수동으로 정의하려면 다음 형식을 사용하세요: [[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]].", + "min-inside-duration": "최소 내부 지속 시간", + "min-inside-duration-value-required": "최소 내부 지속 시간이 필요합니다", + "min-inside-duration-time-unit": "최소 내부 지속 시간 단위", + "min-outside-duration": "최소 외부 지속 시간", + "min-outside-duration-value-required": "최소 외부 지속 시간이 필요합니다", + "min-outside-duration-time-unit": "최소 외부 지속 시간 단위", + "tell-failure-if-absent": "실패 알림", + "tell-failure-if-absent-hint": "선택한 키 중 하나라도 존재하지 않으면 아웃바운드 메시지가 \"실패\"를 보고합니다.", + "get-latest-value-with-ts": "최신 텔레메트리 값에 대한 타임스탬프 가져오기", + "get-latest-value-with-ts-hint": "선택하면 최신 텔레메트리 값에 타임스탬프도 포함됩니다. 예: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "빈 문자열 무시", + "ignore-null-strings-hint": "선택하면 규칙 노드가 빈 값이 있는 엔티티 필드를 무시합니다.", + "add-metadata-key-values-as-kafka-headers": "Kafka 레코드 헤더에 메시지 메타데이터 키-값 쌍 추가", + "add-metadata-key-values-as-kafka-headers-hint": "선택하면 메시지 메타데이터의 키-값 쌍이 미리 정의된 문자셋 인코딩으로 바이트 배열로 나가는 레코드 헤더에 추가됩니다.", + "charset-encoding": "문자셋 인코딩", + "charset-encoding-required": "문자셋 인코딩이 필요합니다.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "큐 이름은 드롭다운 목록에서 선택하거나 사용자 지정 이름을 추가할 수 있습니다.", + "device-profile-node-hint": "알람 상태 평가의 연속성을 보장하기 위해 지속 시간 또는 반복 조건이 있는 경우에 유용합니다.", + "persist-alarm-rules": "알람 규칙 상태 저장", + "persist-alarm-rules-hint": "활성화하면 규칙 노드가 처리 상태를 데이터베이스에 저장합니다.", + "fetch-alarm-rules": "알람 규칙 상태 가져오기", + "fetch-alarm-rules-hint": "활성화하면 규칙 노드가 초기화 시 처리 상태를 복원하고 서버가 다시 시작된 후에도 알람이 발생하도록 합니다. 그렇지 않으면 장치에서 첫 번째 메시지가 도착할 때 상태가 복원됩니다.", + "input-value-key": "입력 값 키", + "input-value-key-required": "입력 값 키가 필요합니다.", + "output-value-key": "출력 값 키", + "output-value-key-required": "출력 값 키가 필요합니다.", + "number-of-digits-after-floating-point": "소숫점 뒤 자릿수", + "number-of-digits-after-floating-point-range": "소수점 이하 자릿수는 0에서 15 사이여야 합니다.", + "failure-if-delta-negative": "델타가 음수인 경우 실패 알림", + "failure-if-delta-negative-tooltip": "규칙 노드는 델타 값이 음수인 경우 메시지 처리 실패를 강제합니다.", + "use-caching": "캐싱 사용", + "use-caching-tooltip": "규칙 노드는 성능을 향상시키기 위해 수신 메시지에서 도착하는 \"{{inputValueKey}}\"의 값을 캐시합니다. 다른 곳에서 \"{{inputValueKey}}\" 값을 수정하면 캐시가 업데이트되지 않습니다.", + "add-time-difference-between-readings": "\"{{inputValueKey}}\" 판독 간의 시간 차이 추가", + "add-time-difference-between-readings-tooltip": "활성화하면 규칙 노드가 \"{{periodValueKey}}\"를 아웃바운드 메시지에 추가합니다.", + "period-value-key": "기간 값 키", + "period-value-key-required": "기간 값 키가 필요합니다.", + "general-pattern-hint": "메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요.", + "alarm-severity-pattern-hint": "메타데이터의 값에는 ${metadataKey}를 사용하고, 메시지 본문의 값에는 $[messageKey]를 사용하세요. 알람 심각도는 시스템(CRITICAL, MAJOR 등)이어야 합니다.", + "output-node-name-hint": "규칙 노드 이름은 출력 메시지의 관계 유형에 해당하며, 호출자 규칙 체인의 다른 규칙 노드로 메시지를 전달하는 데 사용됩니다.", + "use-server-ts": "서버 타임스탬프 사용", + "use-server-ts-hint": "명시적 타임스탬프가 없는 시계열 데이터에 대해 서버의 현재 타임스탬프를 사용합니다. 이는 여러 소스에서 메시지를 처리하거나 메시지가 순서 없이 도착할 때 적절한 순서를 유지하는 데 도움이 됩니다.", + "kv-map-pattern-hint": "모든 입력 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "kv-map-single-pattern-hint": "입력 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "shared-scope": "공유 범위", + "server-scope": "서버 범위", + "client-scope": "클라이언트 범위", + "attribute-type": "속성", + "attribute-type-description": "데이터베이스에서 속성 값 가져오기", + "attribute-type-result-description": "결과를 데이터베이스의 엔티티 속성으로 저장", + "constant-type": "상수", + "constant-type-description": "상수 값 정의", + "time-series-type": "시계열", + "time-series-type-description": "데이터베이스에서 최신 시계열 값 가져오기", + "time-series-type-result-description": "결과를 데이터베이스의 엔티티 시계열로 저장", + "message-body-type": "메시지", + "message-body-type-description": "수신 메시지에서 인수 값 가져오기", + "message-body-type-result-description": "아웃바운드 메시지에 결과 추가", + "message-metadata-type": "메타데이터", + "message-metadata-type-description": "수신 메시지 메타데이터에서 인수 값 가져오기", + "message-metadata-result-description": "아웃바운드 메시지 메타데이터에 결과 추가", + "argument-tile": "인수", + "no-arguments-prompt": "구성된 인수 없음", + "result-title": "결과", + "functions-field-input": "함수", + "no-option-found": "옵션을 찾을 수 없음", + "argument-source-field-input": "소스", + "argument-source-field-input-required": "인수 소스가 필요합니다.", + "argument-key-field-input": "키", + "argument-key-field-input-required": "인수 키가 필요합니다.", + "constant-value-field-input": "상수 값", + "constant-value-field-input-required": "상수 값이 필요합니다.", + "attribute-scope-field-input": "속성 범위", + "attribute-scope-field-input-required": "속성 범위가 필요합니다.", + "default-value-field-input": "기본값", + "type-field-input": "유형", + "type-field-input-required": "유형일 입력하세요.", + "key-field-input": "키", + "add-entity-type": "엔티티 유형 추가", + "add-device-profile": "장치 프로필 추가", + "key-field-input-required": "키는 필수 항목입니다.", + "number-floating-point-field-input": "소숫점 뒤 자릿수", + "number-floating-point-field-input-hint": "결과를 정수로 변환하려면 0을 사용하세요", + "add-to-message-field-input": "메시지에 추가", + "add-to-metadata-field-input": "메타데이터에 추가", + "custom-expression-field-input": "수학 표현식", + "custom-expression-field-input-required": "수학 표현식이 필요합니다", + "custom-expression-field-input-hint": "평가할 수학 표현식을 지정하세요. 기본 표현식은 화씨를 섭씨로 변환하는 방법을 보여줍니다", + "retained-message": "보존됨", + "attributes-mapping": "속성 매핑", + "latest-telemetry-mapping": "최신 텔레메트리 매핑", + "add-mapped-attribute-to": "매핑된 속성 추가 위치", + "add-mapped-latest-telemetry-to": "매핑된 최신 텔레메트리 추가 위치", + "add-mapped-fields-to": "매핑된 필드 추가 위치", + "add-selected-details-to": "선택한 세부 정보 추가 위치", + "clear-selected-types": "선택된 유형 지우기", + "clear-selected-details": "선택된 상세 지우기", + "clear-selected-fields": "선택된 필드 지우기", + "clear-selected-keys": "선택된 키 지우기", + "geofence-configuration": "지오펜스 구성", + "coordinate-field-names": "좌표 필드 이름", + "coordinate-field-hint": "규칙 노드는 메시지에서 지정된 필드를 검색하려고 시도합니다. 필드가 없으면 메타데이터에서 조회합니다.", + "presence-monitoring-strategy": "존재 모니터링 전략", + "presence-monitoring-strategy-on-first-message": "첫 번째 메시지에서", + "presence-monitoring-strategy-on-each-message": "각 메시지에서", + "presence-monitoring-strategy-on-first-message-hint": "이전 존재 상태 '진입' 또는 '이탈' 업데이트 이후 구성된 최소 지속 시간이 지난 후 첫 번째 메시지에서 존재 상태 '내부' 또는 '외부'를 보고합니다.", + "presence-monitoring-strategy-on-each-message-hint": "존재 상태 '진입' 또는 '이탈' 업데이트 후 각 메시지에서 존재 상태 '내부' 또는 '외부'를 보고합니다.", + "fetch-credentials-to": "자격 증명 가져오기 위치", + "add-originator-attributes-to": "발신자 속성 추가 위치", + "originator-attributes": "발신자 속성", + "fetch-latest-telemetry-with-timestamp": "타임스탬프와 함께 최신 텔레메트리 가져오기", + "fetch-latest-telemetry-with-timestamp-tooltip": "선택하면 최신 텔레메트리 값이 타임스탬프와 함께 아웃바운드 메타데이터에 추가됩니다. 예: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "속성이 누락된 경우 실패 알림", + "tell-failure-tooltip": "선택한 키 중 하나라도 존재하지 않으면 아웃바운드 메시지가 \"실패\"를 보고합니다.", + "created-time": "생성 일시", + "chip-help": "'Enter'를 눌러 {{inputName}} 입력을 완료하세요.\n'Backspace'를 눌러 {{inputName}}을(를) 삭제하세요.\n여러 값이 지원됩니다.", + "detail": "상세", + "field-name": "필드 이름", + "device-profile": "장치 프로필", + "entity-type": "엔티티 유형", + "message-type": "메시지 유형", + "timeseries-key": "시계열 키", + "type": "유형", + "first-name": "이름", + "last-name": "성", + "label": "라벨", + "originator-fields-mapping": "발신자 필드 매핑", + "add-mapped-originator-fields-to": "매핑된 발신자 필드 추가 위치", + "fields": "필드", + "skip-empty-fields": "빈 필드 건너뛰기", + "skip-empty-fields-tooltip": "빈 값이 있는 필드는 출력 메시지/출력 메타데이터에 추가되지 않습니다.", + "fetch-interval": "가져오기 간격", + "fetch-strategy": "가져오기 전략", + "fetch-timeseries-from-to": "{{startInterval}} {{startIntervalTimeUnit}} 전부터 {{endInterval}} {{endIntervalTimeUnit}} 전까지의 시계열을 가져옵니다.", + "fetch-timeseries-from-to-invalid": "시계열 가져오기가 잘못되었습니다 (\"간격 시작\"은 \"간격 종료\"보다 작아야 함).", + "use-metadata-dynamic-interval-tooltip": "선택하면 규칙 노드가 메시지와 메타데이터 패턴을 기반으로 동적 간격 시작 및 종료를 사용합니다.", + "all-mode-hint": "가져오기 모드 \"모두\"를 선택하면 규칙 노드가 구성 가능한 쿼리 매개변수로 가져오기 간격에서 텔레메트리를 검색합니다.", + "first-mode-hint": "가져오기 모드 \"첫 번째\"를 선택하면 규칙 노드가 가져오기 간격의 시작에 가장 가까운 텔레메트리를 검색합니다.", + "last-mode-hint": "가져오기 모드 \"마지막\"을 선택하면 규칙 노드가 가져오기 간격의 종료에 가장 가까운 텔레메트리를 검색합니다.", + "ascending": "오름차순", + "descending": "내림차순", + "min": "최소", + "max": "최대", + "average": "평균", + "sum": "합계", + "count": "숫자", + "none": "없음", + "last-level-relation-tooltip": "선택하면 규칙 노드가 최대 관계 레벨에 설정된 레벨에서만 관련 엔티티를 검색합니다.", + "last-level-device-relation-tooltip": "선택하면 규칙 노드가 최대 관계 레벨에 설정된 레벨에서만 관련 장치를 검색합니다.", + "data-to-fetch": "가져올 데이터", + "mapping-of-customers": "고객 매핑", + "map-fields-required": "모든 매핑 필드가 필요합니다.", + "attributes": "속성", + "related-device-attributes": "관련 장치 속성", + "add-selected-attributes-to": "선택한 속성 추가 위치", + "device-profiles": "장치 프로필", + "mapping-of-tenant": "테넌트 매핑", + "add-attribute-key": "속성 키 추가", + "message-template": "메시지 템플릿", + "message-template-required": "메시지 템플릿이 필요합니다", + "use-system-slack-settings": "시스템 Slack 설정 사용", + "slack-api-token": "Slack API 토큰", + "slack-api-token-required": "Slack API 토큰이 필요합니다", + "keys-mapping": "키 매핑", + "add-key": "키 추가", + "recipients": "수신자", + "message-subject-and-content": "메시지 제목 및 내용", + "template-rules-hint": "두 입력 필드 모두 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메시지 메타데이터에서 값을 추출하세요.", + "originator-customer-desc": "수신 메시지 발신자의 고객을 새 발신자로 사용합니다.", + "originator-tenant-desc": "현재 테넌트를 새 발신자로 사용합니다.", + "originator-related-entity-desc": "관련 엔티티를 새 발신자로 사용합니다. 구성된 관계 유형과 방향을 기반으로 조회합니다.", + "originator-alarm-originator-desc": "알람 발신자를 새 발신자로 사용합니다. 수신 메시지 발신자가 알람 엔티티인 경우에만.", + "originator-entity-by-name-pattern-desc": "DB에서 가져온 엔티티를 새 발신자로 사용합니다. 엔티티 유형과 지정된 이름 패턴을 기반으로 조회합니다.", + "email-from-template-hint": "$[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "recipients-block-main-hint": "쉼표로 구분된 주소 목록입니다. 모든 입력 필드는 템플릿을 지원합니다. $[messageKey]를 사용하여 메시지에서 값을 추출하고 ${metadataKey}를 사용하여 메타데이터에서 값을 추출하세요.", + "forward-msg-default-rule-chain": "메시지를 발신자의 기본 규칙 체인으로 전달", + "forward-msg-default-rule-chain-tooltip": "활성화하면 메시지가 발신자의 기본 규칙 체인으로 전달되거나, 발신자가 엔티티 프로필에 기본 규칙 체인을 정의하지 않은 경우 구성의 규칙 체인으로 전달됩니다.", + "exclude-zero-deltas": "아웃바운드 메시지에서 0 델타 제외", + "exclude-zero-deltas-hint": "활성화하면 값이 0이 아닌 경우 \"{{outputValueKey}}\" 출력 키가 아웃바운드 메시지에 추가됩니다.", + "exclude-zero-deltas-time-difference-hint": "활성화하면 \"{{outputValueKey}}\" 값이 0이 아닌 경우에만 \"{{outputValueKey}}\" 및 \"{{periodValueKey}}\" 출력 키가 아웃바운드 메시지에 추가됩니다.", + "search-direction-from": "발신자에서 대상 엔티티로", + "search-direction-to": "대상 엔티티에서 발신자로", + "del-relation-direction-from": "발신자에서", + "del-relation-direction-to": "발신자로", + "target-entity": "대상 엔티티", + "function-configuration": "함수 구성", + "function-name": "함수 이름", + "function-name-required": "함수 이름이 필요합니다.", + "qualifier": "한정자", + "qualifier-hint": "한정자가 지정되지 않으면 기본 한정자 \"$LATEST\"가 사용됩니다.", + "aws-credentials": "AWS 자격 증명", + "connection-timeout": "연결 시간 제한", + "connection-timeout-required": "연결 시간 초과는 필수 항목입니다.", + "connection-timeout-min": "최소 연결 시간 제한은 0입니다.", + "connection-timeout-hint": "연결을 처음 설정할 때 포기하고 시간 초과하기 전까지 기다리는 시간(초)입니다. 값이 0이면 무한을 의미하며 권장되지 않습니다.", + "request-timeout": "요청 시간 초과", + "request-timeout-required": "요청 시간 제한이 필요합니다", + "request-timeout-min": "최소 요청 시간 초과는 0입니다.", + "request-timeout-hint": "요청이 완료될 때까지 포기하고 시간 초과하기 전까지 기다리는 시간(초)입니다. 값이 0이면 무한을 의미하며 권장되지 않습니다.", + "units": "단위", + "tell-failure-aws-lambda": "AWS Lambda 함수 실행에서 예외가 발생하면 실패 알림", + "tell-failure-aws-lambda-hint": "AWS Lambda 함수 실행이 예외를 발생시키면 규칙 노드가 메시지 처리 실패를 강제합니다.", + "basic-mode": "기본", + "advanced-mode": "고급", + "save-time-series": { + "processing-settings": "처리 설정", + "processing-settings-hint": "수신 메시지가 처리되는 방식을 정의합니다. 기본 처리 설정에서는 미리 구성된 전략을 선택할 수 있고, 고급 설정에서는 각 작업에 대한 개별 처리 전략을 선택할 수 있습니다.", + "advanced-settings-hint": "처리 전략을 구성할 때 주의하세요. 특정 조합은 예기치 않은 동작을 초래할 수 있습니다.", + "strategy": "전략", + "deduplication-interval": "중복 제거 간격", + "deduplication-interval-required": "중복 제거 간격은 필수 항목입니다.", + "deduplication-interval-min-max-range": "중복 제거 간격은 최소 1초에서 최대 1일이어야 합니다.", + "strategy-type": { + "every-message": "모든 메시지마다", + "skip": "건너뛰기", + "deduplicate": "중복 제거", + "web-sockets-only": "WebSocket만" + }, + "time-series": "시계열", + "latest": "최근 값", + "web-sockets": "웹소켓", + "calculated-fields-and-alarm-rules": "계산 필드 및 알람 규칙" + }, + "save-attribute": { + "processing-settings": "처리 설정", + "processing-settings-hint": "수신 메시지가 처리되는 방식을 정의합니다. 기본 처리 설정에서는 미리 구성된 전략을 선택할 수 있고, 고급 설정에서는 각 작업에 대한 개별 처리 전략을 선택할 수 있습니다.", + "advanced-settings-hint": "처리 전략을 구성할 때 주의하세요. 특정 조합은 예기치 않은 동작을 초래할 수 있습니다.", + "strategy": "전략", + "deduplication-interval": "중복 제거 간격", + "deduplication-interval-required": "중복 제거 간격은 필수 항목입니다.", + "deduplication-interval-min-max-range": "중복 제거 간격은 최소 1초에서 최대 1일이어야 합니다.", + "scope": "범위", + "strategy-type": { + "every-message": "모든 메시지마다", + "skip": "건너뛰기", + "deduplicate": "중복 제거", + "web-sockets-only": "WebSocket만" + }, + "attributes": "속성" + }, + "key-val": { + "key": "키", + "value": "값", + "see-examples": "예제를 참조하세요.", + "remove-entry": "앤트리 삭제", + "remove-mapping-entry": "매핑 항목 제거", + "add-mapping-entry": "매핑 추가", + "add-entry": "앤트리 추가", + "copy-key-values-from": "키-값 복사 원본", + "delete-key-values": "키-값 삭제", + "delete-key-values-from": "키-값 삭제 원본", + "at-least-one-key-error": "최소 하나의 키를 선택해야 합니다.", + "unique-key-value-pair-error": "'{{keyText}}'은(는) '{{valText}}'와(과) 달라야 합니다!" + }, + "mail-body-types": { + "plain-text": "일반 텍스트", + "html": "HTML", + "dynamic": "동적", + "use-body-type-template": "본문 유형 템플릿 사용", + "plain-text-description": "특별한 스타일이나 형식이 없는 간단한 서식 없는 텍스트입니다.", + "html-text-description": "메일 본문에 서식, 링크 및 이미지를 위한 HTML 태그를 사용할 수 있습니다.", + "dynamic-text-description": "템플릿 기능을 기반으로 일반 텍스트 또는 HTML 본문 유형을 동적으로 사용할 수 있습니다.", + "after-template-evaluation-hint": "템플릿 평가 후 값은 HTML의 경우 true, 일반 텍스트의 경우 false여야 합니다." + }, + "ai": { + "ai-model": "AI 모델", + "model": "모델", + "ai-model-hint": "이 규칙 노드에서 보낸 요청을 처리할 미리 구성된 AI 모델을 선택하거나 \"새로 만들기\"를 사용하여 새 모델을 구성하세요.", + "prompt-settings": "프롬프트 설정", + "prompt-settings-hint": "선택적 시스템 프롬프트는 AI의 일반적인 역할과 제약 조건을 설정하고, 사용자 프롬프트는 수행할 특정 작업을 정의합니다. 두 필드 모두 템플릿을 지원합니다.", + "system-prompt": "시스템 프롬프트", + "system-prompt-max-length": "시스템 프롬프트는 500000자 이하여야 합니다.", + "system-prompt-blank": "시스템 프롬프트는 비워둘 수 없습니다.", + "user-prompt": "사용자 프롬프트", + "user-prompt-required": "사용자 프롬프트는 필수 항목입니다.", + "user-prompt-max-length": "사용자 프롬프트는 500000자 이하여야 합니다.", + "user-prompt-blank": "사용자 프롬프트는 비워둘 수 없습니다.", + "response-format": "응답 형식", + "response-text": "텍스트", + "response-json": "JSON", + "response-json-schema": "JSON 스키마", + "response-format-hint-TEXT": "모델이 임의의 텍스트를 생성할 수 있습니다. 이는 유효한 JSON 엔티티이거나 아닐 수 있습니다. 출력이 유효한 JSON 엔티티가 아니면 \"response\" 키 아래의 JSON 엔티티 내에 자동으로 래핑됩니다.", + "response-format-hint-JSON": "모델은 유효한 JSON인 응답을 생성해야 합니다. 출력이 유효한 JSON 엔티티가 아니면 \"response\" 키 아래의 JSON 엔티티 내에 자동으로 래핑됩니다.", + "response-format-hint-JSON_SCHEMA": "모델은 제공된 스키마에 정의된 특정 구조와 데이터 유형과 일치하는 JSON을 생성해야 합니다. 출력이 유효한 JSON 엔티티가 아니면 \"response\" 키 아래의 JSON 엔티티 내에 자동으로 래핑됩니다.", + "response-json-schema-hint": "유효한 JSON 스키마를 입력할 수 있지만 이 규칙 노드는 기능의 제한된 하위 집합만 지원합니다. 자세한 내용은 노드 문서를 참조하세요.", + "response-json-schema-required": "JSON 스키마는 필수 항목입니다.", + "advanced-settings": "고급 설정", + "timeout": "시간 초과", + "timeout-hint": "요청이 종료되기 전에 AI 모델로부터 응답을 기다리는 최대 시간.", + "timeout-required": "시간 초과는 필수 항목입니다.", + "timeout-validation": "1초에서 10분 사이여야 합니다.", + "force-acknowledgement": "강제 확인", + "force-acknowledgement-hint": "활성화하면 수신 메시지가 즉시 확인됩니다. 그런 다음 모델의 응답이 별도의 새 메시지로 큐에 추가됩니다.", + "ai-resources": "AI 리소스" + } }, "timezone": { - "timezone": "Timezone", - "select-timezone": "Select timezone", - "no-timezones-matching": "No timezones matching '{{timezone}}' were found.", - "timezone-required": "Timezone is required." + "timezone": "시간대", + "select-timezone": "시간대 선택", + "no-timezones-matching": "'{{timezone}}'와(과) 일치하는 시간대를 찾을 수 없습니다.", + "timezone-required": "시간대는 필수 항목입니다.", + "browser-time": "브라우저 시간" }, "queue": { - "select_name": "Select queue name", - "name": "Queue Name", - "name_required": "Queue name is required!" + "queue-name": "큐", + "no-queues-found": "큐를 찾을 수 없습니다.", + "no-queues-matching": "'{{queue}}'와(과) 일치하는 큐를 찾을 수 없습니다.", + "select-name": "큐 이름 선택", + "name": "이름", + "name-required": "큐 이름은 필수 항목입니다!", + "name-unique": "큐 이름이 고유하지 않습니다!", + "name-pattern": "큐 이름에 ASCII 영숫자, '.', '_', '-' 이외의 문자가 포함되어 있습니다!", + "queue-required": "큐는 필수 항목입니다!", + "topic-required": "큐 토픽은 필수 항목입니다!", + "poll-interval-required": "폴링 간격은 필수 항목입니다!", + "poll-interval-min-value": "폴링 간격 값은 1 미만일 수 없습니다.", + "partitions-required": "파티션은 필수 항목입니다!", + "partitions-min-value": "파티션 값은 1 미만일 수 없습니다.", + "pack-processing-timeout-required": "처리 시간 초과는 필수 항목입니다.", + "pack-processing-timeout-min-value": "처리 시간 초과 값은 1 미만일 수 없습니다.", + "batch-size-required": "배치 크기는 필수 항목입니다!", + "batch-size-min-value": "배치 크기 값은 1 미만일 수 없습니다.", + "retries-required": "재시도는 필수 항목입니다!", + "retries-min-value": "재시도 값은 음수일 수 없습니다.", + "failure-percentage-required": "실패 비율은 필수 항목입니다!", + "failure-percentage-min-value": "실패 비율 값은 0 미만일 수 없습니다.", + "failure-percentage-max-value": "실패 비율 값은 100을 초과할 수 없습니다.", + "pause-between-retries-required": "재시도 사이의 일시 중지는 필수 항목입니다!", + "pause-between-retries-min-value": "재시도 사이의 일시 중지 값은 1 미만일 수 없습니다.", + "max-pause-between-retries-required": "재시도 사이의 최대 일시 중지는 필수 항목입니다!", + "max-pause-between-retries-min-value": "재시도 사이의 최대 일시 중지 값은 1 미만일 수 없습니다.", + "submit-strategy-type-required": "제출 전략 유형은 필수 항목입니다!", + "processing-strategy-type-required": "처리 전략 유형은 필수 항목입니다!", + "queues": "큐", + "selected-queues": "{ count, plural, =1 {1개 큐} other {#개 큐} } 선택됨", + "delete-queue-title": "큐 '{{queueName}}'을(를) 삭제하시겠습니까?", + "delete-queues-title": "{ count, plural, =1 {1개 큐} other {#개 큐} }를 삭제하시겠습니까?", + "delete-queue-text": "주의: 확인 후 큐와 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-queues-text": "확인 후 선택한 모든 큐가 삭제되어 액세스할 수 없게 됩니다.", + "search": "큐 검색", + "add": "큐 추가", + "details": "큐 세부 정보", + "topic": "토픽", + "submit-settings": "제출 설정", + "submit-strategy": "전략 유형 *", + "grouping-parameter": "그룹화 매개변수", + "processing-settings": "재시도 처리 설정", + "processing-strategy": "처리 유형 *", + "retries-settings": "재시도 설정", + "polling-settings": "폴링 설정", + "batch-processing": "배치 처리", + "poll-interval": "폴링 간격", + "partitions": "파티션", + "immediate-processing": "즉시 처리", + "consumer-per-partition": "각 소비자에게 메시지 폴 전송", + "consumer-per-partition-hint": "각 파티션에 별도의 소비자 활성화", + "duplicate-msg-to-all-partitions": "모든 파티션에 메시지 복제", + "processing-timeout": "처리 시간 내 (ms)", + "batch-size": "배치 크기", + "retries": "재시도 횟수 (0 – 무제한)", + "failure-percentage": "재시도 건너뛰기 실패 메시지 (%)", + "pause-between-retries": "재시도 시간 내 (초)", + "max-pause-between-retries": "추가 재시도 시간 내 (초)", + "delete": "큐 삭제", + "copyId": "큐 ID 복사", + "idCopiedMessage": "큐 ID가 클립보드에 복사되었습니다.", + "description": "설명", + "description-hint": "이 텍스트는 선택한 전략 대신 큐 설명에 표시됩니다", + "alt-description": "제출 전략: {{submitStrategy}}, 처리 전략: {{processingStrategy}}", + "custom-properties": "사용자 지정 속성", + "custom-properties-hint": "사용자 지정 큐 (토픽) 생성 속성, 예: 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "발신자별 순차", + "sequential-by-originator-hint": "예: 장치 A의 이전 메시지가 확인될 때까지 장치 A의 새 메시지가 제출되지 않습니다", + "sequential-by-tenant-label": "테넌트별 순차", + "sequential-by-tenant-hint": "예: 테넌트 A의 이전 메시지가 확인될 때까지 테넌트 A의 새 메시지가 제출되지 않습니다", + "sequential-label": "순차", + "sequential-hint": "새 메시지는 이전 메시지가 확인될 때까지 제출되지 않습니다.", + "burst-label": "버스트", + "burst-hint": "모든 메시지가 도착한 순서대로 규칙 체인에 제출됩니다.", + "batch-label": "배치", + "batch-hint": "새 배치는 이전 배치가 확인될 때까지 제출되지 않습니다.", + "skip-all-failures-label": "모든 실패 건너뛰기", + "skip-all-failures-hint": "모든 실패 무시", + "skip-all-failures-and-timeouts-label": "모든 실패 및 시간 초과 건너뛰기", + "skip-all-failures-and-timeouts-hint": "모든 실패 및 시간 초과 무시", + "retry-all-label": "모두 재시도", + "retry-all-hint": "처리 팩의 모든 메시지 재시도", + "retry-failed-label": "실패 재시도", + "retry-failed-hint": "처리 팩의 모든 실패한 메시지 재시도", + "retry-timeout-label": "시간 초과 재시도", + "retry-timeout-hint": "처리 팩의 모든 시간 초과된 메시지 재시도", + "retry-failed-and-timeout-label": "실패 및 시간 초과 재시도", + "retry-failed-and-timeout-hint": "처리 팩의 모든 실패 및 시간 초과된 메시지 재시도" + } + }, + "queue-statistics": { + "queue-statistics": "큐 통계", + "no-queue-statistics-matching": "'{{entity}}'와(과) 일치하는 큐 통계를 찾을 수 없습니다.", + "queue-statistics-required": "큐 통계는 필수 항목입니다.", + "list-of-queue-statistics": "{ count, plural, =1 {하나의 큐 통계} other {#개 큐 통계 목록} }", + "selected-queue-statistics": "{ count, plural, =1 {1개 큐 통계} other {#개 큐 통계} } 선택됨", + "no-queue-statistics-text": "큐 통계를 찾을 수 없습니다", + "queue-statistics-starts-with": "이름이 '{{prefix}}'로 시작하는 큐 통계" + }, + "server-error": { + "general": "일반 서버 오류", + "authentication": "인증 오류", + "jwt-token-expired": "JWT 토큰 만료", + "tenant-trial-expired": "테넌트 평가판 만료", + "credentials-expired": "자격 증명 만료", + "permission-denied": "권한 거부", + "invalid-arguments": "잘못된 인수", + "bad-request-params": "잘못된 요청 매개변수", + "item-not-found": "항목을 찾을 수 없음", + "too-many-requests": "요청이 너무 많음", + "too-many-updates": "업데이트가 너무 많음", + "entities-limit-exceeded": "엔티티 제한 초과" }, "tenant": { "tenant": "테넌트", @@ -1900,6 +6176,7 @@ "add-tenant-text": "테넌트 추가", "no-tenants-text": "테넌트가 없습니다.", "tenant-details": "테넌트 상세 정보", + "title-max-length": "제목은 256자 미만이어야 합니다.", "delete-tenant-title": "'{{tenantTitle}}' 테넌트를 삭제하시겠습니까?", "delete-tenant-text": "테넌트 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", "delete-tenants-title": "{ count, plural, =1 {테넌트 1 개} other {테넌트 # 개} }를 삭제하시겠습니까?", @@ -1917,91 +6194,277 @@ "tenant-required": "테넌트가 필요합니다.", "search": "테넌트 검색", "selected-tenants": "{ count, plural, =1 {1 개 테넌트} other {# 개 테넌트} } 선택됨", - "isolated-tb-rule-engine": "Processing in isolated ThingsBoard Rule Engine container", - "isolated-tb-rule-engine-details": "Requires separate microservice(s) per isolated Tenant" - }, + "isolated-tb-rule-engine": "격리된 ThingsBoard 규칙 엔진 큐 사용", + "isolated-tb-rule-engine-details": "각 테넌트는 전용 규칙 엔진 큐를 갖습니다" + }, "tenant-profile": { - "tenant-profile": "테넌트 프로파일", - "tenant-profiles": "테넌트 프로파일", - "add": "테넌트 프로파일 추가", - "edit": "테넌트 프로파일 편집", - "tenant-profile-details": "Tenant profile details", - "no-tenant-profiles-text": "아무 테넌트 프로파일도 찾지 못했습니다", - "search": "테넌트 프로파일 검색", - "selected-tenant-profiles": "{ count, plural, =1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일} } 선택됨", - "no-tenant-profiles-matching": "No tenant profile matching '{{entity}}' were found.", - "tenant-profile-required": "테넌트 프로파일을 입력하세요", - "idCopiedMessage": "테넌트 프로파일 ID가 클립보드에 저장되었습니다", - "set-default": "테넌트 프로파일 기본값으로 지정", - "delete": "테넌트 프로파일 삭제", - "copyId": "테넌트 프로파일 ID 복사", + "tenant-profile": "테넌트 프로필", + "tenant-profiles": "테넌트 프로필", + "add": "테넌트 프로필 추가", + "add-profile": "프로필 추가", + "debug": "디버그", + "edit": "테넌트 프로필 편집", + "tenant-profile-details": "테넌트 프로필 세부 정보", + "no-tenant-profiles-text": "아무 테넌트 프로필도 찾지 못했습니다", + "name-max-length": "이름은 256자 미만이어야 합니다.", + "search": "테넌트 프로필 검색", + "selected-tenant-profiles": "{ count, plural, =1 {1 개 테넌트 프로필} other {# 개 테넌트 프로필} } 선택됨", + "no-tenant-profiles-matching": "'{{entity}}'와(과) 일치하는 테넌트 프로필을 찾을 수 없습니다.", + "tenant-profile-required": "테넌트 프로필을 입력하세요", + "idCopiedMessage": "테넌트 프로필 ID가 클립보드에 저장되었습니다", + "set-default": "테넌트 프로필 기본값으로 지정", + "delete": "테넌트 프로필 삭제", + "copyId": "테넌트 프로필 ID 복사", "name": "이름", "name-required": "이름을 입력하세요.", - "data": "프로파일 데이터", - "profile-configuration": "프로파일 설정", + "data": "프로필 데이터", + "profile-configuration": "프로필 설정", "description": "설명", "default": "기본값", - "delete-tenant-profile-title": "Are you sure you want to delete the tenant profile '{{tenantProfileName}}'?", - "delete-tenant-profile-text": "Be careful, after the confirmation the tenant profile and all related data will become unrecoverable.", - "delete-tenant-profiles-title": "{ count, plural, =1 {1 개 테넌트 프로파일} other {# 개 테넌트 프로파일} }을 삭제하시겠습니까?", - "delete-tenant-profiles-text": "Be careful, after the confirmation all selected tenant profiles will be removed and all related data will become unrecoverable.", - "set-default-tenant-profile-title": "Are you sure you want to make the tenant profile '{{tenantProfileName}}' default?", - "set-default-tenant-profile-text": "After the confirmation the tenant profile will be marked as default and will be used for new tenants with no profile specified.", - "no-tenant-profiles-found": "아무 테넌트 프로파일도 찾지 못했습니다.", - "create-new-tenant-profile": "Create a new one!", + "delete-tenant-profile-title": "테넌트 프로필 '{{tenantProfileName}}'을(를) 삭제하시겠습니까?", + "delete-tenant-profile-text": "주의: 확인 후 테넌트 프로필과 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-tenant-profiles-title": "{ count, plural, =1 {1 개 테넌트 프로필} other {# 개 테넌트 프로필} }을 삭제하시겠습니까?", + "delete-tenant-profiles-text": "주의: 확인 후 선택한 모든 테넌트 프로필이 제거되고 모든 관련 데이터를 복구할 수 없게 됩니다.", + "set-default-tenant-profile-title": "테넌트 프로필 '{{tenantProfileName}}'을(를) 기본값으로 설정하시겠습니까?", + "set-default-tenant-profile-text": "확인 후 테넌트 프로필이 기본값으로 표시되며 프로필이 지정되지 않은 새 테넌트에 사용됩니다.", + "no-tenant-profiles-found": "아무 테넌트 프로필도 찾지 못했습니다.", + "create-new-tenant-profile": "새로 만들기!", + "create-tenant-profile": "새 테넌트 프로필 생성", + "import": "테넌트 프로필 가져오기", + "export": "테넌트 프로필 내보내기", + "export-failed-error": "테넌트 프로필을 내보낼 수 없습니다: {{error}}", + "tenant-profile-file": "테넌트 프로필 파일", + "invalid-tenant-profile-file-error": "테넌트 프로필을 가져올 수 없습니다: 잘못된 테넌트 프로필 데이터 구조입니다.", + "advanced-settings": "고급 설정", + "entities": "엔티티", + "rule-engine": "규칙 엔진", + "time-to-live": "수명", + "calculated-fields": "계산 필드", + "alarms-and-notifications": "알람 및 알림", + "ota-files-in-bytes": "파일", + "ws-title": "WS", + "unlimited": "(0 - 무제한)", "maximum-devices": "최대 장치 수 (0 - 무제한)", - "maximum-devices-required": "Maximum number of devices is required.", - "maximum-devices-range": "Minimum number of devices can't be negative", + "maximum-devices-required": "장치 최대 수는 필수 항목입니다.", + "maximum-devices-range": "장치 최대 수는 음수일 수 없습니다.", "maximum-assets": "최대 자산 수 (0 - 무제한)", - "maximum-assets-required": "Maximum number of assets is required.", - "maximum-assets-range": "Maximum number of assets can't be negative", - "maximum-customers": "최대 커스터머 수 (0 - 무제한)", - "maximum-customers-required": "최대 커스터머 수를 입력하세요.", - "maximum-customers-range": "Maximum number of customers can't be negative", - "maximum-users": "Maximum number of users (0 - unlimited)", - "maximum-users-required": "Maximum number of users is required.", - "maximum-users-range": "Maximum number of users can't be negative", - "maximum-dashboards": "Maximum number of dashboards (0 - unlimited)", - "maximum-dashboards-required": "Maximum number of dashboards is required.", - "maximum-dashboards-range": "Maximum number of dashboards can't be negative", - "maximum-edges": "Edges maximum number (0 - unlimited)", - "maximum-edges-required": "Edges maximum number is required.", - "maximum-edges-range": "Edges maximum number can't be negative", - "maximum-rule-chains": "Maximum number of rule chains (0 - unlimited)", - "maximum-rule-chains-required": "Maximum number of rule chains is required.", - "maximum-rule-chains-range": "Maximum number of rule chains can't be negative", - "transport-tenant-telemetry-msg-rate-limit": "Transport tenant telemetry messages rate limit.", - "transport-tenant-telemetry-data-points-rate-limit": "Transport tenant telemetry data points rate limit.", - "transport-device-msg-rate-limit": "Transport device messages rate limit.", - "transport-device-telemetry-msg-rate-limit": "Transport device telemetry messages rate limit.", - "transport-device-telemetry-data-points-rate-limit": "Transport device telemetry data points rate limit.", - "max-transport-messages": "Maximum number of transport messages (0 - unlimited)", - "max-transport-messages-required": "Maximum number of transport messages is required.", - "max-transport-messages-range": "Maximum number of transport messages can't be negative", - "max-transport-data-points": "Maximum number of transport data points (0 - unlimited)", - "max-transport-data-points-required": "Maximum number of transport data points is required.", - "max-transport-data-points-range": "Maximum number of transport data points can't be negative", - "max-r-e-executions": "Maximum number of Rule Engine executions (0 - unlimited)", - "max-r-e-executions-required": "Maximum number of Rule Engine executions is required.", - "max-r-e-executions-range": "Maximum number of Rule Engine executions can't be negative", - "max-j-s-executions": "Maximum number of JavaScript executions (0 - unlimited)", - "max-j-s-executions-required": "Maximum number of JavaScript executions is required.", - "max-j-s-executions-range": "Maximum number of JavaScript executions can't be negative", - "max-d-p-storage-days": "Maximum number of data points storage days (0 - unlimited)", - "max-d-p-storage-days-required": "Maximum number of data points storage days is required.", - "max-d-p-storage-days-range": "Maximum number of data points storage days can't be negative", - "default-storage-ttl-days": "Default storage TTL days (0 - unlimited)", - "default-storage-ttl-days-required": "Default storage TTL days is required.", - "default-storage-ttl-days-range": "Default storage TTL days can't be negative", - "max-rule-node-executions-per-message": "Maximum number of rule node executions per message (0 - unlimited)", - "max-rule-node-executions-per-message-required": "Maximum number of rule node executions per message is required.", - "max-rule-node-executions-per-message-range": "Maximum number of rule node executions per message can't be negative", - "max-emails": "Maximum number of emails sent (0 - unlimited)", - "max-emails-required": "Maximum number of emails sent is required.", - "max-emails-range": "Maximum number of emails sent can't be negative", - "max-sms": "Maximum number of SMS sent (0 - unlimited)", - "max-sms-required": "Maximum number of SMS sent is required.", - "max-sms-range": "Maximum number of SMS sent can't be negative" + "maximum-assets-required": "자산 최대 수는 필수 항목입니다.", + "maximum-assets-range": "자산 최대 수는 음수일 수 없습니다.", + "maximum-customers": "최대 고객 수 (0 - 무제한)", + "maximum-customers-required": "최대 고객 수를 입력하세요.", + "maximum-customers-range": "고객 최대 수는 음수일 수 없습니다.", + "maximum-users": "사용자 최대 수", + "maximum-users-required": "사용자 최대 수는 필수 항목입니다.", + "maximum-users-range": "사용자 최대 수는 음수일 수 없습니다.", + "maximum-dashboards": "대시보드 최대 수", + "maximum-dashboards-required": "대시보드 최대 수는 필수 항목입니다.", + "maximum-dashboards-range": "대시보드 최대 수는 음수일 수 없습니다.", + "maximum-edges": "엣지 최대 수", + "maximum-edges-required": "Edge 최대 수는 필수 항목입니다.", + "maximum-edges-range": "Edge 최대 수는 음수일 수 없습니다.", + "maximum-rule-chains": "규칙 체인 최대 수", + "maximum-rule-chains-required": "규칙 체인 최대 수는 필수 항목입니다.", + "maximum-rule-chains-range": "규칙 체인 최대 수는 음수일 수 없습니다.", + "maximum-resources-sum-data-size": "리소스 파일의 최대 총 크기 (바이트)", + "maximum-resources-sum-data-size-required": "리소스 파일의 최대 총 크기는 필수 항목입니다.", + "maximum-resources-sum-data-size-range": "리소스 파일의 최대 총 크기는 음수일 수 없습니다.", + "maximum-resource-size": "최대 리소스 파일 크기 (바이트)", + "maximum-resource-size-required": "최대 리소스 파일 크기는 필수 항목입니다.", + "maximum-resource-size-range": "최대 리소스 파일 크기는 음수일 수 없습니다.", + "maximum-ota-packages-sum-data-size": "OTA 패키지 파일의 최대 총 크기 (바이트)", + "maximum-ota-package-sum-data-size-required": "OTA 패키지 파일의 최대 총 크기는 필수 항목입니다.", + "maximum-ota-package-sum-data-size-range": "OTA 패키지 파일의 최대 총 크기는 음수일 수 없습니다.", + "maximum-debug-duration-min": "최대 디버그 기간 (분)", + "maximum-debug-duration-min-range": "최대 디버그 기간은 음수일 수 없습니다.", + "rest-requests-for-tenant": "테넌트에 대한 REST 요청", + "transport-tenant-telemetry-msg-rate-limit": "테넌트 텔레메트리 메시지 전송", + "transport-tenant-telemetry-data-points-rate-limit": "테넌트 텔레메트리 데이터 포인트 전송", + "transport-device-msg-rate-limit": "장치 메시지 전송", + "transport-device-telemetry-msg-rate-limit": "장치 텔레메트리 메시지 전송", + "transport-device-telemetry-data-points-rate-limit": "장치 텔레메트리 데이터 포인트 전송", + "transport-gateway-msg-rate-limit": "전송 게이트웨이 메시지", + "transport-gateway-telemetry-msg-rate-limit": "전송 게이트웨이 텔레메트리 메시지", + "transport-gateway-telemetry-data-points-rate-limit": "전송 게이트웨이 텔레메트리 데이터 포인트", + "transport-gateway-device-msg-rate-limit": "전송 게이트웨이 장치 메시지", + "transport-gateway-device-telemetry-msg-rate-limit": "전송 게이트웨이 장치 텔레메트리 메시지", + "transport-gateway-device-telemetry-data-points-rate-limit": "전송 게이트웨이 장치 텔레메트리 데이터 포인트", + "tenant-entity-export-rate-limit": "엔티티 버전 생성", + "tenant-entity-import-rate-limit": "엔티티 버전 불러오기", + "tenant-notification-request-rate-limit": "알림 요청", + "tenant-notification-requests-per-rule-rate-limit": "알림 규칙당 알림 요청", + "max-calculated-fields": "엔티티당 계산 필드 최대 수", + "max-calculated-fields-range": "엔티티당 계산 필드 최대 수는 음수일 수 없습니다", + "max-calculated-fields-required": "엔티티당 계산 필드 최대 수가 필요합니다", + "max-data-points-per-rolling-arg": "롤링 인수의 최대 데이터 포인트 수", + "max-data-points-per-rolling-arg-range": "롤링 인수의 최대 데이터 포인트 수는 음수일 수 없습니다", + "max-data-points-per-rolling-arg-required": "롤링 인수의 최대 데이터 포인트 수가 필요합니다", + "max-arguments-per-cf": "계산 필드당 인수 최대 수", + "max-arguments-per-cf-range": "계산 필드당 인수 최대 수는 음수일 수 없습니다", + "max-arguments-per-cf-required": "계산 필드당 인수 최대 수가 필요합니다", + "max-related-level-per-argument": "'관련 엔티티' 인수당 최대 관계 레벨", + "max-related-level-per-argument-range": "'관련 엔티티' 인수당 관계 레벨 최대 수는 '1'보다 작을 수 없습니다", + "max-related-level-per-argument-required": "'관련 엔티티' 인수당 관계 레벨 최대 수가 필요합니다", + "min-allowed-scheduled-update-interval": "'관련 엔티티' 인수에 허용되는 최소 업데이트 간격 (초)", + "min-allowed-scheduled-update-interval-range": "허용되는 최소 업데이트 간격 최소 수는 음수일 수 없습니다", + "min-allowed-deduplication-interval": "허용되는 최소 중복 제거 간격 (초)", + "min-allowed-deduplication-interval-range": "허용되는 최소 중복 제거 간격 값은 음수일 수 없습니다", + "min-allowed-deduplication-interval-required": "허용되는 최소 중복 제거 간격이 필요합니다", + "intermediate-aggregation-interval": "중간 집계 간격 (초)", + "intermediate-aggregation-interval-range": "중간 집계 간격 값은 '1'보다 작을 수 없습니다", + "intermediate-aggregation-interval-required": "중간 집계 간격이 필요합니다", + "reevaluation-check-interval": "재평가 확인 간격 (초)", + "reevaluation-check-interval-range": "재평가 확인 간격 값은 '1'보다 작을 수 없습니다", + "reevaluation-check-interval-required": "재평가 확인 간격이 필요합니다", + "alarms-reevaluation-interval": "알람 재평가 간격 (초)", + "alarms-reevaluation-interval-range": "알람 재평가 간격 값은 '1'보다 작을 수 없습니다", + "alarms-reevaluation-interval-required": "알람 재평가 간격이 필요합니다", + "min-allowed-aggregation-interval": "허용되는 최소 집계 간격 (초)", + "min-allowed-aggregation-interval-range": "허용되는 최소 집계 간격 값은 음수일 수 없습니다", + "min-allowed-aggregation-interval-required": "허용되는 최소 집계 간격이 필요합니다", + "min-allowed-scheduled-update-interval-required": "허용되는 최소 업데이트 간격 최소 수가 필요합니다", + "max-state-size": "상태 최대 크기 (KB)", + "max-state-size-range": "상태 최대 크기 (KB)는 음수일 수 없습니다", + "max-state-size-required": "상태 최대 크기 (KB)가 필요합니다", + "max-value-argument-size": "단일 값 인수 최대 크기 (KB)", + "max-value-argument-size-range": "단일 값 인수 최대 크기 (KB)는 음수일 수 없습니다", + "max-value-argument-size-required": "단일 값 인수 최대 크기 (KB)가 필요합니다", + "max-transport-messages": "전송 메시지 최대 수", + "max-transport-messages-required": "전송 메시지 최대 수는 필수 항목입니다.", + "max-transport-messages-range": "전송 메시지 최대 수는 음수일 수 없습니다.", + "max-transport-data-points": "전송 데이터 포인트 최대 수", + "max-transport-data-points-required": "전송 데이터 포인트 최대 수는 필수 항목입니다.", + "max-transport-data-points-range": "전송 데이터 포인트 최대 수는 음수일 수 없습니다.", + "max-r-e-executions": "규칙 엔진 실행 최대 수", + "max-r-e-executions-required": "규칙 엔진 실행 최대 수는 필수 항목입니다.", + "max-r-e-executions-range": "규칙 엔진 실행 최대 수는 음수일 수 없습니다.", + "max-j-s-executions": "JavaScript 실행 최대 수", + "max-j-s-executions-required": "JavaScript 실행 최대 수는 필수 항목입니다.", + "max-j-s-executions-range": "JavaScript 실행 최대 수는 음수일 수 없습니다.", + "max-tbel-executions": "TBEL 실행 최대 수", + "max-tbel-executions-required": "TBEL 실행 최대 수가 필요합니다.", + "max-tbel-executions-range": "TBEL 실행 최대 수는 음수일 수 없습니다", + "max-d-p-storage-days": "데이터 포인트 저장 일수 최대 수", + "max-d-p-storage-days-required": "데이터 포인트 저장 일수 최대 수는 필수 항목입니다.", + "max-d-p-storage-days-range": "데이터 포인트 저장 일수 최대 수는 음수일 수 없습니다.", + "default-storage-ttl-days": "기본 저장소 TTL 일수", + "default-storage-ttl-days-required": "기본 저장소 TTL 일수는 필수 항목입니다.", + "default-storage-ttl-days-range": "기본 저장소 TTL 일수는 음수일 수 없습니다.", + "alarms-ttl-days": "알람 TTL 일수", + "alarms-ttl-days-required": "알람 TTL 일수가 필요합니다", + "alarms-ttl-days-days-range": "알람 TTL 일수는 음수일 수 없습니다", + "rpc-ttl-days": "RPC TTL 일수", + "rpc-ttl-days-required": "RPC TTL 일수가 필요합니다", + "rpc-ttl-days-days-range": "RPC TTL 일수는 음수일 수 없습니다", + "queue-stats-ttl-days": "큐 통계 TTL 일수", + "queue-stats-ttl-days-required": "큐 통계 TTL 일수가 필요합니다", + "queue-stats-ttl-days-range": "큐 통계 TTL 일수는 음수일 수 없습니다", + "rule-engine-exceptions-ttl-days": "규칙 엔진 예외 TTL 일수", + "rule-engine-exceptions-ttl-days-required": "규칙 엔진 예외 TTL 일수가 필요합니다", + "rule-engine-exceptions-ttl-days-range": "규칙 엔진 예외 TTL 일수는 음수일 수 없습니다", + "max-rule-node-executions-per-message": "메시지당 규칙 노드 실행 최대 수", + "max-rule-node-executions-per-message-required": "메시지당 규칙 노드 실행 최대 수는 필수 항목입니다.", + "max-rule-node-executions-per-message-range": "메시지당 규칙 노드 실행 최대 수는 음수일 수 없습니다.", + "max-emails": "발송 이메일 최대 수", + "max-emails-required": "발송 이메일 최대 수는 필수 항목입니다.", + "max-emails-range": "발송 이메일 최대 수는 음수일 수 없습니다.", + "sms-enabled": "SMS 활성화됨", + "max-sms": "발송 SMS 최대 수", + "max-sms-required": "발송 SMS 최대 수는 필수 항목입니다.", + "max-sms-range": "발송 SMS 최대 수는 음수일 수 없습니다.", + "max-created-alarms": "생성된 알람 최대 수", + "max-created-alarms-required": "생성된 알람 최대 수가 필요합니다.", + "max-created-alarms-range": "생성된 알람 최대 수는 음수일 수 있습니다", + "no-queue": "구성된 큐 없음", + "add-queue": "큐 추가", + "queues-with-count": "큐 ({{count}})", + "tenant-rest-limits": "테넌트에 대한 REST 요청", + "customer-rest-limits": "고객에 대한 REST 요청", + "incorrect-pattern-for-rate-limits": "형식은 용량과 기간(초)의 쉼표로 구분된 쌍이며 콜론으로 구분됩니다. 예: 100:1,2000:60", + "too-small-value-zero": "값은 0보다 커야 합니다", + "too-small-value-one": "값은 1보다 커야 합니다", + "queue-size-is-limited-by-system-configuration": "큐 크기는 시스템 구성에 의해서도 제한됩니다.", + "cassandra-write-tenant-core-limits-configuration": "REST API Cassandra 쓰기 쿼리", + "cassandra-read-tenant-core-limits-configuration": "REST API 및 WS 텔레메트리 Cassandra 읽기 쿼리", + "cassandra-write-tenant-rule-engine-limits-configuration": "규칙 엔진 텔레메트리 Cassandra 쓰기 쿼리", + "cassandra-read-tenant-rule-engine-limits-configuration": "규칙 엔진 텔레메트리 Cassandra 읽기 쿼리", + "ws-limit-max-sessions-per-tenant": "테넌트당 최대 세션 수", + "ws-limit-max-sessions-per-customer": "고객당 최대 세션 수", + "ws-limit-max-sessions-per-regular-user": "일반 사용자당 최대 세션 수", + "ws-limit-max-sessions-per-public-user": "공개 사용자당 최대 세션 수", + "ws-limit-queue-per-session": "세션당 최대 메시지 큐 크기", + "ws-limit-max-subscriptions-per-tenant": "테넌트당 최대 구독 수", + "ws-limit-max-subscriptions-per-customer": "고객당 최대 구독 수", + "ws-limit-max-subscriptions-per-regular-user": "일반 사용자당 최대 구독 수", + "ws-limit-max-subscriptions-per-public-user": "공개 사용자당 최대 구독 수", + "ws-limit-updates-per-session": "세션당 WS 업데이트", + "relation-search-entity-limit": "관계 검색 엔티티 제한", + "relation-search-entity-limit-hint": "관계 경로의 마지막 레벨에서 확인되는 엔티티 수를 제한합니다. '관련 엔티티' 인수 및 전파 필드에 적용됩니다.", + "relation-search-entity-limit-required": "관계 검색 엔티티 제한", + "relation-search-entity-limit-range": "관계 검색 엔티티 제한은 '1'보다 작을 수 없습니다", + "rate-limits": { + "add-limit": "제한 추가", + "and-also-less-than": "그리고 다음보다 작음", + "advanced-settings": "고급 설정", + "edit-limit": "제한 편집", + "calculated-field-debug-event-rate-limit": "계산 필드 디버그 이벤트", + "edit-calculated-field-debug-event-rate-limit": "계산 필드 디버그 이벤트 속도 제한 편집", + "edit-transport-tenant-msg-title": "전송 테넌트 메시지 속도 제한 편집", + "edit-transport-tenant-telemetry-msg-title": "전송 테넌트 텔레메트리 메시지 속도 제한 편집", + "edit-transport-tenant-telemetry-data-points-title": "전송 테넌트 텔레메트리 데이터 포인트 속도 제한 편집", + "edit-transport-device-msg-title": "전송 장치 메시지 속도 제한 편집", + "edit-transport-device-telemetry-msg-title": "전송 장치 텔레메트리 메시지 속도 제한 편집", + "edit-transport-device-telemetry-data-points-title": "전송 장치 텔레메트리 데이터 포인트 속도 제한 편집", + "edit-transport-gateway-msg-title": "전송 게이트웨이 메시지 속도 제한 편집", + "edit-transport-gateway-telemetry-msg-title": "전송 게이트웨이 텔레메트리 메시지 속도 제한 편집", + "edit-transport-gateway-telemetry-data-points-title": "전송 게이트웨이 텔레메트리 데이터 포인트 속도 제한 편집", + "edit-transport-gateway-device-msg-title": "전송 게이트웨이 장치 메시지 속도 제한 편집", + "edit-transport-gateway-device-telemetry-msg-title": "전송 게이트웨이 장치 텔레메트리 메시지 속도 제한 편집", + "edit-transport-gateway-device-telemetry-data-points-title": "전송 게이트웨이 장치 텔레메트리 데이터 포인트 속도 제한 편집", + "edit-tenant-rest-limits-title": "테넌트에 대한 REST 요청 속도 제한 편집", + "edit-customer-rest-limits-title": "고객에 대한 REST 요청 속도 제한 편집", + "edit-ws-limit-updates-per-session-title": "세션당 WS 업데이트 속도 제한 편집", + "edit-cassandra-write-tenant-core-limits-configuration": "Rest API Cassandra 쓰기 쿼리 편집", + "edit-cassandra-read-tenant-core-limits-configuration": "Rest API 및 WS 텔레메트리 Cassandra 읽기 쿼리 편집", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "규칙 엔진 텔레메트리 Cassandra 쓰기 쿼리 편집", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "규칙 엔진 텔레메트리 Cassandra 읽기 쿼리 편집", + "edit-tenant-entity-export-rate-limit-title": "엔티티 버전 생성 속도 제한 편집", + "edit-tenant-entity-import-rate-limit-title": "엔티티 버전 로드 속도 제한 편집", + "edit-tenant-notification-request-rate-limit-title": "알림 요청 속도 제한 편집", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "알림 규칙당 알림 요청 속도 제한 편집", + "edit-edge-events-rate-limit": "엣지 이벤트 속도 제한 편집", + "edit-edge-events-per-edge-rate-limit": "엣지당 엣지 이벤트 속도 제한 편집", + "edge-events-rate-limit": "엣지 이벤트", + "edge-events-per-edge-rate-limit": "엣지당 엣지 이벤트", + "edit-edge-uplink-messages-rate-limit": "엣지 업링크 메시지 속도 제한 편집", + "edit-edge-uplink-messages-per-edge-rate-limit": "엣지당 엣지 업링크 메시지 속도 제한 편집", + "edge-uplink-messages-rate-limit": "엣지 업링크 메시지", + "edge-uplink-messages-per-edge-rate-limit": "엣지당 업링크 메시지", + "messages-per": "메시지당", + "not-set": "설정되지 않음", + "number-of-messages": "메시지 수", + "number-of-messages-required": "메시지 수가 필요합니다.", + "number-of-messages-min": "최소 값은 1입니다.", + "preview": "미리보기", + "per-seconds": "초당", + "per-seconds-required": "시간 속도가 필요합니다.", + "per-seconds-min": "최소 값은 1입니다.", + "per-seconds-duplicate": "중복 시간 속도입니다. 각 시간 간격은 고유해야 합니다.", + "rate-limits": "속도 제한", + "remove-limit": "제한 제거", + "transport-tenant-msg": "전송 테넌트 메시지", + "transport-tenant-telemetry-msg": "테넌트 텔레메트리 메시지 전송", + "transport-tenant-telemetry-data-points": "테넌트 텔레메트리 데이터 포인트 전송", + "transport-device-msg": "장치 메시지 전송", + "transport-device-telemetry-msg": "장치 텔레메트리 메시지 전송", + "transport-device-telemetry-data-points": "장치 텔레메트리 데이터 포인트 전송", + "transport-gateway-msg": "전송 게이트웨이 메시지", + "transport-gateway-telemetry-msg": "전송 게이트웨이 텔레메트리 메시지", + "transport-gateway-telemetry-data-points": "전송 게이트웨이 텔레메트리 데이터 포인트", + "transport-gateway-device-msg": "전송 게이트웨이 장치 메시지", + "transport-gateway-device-telemetry-msg": "전송 게이트웨이 장치 텔레메트리 메시지", + "transport-gateway-device-telemetry-data-points": "전송 게이트웨이 장치 텔레메트리 데이터 포인트", + "sec": "초" + } }, "timeinterval": { "seconds-interval": "{ seconds, plural, =1 {1 초} other {# 초} }", @@ -2012,37 +6475,691 @@ "hours": "시간", "minutes": "분", "seconds": "초", - "advanced": "고급" + "advanced": "고급", + "custom": "사용자 지정", + "predefined": { + "yesterday": "어제", + "day-before-yesterday": "그저께", + "this-day-last-week": "지난주 오늘", + "previous-week": "지난주 (일 - 토)", + "previous-week-iso": "지난주 (월 - 일)", + "previous-month": "지난달", + "previous-quarter": "지난 분기", + "previous-half-year": "지난 반기", + "previous-year": "작년", + "current-hour": "현재 시간", + "current-day": "오늘", + "current-day-so-far": "오늘 현재", + "current-week": "이번주 (일 - 토)", + "current-week-iso": "이번주 (월 - 일)", + "current-week-so-far": "이번주 현재 (일 - 토)", + "current-week-iso-so-far": "이번주 현재 (월 - 일)", + "current-month": "이번달", + "current-month-so-far": "이번달 현재", + "current-quarter": "이번 분기", + "current-quarter-so-far": "이번 분기 현재", + "current-half-year": "이번 반기", + "current-half-year-so-far": "이번 반기 현재", + "current-year": "올해", + "current-year-so-far": "올해 현재" + }, + "type": { + "week": "주 (일 - 토)", + "week-iso": "주 (월 - 일)", + "month": "달", + "quarter": "분기" + } }, "timeunit": { + "milliseconds": "밀리초", "seconds": "초", "minutes": "분", "hours": "시간", "days": "일" }, "timewindow": { + "timewindow": "타임윈도우", + "timewindow-settings": "시간 창 설정", + "years": "{ years, plural, =1 {1년 } other {#년 } }", + "years-short": "{{ years }}년", + "months": "{ months, plural, =1 {1개월 } other {#개월 } }", + "months-short": "{{ months }}개월", + "weeks": "{ weeks, plural, =1 {1주 } other {#주 } }", + "weeks-short": "{{ weeks }}주", "days": "{ days, plural, =1 {1 일 } other {# 일 } }", + "days-short": "{{ days }}일", "hours": "{ hours, plural, =0 {0 시간 } =1 {1 시간 } other {# 시간 } }", + "hr": "{{ hr }} 시간", + "hr-short": "{{ hr }}시간", "minutes": "{ minutes, plural, =0 {0 분 } =1 {1 분 } other {# 분 } }", + "min": "{{ min }} 분", + "min-short": "{{ min }}분", "seconds": "{ seconds, plural, =0 {0 초 } =1 {1 초 } other {# 초 } }", + "sec": "{{ sec }} 초", + "sec-short": "{{ sec }}초", + "short": { + "years": "{ years, plural, =1 {1년 } other {#년 } }", + "days": "{ days, plural, =1 {1 일 } other {# 일 } }", + "hours": "{ hours, plural, =1 {1시간 } other {#시간 } }", + "minutes": "{{minutes}}분 ", + "seconds": "{{seconds}}초 " + }, "realtime": "실시간", "history": "기록", "last-prefix": "과거", "period": "{{ startTime }}부터 {{ endTime }}까지", "edit": "타임윈도우 편집", "date-range": "날짜 범위", + "for-all-time": "전체 기간", "last": "과거", "time-period": "기간", - "hide": "숨기기" + "hide": "숨기기", + "interval": "간격", + "just-now": "방금", + "just-now-lower": "방금", + "ago": "전", + "style": "시간 창 스타일", + "icon": "아이콘", + "icon-position": "아이콘 위치", + "icon-position-left": "왼쪽", + "icon-position-right": "오른쪽", + "font": "글꼴", + "color": "색상", + "displayTypePrefix": "실시간/기록 접두사 표시", + "preview": "미리보기", + "relative": "상대적", + "range": "범위", + "hide-timewindow-section": "최종 사용자에게 시간 창 섹션 숨기기", + "hide-last-interval": "최종 사용자에게 마지막 간격 숨기기", + "hide-relative-interval": "최종 사용자에게 상대적 간격 숨기기", + "hide-fixed-interval": "최종 사용자에게 고정 간격 숨기기", + "hide-aggregation": "최종 사용자에게 집계 숨기기", + "hide-group-interval": "최종 사용자에게 그룹화 간격 숨기기", + "hide-max-values": "최종 사용자에게 최대 값 숨기기", + "hide-timezone": "최종 사용자에게 시간대 숨기기", + "disable-custom-interval": "사용자 지정 간격 선택 비활성화", + "edit-aggregation-functions-list": "집계 함수 목록 편집", + "edit-aggregation-functions-list-hint": "사용 가능한 옵션 목록을 지정할 수 있습니다.", + "allowed-aggregation-functions": "허용된 집계 함수", + "edit-intervals-list": "간격 목록 편집", + "allowed-agg-intervals": "그룹화 간격", + "default-agg-interval": "기본 그룹화 간격", + "edit-intervals-list-hint": "사용 가능한 간격 옵션 목록을 지정할 수 있습니다.", + "edit-grouping-intervals-list-hint": "그룹화 간격 목록과 기본 그룹화 간격을 구성할 수 있습니다.", + "all": "모두", + "save-current-settings-as-default": "현재 설정을 기본 시간 창으로 저장", + "hide-option-from-end-users": "최종 사용자에게 옵션 숨기기" + }, + "tooltip": { + "trigger": "트리거", + "trigger-point": "지점", + "trigger-axis": "축", + "label": "라벨", + "value": "값", + "date": "날짜", + "show-date-time-interval": "날짜 시간 간격 표시", + "show-date-time-interval-hint": "데이터 집계에 따라 날짜 시간 간격을 표시합니다.", + "hide-zero-tooltip-values": "0 값 숨기기", + "show-stack-total": "스택 모드에서 총 값 표시", + "background-color": "배경색", + "background-blur": "배경 블러" + }, + "unit": { + "set-unit-conversion": "단위 변환 설정", + "unit-settings": { + "unit-settings": "단위 설정", + "source-unit": "소스 단위", + "source-unit-hint": "저장된 값의 단위입니다. 변환할 원본 단위입니다. 소스 데이터에서 사용하는 기호를 입력하세요 (예: m, km, ft, in).", + "target-metric-unit": "대상 미터법 단위", + "target-metric-unit-hint": "소스 값을 변환할 미터법 단위(SI)를 선택하세요 (예: cm, mm, km).", + "target-imperial-unit": "대상 야드파운드법 단위", + "target-imperial-unit-hint": "소스 값을 변환할 야드파운드법 단위를 선택하세요 (예: in, ft, yd).", + "target-hybrid-unit": "대상 하이브리드 단위", + "target-hybrid-unit-hint": "소스 값을 변환할 하이브리드 단위를 선택하세요 (예: cm, in, km). 하이브리드 단위는 미터법 또는 야드파운드법 단위를 결합합니다.", + "enable-unit-conversion": "단위 변환 활성화", + "enable-unit-conversion-hint": "켜기로 전환하여 변환을 활성화합니다. 꺼져 있으면 소스 값이 변경되지 않고 전달됩니다. 해당 측정 그룹에 단위가 하나만 있는 경우 비활성화됩니다 (예: 광속, AQI)." + }, + "unit-system": "단위 시스템", + "unit-system-type": { + "AUTO": "자동", + "METRIC": "미터법", + "IMPERIAL": "야드파운드법", + "HYBRID": "하이브리드" + }, + "measures": { + "absorbed-dose-rate": "흡수 선량률", + "acceleration": "가속도", + "acidity": "산도", + "air-quality-index": "대기질 지수", + "amount-of-substance": "물질량", + "angle": "각도", + "angular-acceleration": "각가속도", + "area": "영역", + "area-density": "면적 밀도", + "capacitance": "정전 용량", + "catalytic-activity": "촉매 활성", + "catalytic-concentration": "촉매 농도", + "charge": "전하", + "current-density": "전류 밀도", + "data-transfer-rate": "데이터 전송 속도", + "density": "밀도", + "digital": "디지털", + "dimension-ratio": "차원 비율", + "dynamic-viscosity": "동점도", + "earthquake-magnitude": "지진 규모", + "electric-charge-density": "전하 밀도", + "electric-current": "전류", + "electric-dipole-moment": "전기 쌍극자 모멘트", + "electric-field-strength": "전기장 세기", + "electric-flux": "전기 선속", + "electric-permittivity": "유전율", + "electric-polarizability": "전기 분극률", + "electrical-conductance": "전도도", + "electrical-conductivity": "전도율", + "energy": "에너지", + "energy-density": "에너지 밀도", + "force": "힘", + "frequency": "주파수", + "fuel-efficiency": "연료 효율", + "heat-capacity": "열용량", + "illuminance": "조도", + "inductance": "인덕턴스", + "kinematic-viscosity": "동점도", + "length": "길이", + "light-exposure": "노광량", + "linear-charge-density": "선형 전하 밀도", + "logarithmic-ratio": "로그 비율", + "luminous-efficacy": "발광 효율", + "luminous-flux": "광선속", + "luminous-intensity": "광도", + "magnetic-field-gradient": "자기장 기울기", + "magnetic-flux": "자기 선속", + "magnetic-flux-density": "자기 선속 밀도", + "magnetic-moment": "자기 모멘트", + "magnetic-permeability": "투자율", + "mass": "질량", + "mass-fraction": "질량 분율", + "molar-concentration": "몰 농도", + "molar-energy": "몰 에너지", + "molar-heat-capacity": "몰 열용량", + "molar-mass": "몰 질량", + "number-concentration": "수 농도", + "parts-per-million": "백만분율", + "power": "전력", + "power-density": "전력 밀도", + "pressure": "압력", + "radiance": "복사 휘도", + "radiant-intensity": "복사 강도", + "radiation-dose": "방사선량", + "radioactive-decay": "방사성 붕괴", + "radioactivity": "방사능", + "radioactivity-concentration": "방사능 농도", + "reciprocal-length": "역 길이", + "resistance": "저항", + "reynolds-number": "레이놀즈 수", + "signal-level": "신호 레벨", + "solid-angle": "입체각", + "specific-energy": "비에너지", + "specific-heat-capacity": "비열", + "specific-humidity": "비습도", + "specific-volume": "비체적", + "speed": "속도", + "surface-charge-density": "표면 전하 밀도", + "surface-tension": "표면 장력", + "temperature": "온도", + "thermal-conductivity": "열전도율", + "time": "시간", + "torque": "토크", + "turbidity": "탁도", + "voltage": "전압", + "volume": "부피", + "volume-flow": "체적 유량" + }, + "millimeter": "밀리미터", + "centimeter": "센티미터", + "decimeter": "데시미터", + "angstrom": "옹스트롬", + "nanometer": "나노미터", + "micrometer": "마이크로미터", + "meter": "미터", + "kilometer": "킬로미터", + "inch": "인치", + "foot": "피트", + "foot-us": "피트 (미국 측량)", + "yard": "야드", + "mile": "마일", + "nautical-mile": "해리", + "astronomical-unit": "천문 단위", + "reciprocal-metre": "역 미터", + "meter-per-meter": "미터당 미터", + "steradian": "스테라디안", + "thou": "파운드", + "barleycorn": "보리콘", + "hand": "핸드", + "chain": "체인", + "furlong": "퍼롱", + "league": "리그", + "fathom": "패덤", + "cable": "케이블", + "link": "링크", + "rod": "로드", + "nanogram": "나노그램", + "microgram": "마이크로그램", + "milligram": "밀리그램", + "gram": "그램", + "kilogram": "킬로그램", + "tonne": "톤", + "ounce": "온스", + "pound": "파운드", + "stone": "스톤", + "hundredweight-count": "헌드레드웨이트", + "short-tons": "단톤", + "dalton": "달튼", + "grain": "그레인", + "drachm": "드램", + "quarter": "분기", + "slug": "슬러그", + "carat": "캐럿", + "cubic-millimeter": "세제곱밀리미터", + "cubic-centimeter": "세제곱센티미터", + "cubic-meter": "세제곱미터", + "cubic-kilometer": "세제곱킬로미터", + "microliter": "마이크로리터", + "milliliter": "밀리리터", + "liter": "리터", + "hectoliter": "헥토리터", + "cubic-inch": "세제곱인치", + "cubic-foot": "세제곱피트", + "cubic-yard": "세제곱야드", + "fluid-ounce": "액량 온스", + "fluid-ounce-per-second": "초당 액량 온스", + "pint": "파인트", + "quart": "쿼트", + "gallon": "갤런", + "oil-barrels": "석유 배럴", + "cubic-meter-per-kilogram": "킬로그램당 세제곱미터", + "gill": "길", + "hogshead": "혹스헤드", + "teaspoon": "티스푼", + "tablespoon": "테이블스푼", + "cup": "컵", + "celsius": "섭씨", + "kelvin": "켈빈", + "rankine": "랭킨", + "fahrenheit": "화씨", + "percent": "퍼센트", + "meter-per-second": "미터 매 초", + "kilometer-per-hour": "킬로미터 매 시간", + "foot-per-second": "피트 매 초", + "foot-per-minute": "피트 매 분", + "mile-per-hour": "마일 매 시간", + "knot": "노트", + "inch-per-second": "인치 매 초", + "inch-per-hour": "인치 매 시간", + "millimeters-per-minute": "밀리미터 매 분", + "meter-per-minute": "미터 매 분", + "kilometer-per-hour-squared": "킬로미터 매 시간 제곱", + "foot-per-second-squared": "피트 매 초 제곱", + "pascal": "파스칼", + "kilopascal": "킬로파스칼", + "megapascal": "메가파스칼", + "gigapascal": "기가파스칼", + "millibar": "밀리바", + "bar": "막대", + "kilobar": "킬로바", + "newton": "뉴턴", + "newton-meter": "뉴턴 미터", + "foot-pounds": "피트파운드", + "inch-pounds": "인치파운드", + "newton-per-meter": "뉴턴 매 미터", + "atmospheres": "기압", + "pounds-per-square-inch": "파운드 매 제곱인치", + "kilopound-per-square-inch": "킬로파운드 매 제곱인치", + "torr": "토르", + "inches-of-mercury": "수은주 인치", + "pascal-per-square-meter": "파스칼 매 제곱미터", + "pound-per-square-inch": "파운드 매 제곱인치", + "newton-per-square-meter": "뉴턴 매 제곱미터", + "kilogram-force-per-square-meter": "킬로그램힘 매 제곱미터", + "pascal-per-square-centimeter": "파스칼 매 제곱센티미터", + "ton-force-per-square-inch": "톤힘 매 제곱인치", + "kilonewton-per-square-meter": "킬로뉴턴 매 제곱미터", + "newton-per-square-millimeter": "뉴턴 매 제곱밀리미터", + "microjoule": "마이크로줄", + "millijoule": "밀리줄", + "joule": "줄", + "kilojoule": "킬로줄", + "megajoule": "메가줄", + "gigajoule": "기가줄", + "watt-hour": "와트시", + "watt-minute": "와트분", + "kilowatt-hour": "킬로와트시", + "milliwatt-hour": "밀리와트시", + "megawatt-hour": "메가와트시", + "gigawatt-hour": "기가와트시", + "electron-volts": "전자볼트", + "joules-per-coulomb": "줄 매 쿨롱", + "british-thermal-unit": "영국 열량 단위", + "thousand-british-thermal-unit": "천 영국 열량 단위", + "million-british-thermal-unit": "백만 영국 열량 단위", + "foot-pound": "피트파운드", + "calorie": "칼로리", + "small-calorie": "소칼로리", + "kilocalorie": "킬로칼로리", + "joule-per-kelvin": "줄 매 켈빈", + "joule-per-kilogram-kelvin": "줄 매 킬로그램켈빈", + "joule-per-kilogram": "줄 매 킬로그램", + "watt-per-meter-kelvin": "와트 매 미터켈빈", + "joule-per-cubic-meter": "줄 매 세제곱미터", + "therm": "덤", + "electric-dipole-moment": "전기 쌍극자 모멘트", + "magnetic-dipole-moment": "자기 쌍극자 모멘트", + "debye": "데바이", + "coulomb-per-square-meter-per-volt": "쿨롱 매 제곱미터 매 볼트", + "milliwatt": "밀리와트", + "microwatt": "마이크로와트", + "watt": "와트", + "kilowatt": "킬로와트", + "megawatt": "메가와트", + "gigawatt": "기가와트", + "metric-horsepower": "미터법 마력", + "milliwatt-per-square-centimeter": "밀리와트 매 제곱센티미터", + "watt-per-square-centimeter": "와트 매 제곱센티미터", + "kilowatt-per-square-centimeter": "킬로와트 매 제곱센티미터", + "milliwatt-per-square-meter": "밀리와트 매 제곱미터", + "watt-per-square-meter": "와트 매 제곱미터", + "kilowatt-per-square-meter": "킬로와트 매 제곱미터", + "watt-per-square-inch": "와트 매 제곱인치", + "kilowatt-per-square-inch": "킬로와트 매 제곱인치", + "horsepower": "마력", + "btu-per-hour": "영국 열량 단위 매 시간", + "btu-per-second": "영국 열량 단위 매 초", + "btu-per-day": "영국 열량 단위 매 일", + "mbtu-per-hour": "천 영국 열량 단위 매 시간", + "mbtu-per-second": "천 영국 열량 단위 매 초", + "mbtu-per-day": "천 영국 열량 단위 매 일", + "mmbtu-per-hour": "백만 영국 열량 단위 매 시간", + "mmbtu-per-second": "백만 영국 열량 단위 매 초", + "mmbtu-per-day": "백만 영국 열량 단위 매 일", + "foot-pound-per-second": "피트파운드 매 초", + "coulomb": "쿨롱", + "millicoulomb": "밀리쿨롱", + "microcoulomb": "마이크로쿨롱", + "nanocoulomb": "나노쿨롱", + "picocoulomb": "피코쿨롱", + "coulomb-per-meter": "쿨롱 매 미터", + "coulomb-per-cubic-meter": "쿨롱 매 세제곱미터", + "coulomb-per-square-meter": "쿨롱 매 제곱미터", + "square-millimeter": "제곱밀리미터", + "square-centimeter": "제곱센티미터", + "square-meter": "제곱미터", + "hectare": "헥타르", + "square-kilometer": "제곱킬로미터", + "square-inch": "제곱인치", + "square-foot": "제곱피트", + "square-yard": "제곱야드", + "acre": "에이커", + "square-mile": "제곱마일", + "are": "아르", + "barn": "반", + "circular-inch": "원형 인치", + "milliampere-hour": "밀리암페어시", + "ampere-hours": "암페어시", + "kiloampere-hours": "킬로암페어시", + "nanoampere": "나노암페어", + "picoampere": "피코암페어", + "microampere": "마이크로암페어", + "milliampere": "밀리암페어", + "ampere": "암페어", + "kiloampere": "킬로암페어", + "megaampere": "메가암페어", + "gigaampere": "기가암페어", + "microampere-per-square-centimeter": "마이크로암페어 매 제곱센티미터", + "ampere-per-square-meter": "암페어 매 제곱미터", + "ampere-per-meter": "암페어 매 미터", + "oersted": "에르스테드", + "bohr-magneton": "보어 마그네톤", + "ampere-meter-squared": "암페어미터 제곱", + "nanovolt": "나노볼트", + "picovolt": "피코볼트", + "millivolt": "밀리볼트", + "microvolt": "마이크로볼트", + "volt": "볼트", + "kilovolt": "킬로볼트", + "megavolt": "메가볼트", + "dbmV": "데시벨 볼트", + "dbm": "데시벨 밀리와트", + "volt-meter": "볼트미터", + "kilovolt-meter": "킬로볼트미터", + "megavolt-meter": "메가볼트미터", + "microvolt-meter": "마이크로볼트미터", + "millivolt-meter": "밀리볼트미터", + "nanovolt-meter": "나노볼트미터", + "ohm": "옴", + "microohm": "마이크로옴", + "milliohm": "밀리옴", + "kilohm": "킬로옴", + "megohm": "메가옴", + "gigohm": "기가옴", + "millihertz": "밀리헤르츠", + "hertz": "헤르츠", + "kilohertz": "킬로헤르츠", + "megahertz": "메가헤르츠", + "gigahertz": "기가헤르츠", + "terahertz": "테라헤르츠", + "rpm": "분당 회전수", + "candela-per-square-meter": "칸델라 매 제곱미터", + "candela": "칸델라", + "lumen": "루멘", + "lux": "럭스", + "foot-candle": "피트캔들", + "lumen-per-square-meter": "루멘 매 제곱미터", + "lux-second": "럭스 초", + "lumen-second": "루멘 초", + "lumens-per-watt": "루멘 매 와트", + "mole": "몰", + "nanomole": "나노몰", + "micromole": "마이크로몰", + "millimole": "밀리몰", + "kilomole": "킬로몰", + "mole-per-cubic-meter": "몰 매 세제곱미터", + "rssi": "수신 신호 강도 표시기", + "ppm": "백만분율", + "ppb": "10억분의 1", + "micrograms-per-cubic-meter": "마이크로그램 매 세제곱미터", + "aqi": "AQI", + "gram-per-cubic-meter": "그램 매 세제곱미터", + "gram-per-kilogram": "비습도", + "millimeters-per-second": "밀리미터 매 초", + "neper": "네퍼", + "bel": "벨", + "decibel": "데시벨", + "meters-per-second-squared": "미터 매 초 제곱", + "becquerel": "베크렐", + "curie": "퀴리", + "gray": "그레이", + "sievert": "시버트", + "roentgen": "뢴트겐", + "cps": "초당 카운트", + "rad": "라드", + "rem": "렘", + "dps": "초당 붕괴", + "rutherford": "러더퍼드", + "coulombs-per-kilogram": "쿨롱 매 킬로그램", + "becquerels-per-cubic-meter": "베크렐 매 세제곱미터", + "curies-per-liter": "퀴리 매 리터", + "becquerels-per-second": "초당 베크렐", + "curies-per-second": "초당 퀴리", + "gy-per-second": "초당 그레이", + "watt-per-steradian": "스테라디안당 와트", + "watt-per-square-metre-steradian": "제곱미터-스테라디안당 와트", + "ph-level": "pH 레벨", + "turbidity": "탁도", + "mg-per-liter": "리터당 밀리그램", + "microsiemens-per-centimeter": "센티미터당 마이크로지멘스", + "millisiemens-per-meter": "미터당 밀리지멘스", + "siemens-per-meter": "미터당 지멘스", + "kilogram-per-cubic-meter": "세제곱미터당 킬로그램", + "gram-per-cubic-centimeter": "세제곱센티미터당 그램", + "kilogram-per-square-meter": "제곱미터당 킬로그램", + "milligram-per-milliliter": "밀리리터당 밀리그램", + "milligram-per-cubic-meter": "세제곱미터당 밀리그램", + "pound-per-cubic-foot": "세제곱피트당 파운드", + "ounces-per-cubic-inch": "세제곱인치당 온스", + "tons-per-cubic-yard": "세제곱야드당 톤", + "particle-density": "입자 밀도", + "kilometers-per-liter": "리터당 킬로미터", + "miles-per-gallon": "갤런당 마일", + "liters-per-100-km": "100km당 리터", + "gallons-per-mile": "마일당 갤런", + "liters-per-hour": "시간당 리터", + "gallons-per-hour": "시간당 갤런", + "beats-per-minute": "분당 박동수", + "millimeters-of-mercury": "수은주 밀리미터", + "milligrams-per-deciliter": "데시리터당 밀리그램", + "g-force": "중력 가속도", + "kilonewton": "킬로뉴턴", + "kilogram-force": "킬로그램힘", + "pound-force": "파운드힘", + "kilopound-force": "킬로파운드힘", + "dyne": "다인", + "poundal": "파운달", + "kip": "킵", + "gal": "갈", + "gravity": "중력", + "hectopascal": "헥토파스칼", + "atmosphere": "기압", + "millibars": "밀리바", + "inch-of-mercury": "수은주 인치", + "richter-scale": "리히터 규모", + "nanosecond": "나노초", + "microsecond": "마이크로초", + "millisecond": "밀리초", + "second": "초", + "minute": "분", + "hour": "시간", + "day": "일", + "week": "주", + "month": "달", + "year": "년", + "cubic-foot-per-minute": "분당 세제곱피트", + "cubic-meters-per-hour": "시간당 세제곱미터", + "cubic-meters-per-second": "초당 세제곱미터", + "liter-per-second": "초당 리터", + "liter-per-minute": "분당 리터", + "gallons-per-minute": "분당 갤런", + "cubic-foot-per-second": "초당 세제곱피트", + "milliliters-per-minute": "분당 밀리리터", + "cubic-decimeter-per-second": "초당 세제곱데시미터", + "bit": "비트", + "byte": "바이트", + "kilobyte": "킬로바이트", + "megabyte": "메가바이트", + "gigabyte": "기가바이트", + "terabyte": "테라바이트", + "petabyte": "페타바이트", + "exabyte": "엑사바이트", + "zettabyte": "제타바이트", + "yottabyte": "요타바이트", + "bit-per-second": "초당 비트", + "kilobit-per-second": "초당 킬로비트", + "megabit-per-second": "초당 메가비트", + "gigabit-per-second": "초당 기가비트", + "terabit-per-second": "초당 테라비트", + "byte-per-second": "초당 바이트", + "kilobyte-per-second": "초당 킬로바이트", + "megabyte-per-second": "초당 메가바이트", + "gigabyte-per-second": "초당 기가바이트", + "degree": "도", + "radian": "라디안", + "gradian": "그라디안", + "arcminute": "각분", + "arcsecond": "각초", + "milliradian": "밀리라디안", + "revolution": "회전", + "siemens": "지멘스", + "millisiemens": "밀리지멘스", + "microsiemens": "마이크로지멘스", + "kilosiemens": "킬로지멘스", + "megasiemens": "메가지멘스", + "gigasiemens": "기가지멘스", + "farad": "패럿", + "millifarad": "밀리패럿", + "microfarad": "마이크로패럿", + "nanofarad": "나노패럿", + "picofarad": "피코패럿", + "kilofarad": "킬로패럿", + "megafarad": "메가패럿", + "gigafarad": "기가패럿", + "terfarad": "테라패럿", + "farad-per-meter": "미터당 패럿", + "tesla": "테슬라", + "gauss": "가우스", + "kilogauss": "킬로가우스", + "millitesla": "밀리테슬라", + "microtesla": "마이크로테슬라", + "nanotesla": "나노테슬라", + "kilotesla": "킬로테슬라", + "megatesla": "메가테슬라", + "millitesla-square-meters": "밀리테슬라 제곱미터", + "gamma": "감마", + "lambda": "람다", + "square-meter-per-second": "초당 제곱미터", + "square-centimeter-per-second": "초당 제곱센티미터", + "stoke": "스토크", + "centistokes": "센티스토크", + "square-foot-per-second": "초당 제곱피트", + "square-inch-per-second": "초당 제곱인치", + "pascal-second": "파스칼-초", + "centipoise": "센티푸아즈", + "poise": "푸아즈", + "reynolds": "레이놀즈", + "pound-per-foot-hour": "피트-시간당 파운드", + "newton-second-per-square-meter": "제곱미터당 뉴턴 초", + "dyne-second-per-square-centimeter": "제곱센티미터당 다인 초", + "kilogram-per-meter-second": "미터-초당 킬로그램", + "tesla-square-meters": "테슬라 제곱미터", + "maxwell": "맥스웰", + "tesla-per-meter": "미터당 테슬라", + "gauss-per-centimeter": "센티미터당 가우스", + "weber": "웨버", + "microweber": "마이크로웨버", + "milliweber": "밀리웨버", + "gauss-square-centimeter": "가우스-제곱센티미터", + "kilogauss-square-centimeter": "킬로가우스-제곱센티미터", + "henry": "헨리", + "millihenry": "밀리헨리", + "microhenry": "마이크로헨리", + "nanohenry": "나노헨리", + "henry-per-meter": "미터당 헨리", + "tesla-meter-per-ampere": "암페어당 테슬라 미터", + "gauss-per-oersted": "에르스테드당 가우스", + "kilogram-per-mole": "몰당 킬로그램", + "gram-per-mole": "몰당 그램", + "milligram-per-mole": "몰당 밀리그램", + "joule-per-mole": "몰당 줄", + "joule-per-mole-kelvin": "몰-켈빈당 줄", + "millivolts-per-meter": "미터당 밀리볼트", + "volts-per-meter": "미터당 볼트", + "kilovolts-per-meter": "미터당 킬로볼트", + "radian-per-second": "초당 라디안", + "radian-per-second-squared": "초 제곱당 라디안", + "revolutions-per-minute-per-second": "각가속도", + "deg-per-second": "초당 도", + "rotation-per-minute": "분당 회전", + "degrees-brix": "브릭스 도", + "katal": "카탈", + "katal-per-cubic-metre": "세제곱미터당 카탈", + "paris-inch": "파리 인치" }, "user": { "user": "사용자", "users": "사용자", - "customer-users": "커스터머 사용자", + "customer-users": "고객 사용자", "tenant-admins": "테넌트 관리자", "sys-admin": "시스템 관리자", "tenant-admin": "테넌트 관리자", - "customer": "커스터머", + "customer": "고객", "anonymous": "익명", "add": "사용자 추가", "delete": "사용자 삭제", @@ -2056,9 +7173,9 @@ "delete-users-text": "사용자 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", "activation-email-sent-message": "활성화 이메일을 보냈습니다!", "resend-activation": "활성화 재전송", - "email": "Email", + "email": "이메일", "email-required": "Email을 입력하세요.", - "invalid-email-format": "잘못된 Email 형식입니다.", + "invalid-email-format": "잘못된 Email 형식입니다.", "first-name": "이름", "last-name": "성", "description": "설명", @@ -2076,22 +7193,27 @@ "activation-link-copied-message": "사용자 활성화 링크가 클립보드로 복사되었습니다.", "details": "상세", "login-as-tenant-admin": "테넌트 관리자로 로그인", - "login-as-customer-user": "커스터머 사용자로 로그인", + "login-as-customer-user": "고객 사용자로 로그인", "search": "사용자 검색", "selected-users": "{ count, plural, =1 {1명 사용자} other {#명 사용자} } 선택됨", "disable-account": "사용자 계정 비활성화", "enable-account": "사용자 계정 활성", "enable-account-message": "사용자 계정이 성공적으로 활성화 되었습니다!", - "disable-account-message": "사용자 계정이 성공적으로 비활성화 되었습니다!" + "disable-account-message": "사용자 계정이 성공적으로 비활성화 되었습니다!", + "copyId": "사용자 ID 복사", + "idCopiedMessage": "사용자 ID가 클립보드에 복사되었습니다.", + "user-list": "사용자 목록", + "user-list-required": "사용자 목록은 필수 항목입니다.", + "printable-one-time-password-hint": "이 인쇄 가능한 일회용 암호는 여행 중처럼 휴대전화를 사용할 수 없을 때 로그인할 수 있도록 해줍니다." }, "value": { "type": "값 유형", "string": "문자열", "string-value": "문자열 값", - "string-value-required": "문자열 값을 입력하세요", + "string-value-required": "문자열 값을 입력하세요", "integer": "정수", "integer-value": "정수 값", - "integer-value-required": "정수 값을 입력하세요", + "integer-value-required": "정수 값을 입력하세요", "invalid-integer-value": "유효하지 않은 정수 값", "double": "실수", "double-value": "실수 값", @@ -2100,24 +7222,109 @@ "boolean-value": "불리언 값", "false": "거짓", "true": "참", - "long": "Long", + "long": "정수", "json": "JSON", "json-value": "JSON 값", "json-value-invalid": "JSON 값의 형식이 잘못되었습니다", "json-value-required": "JSON 값을 입력하세요." }, + "version-control": { + "version-control": "버전 관리", + "management": "버전 관리 관리", + "search": "버전 검색", + "branch": "브랜치", + "default": "기본값", + "select-branch": "브랜치 선택", + "branch-required": "브랜치가 필요합니다", + "create-entity-version": "엔티티 버전 생성", + "version-name": "버전 이름", + "version-name-required": "버전 이름은 필수 항목입니다.", + "author": "작성자", + "export-relations": "관계 내보내기", + "export-attributes": "속성 내보내기", + "export-credentials": "자격 증명 내보내기", + "export-calculated-fields": "계산 필드 및 알람 규칙 내보내기", + "export-alarm-rules": "알람 규칙 내보내기", + "entity-versions": "엔티티 버전", + "versions": "버전", + "created-time": "생성 일시", + "version-id": "버전 ID", + "no-entity-versions-text": "엔티티 버전을 찾을 수 없습니다.", + "no-versions-text": "버전을 찾을 수 없습니다.", + "copy-full-version-id": "전체 버전 ID 복사", + "create-version": "버전 생성", + "creating-version": "버전 생성 중... 잠시 기다려주세요", + "nothing-to-commit": "커밋할 변경 사항 없음", + "restore-version": "버전 복원", + "restore-entity-from-version": "버전 '{{versionName}}'에서 엔티티 복원", + "restoring-entity-version": "엔티티 버전 복원 중... 잠시 기다려주세요", + "load-relations": "관계 로드", + "load-attributes": "속성 로드", + "load-credentials": "자격 증명 로드", + "load-calculated-fields": "계산 필드 및 알람 규칙 로드", + "load-alarm-rules": "알람 규칙 로드", + "compare-with-current": "현재와 비교", + "diff-entity-with-version": "엔티티 버전 '{{versionName}}'과(와) 차이점", + "previous-difference": "이전 차이점", + "next-difference": "다음 차이점", + "current": "현재", + "differences": "{ count, plural, =1 {1개 차이점} other {#개 차이점} }", + "create-entities-version": "엔티티 버전 생성", + "default-sync-strategy": "기본 동기화 전략", + "sync-strategy-merge": "병합", + "sync-strategy-overwrite": "덮어쓰기", + "entities-to-export": "내보낼 엔티티", + "entities-to-restore": "복원할 엔티티", + "sync-strategy": "동기화 전략", + "all-entities": "모든 엔티티", + "no-entities-to-export-prompt": "내보낼 엔티티를 지정하세요", + "no-entities-to-restore-prompt": "복원할 엔티티를 지정하세요", + "add-entity-type": "엔티티 유형 추가", + "remove-all": "모두 제거", + "version-create-result": "{ added, plural, =0 {엔티티 없음} =1 {1개 엔티티} other {#개 엔티티} } 추가됨.
{ modified, plural, =0 {엔티티 없음} =1 {1개 엔티티} other {#개 엔티티} } 수정됨.
{ removed, plural, =0 {엔티티 없음} =1 {1개 엔티티} other {#개 엔티티} } 제거됨.", + "remove-other-entities": "다른 엔티티 제거", + "find-existing-entity-by-name": "이름으로 기존 엔티티 찾기", + "restore-entities-from-version": "버전 '{{versionName}}'에서 엔티티 복원", + "restoring-entities-from-version": "엔티티 복원 중... 잠시 기다려 주세요", + "no-entities-restored": "복원된 엔티티 없음", + "created": "{{created}}개 생성됨", + "updated": "{{updated}}개 업데이트됨", + "deleted": "{{deleted}}개 삭제됨", + "remove-other-entities-confirm-text": "주의! 이 작업은 복원하려는 버전에 없는 모든 현재 엔티티를 영구적으로 삭제합니다.

확인하려면 \"다른 엔티티 제거\"를 입력하세요.", + "auto-commit-to-branch": "{{ branch }} 브랜치에 자동 커밋", + "default-create-entity-version-name": "{{entityName}} 업데이트", + "sync-strategy-merge-hint": "저장소에서 선택한 엔티티를 생성하거나 업데이트합니다. 다른 모든 저장소 엔티티는 수정되지 않습니다.", + "sync-strategy-overwrite-hint": "저장소에서 선택한 엔티티를 생성하거나 업데이트합니다. 다른 모든 저장소 엔티티는 삭제됩니다.", + "device-credentials-conflict": "외부 ID가 {{entityId}}인 장치를 로드하지 못했습니다.
동일한 자격 증명이 이미 다른 장치에 대해 데이터베이스에 있기 때문입니다.
복원 양식에서 자격 증명 로드 설정을 비활성화하는 것을 고려하세요.", + "missing-referenced-entity": "외부 ID가 {{sourceEntityId}}인 {{sourceEntityTypeName}}을(를) 로드하지 못했습니다.
ID가 {{targetEntityId}}인 누락된 {{targetEntityTypeName}}을(를) 참조하기 때문입니다.", + "runtime-failed": "실패: {{message}}", + "auto-commit-settings-read-only-hint": "자동 커밋 기능은 저장소 설정에서 읽기 전용 옵션이 활성화된 경우 작동하지 않습니다.", + "rollback-on-error": "오류 발생 시 롤백", + "rollback-on-error-hint": "복원할 엔티티가 많은 경우 이 옵션을 비활성화하여 성능을 향상시키는 것을 고려하세요.\n 참고: 버전 로드 중에 오류가 발생하면 이미 저장된 엔티티(관계, 속성 등 포함)가 그대로 유지됩니다." + }, "widget": { "widget-library": "위젯 라이브러리", "widget-bundle": "위젯 번들", + "all-bundles": "모든 번들", "select-widgets-bundle": "위젯 번들 선택", + "widgets": "위젯", + "all-widgets": "모든 위젯", + "widget": "위젯", + "select-widget": "위젯 선택", + "no-widgets-matching": "'{{entity}}'과(와) 일치하는 위젯이 없습니다.", + "no-widgets": "아직 위젯이 없음", + "no-widgets-text": "위젯을 찾을 수 없음", "management": "위젯 관리", "editor": "위젯 편집기", + "confirm-to-exit-editor-html": "저장되지 않은 위젯 설정이 있습니다.
이 페이지를 떠나시겠습니까?", "widget-type-not-found": "위젯 구성을 로드하는 중 문제가 발생했습니다.
연결된 위젯 타입이 삭제되었습니다.", "widget-type-load-error": "다음과 같은 오류로 인해 위젯을 불러오지 못했습니다:", "remove": "위젯 삭제", + "delete": "위젯 삭제", "edit": "위젯 수정", "remove-widget-title": "'{{widgetTitle}}' 위젯을 삭제하시겠습니까?", "remove-widget-text": "위젯 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "replace-reference-with-widget-copy": "참조를 위젯 복사본으로 교체", "timeseries": "시계열", "search-data": "데이터 검색", "no-data-found": "아무 데이터도 없습니다", @@ -2125,59 +7332,202 @@ "rpc": "컨트롤 위젯", "alarm": "알람 위젯", "static": "상태 위젯", + "timeseries-short": "시리즈", + "latest-short": "최신", + "rpc-short": "제어", + "alarm-short": "알람", + "static-short": "정적", "select-widget-type": "위젯 타입 선택", "missing-widget-title-error": "위젯 제목을 입력하세요!", "widget-saved": "위젯이 저장되었습니다.", "unable-to-save-widget-error": "위젯을 저장할 수 없습니다! 위젯에 오류가 있습니다!", "save": "위젯 저장", "saveAs": "다른 이름으로 위젯 저장", - "save-widget-type-as": "다른 이름으로 위젯 타입 저장", - "save-widget-type-as-text": "새로운 위젯 이름과 위젯 번들을 선택하세요.", + "move": "위젯 이동", + "save-widget-as": "다른 이름으로 위젯 저장", + "save-widget-as-text": "새 위젯 제목을 입력하세요", "toggle-fullscreen": "전체화면 전환", "run": "위젯 실행", + "widget-title": "위젯 제목", "title": "위젯 제목", "title-required": "위젯 제목을 입력하세요.", + "title-max-length": "제목은 256자 미만이어야 합니다.", + "system": "시스템", "type": "위젯 타입", "resources": "리소스", - "resource-url": "JavaScript/CSS URI", + "resource-url": "JavaScript/CSS URL", + "resource-is-extension": "확장 프로그램임", "remove-resource": "리소스 삭제", "add-resource": "리소스 추가", "html": "HTML", - "tidy": "Tidy", + "tidy": "깔끔한", "css": "CSS", - "settings-schema": "스키마 설정", - "datakey-settings-schema": "데이터 키 설정 스키마", - "javascript": "Javascript", + "settings-form": "설정 폼", + "data-key-settings-form": "데이터 키 설정 폼", + "latest-data-key-settings-form": "최신 데이터 키 설정 폼", + "widget-settings": "위젯 설정", + "description": "설명", + "tags": "태그", + "image-preview": "이미지 미리보기", + "settings-form-selector": "설정 폼 선택기", + "data-key-settings-form-selector": "데이터 키 설정 폼 선택기", + "latest-data-key-settings-form-selector": "최신 데이터 키 설정 폼 선택기", + "all": "모두", + "actual": "실제", + "scada": "SCADA 심볼", + "deprecated": "사용 중단됨", + "has-basic-mode": "기본 모드 있음", + "basic-mode-form-selector": "기본 모드 폼 선택기", + "basic-mode": "기본", + "advanced-mode": "고급", + "javascript": "JavaScript", "js": "JS", - "add-widget-type": "새로운 위젯 타입 추가", + "delete-widget-title": "위젯 '{{widgetName}}'을(를) 삭제하시겠습니까?", + "delete-widget-text": "위젯 및 관련된 모든 데이터를 복구할 수 없으므로 주의하십시오.", + "delete-widgets-title": "{ count, plural, =1 {1개 위젯} other {#개 위젯} }을(를) 삭제하시겠습니까?", + "delete-widgets-text": "주의: 확인 후 선택한 모든 위젯이 제거되고 모든 관련 데이터를 복구할 수 없게 됩니다.", + "delete-widget": "위젯 삭제", "widget-template-load-failed-error": "위젯 템플릿을 로드하지 못했습니다!", + "details": "상세 내역", + "widget-details": "위젯 세부 정보", "add": "위젯 추가", + "add-existing-widget": "기존 위젯 추가", + "add-new-widget": "위젯 추가", + "search-widgets": "위젯 검색", + "selected-widgets": "{ count, plural, =1 {1개 위젯} other {#개 위젯} } 선택됨", "undo": "위젯 변경사항 취소", "export": "위젯 내보내기", - "no-data": "No data to display on widget", - "data-overflow": "Widget displays {{count}} out of {{total}} entities", - "alarm-data-overflow": "Widget displays alarms for {{allowedEntities}} (maximum allowed) entities out of {{totalEntities}} entities" + "export-prompt": "위젯 이미지 및 리소스 포함", + "export-widgets": "위젯 내보내기", + "export-widgets-prompt": "위젯 이미지 및 리소스 포함", + "import": "위젯 가져오기", + "no-data": "위젯에 표시할 데이터 없음", + "data-overflow": "위젯이 {{total}}개 엔티티 중 {{count}}개를 표시합니다", + "alarm-data-overflow": "위젯이 {{totalEntities}}개 엔티티 중 {{allowedEntities}}개(최대 허용)에 대한 알람을 표시합니다", + "search": "위젯 검색", + "filter": "위젯 필터 유형", + "loading-widgets": "위젯 로드 중...", + "widget-template-error": "잘못된 위젯 HTML 템플릿입니다.", + "reference": "참조" }, "widget-action": { "header-button": "위젯 헤더 버튼", + "do-nothing": "아무것도 하지 않음", "open-dashboard-state": "새로운 대시보드 상태 탐색", "update-dashboard-state": "현재 대시보드 상태 업데이트", "open-dashboard": "다른 대시보드 탐색", - "custom": "Custom action", + "custom": "사용자 지정 작업", "custom-pretty": "Custom action (HTML 템플릿)", + "custom-pretty-error-title": "사용자 지정 대화 상자 오류", + "custom-pretty-template-error": "잘못된 사용자 지정 대화 상자 템플릿입니다.", + "custom-pretty-controller-error": "사용자 지정 대화 상자 함수를 평가하는 동안 오류가 발생했습니다.", + "mobile-action": "모바일 작업", "target-dashboard-state": "대상 대시보드 상태", "target-dashboard-state-required": "대상 대시보드 상태가 필요합니다.", "set-entity-from-widget": "위젯으로 부터 객체 설정", "target-dashboard": "대상 대시보드", - "open-right-layout": "Open right dashboard layout (모바일 보기)" + "select-target-dashboard": "대상 대시보드 선택", + "target-dashboard-required": "대상 대시보드가 필요합니다.", + "open-right-layout": "Open right dashboard layout (모바일 보기)", + "state-display-type": "대시보드 상태 표시 옵션", + "open-normal": "정상", + "open-in-separate-dialog": "별도의 대화 상자에서 열기", + "open-in-popover": "팝오버에서 열기", + "dialog-title": "대화 상자 제목", + "dialog-hide-dashboard-toolbar": "대화 상자에서 대시보드 도구 모음 숨기기", + "dialog-width": "뷰포트 너비에 대한 대화 상자 너비 비율", + "dialog-height": "뷰포트 높이에 대한 대화 상자 높이 비율", + "dialog-size-range-error": "대화 상자 크기 비율 값은 1에서 100 사이여야 합니다.", + "popover-preferred-placement": "선호하는 팝오버 배치", + "popover-placement-top": "상단", + "popover-placement-topLeft": "왼쪽 상단", + "popover-placement-topRight": "오른쪽 상단", + "popover-placement-right": "오른쪽", + "popover-placement-rightTop": "오른쪽 상단", + "popover-placement-rightBottom": "오른쪽 하단", + "popover-placement-bottom": "하단", + "popover-placement-bottomLeft": "왼쪽 하단", + "popover-placement-bottomRight": "오른쪽 하단", + "popover-placement-left": "왼쪽", + "popover-placement-leftTop": "왼쪽 상단", + "popover-placement-leftBottom": "왼쪽 하단", + "popover-hide-on-click-outside": "외부 클릭 시 팝오버 숨기기", + "popover-hide-dashboard-toolbar": "팝오버에서 대시보드 도구 모음 숨기기", + "popover-width": "팝오버 너비", + "popover-height": "팝오버 높이", + "popover-style": "팝오버 스타일", + "open-new-browser-tab": "새 브라우저 탭에서 열기", + "open-URL": "URL 열기", + "URL": "URL", + "url-required": "URL을(를) 입력하세요.", + "mobile": { + "device-provision": "장치 프로비저닝", + "action-type": "모바일 작업 유형", + "select-action-type": "모바일 작업 유형 선택", + "action-type-required": "모바일 작업 유형이 필요합니다", + "take-picture-from-gallery": "갤러리에서 사진 가져오기", + "take-photo": "사진 촬영", + "map-direction": "지도 경로 열기", + "map-location": "지도 위치 열기", + "scan-qr-code": "QR 코드 스캔", + "make-phone-call": "전화 걸기", + "get-location": "휴대전화 위치 가져오기", + "take-screenshot": "스크린샷 촬영", + "handle-provision-success-function": "프로비저닝 성공 처리 함수", + "get-location-function": "위치 가져오기 함수", + "process-launch-result-function": "실행 결과 처리 함수", + "get-phone-number-function": "전화번호 가져오기 함수", + "process-image-function": "이미지 처리 함수", + "process-qr-code-function": "QR 코드 처리 함수", + "process-location-function": "위치 처리 함수", + "handle-empty-result-function": "빈 결과 처리 함수", + "handle-error-function": "오류 처리 함수", + "handle-non-mobile-fallback-function": "비모바일 폴백 처리 함수", + "save-to-gallery": "갤러리에 저장", + "provision-type": "프로비저닝 유형", + "auto": "자동", + "wi-fi": "와이파이", + "ble": "BLE", + "soft-ap": "소프트 AP" + }, + "custom-action-function": "사용자 지정 작업 함수", + "custom-pretty-function": "사용자 지정 작업 (HTML 템플릿 포함) 함수", + "map-item-type": "지도 항목 유형", + "map-item": { + "marker": "마커", + "polygon": "다각형", + "rectangle": "사각형", + "circle": "원", + "polyline": "폴리라인" + }, + "place-map-item": "지도 항목 배치", + "map-item-tooltip": { + "customize-map-item-tooltips": "지도 항목 툴팁 사용자 지정", + "place-marker": "마커 배치", + "start-draw-rectangle": "사각형 그리기 시작", + "finish-draw-rectangle": "사각형 그리기 완료", + "start-draw-polygon": "다각형 그리기 시작", + "continue-draw-polygon": "다각형 그리기 계속", + "finish-draw-polygon": "다각형 그리기 완료", + "start-draw-circle": "원 그리기 시작", + "finish-draw-circle": "원 그리기 완료", + "start-draw-polyline": "폴리라인 그리기 시작", + "finish-draw-polyline": "폴리라인 그리기 완료" + } }, "widgets-bundle": { "current": "현재 번들", "widgets-bundles": "위젯 번들", + "widgets-bundle-widgets": "위젯 번들 위젯", "add": "위젯 번들 추가", "delete": "위젯 번들 삭제", "title": "제목", "title-required": "제목을 입력하세요.", + "title-max-length": "제목은 256자 미만이어야 합니다.", + "description": "설명", + "image-preview": "이미지 미리보기", + "scada": "SCADA 위젯 번들", + "order": "순서", "add-widgets-bundle-text": "위젯 번들 추가", "no-widgets-bundles-text": "위젯 번들이 없습니다.", "empty": "위젯 번들이 비어있습니다.", @@ -2193,41 +7543,63 @@ "system": "시스템", "import": "위젯 번들 가져오기", "export": "위젯 번들 내보내기", + "export-widgets-bundle-widgets-prompt": "내보낸 데이터에 번들 위젯 포함 (그렇지 않으면 참조된 위젯 FQN만 내보내짐)", "export-failed-error": "위젯 번들을 내보내기 할 수 없습니다.: {{error}}", "create-new-widgets-bundle": "새로운 위젯 번들 생성", "widgets-bundle-file": "위젯 번들 파일", "invalid-widgets-bundle-file-error": "위젯 번들을 가져오기 할 수 없습니다.: 잘못된 위젯 번들 데이터 구조입니다.", "search": "위젯 번들 검색", "selected-widgets-bundles": "{ count, plural, =1 {1 개 위젯 번들} other {# 개 위젯 번들} } 선택됨", - "open-widgets-bundle": "위젯 번들 열기" + "open-widgets-bundle": "위젯 번들 열기", + "loading-widgets-bundles": "위젯 번들 로드 중...", + "create-new": "새 위젯 번들 만들기" }, "widget-config": { "data": "데이터", "settings": "설정", "advanced": "고급", + "appearance": "외관", + "widget-card": "위젯 카드", + "mobile": "모바일", "title": "제목", "title-tooltip": "제목 툴팁", "general-settings": "일반 설정", "display-title": "제목 표시", + "card-title": "카드 제목", "drop-shadow": "그림자", "enable-fullscreen": "전체화면 사용 ", "background-color": "배경 색", "text-color": "글자 색", + "border-radius": "테두리 반경", "padding": "패딩", "margin": "여백", "widget-style": "위젯 스타일", + "widget-css": "위젯 CSS", "title-style": "제목 스타일", "mobile-mode-settings": "모바일 모드 설정", "order": "순서", "height": "높이", + "mobile-hide": "모바일 모드에서 위젯 숨기기", + "desktop-hide": "데스크톱 모드에서 위젯 숨기기", "units": "값 옆에 표시할 특수 기호", + "units-by-default": "기본 단위", "decimals": "소수점 이하 자릿수", + "decimals-by-default": "기본 소수 자릿수", + "default-data-key-parameter-hint": "이 매개변수는 데이터 키 구성에 의해 재정의되지 않는 한 모든 위젯 값에 적용됩니다", + "units-short": "단위", + "decimals-short": "소수 자릿수", + "decimals-suffix": "소수 자릿수", + "digits-suffix": "자릿수", "timewindow": "타임윈도우", "use-dashboard-timewindow": "대시보드 타임윈도우", + "use-widget-timewindow": "위젯 시간 창 사용", "display-timewindow": "타임윈도우 표시", + "legend": "범례", "display-legend": "범례 표시", "datasources": "데이터 소스", - "maximum-datasources": "최대 { count, plural, =1 {1 개의 데이터 소스만 허용됩니다.} other {# 개의 데이터 소스만 허용됩니다.} }", + "datasource": "데이터소스", + "maximum-datasources": "최대 { count, plural, =1 {1 개의 데이터 소스만 허용됩니다.} other {# 개의 데이터 소스만 허용됩니다.} }", + "timeseries-key-error": "최소 하나의 시계열 데이터 키를 지정해야 합니다", "datasource-type": "유형", "datasource-parameters": "파라미터", "remove-datasource": "데이터소스 삭제", @@ -2240,31 +7612,548 @@ "search-actions": "액션 검색", "no-actions-text": "아무 액션도 없습니다", "action-source": "액션 소스", + "select-action-source": "작업 소스 선택", "action-source-required": "액션 소스를 입력하세요.", + "column-index": "열 인덱스", + "select-column-index": "열 인덱스 선택", + "column-index-required": "열 인덱스가 필요합니다.", + "not-set": "설정되지 않음", "action-name": "이름", "action-name-required": "액션 이름을 입력하세요.", "action-name-not-unique": "같은 이름의 액션이 이미 존재합니다.\n같은 액션 소스에서 액션 이름이 중복될 수 없습니다.", "action-icon": "아이콘", + "header-button": { + "button-settings": "버튼 설정", + "button-type": "버튼 유형", + "button-type-basic": "기본", + "button-type-raised": "양각", + "button-type-stroked": "윤곽선", + "button-type-flat": "평면", + "button-type-icon": "아이콘", + "button-type-mini-fab": "플로팅 버튼", + "colors": "색상", + "color": "색상", + "background": "배경", + "border": "테두리", + "advanced-button-style": "고급 버튼 스타일", + "button-style": "버튼 스타일" + }, + "show-hide-action-using-function": "함수를 사용하여 작업 표시/숨기기", + "show-action-function": "작업 표시 함수", "action-type": "유형", "action-type-required": "액션 유형을 입력하세요.", "edit-action": "액션 편집", "delete-action": "액션 삭제", "delete-action-title": "위젯 액션 삭제", "delete-action-text": "위젯 액션 '{{actionName}}'을(를) 삭제하시겠습니까?", + "title-icon": "제목 아이콘", "display-icon": "제목 아이콘 표시", + "card-icon": "카드 아이콘", + "icon": "아이콘", "icon-color": "아이콘 색상", - "icon-size": "아이콘 크기" + "icon-size": "아이콘 크기", + "advanced-settings": "고급 설정", + "data-settings": "데이터 설정", + "limits": "제한", + "no-data-display-message": "\"표시할 데이터 없음\" 대체 메시지", + "data-page-size": "데이터 소스당 최대 엔티티 수", + "settings-component-not-found": "선택기 '{{selector}}'에 대한 설정 폼 구성 요소를 찾을 수 없습니다", + "preview": "미리보기", + "set": "설정", + "set-message": "메시지 설정", + "advanced-title-style": "고급 제목 스타일", + "card-style": "카드 스타일", + "text": "텍스트", + "background": "배경", + "advanced-widget-style": "고급 위젯 스타일", + "card-buttons": "카드 버튼", + "show-card-buttons": "카드 버튼 표시", + "card-border-radius": "카드 테두리 반경", + "card-padding": "카드 패딩", + "card-appearance": "카드 외관", + "color": "색상", + "tooltip": "툴팁", + "units-required": "단위는 필수 항목입니다.", + "list-layout": "목록 레이아웃", + "layout": "레이아웃", + "resize-options": "크기 조정 옵션", + "resizable": "크기 조정 가능", + "preserve-aspect-ratio": "가로세로 비율 유지" }, "widget-type": { "import": "위젯 타입 가져오기", "export": "위젯 타입 내보내기", "export-failed-error": "위젯 타입을 내보내기 할 수 없습니다.: {{error}}", - "create-new-widget-type": "새로운 위젯 타입 생성", - "widget-type-file": "위젯 타입 파일", - "invalid-widget-type-file-error": "위젯 타입을 가져오기 할 수 없습니다.: 잘못된 위젯 타입 데이터 구조입니다." + "widget-file": "위젯 파일", + "invalid-widget-file-error": "위젯 가져오기를 할 수 없습니다: 위젯 데이터 구조가 잘못되었습니다." }, "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "구성은 플랫폼 기본 설정의 모바일 앱 QR 코드 위젯에 따라 달라집니다", + "get-it-on-google-play": "Google Play에서 받기", + "download-on-the-app-store": "App Store에서 다운로드" + }, + "action-button": { + "behavior": "동작", + "on-click": "클릭 시", + "on-click-hint": "버튼을 클릭할 때 트리거되는 동작", + "first-button-click": "첫 번째 버튼 클릭", + "first-button-click-hint": "첫 번째 버튼을 누르는 동안 동작.", + "second-button-click": "두 번째 버튼 클릭", + "second-button-click-hint": "두 번째 버튼을 누르는 동안 동작.", + "button-click-hint": "위젯을 누르는 동안 동작." + }, + "command-button": { + "behavior": "동작", + "on-click": "클릭 시", + "on-click-hint": "버튼을 클릭할 때 수행되는 동작." + }, + "power-button": { + "behavior": "동작", + "power-on": "전원 '켜기'", + "power-on-hint": "구성 요소의 전원을 켜기 위해 수행되는 동작.", + "power-off": "전원 '끄기'", + "power-off-hint": "구성 요소의 전원을 끄기 위해 수행되는 동작.", + "on-label": "켜기", + "off-label": "끄기", + "layout": "레이아웃", + "layout-default": "기본값", + "layout-simplified": "간소화", + "layout-outlined": "윤곽선", + "layout-default-volume": "기본.볼륨", + "layout-simplified-volume": "간소화.볼륨", + "layout-outlined-volume": "윤곽선.볼륨", + "layout-default-icon": "기본.아이콘", + "layout-simplified-icon": "간소화.아이콘", + "layout-outlined-icon": "윤곽선.아이콘", + "main": "메인", + "background": "배경", + "button-icon-on": "버튼 아이콘 '켜기'", + "button-icon-off": "버튼 아이콘 '끄기'", + "power-on-colors": "전원 '켜기' 색상", + "power-off-colors": "전원 '끄기' 색상", + "disabled-colors": "비활성화 색상", + "button": "버튼" + }, + "toggle-button": { + "behavior": "동작", + "checked": "선택됨", + "unchecked": "선택 해제됨", + "check": "선택", + "check-hint": "구성 요소를 선택하기 위해 수행되는 동작.", + "uncheck": "선택 해제", + "uncheck-hint": "구성 요소를 선택 해제하기 위해 수행되는 동작.", + "auto-scale": "자동 크기 조정", + "horizontal-fill": "가로 채우기", + "vertical-fill": "세로 채우기", + "button-appearance": "버튼 외관" + }, + "segmented-button": { + "layout": "레이아웃", + "layout-squared": "사각형", + "layout-rounded": "둥근", + "card-border": "카드 테두리", + "button-appearance": "버튼 외관", + "first": "첫 번째", + "second": "초", + "color-styles": "색상 스타일", + "selected": "선택됨", + "unselected": "선택되지 않음" + }, + "button": { + "layout": "레이아웃", + "outlined": "윤곽선", + "filled": "채워짐", + "underlined": "밑줄", + "basic": "기본", + "auto-scale": "자동 크기 조정", + "label": "라벨", + "icon": "아이콘", + "border-radius": "테두리 반경", + "color-palette": "색상 팔레트", + "main": "메인", + "background": "배경", + "border": "테두리", + "custom-styles": "사용자 지정 스타일", + "clear-style": "스타일 지우기", + "shadow": "그림자", + "enabled": "사용", + "disabled": "비활성화됨", + "preview": "미리보기", + "copy-style-from": "스타일 복사 원본" + }, + "value-stepper": { + "behavior": "동작", + "simplified": "간소화", + "filled": "채워짐", + "outlined": "윤곽선", + "volume": "부피", + "initial-state": "초기 상태", + "initial-state-hint": "초기 값을 가져오는 동작.", + "disabled-state": "비활성화 상태", + "disabled-state-hint": "구성 요소가 비활성화되는 조건을 구성합니다.", + "right-button-click": "오른쪽 버튼 클릭", + "right-button-click-hint": "오른쪽 버튼을 누르는 동안 동작.", + "left-button-click": "왼쪽 버튼 클릭", + "left-button-click-hint": "왼쪽 버튼을 누르는 동안 동작.", + "auto-scale": "자동 크기 조정", + "value-range": "범위", + "min-range": "최소", + "max-range": "최대", + "value-increment-decrement-step": "값 증가/감소 단계", + "value": "값", + "value-box-background": "값 상자 배경", + "border": "테두리", + "button-appearance": "버튼 외관", + "left": "왼쪽", + "right": "오른쪽", + "left-button": "왼쪽 버튼", + "right-button": "오른쪽 버튼", + "icon": "아이콘", + "color-palette": "색상 팔레트", + "main": "메인", + "background": "배경", + "button-icon-on": "버튼 아이콘 '켜기'", + "button-on-colors": "전원 '켜기' 색상", + "disabled-colors": "비활성화 색상" + }, + "button-state": { + "activated-state": "활성화 상태", + "activated-state-hint": "버튼이 활성화되는 조건을 구성합니다.", + "disabled-state": "비활성화 상태", + "disabled-state-hint": "버튼이 비활성화되는 조건을 구성합니다.", + "selected-state": "선택 버튼", + "selected-state-hint": "버튼이 선택되는 조건을 구성합니다.", + "enabled": "사용", + "hovered": "호버됨", + "pressed": "눌림", + "activated": "활성", + "disabled": "비활성화됨", + "initial": "첫 번째 버튼", + "first": "첫 번째", + "second": "초" + }, + "background": { + "background": "배경", + "background-settings": "배경 설정", + "background-type-image": "이미지", + "background-type-color": "색상", + "image-url": "이미지 URL", + "overlay": "오버레이", + "enable-overlay": "오버레이 활성화", + "blur": "흐림", + "preview": "미리보기" + }, + "bar-chart": { + "bar-appearance": "막대 외관", + "label-on-bar": "막대에 레이블", + "value-on-bar": "막대에 값", + "bar-chart-style": "막대 차트 스타일", + "bar-axis": "막대 축" + }, + "polar-area-chart": { + "polar-axis": "극축", + "start-angle": "시작 각도", + "polar-area-chart-style": "극 영역 차트 스타일" + }, + "battery-level": { + "layout": "레이아웃", + "layout-vertical-solid": "수직. 실선", + "layout-horizontal-solid": "수평. 실선", + "layout-vertical-divided": "수직. 분할", + "layout-horizontal-divided": "수평. 분할", + "icon": "아이콘", + "value": "값", + "auto-scale": "자동 크기 조정", + "battery-level-color": "배터리 레벨 색상", + "battery-shape-color": "배터리 모양 색상", + "battery-level-card-style": "배터리 레벨 카드 스타일", + "sections-count": "섹션 수" + }, + "signal-strength": { + "value": "값", + "last-update": "마지막 업데이트", + "no-signal": "신호 없음", + "layout": "레이아웃", + "layout-wifi": "와이파이", + "layout-cellular-bar": "셀룰러 막대", + "icon": "아이콘", + "date": "날짜", + "active-bars-color": "활성 신호 막대 색상", + "inactive-bars-color": "비활성 신호 막대 색상", + "signal-strength-card-style": "신호 강도 카드 스타일", + "no-signal-rssi-value": "\"신호 없음\" RSSI 값" + }, + "status-widget": { + "behavior": "동작", + "layout": "레이아웃", + "layout-default": "기본값", + "layout-center": "중앙", + "layout-icon": "아이콘", + "on": "켜기", + "off": "끄기", + "label": "라벨", + "status": "상태", + "icon": "아이콘", + "color-palette": "색상 팔레트", + "disabled-color-palette": "비활성화 색상 팔레트", + "primary": "기본", + "primary-color-hint": "아이콘 및 레이블 색상", + "secondary": "보조", + "secondary-color-hint": "상태 색상", + "background": "배경" + }, + "chart": { + "common-settings": "일반 설정", + "enable-stacking-mode": "스택 모드 활성화", + "selection": "시간 범위 선택", + "enable-selection-mode": "선택 모드 활성화", + "line-shadow-size": "선 그림자 크기", + "display-smooth-lines": "부드러운 (곡선) 선 표시", + "default-bar-width": "비집계 데이터의 기본 막대 너비 (밀리초)", + "bar-alignment": "막대 정렬", + "bar-alignment-left": "왼쪽", + "bar-alignment-right": "오른쪽", + "bar-alignment-center": "중앙", + "default-font": "기본 글꼴", + "default-font-size": "기본 글꼴 크기", + "default-font-color": "기본 글꼴 색상", + "thresholds-line-width": "모든 임계값의 기본 선 너비", + "tooltip-settings": "툴팁 설정", + "tooltip": "툴팁", + "show-tooltip": "툴팁 표시", + "hover-individual-points": "개별 지점 호버", + "show-cumulative-values": "스택 모드에서 누적 값 표시", + "hide-zero-false-values": "툴팁에서 0/false 값 숨기기", + "tooltip-value-format-function": "툴팁 값 형식 함수", + "grid-settings": "그리드 설정", + "show-vertical-lines": "수직선 표시", + "show-horizontal-lines": "수평선 표시", + "grid-outline-border-width": "그리드 윤곽선/테두리 너비 (px)", + "primary-color": "기본 색상", + "background-color": "배경색", + "ticks-color": "눈금 색상", + "xaxis-settings": "X축 설정", + "axis-title": "축 제목", + "xaxis-tick-labels-settings": "X축 눈금 레이블 설정", + "show-tick-labels": "축 눈금 레이블 표시", + "yaxis-settings": "Y축 설정", + "min-scale-value": "눈금의 최소 값", + "max-scale-value": "눈금의 최대 값", + "yaxis-tick-labels-settings": "Y축 눈금 레이블 설정", + "tick-step-size": "눈금 사이의 단계 크기", + "number-of-decimals": "표시할 소수 자릿수", + "ticks-formatter-function": "눈금 형식 함수", + "comparison-settings": "비교 설정", + "enable-comparison": "비교 활성화", + "time-for-comparison": "비교 기간", + "time-for-comparison-previous-interval": "이전 간격 (기본값)", + "time-for-comparison-days": "1일 전", + "time-for-comparison-weeks": "1주 전", + "time-for-comparison-months": "1개월 전", + "time-for-comparison-years": "1년 전", + "time-for-comparison-custom-interval": "사용자 지정 간격", + "custom-interval-value": "사용자 지정 간격 값 (ms)", + "comparison-x-axis-settings": "비교 X축 설정", + "axis-position": "축 위치", + "axis-position-top": "위 (기본값)", + "axis-position-bottom": "하단", + "custom-legend-settings": "사용자 지정 범례 설정", + "enable-custom-legend": "사용자 지정 범례 활성화 (키 레이블에 속성/시계열 값을 사용할 수 있음)", + "key-name": "키 이름", + "key-name-required": "키 이름은 필수 항목입니다.", + "key-type": "키 유형", + "key-type-attribute": "속성", + "key-type-timeseries": "시계열", + "label-keys-list": "레이블에 사용할 키 목록", + "no-label-keys": "구성된 키 없음", + "add-label-key": "새 키 추가", + "line-width": "선 너비", + "color": "색상", + "data-is-hidden-by-default": "데이터가 기본적으로 숨겨짐", + "disable-data-hiding": "데이터 숨기기 비활성화", + "remove-from-legend": "범례에서 데이터 키 제거", + "exclude-from-stacking": "스택에서 제외 (\"스택\" 모드에서 사용 가능)", + "line-settings": "선 설정", + "show-line": "선 표시", + "fill-line": "선 채우기", + "fill-line-opacity": "채우기 불투명도", + "points-settings": "포인트 설정", + "show-points": "지점 표시", + "points-line-width": "포인트 선 너비", + "points-radius": "포인트 반지름", + "point-shape": "포인트 모양", + "point-shape-circle": "원", + "point-shape-cross": "십자가", + "point-shape-diamond": "다이아몬드", + "point-shape-square": "사각형", + "point-shape-triangle": "삼각형", + "point-shape-custom": "사용자 지정 함수", + "point-shape-draw-function": "포인트 모양 그리기 함수", + "show-separate-axis": "별도의 축 표시", + "axis-position-left": "왼쪽", + "axis-position-right": "오른쪽", + "thresholds": "임계값", + "no-thresholds": "구성된 임계값 없음", + "add-threshold": "임계값 추가", + "show-values-for-comparison": "비교를 위한 과거 값 표시", + "comparison-values-label": "과거 값 레이블", + "comparison-line-color": "비교 선 색상", + "threshold-settings": "임계값 설정", + "use-as-threshold": "키 값을 임계값으로 사용", + "threshold-line-width": "임계값 선 너비", + "threshold-color": "임계값 색상", + "common-pie-settings": "공통 원형 차트 설정", + "radius": "반경", + "inner-radius": "내부 반지름", + "tilt": "기울기", + "common-pie-settings-range-error": "값은 0에서 1 사이여야 합니다", + "stroke-settings": "획 설정", + "width-pixels": "너비 (픽셀)", + "show-labels": "레이블 표시", + "animation-settings": "애니메이션 설정", + "animated-pie": "원형 차트 애니메이션 활성화 (실험적)", + "border-settings": "테두리 설정", + "border-width": "테두리 너비", + "border-color": "테두리 색상", + "legend-settings": "범례 설정", + "display-legend": "범례 표시", + "labels-font-color": "레이블 글꼴 색상", + "series": "시리즈", + "add-series": "시리즈 추가", + "series-settings": "시리즈 설정", + "remove-series": "시리즈 제거", + "no-series": "구성된 시리즈 없음", + "no-series-error": "최소 하나의 시리즈를 지정해야 합니다", + "chart-appearance": "차트 모양", + "vertical-grid-lines": "수직 격자선", + "horizontal-grid-lines": "수평 격자선", + "chart-background": "차트 배경", + "grid-lines-color": "격자선 색상", + "border": "테두리", + "axis": "축", + "vertical-axis": "수직 축", + "ticks": "눈금", + "horizontal-axis": "수평 축", + "shape-empty-circle": "빈 원", + "shape-circle": "원", + "shape-rect": "사각형", + "shape-round-rect": "둥근 사각형", + "shape-triangle": "삼각형", + "shape-diamond": "다이아몬드", + "shape-pin": "핀", + "shape-arrow": "화살표", + "shape-none": "없음", + "line-type-solid": "실선", + "line-type-dashed": "파선", + "line-type-dotted": "점선", + "label-position-top": "상단", + "label-position-bottom": "하단", + "label-position-outside": "외부", + "label-position-inside": "내부", + "fill": "채우기", + "fill-type-none": "없음", + "fill-type-solid": "실선", + "fill-type-opacity": "불투명도", + "fill-type-gradient": "그라데이션", + "background": "배경", + "opacity": "불투명도", + "gradient-stops": "그라데이션 중지점", + "gradient-start": "시작", + "gradient-end": "종료", + "animation": { + "animation": "애니메이션", + "animation-threshold": "애니메이션 임계값", + "animation-duration": "애니메이션 지속 시간", + "animation-easing": "애니메이션 완화", + "animation-delay": "애니메이션 지연", + "update-animation-duration": "업데이트 애니메이션 지속 시간", + "update-animation-easing": "업데이트 애니메이션 완화", + "update-animation-delay": "업데이트 애니메이션 지연" + }, + "chart-axis": { + "limit": "제한", + "source": "소스", + "key-value": "키 / 값", + "value-required": "값이 필요합니다.", + "entity-key-required": "엔티티 키는 필수 항목입니다.", + "key-required": "키는 필수 항목입니다.", + "scale-limits": "스케일 제한", + "scale-appearance": "스케일 모양", + "scale": "눈금", + "scale-min": "최소", + "scale-max": "최대", + "scale-auto": "자동" + }, + "bar": { + "show-border": "테두리 표시", + "border-width": "테두리 너비", + "border-radius": "테두리 반경", + "bar-width": "막대 너비", + "label": "라벨", + "label-hint": "막대 위에 레이블 표시.", + "series-label-hint": "막대 위에 값이 있는 레이블 표시.", + "label-background": "레이블 배경" + } + }, + "color": { + "color-settings": "색상 설정", + "color-type-constant": "상수", + "color-type-gradient": "그라데이션", + "color-type-range": "범위", + "color-type-function": "함수", + "color": "색상", + "value-range": "값 범위", + "from": "부터", + "to": "까지", + "color-function": "색상 함수", + "copy-color-settings-from": "색상 설정 복사 위치", + "copy-from": "복사 원본", + "settings-type": "설정 유형", + "basic-mode": "기본", + "advanced-mode": "고급", + "entity-alias": "엔티티 별명", + "entity-attribute": "엔티티 속성", + "gradient-color": "그라데이션 색상", + "gradient-color-min": "색상", + "gradient-start": "그라데이션 시작 색상", + "gradient-start-min": "시작", + "gradient-end": "그라데이션 종료 색상", + "gradient-end-min": "끝", + "start-value": "시작 값", + "end-value": "종료 값", + "gradient-type": "그라데이션 유형" + }, + "dashboard-state": { + "dashboard-state-settings": "대시보드 상태 설정", + "dashboard-state": "대시보드 상태 ID", + "autofill-state-layout": "기본적으로 상태 레이아웃 높이 자동 채우기", + "default-margin": "기본 위젯 여백", + "default-background-color": "기본 배경색", + "sync-parent-state-params": "상태 매개변수를 부모 대시보드와 동기화" + }, "date-range-navigator": { + "date-range-picker-settings": "날짜 범위 선택기 설정", + "hide-date-range-picker": "날짜 범위 선택기 숨기기", + "picker-one-panel": "날짜 범위 선택기 단일 패널", + "picker-auto-confirm": "날짜 범위 선택기 자동 확인", + "picker-show-template": "날짜 범위 선택기 템플릿 표시", + "first-day-of-week": "주의 첫 번째 요일", + "interval-settings": "간격 설정", + "hide-interval": "간격 숨기기", + "initial-interval": "초기 간격", + "interval-hour": "시간", + "interval-day": "일", + "interval-week": "주", + "interval-two-weeks": "2주", + "interval-month": "달", + "interval-three-months": "3개월", + "interval-six-months": "6개월", + "step-settings": "단계 설정", + "hide-step-size": "단계 크기 숨기기", + "initial-step-size": "초기 단계 크기", + "hide-labels": "레이블 숨기기", + "use-session-storage": "세션 저장소 사용", "localizationMap": { "Sun": "일", "Mon": "월", @@ -2321,6 +8210,228 @@ "Ok": "확인" } }, + "doughnut": { + "doughnut-appearance": "도넛 모양", + "layout": "레이아웃", + "layout-default": "기본값", + "layout-with-total": "합계 포함", + "central-total-value": "중앙 합계 값", + "doughnut-card-style": "도넛 카드 스타일" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "계층 데이터 설정", + "relations-query-function": "노드 관계 쿼리 함수", + "has-children-function": "노드에 자식 있음 함수", + "node-state-settings": "노드 상태 설정", + "node-opened-function": "기본 노드 열림 함수", + "node-disabled-function": "노드 비활성화 함수", + "display-settings": "표시 설정", + "node-icon-function": "노드 아이콘 함수", + "node-text-function": "노드 텍스트 함수", + "sort-settings": "정렬 설정", + "nodes-sort-function": "노드 정렬 함수" + }, + "edge": { + "display-default-title": "기본 제목 표시" + }, + "gateway": { + "general-settings": "일반 설정", + "widget-title": "위젯 제목", + "default-archive-file-name": "기본 아카이브 파일 이름", + "device-type-for-new-gateway": "새 게이트웨이에 대한 장치 유형", + "messages-settings": "메시지 설정", + "save-config-success-message": "게이트웨이 구성이 성공적으로 저장되었다는 텍스트 메시지", + "device-name-exists-message": "입력한 이름의 장치가 이미 존재할 때 텍스트 메시지", + "gateway-title": "게이트웨이 양식", + "read-only": "읽기 전용", + "events-title": "게이트웨이 이벤트 양식 제목", + "events-filter": "이벤트 필터", + "event-key-contains": "이벤트 키 포함...", + "show-connector": "커넥터에 표시", + "connector-state-param-key": "커넥터 상태 매개변수 키", + "message": "메시지", + "level": "수준", + "created-time": "생성 일시" + }, + "gauge": { + "default-color": "기본 색상", + "radial-gauge-settings": "방사형 게이지 설정", + "ticks-settings": "눈금 설정", + "min-value": "최소값", + "max-value": "최대값", + "min-value-short": "최소", + "max-value-short": "최대", + "start-ticks-angle": "시작 눈금 각도", + "ticks-angle": "눈금 각도", + "major-ticks": "주 눈금", + "major-ticks-count": "주 눈금 수", + "major-ticks-color": "주 눈금 색상", + "minor-ticks": "보조 눈금", + "minor-ticks-count": "보조 눈금 수", + "minor-ticks-color": "보조 눈금 색상", + "tick-numbers-font": "눈금 숫자 글꼴", + "unit-title-settings": "단위 제목 설정", + "show-unit-title": "단위 제목", + "unit-title": "단위 제목", + "title-font": "제목 텍스트 글꼴", + "units-settings": "단위 설정", + "units-font": "단위 텍스트 글꼴", + "value-box-settings": "값 상자 설정", + "show-value-box": "값 상자 표시", + "value-box": "값 상자", + "value-int": "값의 정수 부분 자릿수", + "value-text": "값 텍스트", + "value-text-shadow": "값 텍스트 그림자", + "value-font": "값 텍스트 글꼴", + "rect-stroke-color-start": "사각형 획 색상 - 시작 그라데이션", + "rect-stroke-color-end": "사각형 획 색상 - 종료 그라데이션", + "background-color": "배경색", + "shadow-color": "그림자 색상", + "value-box-rect-stroke-color": "값 상자 사각형 획 색상", + "value-box-rect-stroke-color-end": "값 상자 사각형 획 색상 - 종료 그라데이션", + "value-box-background-color": "값 상자 배경색", + "value-box-shadow-color": "값 상자 그림자 색상", + "plate-settings": "플레이트 설정", + "show-plate-border": "플레이트 테두리", + "plate-color": "플레이트 색상", + "needle-settings": "바늘 설정", + "needle-circle-size": "바늘 원 크기", + "needle-color": "바늘 색상", + "needle-color-start": "바늘 색상 - 시작 그라데이션", + "needle-color-end": "바늘 색상 - 종료 그라데이션", + "needle-color-shadow-up": "바늘 상반부 그림자 색상", + "needle-color-shadow-down": "그림자", + "highlights-settings": "하이라이트 설정", + "highlights-width": "하이라이트 너비", + "highlights": "하이라이트", + "highlight-from": "부터", + "highlight-to": "까지", + "highlight-color": "색상", + "no-highlights": "구성된 하이라이트 없음", + "add-highlight": "하이라이트 추가", + "animation-settings": "애니메이션 설정", + "enable-animation": "애니메이션", + "animation-duration-rule": "애니메이션 지속 시간 및 규칙", + "animation-duration": "애니메이션 지속 시간", + "animation-rule": "애니메이션 규칙", + "animation-linear": "선형", + "animation-quad": "2차", + "animation-quint": "5차", + "animation-cycle": "사이클", + "animation-bounce": "바운스", + "animation-elastic": "탄성", + "animation-dequad": "역2차", + "animation-dequint": "역5차", + "animation-decycle": "역사이클", + "animation-debounce": "역바운스", + "animation-delastic": "역탄성", + "linear-gauge-settings": "선형 게이지 설정", + "bar-stroke": "막대 획", + "bar-stroke-width": "막대 획 너비", + "bar-stroke-color": "막대 획 색상", + "bar-background-color": "막대 배경색 - 시작 그라데이션", + "bar-background-color-end": "막대 배경색 - 끝 그라데이션", + "progress-bar-color": "진행 막대 색상", + "progress-bar": "진행 막대", + "progress-bar-color-start": "진행 막대 색상 - 시작 그라데이션", + "progress-bar-color-end": "진행 막대 색상 - 끝 그라데이션", + "major-ticks-names": "주요 눈금 이름", + "show-stroke-ticks": "눈금 획 표시", + "major-ticks-font": "주요 눈금 글꼴", + "border-color": "테두리 색상", + "border-width": "테두리 너비", + "needle-circle": "바늘 원", + "needle-circle-color": "바늘 원 색상", + "animation-target": "애니메이션 대상", + "animation-target-needle": "바늘", + "animation-target-plate": "플레이트", + "common-settings": "일반 게이지 설정", + "gauge-type": "게이지 유형", + "gauge-type-arc": "호", + "gauge-type-donut": "도넛", + "gauge-type-horizontal-bar": "수평 막대", + "gauge-type-vertical-bar": "수직 막대", + "donut-start-angle": "시작 각도 (도)", + "bar-settings": "게이지 막대 설정", + "relative-bar-width": "상대적 막대 너비", + "neon-glow-brightness": "네온 글로우 효과 밝기 (0-100)", + "neon-glow-brightness-hint": "0 - 효과 비활성화", + "stripes-thickness": "줄무늬 두께", + "stripes-thickness-hint": "0 - 줄무늬 없음", + "rounded-line-cap": "둥근 선 끝", + "bar-color-settings": "막대 색상 설정", + "use-precise-level-color-values": "정밀 색상 레벨 사용", + "bar-colors": "막대 색상, 아래에서 위로", + "color": "색상", + "no-bar-colors": "구성된 막대 색상 없음", + "add-bar-color": "막대 색상 추가", + "from": "부터", + "to": "까지", + "fixed-level-colors": "경계 값을 사용한 막대 색상", + "gauge-title-settings": "게이지 제목 설정", + "show-gauge-title": "게이지 제목 표시", + "gauge-title": "게이지 제목", + "gauge-title-font": "게이지 제목 글꼴", + "unit-title-and-timestamp-settings": "단위 제목 및 타임스탬프 설정", + "show-timestamp": "타임스탬프", + "timestamp-format": "타임스탬프 형식", + "label-font": "값 아래에 표시되는 레이블 글꼴", + "value-settings": "값 설정", + "show-value": "값 텍스트 표시", + "min-max-settings": "최소/최대 레이블 설정", + "show-min-max": "최소 및 최대 값 표시", + "min-max-font": "최소 및 최대 레이블 글꼴", + "show-ticks": "눈금 표시", + "tick-width": "눈금 너비", + "tick-color": "눈금 색상", + "tick-values": "눈금 값", + "no-tick-values": "구성된 눈금 값 없음", + "add-tick-value": "눈금 값 추가", + "gauge-appearance": "게이지 외관", + "units-title": "단위 제목", + "value": "값", + "ticks": "눈금", + "arrow-and-scale-color": "화살표 및 눈금 기본 색상", + "scale-settings": "눈금 설정", + "scale": "눈금", + "scale-color": "눈금 색상", + "compass-appearance": "나침반 외관", + "label": "라벨", + "labels": "레이블", + "label-style": "레이블 스타일", + "simple-gauge-type": "유형", + "gauge-bar-background": "게이지 막대 배경", + "bar-color": "막대 색상", + "min-and-max-value": "최소 및 최대 값", + "min-and-max-label": "최소 및 최대 레이블", + "font": "글꼴", + "tick-width-and-color": "눈금 너비 및 색상", + "min-max-validation-text": "최대 값은 최소 값보다 커야 합니다." + }, + "gpio": { + "pin": "핀", + "label": "라벨", + "row": "행", + "column": "열", + "color": "색상", + "panel-settings": "패널 설정", + "background-color": "배경색", + "gpio-switches": "GPIO 스위치", + "no-gpio-switches": "구성된 GPIO 스위치 없음", + "add-gpio-switch": "GPIO 스위치 추가", + "gpio-status-request": "GPIO 상태 요청", + "method-name": "메서드 이름", + "method-body": "메서드 본문", + "gpio-status-change-request": "GPIO 상태 변경 요청", + "parse-gpio-status-function": "GPIO 상태 파싱 함수", + "gpio-leds": "GPIO LED", + "no-gpio-leds": "구성된 GPIO LED 없음", + "add-gpio-led": "GPIO LED 추가" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, "input-widgets": { "attribute-not-allowed": "이 위젯에서 속성 파라미터를 사용할 수 없습니다", "blocked-location": "당신의 브라우저에서 위치 정보가 차단되었습니다", @@ -2332,20 +8443,20 @@ "device-name": "장치 이름", "device-name-required": "장치 이름을 입력하세요", "discard-changes": "변경 취소", - "entity-attribute-required": "개체 소성을 입력하세요", + "entity-attribute-required": "엔티티 소성을 입력하세요", "entity-coordinate-required": "위도와 경도를 입력하세요", - "entity-timeseries-required": "개체 시계열을 입력하세요", + "entity-timeseries-required": "엔티티 시계열을 입력하세요", "get-location": "현재 위치 얻기", "invalid-date": "잘못된 날짜", "latitude": "위도", "longitude": "경도", "min-value-error": "최솟값은 {{value}}입니다", "max-value-error": "최댓값은{{value}}입니다", - "not-allowed-entity": "선택된 개체는 속성을 공유할 수 없습니다", + "not-allowed-entity": "선택된 엔티티는 속성을 공유할 수 없습니다", "no-attribute-selected": "아무 속성도 선택되지 않았습니다", "no-datakey-selected": "아무 데이터 키도 선택되지 않았습니다", "no-coordinate-specified": "위도/경도를 위한 데이터가 특정되지 않았습니다", - "no-entity-selected": "아무 개체도 선택되지 않았습니다", + "no-entity-selected": "아무 엔티티도 선택되지 않았습니다", "no-image": "이미지 없음", "no-support-geolocation": "당신의 브라우저는 위치정보를 지원하지 않습니다", "no-support-web-camera": "당신의 브라우저는 카메라를 지원하지 않습니다", @@ -2355,9 +8466,9 @@ "no-timeseries-selected": "아무 시계열도 선택되지 않았습니다", "secret-key": "보안키", "secret-key-required": "보안키를 입력하세요", - "switch-attribute-value": "개체 속성 값으로 전환", + "switch-attribute-value": "엔티티 속성 값으로 전환", "switch-camera": "카메라 전환", - "switch-timeseries-value": "개체 시계열 값으로 전환", + "switch-timeseries-value": "엔티티 시계열 값으로 전환", "take-photo": "사진 촬영", "time": "시간", "timeseries-not-allowed": "이 위젯에서 시계열 파라미터를 사용할 수 없습니다", @@ -2365,29 +8476,1739 @@ "update-successful": "업데이트 성공", "update-attribute": "속성 업데이트", "update-timeseries": "시계열 업데이트", - "value": "값" + "value": "값", + "general-settings": "일반 설정", + "widget-title": "위젯 제목", + "claim-button-label": "등록 버튼 레이블", + "show-secret-key-field": "'비밀 키' 입력 필드 표시", + "labels-settings": "레이블 설정", + "show-labels": "레이블 표시", + "device-name-label": "장치 이름 입력 필드 레이블", + "secret-key-label": "비밀 키 입력 필드 레이블", + "messages-settings": "메시지 설정", + "claim-device-success-message": "장치 등록 성공 메시지", + "claim-device-not-found-message": "장치를 찾을 수 없을 때 메시지", + "claim-device-failed-message": "장치 등록 실패 메시지", + "claim-device-name-required-message": "'장치 이름 필요' 오류 메시지", + "claim-device-secret-key-required-message": "'비밀 키 필요' 오류 메시지", + "show-label": "레이블 표시", + "label": "라벨", + "required": "필수", + "required-error-message": "'필수' 오류 메시지", + "show-result-message": "결과 메시지 표시", + "integer-field-settings": "정수 필드 설정", + "min-value": "최소 값", + "max-value": "최대 값", + "double-field-settings": "실수 필드 설정", + "text-field-settings": "텍스트 필드 설정", + "min-length": "최소 길이", + "max-length": "최대 길이", + "checkbox-settings": "체크박스 설정", + "true-label": "선택됨 레이블", + "false-label": "선택 해제됨 레이블", + "image-input-settings": "이미지 입력 설정", + "display-preview": "미리보기 표시", + "display-clear-button": "지우기 버튼 표시", + "display-apply-button": "적용 버튼 표시", + "display-discard-button": "취소 버튼 표시", + "datetime-field-settings": "날짜/시간 필드 설정", + "display-time-input": "시간 입력 표시", + "latitude-key-name": "위도 키 이름", + "longitude-key-name": "경도 키 이름", + "show-get-location-button": "'현재 위치 가져오기' 버튼 표시", + "use-high-accuracy": "고정밀 사용", + "location-fields-settings": "위치 필드 설정", + "latitude-label": "위도 레이블", + "longitude-label": "경도 레이블", + "input-fields-alignment": "입력 필드 정렬", + "input-fields-alignment-column": "열 (기본값)", + "input-fields-alignment-row": "행", + "layout": "레이아웃", + "row-gap": "행 사이 간격 (픽셀)", + "column-gap": "열 사이 간격 (픽셀)", + "latitude-field-required": "위도 필드 필수", + "longitude-field-required": "경도 필드 필수", + "attribute-settings": "속성 설정", + "widget-mode": "위젯 모드", + "widget-mode-update-attribute": "속성 업데이트", + "widget-mode-update-timeseries": "시계열 업데이트", + "attribute-scope": "속성 범위", + "attribute-scope-server": "서버 속성", + "attribute-scope-shared": "공유된 속성", + "value-required": "값 필요", + "image-settings": "이미지 출력 설정", + "image-format": "이미지 형식", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "이미지 품질", + "max-image-width": "최대 너비", + "max-image-height": "최대 높이", + "action-buttons": "동작 버튼", + "show-action-buttons": "동작 버튼 표시", + "update-all-values": "수정된 것뿐만 아니라 모든 값 업데이트", + "save-button-label": "'저장' 버튼 레이블", + "reset-button-label": "'실행 취소' 버튼 레이블", + "group-settings": "그룹 설정", + "show-group-title": "다른 엔티티와 관련된 필드 그룹의 제목 표시", + "group-title": "그룹 제목", + "fields-alignment": "필드 정렬", + "fields-alignment-row": "행 (기본값)", + "fields-alignment-column": "열", + "fields-in-row": "행의 필드 수", + "option-value": "값 ('null'을 입력하여 빈 옵션 생성)", + "option-label": "라벨", + "hide-input-field": "입력 필드 숨기기", + "datakey-type": "데이터 키 유형", + "datakey-type-server": "서버 속성 (기본값)", + "datakey-type-shared": "공유된 속성", + "datakey-type-timeseries": "시계열", + "datakey-value-type": "데이터 키 값 유형", + "datakey-value-type-string": "문자열", + "datakey-value-type-double": "실수", + "datakey-value-type-integer": "정수", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "불리언 (체크박스)", + "datakey-value-type-boolean-switch": "불리언 (스위치)", + "datakey-value-type-date-time": "날짜 및 시간", + "datakey-value-type-date": "날짜", + "datakey-value-type-time": "시간", + "datakey-value-type-select": "선택", + "datakey-value-type-radio": "라디오", + "datakey-value-type-color": "색상", + "value-is-required": "값은 필수 항목입니다.", + "ability-to-edit-attribute": "속성 편집 가능 여부", + "ability-to-edit-attribute-editable": "편집 가능 (기본값)", + "ability-to-edit-attribute-disabled": "비활성화됨", + "ability-to-edit-attribute-readonly": "읽기 전용", + "disable-on-datakey-name": "다른 데이터 키의 false 값에서 비활성화 (데이터 키 이름 지정)", + "field-appearance": "필드 외관", + "appearance-fill": "채우기", + "appearance-outline": "윤곽선", + "subscript-sizing": "아래 첨자 크기", + "subscript-sizing-fixed": "고정", + "subscript-sizing-dynamic": "동적", + "slide-toggle-settings": "슬라이드 토글 설정", + "slide-toggle-label-position": "슬라이드 토글 레이블 위치", + "slide-toggle-label-position-after": "이후", + "slide-toggle-label-position-before": "앞", + "select-options": "선택 옵션", + "no-select-options": "구성된 선택 옵션 없음", + "add-select-option": "선택 옵션 추가", + "numeric-field-settings": "숫자 필드 설정", + "step-interval": "값 사이의 단계 간격", + "error-messages": "오류 메시지", + "min-value-error-message": "'최소 값' 오류 메시지", + "max-value-error-message": "'최대 값' 오류 메시지", + "invalid-date-error-message": "'잘못된 날짜' 오류 메시지", + "invalid-JSON-error-message": "'잘못된 JSON' 오류 메시지", + "icon-settings": "아이콘 설정", + "dialog-editor-settings": "대화 상자 편집기 설정", + "use-custom-icon": "사용자 지정 아이콘 사용", + "input-cell-icon": "입력 셀 앞에 표시할 아이콘", + "value-conversion-settings": "값 변환 설정", + "get-value-settings": "값 가져오기 설정", + "use-get-value-function": "getValue 함수 사용", + "get-value-function": "getValue 함수", + "set-value-settings": "값 설정 설정", + "use-set-value-function": "setValue 함수 사용", + "set-value-function": "setValue 함수", + "json-invalid": "JSON 값의 형식이 잘못되었습니다", + "title": "제목", + "cancel-button-label": "'취소' 버튼 레이블", + "radio-button-settings": "라디오 버튼 설정", + "color": "색상", + "columns": "열", + "radio-options": "라디오 옵션", + "no-radio-options": "구성된 라디오 옵션 없음", + "add-radio-option": "라디오 옵션 추가", + "radio-label-position": "레이블 위치", + "radio-label-position-before": "앞", + "radio-label-position-after": "이후", + "save-image": "이미지 저장", + "save-to-gallery": "캡처된 이미지를 이미지 갤러리에 자동 저장", + "public-image": "이미지를 승인되지 않은 모든 사용자가 사용할 수 있게 함" + }, + "invalid-qr-code-text": "QR 코드에 대한 잘못된 입력 텍스트입니다. 입력은 문자열 유형이어야 합니다", + "qr-code": { + "use-qr-code-text-function": "QR 코드 텍스트 함수 사용", + "qr-code-text-pattern": "QR 코드 텍스트 패턴 (예: '${entityName} | ${keyName} - 일부 텍스트.')", + "qr-code-text-pattern-hint": "QR 코드 텍스트 패턴은 엔티티 별명의 엔티티에서 처음 찾은 키의 값을 사용합니다.", + "qr-code-text-pattern-required": "QR 코드 텍스트 패턴이 필요합니다.", + "qr-code-text-function": "QR 코드 텍스트 함수" + }, + "label-widget": { + "label-pattern": "패턴", + "label-pattern-hint": "힌트: 예. '텍스트 ${keyName} 단위.' 또는 ${#<키 인덱스>} 단위'", + "label-pattern-required": "패턴이 필요합니다", + "label-position": "위치 (배경에 대한 비율)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "배경색", + "font-settings": "글꼴 설정", + "background-image": "배경 이미지", + "labels": "레이블", + "no-labels": "구성된 레이블 없음", + "add-label": "레이블 추가" + }, + "navigation": { + "title": "제목", + "navigation-path": "탐색 경로", + "filter-type": "필터 유형", + "filter-type-all": "모든 항목", + "filter-type-include": "항목 포함", + "filter-type-exclude": "항목 제외", + "items": "항목", + "enter-urls-to-filter": "필터링할 URL 입력..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "메시지 유형", + "method": "방법", + "params": "매개변수", + "created-time": "생성 일시", + "expiration-time": "만료 시간", + "retries": "재시도", + "status": "상태", + "filter": "필터", + "refresh": "새로고침", + "add": "RPC 요청 추가", + "details": "상세 내역", + "delete": "삭제", + "delete-request-title": "영구 RPC 요청 삭제", + "delete-request-text": "요청을 삭제하시겠습니까?", + "details-title": "세부 정보 RPC ID: ", + "additional-info": "추가 정보", + "response": "응답", + "any-status": "모든 상태", + "rpc-status-list": "RPC 상태 목록", + "no-request-prompt": "표시할 요청 없음", + "send-request": "요청 보내기", + "add-title": "영구 RPC 요청 생성", + "method-error": "메서드가 필요합니다.", + "timeout-error": "최소 시간 제한 값은 5000 (5초)입니다.", + "white-space-error": "공백은 허용되지 않습니다.", + "rpc-status": { + "QUEUED": "대기열에 있음", + "SENT": "전송됨", + "DELIVERED": "전달됨", + "SUCCESSFUL": "성공", + "TIMEOUT": "시간 초과", + "EXPIRED": "만료됨", + "FAILED": "실패" + }, + "rpc-search-status-all": "모두", + "message-types": { + "false": "양방향", + "true": "단방향" + }, + "general-settings": "일반 설정", + "enable-filter": "필터 활성화", + "enable-sticky-header": "스크롤하는 동안 헤더 표시", + "enable-sticky-action": "스크롤하는 동안 작업 열 표시", + "display-request-details": "요청 세부 정보 표시", + "allow-send-request": "RPC 요청 보내기 허용", + "allow-delete-request": "요청 삭제 허용", + "columns-settings": "열 설정", + "display-columns": "표시할 열", + "column": "열", + "no-columns-found": "열을 찾을 수 없음", + "no-columns-matching": "'{{column}}'을(를) 찾을 수 없습니다." + }, + "range-chart": { + "chart": "차트", + "data-zoom": "데이터 확대/축소", + "range-chart-appearance": "범위 차트 모양", + "range-colors": "범위 색상", + "out-of-range-color": "범위 외 색상", + "show-range-thresholds": "범위 임계값 표시", + "range-thresholds-settings": "범위 임계값 설정", + "fill-area": "영역 채우기", + "fill-area-opacity": "영역 채우기 불투명도", + "range-chart-style": "범위 차트 스타일" + }, + "knob": { + "behavior": "동작", + "initial-value": "초기 값", + "initial-value-hint": "노브의 초기 값을 가져오는 작업입니다.", + "on-value-change": "값 변경 시", + "on-value-change-hint": "노브 값이 변경될 때 트리거되는 작업입니다.", + "range": "범위", + "min": "최소", + "max": "최대", + "value": "값", + "fallback-initial-value": "대체 초기 값" + }, + "rpc": { + "value-settings": "값 설정", + "initial-value": "초기 값", + "retrieve-value-settings": "켜짐/꺼짐 값 가져오기 설정", + "retrieve-value-method": "메서드를 사용하여 값 가져오기", + "retrieve-value-method-none": "가져오지 않음", + "retrieve-value-method-rpc": "RPC 값 가져오기 메서드 호출", + "retrieve-value-method-attribute": "속성 구독", + "retrieve-value-method-timeseries": "시계열 구독", + "attribute-value-key": "속성 키", + "timeseries-value-key": "시계열 키", + "get-value-method": "RPC 값 가져오기 메서드", + "parse-value-function": "값 구문 분석 함수", + "update-value-settings": "값 업데이트 설정", + "set-value-method": "RPC 값 설정 메서드", + "convert-value-function": "값 변환 함수", + "rpc-settings": "RPC 설정", + "request-timeout": "RPC 요청 시간 제한 (ms)", + "persistent-rpc-settings": "영구 RPC 설정", + "request-persistent": "RPC 요청 영구", + "persistent-polling-interval": "영구 RPC 명령 응답을 가져오기 위한 폴링 간격 (ms)", + "common-settings": "일반 설정", + "switch-title": "스위치 제목", + "show-on-off-labels": "켜짐/꺼짐 레이블 표시", + "slide-toggle-label": "슬라이드 토글 레이블", + "label-position": "레이블 위치", + "label-position-before": "앞", + "label-position-after": "이후", + "slider-color": "슬라이더 색상", + "slider-color-primary": "기본", + "slider-color-accent": "강조", + "slider-color-warn": "경고", + "button-style": "버튼 스타일", + "button-raised": "양각 버튼", + "button-primary": "기본 색상", + "button-background-color": "버튼 배경색", + "button-text-color": "버튼 텍스트 색상", + "widget-title": "위젯 제목", + "button-label": "버튼 레이블", + "device-attribute-scope": "장치 속성 범위", + "server-attribute": "서버 속성", + "shared-attribute": "공유된 속성", + "device-attribute-parameters": "장치 속성 매개변수", + "is-one-way-command": "단방향 명령임", + "rpc-method": "RPC 메서드", + "rpc-method-params": "RPC 메서드 매개변수", + "show-rpc-error": "RPC 명령 실행 오류 표시", + "led-title": "LED 제목", + "led-color": "LED 색상", + "check-status-settings": "상태 확인 설정", + "perform-rpc-status-check": "RPC 장치 상태 확인 수행", + "retrieve-led-status-value-method": "메서드를 사용하여 LED 상태 값 가져오기", + "led-status-value-attribute": "LED 상태 값을 포함하는 장치 속성", + "led-status-value-timeseries": "LED 상태 값을 포함하는 장치 시계열", + "check-status-method": "RPC 장치 상태 확인 메서드", + "parse-led-status-value-function": "LED 상태 값 구문 분석 함수", + "knob-title": "노브 제목" + }, + "maps": { + "map-type": { + "type": "지도 유형", + "map": "지도", + "image": "이미지" + }, + "image": { + "image-source": "이미지 소스", + "image-source-image": "이미지", + "image-source-entity-key": "엔티티 키", + "source-entity-alias": "소스 엔티티 별명", + "image-url-key": "이미지 URL 키", + "image-url-key-required": "이미지 URL 키가 필요합니다" + }, + "control": { + "map-controls": "지도 컨트롤", + "position": "범례 위치", + "position-topleft": "왼쪽 상단", + "position-topright": "오른쪽 상단", + "position-bottomleft": "왼쪽 하단", + "position-bottomright": "오른쪽 하단", + "zoom-actions": "확대/축소 작업", + "zoom-scroll": "스크롤", + "zoom-double-click": "더블 클릭", + "zoom-control-buttons": "컨트롤 버튼", + "scale": "눈금", + "scale-metric": "미터법", + "scale-imperial": "야드파운드법", + "switch-to-drag-mode-using-button": "버튼을 사용하여 드래그 모드로 전환" + }, + "timeline": { + "control-panel": "타임라인 컨트롤 패널", + "time-step": "시간 단계", + "speed-options": "속도 옵션", + "timestamp": "타임스탬프", + "snap-to-real-location": "실제 위치에 맞추기", + "location-snap-filter-function": "위치 맞춤 필터 함수", + "no-trips-data-available": "사용 가능한 이동 데이터 없음" + }, + "map-action": { + "map-action-buttons": "지도 작업 버튼", + "label": "라벨", + "icon": "아이콘", + "color": "색상", + "action": "동작", + "add-button": "버튼 추가", + "no-action-buttons-configured": "구성된 작업 버튼 없음", + "remove-action-button": "작업 버튼 제거", + "map-action-button": "지도 작업 버튼", + "button-requires": "버튼에 레이블 또는 아이콘이 필요합니다" + }, + "common": { + "common-map-settings": "공통 지도 설정", + "fit-map-bounds": "모든 마커를 포함하도록 지도 범위 맞춤", + "default-map-center-position": "기본 지도 중심 위치", + "default-map-zoom-level": "기본 지도 확대/축소 레벨", + "entities-limit": "로드할 엔티티 제한" + }, + "layer": { + "label": "라벨", + "layer": "레이어", + "layers": "레이어", + "map-layers": "지도 레이어", + "add-layer": "레이어 추가", + "layer-settings": "레이어 설정", + "remove-layer": "레이어 제거", + "no-layers": "구성된 레이어 없음", + "roadmap": "도로 지도", + "satellite": "위성", + "hybrid": "하이브리드", + "reference": { + "reference-layer": "참조 레이어", + "no-layer": "레이어 없음", + "openstreetmap-hybrid": "OpenStreetMap 하이브리드", + "world-edition-hybrid": "월드 에디션 하이브리드", + "enhanced-contrast-hybrid": "향상된 대비 하이브리드" + }, + "provider": { + "provider": "제공자", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "세계 거리 지도", + "esri-topo": "세계 지형도", + "esri-imagery": "세계 이미지", + "cartodb-positron": "포지트론", + "cartodb-dark-matter": "다크 매터" + }, + "google": { + "title": "Google", + "roadmap": "도로 지도", + "satellite": "위성", + "hybrid": "하이브리드", + "terrain": "지형" + }, + "here": { + "title": "HERE", + "normal-day": "일반 주간", + "normal-night": "일간 야간", + "hybrid-day": "하이브리드 주간", + "terrain-day": "지형 주간" + }, + "tencent": { + "title": "Tencent", + "normal": "정상", + "satellite": "위성", + "terrain": "지형" + }, + "custom": { + "title": "사용자 지정", + "tile-url": "타일 URL" + } + }, + "credentials": { + "credentials": "자격 증명", + "api-key": "API 키" + } + }, + "overlays": { + "overlays": "오버레이", + "overlays-hint": "지도 엔티티에 대한 데이터 소스, 모양, 동작, 편집 옵션 및 그룹화 구성", + "trips": "여행", + "markers": "마커", + "polygons": "다각형", + "circles": "원", + "polylines": "폴리라인" + }, + "data-layer": { + "source": "소스", + "filter": "필터", + "additional-data-keys": "추가 데이터 키", + "additional-datasources": "추가 데이터 소스", + "additional-datasources-hint": "지도에 표시되지 않는 엔티티의 속성 또는 텔레메트리에 액세스하기 위한 데이터 소스, 지도 오버레이 함수에서 사용 가능.", + "more-datasources": "더 많은 데이터 소스", + "data-keys": "데이터 키", + "add-datasource": "데이터소스 추가", + "no-datasources": "구성된 데이터 소스 없음", + "remove-datasource": "데이터소스 삭제", + "behavior": "동작", + "on-click": "클릭 시", + "on-click-hint": "사용자가 지도 항목을 클릭할 때 호출되는 동작.", + "groups": "그룹", + "groups-hint": "오버레이에 할당된 그룹 이름 목록, 지도에서 가시성을 전환하는 데 사용됩니다.", + "color": "색상", + "color-settings": "색상 설정", + "color-type-constant": "상수", + "color-type-range": "범위", + "color-type-function": "함수", + "color-range-source-key": "색상 범위 소스 키", + "color-range-source-key-required": "색상 범위 소스 키는 필수 항목입니다.", + "color-range": "색상 범위", + "color-function": "색상 함수", + "label": "라벨", + "tooltip": "툴팁", + "pattern-type-pattern": "패턴", + "pattern-type-function": "함수", + "label-pattern": "레이블 (패턴 예: '${entityName}', '${entityName}: (텍스트 ${keyName} 단위.)' )", + "label-function": "레이블 함수", + "tooltip-pattern": "툴팁 (예: '텍스트 ${keyName} 단위.' 또는 링크 텍스트)", + "tooltip-function": "툴팁 함수", + "tooltip-trigger": "툴팁 트리거", + "tooltip-trigger-click": "클릭 시 툴팁 표시", + "tooltip-trigger-hover": "호버 시 툴팁 표시", + "auto-close-tooltips": "툴팁 자동 닫기", + "tooltip-offset": "툴팁 오프셋", + "tooltip-offset-horizontal": "가로", + "tooltip-offset-vertical": "세로", + "tooltip-tag-actions": "태그 동작", + "add-tooltip-tag-action": "태그 동작 추가", + "edit-tooltip-tag-action": "태그 동작 편집", + "remove-tooltip-tag-action": "태그 동작 제거", + "action-add": "추가", + "action-edit": "수정", + "action-move": "이동", + "action-remove": "제거", + "edit-instruments": "계기", + "persist-location-attribute-scope": "위치를 저장할 속성의 범위", + "enable-snapping": "정밀 그리기를 위해 다른 정점에 스냅 활성화", + "enable-snapping-hint": "그리기를 더 쉽고 정확하게 만들기 위해 새 포인트를 기존 모양에 자동으로 정렬합니다.", + "drag-drop-mode": "드래그 앤 드롭 모드", + "trip": { + "no-trips": "구성된 여행 없음", + "add-trip": "여행 추가", + "trip-configuration": "여행 구성", + "remove-trip": "여행 제거" + }, + "marker": { + "marker": "마커", + "latitude-key": "위도 키", + "longitude-key": "경도 키", + "x-pos-key": "X 위치 키", + "y-pos-key": "Y 위치 키", + "latitude-key-required": "위도 키 필수", + "longitude-key-required": "경도 키 필수", + "x-pos-key-required": "X 위치 키 필수", + "y-pos-key-required": "Y 위치 키 필수", + "no-markers": "구성된 마커 없음", + "add-marker": "마커 추가", + "marker-configuration": "마커 구성", + "remove-marker": "마커 제거", + "marker-type": "마커 유형", + "marker-type-shape": "모양", + "marker-type-icon": "아이콘", + "marker-type-image": "이미지", + "shape": "모양", + "icon": "아이콘", + "image": "이미지", + "marker-shapes": "마커 모양", + "marker-icon": "마커 아이콘", + "marker-appearance": "마커 외관", + "marker-image": "마커 이미지", + "marker-image-type-image": "이미지", + "marker-image-type-function": "함수", + "custom-marker-image-size": "사용자 지정 마커 이미지 크기", + "marker-image-function": "마커 이미지 함수", + "marker-images": "마커 이미지", + "marker-offset": "마커 오프셋", + "offset-horizontal": "가로", + "offset-vertical": "세로", + "rotate-marker": "마커 회전", + "offset-angle": "오프셋 각도", + "position-conversion": "위치 변환", + "position-conversion-function": "위치 변환 함수, 각각 0에서 1 사이의 double로 x,y 좌표를 반환해야 합니다", + "clustering": { + "use-map-markers-clustering": "지도 마커 클러스터링 사용", + "zoom-on-cluster-click": "클러스터 클릭 시 확대", + "max-zoom": "마커가 클러스터의 일부가 될 수 있는 최대 확대 수준 (0 - 18)", + "max-radius": "클러스터가 커버할 최대 반경", + "zoom-animation": "확대/축소 시 마커 애니메이션", + "bounds-on-cluster-mouse-over": "클러스터 위에 마우스를 올릴 때 마커 경계", + "spiderfy-max-zoom-level": "최대 확대 수준에서 스파이더파이 (모든 클러스터 마커 보기)", + "load-optimization": "로드 최적화", + "chunked-load": "페이지가 멈추지 않도록 청크를 사용하여 마커 추가", + "lazy-load": "마커 추가에 지연 로드 사용", + "use-cluster-marker-color-function": "클러스터 마커 색상 함수 사용", + "marker-color-function": "마커 색상 함수" + }, + "edit": "마커 편집", + "remove-marker-for": "'{{entityName}}'의 마커 제거", + "place-marker": "마커 배치", + "place-marker-hint": "클릭하여 마커 배치", + "place-marker-hint-with-entity": "클릭하여 '{{entityName}}' 엔티티 배치" + }, + "path": { + "path": "경로", + "path-decorator": "경로 장식", + "decorator-symbol": "장식 기호", + "decorator-symbol-arrow-head": "화살표", + "decorator-symbol-dash": "대시", + "decorator-arrangement": "장식 배열", + "decorator-offset": "시작", + "decorator-end-offset": "끝", + "decorator-repeat": "반복" + }, + "points": { + "points": "지점", + "point-tooltip": "지점 툴팁" + }, + "shape": { + "fill": "채우기", + "fill-type-color": "색상", + "fill-type-stripe": "줄무늬", + "fill-type-image": "이미지", + "color": "색상", + "stripe": "줄무늬", + "image": "이미지", + "stroke": "획", + "fill-image": "채우기 이미지", + "fill-image-type-image": "이미지", + "fill-image-type-function": "함수", + "preserve-aspect-ratio": "가로세로 비율 유지", + "opacity": "불투명도", + "angle": "회전 각도", + "scale": "눈금", + "fill-image-function": "모양 채우기 이미지 함수", + "fill-images": "모양 채우기 이미지", + "stripe-pattern": "줄무늬 패턴", + "first-stripe": "첫 번째 줄무늬", + "second-stripe": "두 번째 줄무늬" + }, + "polygon": { + "polygon-key": "다각형 키", + "polygon-key-required": "다각형 키 필수", + "no-polygons": "구성된 다각형 없음", + "add-polygon": "다각형 추가", + "polygon-configuration": "다각형 구성", + "remove-polygon": "다각형 제거", + "edit": "다각형 편집", + "remove-polygon-for": "'{{entityName}}'의 다각형 제거", + "cut": "다각형 영역 자르기", + "rotate": "다각형 회전", + "draw-rectangle": "사각형 그리기", + "draw-polygon": "다각형 그리기", + "polygon-place-first-point-cut-hint": "클릭하여 첫 번째 지점 배치", + "continue-polygon-cut-hint": "클릭하여 계속 그리기", + "finish-polygon-cut-hint": "첫 번째 마커를 클릭하여 완료 및 저장", + "polygon-place-first-point-hint": "다각형: 클릭하여 첫 번째 지점 배치", + "polygon-place-first-point-hint-with-entity": "'{{entityName}}'의 다각형: 클릭하여 첫 번째 지점 배치", + "continue-polygon-hint": "다각형: 클릭하여 계속 그리기", + "continue-polygon-hint-with-entity": "'{{entityName}}'의 다각형: 클릭하여 계속 그리기", + "finish-polygon-hint": "다각형: 첫 번째 마커를 클릭하여 그리기 완료", + "finish-polygon-hint-with-entity": "'{{entityName}}'의 다각형: 첫 번째 마커를 클릭하여 완료 및 저장", + "rectangle-place-first-point-hint": "사각형: 클릭하여 첫 번째 지점 배치", + "rectangle-place-first-point-hint-with-entity": "'{{entityName}}'의 사각형: 클릭하여 첫 번째 지점 배치", + "finish-rectangle-hint": "사각형: 클릭하여 그리기 완료", + "finish-rectangle-hint-with-entity": "'{{entityName}}'의 사각형: 클릭하여 완료 및 저장" + }, + "circle": { + "circle-key": "원 키", + "circle-key-required": "원 키 필수", + "no-circles": "구성된 원 없음", + "add-circle": "원 추가", + "circle-configuration": "원 구성", + "remove-circle": "원 제거", + "edit": "원 편집", + "remove-circle-for": "'{{entityName}}'의 원 제거", + "draw-circle": "원 그리기", + "place-circle-center-hint-with-entity": "'{{entityName}}'의 원: 클릭하여 원 중심 배치", + "place-circle-center-hint": "원: 클릭하여 원 중심 배치", + "finish-circle-hint-with-entity": "'{{entityName}}'의 원: 클릭하여 원 완료 및 저장", + "finish-circle-hint": "원: 클릭하여 그리기 완료" + }, + "polyline": { + "polyline-key": "폴리라인 키", + "polyline-key-required": "폴리라인 키 필수", + "no-polylines": "구성된 폴리라인 없음", + "add-polylines": "폴리라인 추가", + "polyline-configuration": "폴리라인 구성", + "remove-polyline": "폴리라인 제거", + "edit": "폴리라인 편집", + "cut": "폴리라인 영역 자르기", + "rotate": "폴리라인 회전", + "remove-polyline-for": "'{{entityName}}'의 폴리라인 제거", + "draw-polyline": "폴리라인 그리기", + "polyline-place-first-point-hint-with-entity": "'{{entityName}}'의 폴리라인: 클릭하여 첫 번째 지점 배치", + "polyline-place-first-point-hint": "폴리라인: 클릭하여 첫 번째 지점 배치", + "finish-polyline-hint-with-entity": "'{{entityName}}'의 폴리라인: 클릭하여 그리기 완료", + "finish-polyline-hint": "폴리라인: 클릭하여 그리기 완료", + "polyline-place-first-point-cut-hint": "클릭하여 첫 번째 지점 배치", + "finish-polyline-cut-hint": "첫 번째 마커를 클릭하여 완료 및 저장" + }, + "select-entity": "엔티티 선택", + "select-entity-hint": "힌트: 선택 후 지도를 클릭하여 위치 설정" + }, + "select-entity": "엔티티 선택", + "select-entity-hint": "힌트: 선택 후 지도를 클릭하여 위치 설정", + "tooltips": { + "placeMarker": "클릭하여 '{{entityName}}' 엔티티 배치", + "firstVertex": "'{{entityName}}'의 다각형: 클릭하여 첫 번째 지점 배치", + "firstVertex-cut": "클릭하여 첫 번째 지점 배치", + "continueLine": "'{{entityName}}'의 다각형: 클릭하여 계속 그리기", + "continueLine-cut": "클릭하여 계속 그리기", + "finishLine": "완료하려면 기존 마커 클릭", + "finishPoly": "'{{entityName}}'의 다각형: 첫 번째 마커를 클릭하여 완료 및 저장", + "finishPoly-cut": "첫 번째 마커를 클릭하여 완료 및 저장", + "finishRect": "'{{entityName}}'의 다각형: 클릭하여 완료 및 저장", + "startCircle": "'{{entityName}}'의 원: 클릭하여 원 중심 배치", + "finishCircle": "'{{entityName}}'의 원: 클릭하여 원 완료", + "placeCircleMarker": "클릭하여 원 마커 배치" + }, + "actions": { + "finish": "완료", + "cancel": "취소", + "removeLastVertex": "마지막 지점 제거" + }, + "buttonTitles": { + "drawMarkerButton": "엔티티 배치", + "drawPolyButton": "다각형 생성", + "drawLineButton": "폴리라인 생성", + "drawCircleButton": "원 생성", + "drawRectButton": "사각형 생성", + "editButton": "수정 모드", + "dragButton": "드래그 앤 드롭 모드", + "cutButton": "다각형 영역 자르기", + "deleteButton": "제거", + "drawCircleMarkerButton": "원 마커 생성", + "rotateButton": "다각형 회전" + }, + "map-provider-settings": "지도 제공자 설정", + "map-provider": "지도 제공자", + "map-provider-google": "Google 지도", + "map-provider-openstreet": "OpenStreet 지도", + "map-provider-here": "HERE 지도", + "map-provider-image": "이미지 지도", + "map-provider-tencent": "Tencent 지도", + "openstreet-provider": "OpenStreet 지도 공급자", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (기본)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "사용자 지정 공급자 사용", + "custom-provider-tile-url": "사용자 지정 공급자 타일 URL", + "google-maps-api-key": "Google 지도 API 키", + "default-map-type": "기본 지도 유형", + "google-map-type-roadmap": "도로 지도", + "google-map-type-satelite": "위성", + "google-map-type-hybrid": "하이브리드", + "google-map-type-terrain": "지형", + "map-layer": "지도 레이어", + "here-map-normal-day": "HERE.normalDay (기본)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "자격 증명", + "here-app-id": "HERE 앱 ID", + "here-app-code": "HERE 앱 코드", + "here-api-key": "HERE API 키", + "here-use-new-version-api-3": "API 버전 3 사용", + "tencent-maps-api-key": "Tencent 지도 API 키", + "tencent-map-type-roadmap": "도로 지도", + "tencent-map-type-satelite": "위성", + "tencent-map-type-hybrid": "하이브리드", + "image-map-background": "이미지 지도 배경", + "image-map-background-from-entity-attribute": "엔티티 속성에서 이미지 지도 배경 가져오기", + "image-url-source-entity-alias": "이미지 URL 소스 엔티티 별명", + "image-url-source-entity-attribute": "이미지 URL 소스 엔티티 속성", + "common-map-settings": "공통 지도 설정", + "x-pos-key-name": "X 위치 키 이름", + "y-pos-key-name": "Y 위치 키 이름", + "latitude-key-name": "위도 키 이름", + "longitude-key-name": "경도 키 이름", + "default-map-zoom-level": "기본 지도 확대/축소 레벨 (0 - 20)", + "default-map-center-position": "기본 지도 중심 위치 (0,0)", + "disable-scroll-zooming": "스크롤 확대/축소 비활성화", + "disable-double-click-zooming": "더블 클릭 확대/축소 비활성화", + "disable-zoom-control-buttons": "확대/축소 컨트롤 버튼 비활성화", + "fit-map-bounds": "모든 마커를 포함하도록 지도 범위 맞춤", + "use-default-map-center-position": "기본 지도 중심 위치 사용", + "entities-limit": "로드할 엔티티 제한", + "markers-settings": "마커 설정", + "marker-offset-x": "마커 너비를 곱한 위치에 대한 마커 X 오프셋", + "marker-offset-y": "마커 높이를 곱한 위치에 대한 마커 Y 오프셋", + "position-function": "위치 변환 함수, 각각 0에서 1 사이의 double로 x,y 좌표를 반환해야 합니다", + "draggable-marker": "드래그 가능한 마커", + "label": "라벨", + "show-label": "레이블 표시", + "use-label-function": "레이블 함수 사용", + "label-pattern": "레이블 (패턴 예: '${entityName}', '${entityName}: (텍스트 ${keyName} 단위.)' )", + "label-function": "레이블 함수", + "tooltip": "툴팁", + "show-tooltip": "툴팁 표시", + "show-tooltip-action": "툴팁 표시 작업", + "show-tooltip-action-click": "클릭 시 툴팁 표시 (기본)", + "show-tooltip-action-hover": "호버 시 툴팁 표시", + "auto-close-tooltips": "툴팁 자동 닫기", + "use-tooltip-function": "툴팁 함수 사용", + "tooltip-pattern": "툴팁 (예: '텍스트 ${keyName} 단위.' 또는 링크 텍스트')", + "tooltip-function": "툴팁 함수", + "tooltip-offset-x": "마커 너비를 곱한 마커 앵커에 대한 툴팁 X 오프셋", + "tooltip-offset-y": "마커 높이를 곱한 마커 앵커에 대한 툴팁 Y 오프셋", + "color": "색상", + "use-color-function": "색상 함수 사용", + "color-function": "색상 함수", + "marker-image": "마커 이미지", + "use-marker-image-function": "마커 이미지 함수 사용", + "custom-marker-image": "사용자 지정 마커 이미지", + "custom-marker-image-size": "사용자 지정 마커 이미지 크기 (px)", + "marker-image-function": "마커 이미지 함수", + "marker-images": "마커 이미지", + "polygon-settings": "다각형 설정", + "show-polygon": "다각형 표시", + "polygon-key-name": "다각형 키 이름", + "enable-polygon-edit": "다각형 편집 활성화", + "polygon-label": "다각형 레이블", + "show-polygon-label": "다각형 레이블 표시", + "use-polygon-label-function": "다각형 레이블 함수 사용", + "polygon-label-pattern": "다각형 레이블 (패턴 예: '${entityName}', '${entityName}: (텍스트 ${keyName} 단위.)' )", + "polygon-label-function": "다각형 레이블 함수", + "polygon-tooltip": "다각형 툴팁", + "show-polygon-tooltip": "다각형 툴팁 표시", + "auto-close-polygon-tooltips": "다각형 툴팁 자동 닫기", + "use-polygon-tooltip-function": "다각형 툴팁 함수 사용", + "polygon-tooltip-pattern": "툴팁 (예: '텍스트 ${keyName} 단위.' 또는 링크 텍스트')", + "polygon-tooltip-function": "다각형 툴팁 함수", + "polygon-color": "다각형 색상", + "polygon-opacity": "다각형 불투명도", + "use-polygon-color-function": "다각형 색상 함수 사용", + "polygon-color-function": "다각형 색상 함수", + "polygon-stroke": "다각형 획", + "stroke-color": "획 색상", + "stroke-opacity": "획 불투명도", + "stroke-weight": "획 두께", + "use-polygon-stroke-color-function": "다각형 획 색상 함수 사용", + "polygon-stroke-color-function": "다각형 획 색상 함수", + "circle-settings": "원 설정", + "show-circle": "원 표시", + "circle-key-name": "원 키 이름", + "enable-circle-edit": "원 편집 활성화", + "circle-label": "원 레이블", + "show-circle-label": "원 레이블 표시", + "use-circle-label-function": "원 레이블 함수 사용", + "circle-label-pattern": "원 레이블 (패턴 예: '${entityName}', '${entityName}: (텍스트 ${keyName} 단위.)' )", + "circle-label-function": "원 레이블 함수", + "circle-tooltip": "원 툴팁", + "show-circle-tooltip": "원 툴팁 표시", + "auto-close-circle-tooltips": "원 툴팁 자동 닫기", + "use-circle-tooltip-function": "원 툴팁 함수 사용", + "circle-tooltip-pattern": "툴팁 (예: '텍스트 ${keyName} 단위.' 또는 링크 텍스트')", + "circle-tooltip-function": "원 툴팁 함수", + "circle-fill-color": "원 채우기 색상", + "circle-fill-color-opacity": "원 채우기 색상 불투명도", + "use-circle-fill-color-function": "원 채우기 색상 함수 사용", + "circle-fill-color-function": "원 채우기 색상 함수", + "circle-stroke": "원 획", + "use-circle-stroke-color-function": "원 획 색상 함수 사용", + "circle-stroke-color-function": "원 획 색상 함수", + "markers-clustering-settings": "마커 클러스터링 설정", + "use-map-markers-clustering": "지도 마커 클러스터링 사용", + "zoom-on-cluster-click": "클러스터 클릭 시 확대", + "max-cluster-zoom": "마커가 클러스터의 일부가 될 수 있는 최대 확대 수준 (0 - 18)", + "max-cluster-radius-pixels": "클러스터가 덮을 최대 반지름 (픽셀)", + "cluster-zoom-animation": "확대/축소 시 마커에 애니메이션 표시", + "show-markers-bounds-on-cluster-mouse-over": "클러스터 위에 마우스를 올릴 때 마커 범위 표시", + "spiderfy-max-zoom-level": "최대 확대 수준에서 스파이더파이 (모든 클러스터 마커 보기)", + "load-optimization": "로드 최적화", + "cluster-chunked-loading": "페이지가 멈추지 않도록 청크를 사용하여 마커 추가", + "cluster-markers-lazy-load": "마커 추가에 지연 로드 사용", + "editor-settings": "편집기 설정", + "enable-snapping": "정밀 그리기를 위해 다른 정점에 스냅 활성화", + "init-draggable-mode": "드래그 모드로 지도 초기화", + "hide-all-edit-buttons": "모든 편집 컨트롤 버튼 숨기기", + "hide-draw-buttons": "그리기 버튼 숨기기", + "hide-edit-buttons": "편집 버튼 숨기기", + "hide-remove-button": "제거 버튼 숨기기", + "route-map-settings": "경로 지도 설정", + "trip-animation-settings": "이동 애니메이션 설정", + "normalization-step": "정규화 데이터 단계 (ms)", + "tooltip-background-color": "툴팁 배경색", + "tooltip-font-color": "툴팁 글꼴 색상", + "tooltip-opacity": "툴팁 불투명도 (0-1)", + "auto-close-tooltip": "툴팁 자동 닫기", + "rotation-angle": "마커에 대한 추가 회전 각도 설정 (도)", + "path-settings": "경로 설정", + "path-color": "경로 색상", + "use-path-color-function": "경로 색상 함수 사용", + "path-color-function": "경로 색상 함수", + "path-decorator": "경로 장식", + "use-path-decorator": "경로 데코레이터 사용", + "decorator-symbol": "장식 기호", + "decorator-symbol-arrow-head": "화살표", + "decorator-symbol-dash": "대시", + "decorator-symbol-size": "데코레이터 기호 크기 (px)", + "use-path-decorator-custom-color": "경로 데코레이터 사용자 지정 색상 사용", + "decorator-custom-color": "데코레이터 사용자 지정 색상", + "decorator-offset": "데코레이터 오프셋", + "end-decorator-offset": "종료 데코레이터 오프셋", + "decorator-repeat": "데코레이터 반복", + "points-settings": "포인트 설정", + "show-points": "지점 표시", + "point-color": "포인트 색상", + "point-size": "포인트 크기 (px)", + "use-point-color-function": "포인트 색상 함수 사용", + "point-color-function": "포인트 색상 함수", + "use-point-as-anchor": "포인트를 앵커로 사용", + "point-as-anchor-function": "포인트를 앵커로 사용 함수", + "independent-point-tooltip": "독립 포인트 툴팁", + "clustering-markers": "클러스터링 마커", + "use-icon-create-function": "마커 색상 함수 사용", + "marker-color-function": "마커 색상 함수" + }, + "markdown": { + "use-markdown-text-function": "마크다운/HTML 값 함수 사용", + "markdown-text-function": "마크다운/HTML 값 함수", + "markdown-text-pattern": "마크다운/HTML 패턴 (변수가 있는 마크다운 또는 HTML, 예: '${entityName} 또는 ${keyName} - 일부 텍스트.')", + "apply-default-markdown-style": "기본 마크다운 스타일 적용", + "markdown-css": "마크다운/HTML CSS" + }, + "simple-card": { + "label": "라벨", + "label-position": "레이블 위치", + "label-position-left": "왼쪽", + "label-position-top": "상단" + }, + "single-switch": { + "behavior": "동작", + "layout": "레이아웃", + "layout-right": "오른쪽", + "layout-left": "왼쪽", + "layout-centered": "중앙 정렬", + "auto-scale": "자동 크기 조정", + "label": "라벨", + "icon": "아이콘", + "switch-color": "스위치 색상", + "on": "켜기", + "off": "끄기", + "disabled": "비활성화됨", + "tumbler-color": "텀블러 색상", + "on-label": "켜짐 레이블", + "off-label": "꺼짐 레이블", + "switch": "스위치" + }, + "slider": { + "behavior": "동작", + "initial-value": "초기 값", + "initial-value-hint": "슬라이더의 초기 값을 가져오는 작업입니다.", + "on-value-change": "값 변경 시", + "on-value-change-hint": "슬라이더 값이 변경될 때 트리거되는 작업입니다.", + "layout": "레이아웃", + "layout-default": "기본값", + "layout-extended": "확장", + "layout-simplified": "간소화", + "auto-scale": "자동 크기 조정", + "icon": "아이콘", + "value": "값", + "range": "범위", + "min": "최소", + "max": "최대", + "range-ticks": "범위 눈금", + "tick-marks": "눈금 표시", + "colors": "색상", + "main": "메인", + "background": "배경", + "left-icon": "왼쪽 아이콘", + "right-icon": "오른쪽 아이콘", + "slider": "슬라이더" + }, + "value-card": { + "layout": "레이아웃", + "layout-square": "사각형", + "layout-vertical": "세로", + "layout-centered": "중앙 정렬", + "layout-simplified": "간소화", + "layout-horizontal": "가로", + "layout-horizontal-reversed": "수평 반전", + "label": "라벨", + "icon": "아이콘", + "value": "값", + "date": "날짜", + "value-card-style": "값 카드 스타일", + "auto-scale": "자동 크기 조정" + }, + "label-card": { + "auto-scale": "자동 크기 조정", + "label": "라벨", + "icon": "아이콘", + "label-card-style": "레이블 카드 스타일" + }, + "label-value-card": { + "value": "값", + "label-value-card-style": "레이블 및 값 카드 스타일" + }, + "liquid-level-card": { + "layout-simple": "간단", + "layout-percentage": "백분율", + "layout-absolute": "절대값", + "layout": "레이아웃", + "background-overlay": "값 배경 오버레이", + "total-volume": "총 볼륨", + "total-volume-units": "총 볼륨 단위", + "tank": "탱크", + "shape": "모양", + "datasource-units": "소스 단위", + "widget-units": "위젯 단위", + "decimals": "소수 자릿수", + "liquid": "액체", + "liquid-color": "액체 색상", + "value": "값", + "value-font": "값 글꼴", + "level": "수준", + "last-update": "마지막 업데이트", + "shape-by-attribute": "속성 이름으로 탱크 모양 설정", + "tooltip-background": "배경색", + "background-blur": "배경 블러", + "tank-color": "탱크 색상", + "static": "정적", + "see-examples": "예시 보기", + "attribute": "속성", + "shape-type": "유형", + "v-oval": "수직 타원", + "v-cylinder": "수직 원통", + "v-capsule": "수직 캡슐", + "rectangle": "사각형", + "h-oval": "수평 타원", + "h-ellipse": "수평 타원", + "h-dish-ends": "수평 접시 끝", + "h-cylinder": "수평 원통", + "h-capsule": "수평 캡슐", + "h-elliptical_2_1": "수평 2:1 타원", + "icon": "카드 아이콘", + "title": "카드 제목", + "units": "단위", + "color-and-font": "색상 및 글꼴", + "shape-attribute-name": "속성 이름", + "total-volume-required": "총 볼륨이 필요합니다.", + "attribute-name-required": "속성 이름이 필요합니다.", + "attribute-key-not-set": "속성 '{{attributeName}}' 키가 설정되지 않음", + "attribute-key-invalid": "속성 '{{attributeName}}' 키가 유효하지 않음" + }, + "aggregated-value-card": { + "subtitle": "부제목", + "chart": "차트", + "values": "값", + "value-appearance": "값 외관", + "position": "범례 위치", + "position-center": "중앙", + "position-right-top": "오른쪽 상단", + "position-right-bottom": "오른쪽 하단", + "position-left-top": "왼쪽 상단", + "position-left-bottom": "왼쪽 하단", + "font": "글꼴", + "color": "색상", + "arrow": "화살표", + "display-up-down-arrow": "위/아래 화살표 표시", + "add-value": "값 추가", + "remove-value": "값 제거", + "no-values": "구성된 값 없음", + "aggregation": "집합", + "aggregated-value-card-style": "집계 값 카드 스타일", + "auto-scale": "자동 크기 조정" + }, + "value-chart-card": { + "layout": "레이아웃", + "layout-left": "왼쪽", + "layout-right": "오른쪽", + "auto-scale": "자동 크기 조정", + "icon": "아이콘", + "value": "값", + "chart": "차트", + "value-chart-card-style": "값 차트 카드 스타일" + }, + "progress-bar": { + "layout": "레이아웃", + "layout-default": "기본값", + "layout-simplified": "간소화", + "auto-scale": "자동 크기 조정", + "icon": "아이콘", + "value": "값", + "range": "범위", + "min": "최소", + "max": "최대", + "range-ticks": "범위 눈금", + "bar": "막대", + "bar-color": "막대 색상", + "bar-background": "막대 배경", + "progress-bar-card-style": "진행 막대 카드 스타일" + }, + "notification": { + "max-notification-display": "표시할 최대 알림 수", + "counter": "카운터", + "counter-hint": "\"위젯 제목\"이 활성화되면 카운터가 표시됩니다.", + "icon": "아이콘", + "counter-value": "값", + "counter-color": "색상", + "notification-button": "알림 버튼", + "button-view-all": "모두 보기", + "button-filter": "필터", + "type-filter": "유형 필터", + "button-mark-read": "모두 읽음으로 표시", + "notification-types": "알림 유형", + "notification-type": "알림 유형", + "search-type": "유형 검색", + "any-type": "모든 유형" + }, + "alarm-count": { + "alarm-count-card-style": "알람 수 카드 스타일" + }, + "entity-count": { + "entity-count-card-style": "엔티티 수 카드 스타일" + }, + "count": { + "layout": "레이아웃", + "layout-column": "열", + "layout-row": "행", + "label": "라벨", + "icon": "아이콘", + "icon-background": "아이콘 배경", + "value": "값", + "chevron": "갈매기", + "auto-scale": "자동 크기 조정" + }, + "table": { + "common-table-settings": "일반 테이블 설정", + "enable-search": "검색 활성화", + "enable-sticky-header": "항상 헤더 표시", + "enable-sticky-action": "항상 동작 열 표시", + "hidden-cell-button-display-mode": "숨겨진 셀 버튼 동작 표시 모드", + "show-empty-space-hidden-action": "숨겨진 셀 버튼 동작 대신 빈 공간 표시", + "dont-reserve-space-hidden-action": "숨겨진 동작 버튼에 공간 예약 안 함", + "display-timestamp": "타임스탬프", + "timestamp-column-name": "타임스탬프", + "display-pagination": "페이지 매김 표시", + "default-page-size": "기본 페이지 크기", + "page-step-settings": "페이지 단계 설정", + "page-step-count": "단계 수", + "page-step-increment": "단계 증가", + "page-step-count-format-message": "1에서 100 범위의 정수 값이어야 합니다.", + "page-step-increment-format-message": "1 이상의 정수 값이어야 합니다.", + "use-entity-label-tab-name": "탭 이름에 엔티티 레이블 사용", + "hide-empty-lines": "빈 줄 숨기기", + "row-style": "행 스타일", + "use-row-style-function": "행 스타일 함수 사용", + "row-style-function": "행 스타일 함수", + "cell-style": "셀 스타일", + "use-cell-style-function": "셀 스타일 함수 사용", + "cell-style-function": "셀 스타일 함수", + "cell-content": "셀 내용", + "use-cell-content-function": "셀 내용 함수 사용", + "cell-content-function": "셀 내용 함수", + "show-latest-data-column": "최신 데이터 열 표시", + "latest-data-column-order": "최신 데이터 열 순서", + "entities-table-title": "엔티티 테이블 제목", + "enable-select-column-display": "표시할 열 선택 활성화", + "display-entity-name": "엔티티 이름 열 표시", + "entity-name-column-title": "엔티티 이름 열 제목", + "display-entity-label": "엔티티 레이블 열 표시", + "entity-label-column-title": "엔티티 레이블 열 제목", + "display-entity-type": "엔티티 유형 열 표시", + "default-sort-order": "기본 정렬 순서", + "custom-title": "사용자 지정 헤더 제목", + "column-width": "열 너비 (px 또는 %)", + "default-column-visibility": "기본 열 가시성", + "column-visibility-visible": "표시", + "column-visibility-hidden": "숨김", + "column-visibility-hidden-mobile": "모바일 모드에서 숨김", + "column-selection-to-display": "'표시할 열'에서 열 선택", + "column-selection-to-display-enabled": "사용", + "column-selection-to-display-disabled": "비활성화됨", + "alarms-table-title": "알람 테이블 제목", + "enable-alarms-selection": "알람 선택 활성화", + "enable-alarms-search": "알람 검색 활성화", + "enable-alarm-filter": "알람 필터 활성화", + "display-alarm-details": "알람 세부 정보 표시", + "allow-alarms-ack": "알람 확인 허용", + "allow-alarms-clear": "알람 지우기 허용", + "display-alarm-activity": "알람 활동 표시", + "allow-alarms-assign": "알람 할당 허용", + "columns": "열", + "column-settings": "열 설정", + "remove-column": "열 제거", + "add-column": "열 추가", + "no-columns": "구성된 열 없음", + "columns-to-display": "표시할 열", + "table-header": "테이블 헤더", + "header-buttons": "헤더 버튼", + "table-buttons": "테이블 버튼", + "pagination": "페이지 매김", + "rows": "행", + "timeseries-column-error": "최소 하나의 시계열 열을 지정해야 합니다.", + "alarm-column-error": "최소 하나의 알람 열을 지정해야 합니다.", + "table-tabs": "테이블 탭", + "show-cell-actions-menu-mobile": "모바일 모드에서 셀 동작 드롭다운 메뉴 표시", + "disable-sorting": "정렬 비활성화", + "sort-by": "탭 정렬 기준", + "sort-timestamp-option": "생성 일시" + }, + "latest-chart": { + "total": "합계", + "auto-scale": "자동 크기 조정", + "clockwise-layout": "시계 방향 레이아웃", + "sort-series": "레이블로 시리즈 정렬", + "tooltip-value-type-absolute": "절대값", + "tooltip-value-type-percentage": "백분율" + }, + "pie-chart": { + "pie-chart-appearance": "원형 차트 외관", + "label": "라벨", + "border": "테두리", + "radius": "반경", + "pie-chart-card-style": "원형 차트 카드 스타일" + }, + "radar-chart": { + "radar-appearance": "레이더 외관", + "shape": "모양", + "shape-polygon": "다각형", + "shape-circle": "원", + "color": "색상", + "line": "선", + "points": "지점", + "points-label": "지점 레이블", + "radar-axis": "레이더 축", + "axis-label": "축 레이블", + "ticks-label": "눈금 레이블", + "radar-chart-style": "레이더 차트 스타일", + "max-axes-scaling": "최대 축 크기 조정", + "max-axes-scaling-hint": "각 레이더 축이 자체 최대값(별도)을 갖는지 또는 위젯 데이터 세트를 기반으로 모든 축에서 가장 높은 값(공통)을 공유하는지 선택합니다.", + "separate": "분리", + "common": "공통" + }, + "time-series-chart": { + "chart": "차트", + "chart-style": "차트 스타일", + "data-zoom": "데이터 확대/축소", + "stack-mode": "스택 모드", + "stack-mode-hint": "차트에 시리즈를 스택합니다. 동일한 단위의 시리즈는 서로 위에 배치됩니다.", + "axes": "축", + "y-axes": "Y축", + "line-type": "선 유형", + "line-width": "선 너비", + "type-line": "선", + "type-bar": "막대", + "type-point": "지점", + "no-aggregation-bar-width-strategy": "비집계 데이터의 막대 너비 전략", + "no-aggregation-bar-width-strategy-group": "그룹", + "no-aggregation-bar-width-strategy-separate": "분리", + "bar-group-width": "막대 그룹 너비", + "bar-width": "막대 너비", + "bar-width-relative": "시간 창의 백분율", + "bar-width-absolute": "절대값 (ms)", + "comparison": { + "comparison": "비교", + "comparison-hint": "비교는 기록 데이터에서만 작동합니다!", + "show": "표시", + "settings": "비교 설정", + "show-values-for-comparison": "비교를 위한 기록 데이터 표시", + "comparison-values-label": "비교 키 레이블", + "comparison-values-label-auto": "자동", + "comparison-data-color": "비교 데이터 색상" + }, + "threshold": { + "thresholds": "임계값", + "source": "소스", + "key-value": "키 / 값", + "no-thresholds": "구성된 임계값 없음", + "add-threshold": "임계값 추가", + "type-constant": "상수", + "type-latest-key": "키", + "type-entity": "엔티티", + "threshold-settings": "임계값 설정", + "remove-threshold": "임계값 제거", + "threshold-value-required": "임계값은 필수 항목입니다.", + "key-required": "키는 필수 항목입니다.", + "entity-key-required": "엔티티 키는 필수 항목입니다.", + "line-appearance": "선 외관", + "line-color": "선 색상", + "start-symbol": "시작 기호", + "end-symbol": "끝 기호", + "symbol-size": "크기", + "label": "라벨", + "label-position-start": "시작", + "label-position-middle": "중간", + "label-position-end": "끝", + "label-position-inside-start": "내부 시작", + "label-position-inside-start-top": "내부 시작 상단", + "label-position-inside-start-bottom": "내부 시작 하단", + "label-position-inside-middle": "내부 중간", + "label-position-inside-middle-top": "내부 중간 상단", + "label-position-inside-middle-bottom": "내부 중간 하단", + "label-position-inside-end": "내부 끝", + "label-position-inside-end-top": "내부 끝 상단", + "label-position-inside-end-bottom": "내부 끝 하단", + "label-background": "레이블 배경" + }, + "state": { + "states": "상태", + "label": "라벨", + "ticks-value": "눈금 값", + "source": "소스", + "value-range": "값 / 범위", + "no-states": "구성된 상태 없음", + "add-state": "상태 추가", + "type-constant": "상수", + "type-range": "범위", + "from": "부터", + "to": "까지", + "remove-state": "상태 제거" + }, + "grid": { + "grid": "그리드", + "background-color": "배경색", + "border": "테두리" + }, + "axis": { + "axes": "축", + "x-axis": "X축", + "y-axis": "Y축", + "y-axis-settings": "Y축 설정", + "comparison-x-axis-settings": "비교 X축 설정", + "remove-y-axis": "Y축 제거", + "id": "ID", + "label": "라벨", + "position": "범례 위치", + "position-left": "왼쪽", + "position-right": "오른쪽", + "position-top": "상단", + "position-bottom": "하단", + "tick-labels": "눈금 레이블", + "ticks-formatter-function": "눈금 형식 함수", + "ticks-generator-function": "눈금 생성기 함수", + "show-ticks": "눈금 표시", + "show-line": "선 표시", + "show-split-lines": "분할선 표시", + "show-split-lines-x-axis-hint": "활성화하면 차트에 수직선이 표시됩니다.", + "show-split-lines-y-axis-hint": "활성화하면 차트에 수평선이 표시됩니다.", + "ticks-interval": "눈금 간격", + "ticks-interval-hint": "축의 분할 간격을 강제로 설정합니다.", + "split-number": "분할 수", + "split-number-hint": "축이 분할되는 세그먼트 수입니다.", + "min": "최소", + "max": "최대", + "show": "표시", + "add-y-axis": "Y축 추가" + }, + "series": { + "legend-settings": "범례 설정", + "show-in-legend": "범례에 표시", + "show-in-legend-hint": "범례에 시리즈 이름과 데이터를 표시합니다.", + "hidden-by-default": "기본적으로 숨김", + "hidden-by-default-hint": "시리즈를 기본적으로 범례에서 숨깁니다.", + "series-type": "시리즈 유형", + "type": "유형", + "type-line": "선", + "type-bar": "막대", + "line": { + "line": "선", + "show-line": "선 표시", + "step-line": "계단선", + "step-type-start": "시작", + "step-type-middle": "중간", + "step-type-end": "끝", + "smooth-line": "부드러운 선" + }, + "point": { + "points": "지점", + "show-points": "지점 표시", + "point-label": "지점 레이블", + "point-label-hint": "시리즈 지점 위에 값이 있는 레이블을 표시합니다.", + "point-label-background": "지점 레이블 배경", + "point-shape": "포인트 모양", + "point-size": "포인트 크기" + } + } + }, + "wind-speed-direction": { + "layout": "레이아웃", + "layout-default": "기본값", + "layout-advanced": "고급", + "layout-simplified": "간소화", + "values": "값", + "wind-direction": "풍향", + "center-value": "중심 값", + "icon": "아이콘", + "arrow": "화살표", + "ticks": "눈금", + "labels-type": "레이블 유형", + "directional-names": "방향 이름", + "degrees": "도", + "major-ticks": "주 눈금", + "minor-ticks": "보조 눈금", + "wind-speed-direction-card-style": "풍속 및 풍향 카드 스타일", + "ticks-color": "눈금 색상", + "ticks-labels-type": "눈금 레이블 유형", + "arrow-color": "화살표 색상" + }, + "value-source": { + "value-source": "값 소스", + "predefined-value": "상수", + "entity-attribute": "엔티티 속성", + "value": "값", + "value-required": "값이 필요합니다.", + "key-required": "키는 필수 항목입니다.", + "entity-key-required": "엔티티 키는 필수 항목입니다.", + "source-entity-alias": "소스 엔티티 별명", + "source-entity-attribute": "소스 엔티티 속성", + "type-constant": "상수", + "type-latest-key": "키", + "type-entity": "엔티티" + }, + "rpc-state": { + "initial-state": "초기 상태", + "initial-state-hint": "컴포넌트의 초기 상태(켜짐/꺼짐)를 가져오는 작업입니다.", + "disabled-state": "비활성화 상태", + "disabled-state-hint": "구성 요소가 비활성화되는 조건을 구성합니다.", + "turn-on": "'켜짐'으로 전환", + "turn-on-hint": "슬라이더가 '켜짐'으로 전환될 때 트리거되는 작업입니다.", + "turn-off": "'꺼짐'으로 전환", + "turn-off-hint": "슬라이더가 '꺼짐'으로 전환될 때 트리거되는 작업입니다.", + "on": "켜기", + "off": "끄기", + "disabled": "비활성화됨" + }, + "value-action": { + "do-nothing": "아무것도 하지 않음", + "execute-rpc": "RPC 실행", + "get-attribute": "속성 가져오기", + "set-attribute": "속성 설정", + "get-time-series": "시계열 가져오기", + "get-alarm-status": "알람 상태 가져오기", + "get-dashboard-state": "대시보드 상태 ID 가져오기", + "get-dashboard-state-object": "대시보드 상태 엔티티 가져오기", + "add-time-series": "시계열 추가", + "execute-rpc-text": "RPC 메서드 '{{methodName}}' 실행", + "get-time-series-text": "시계열 '{{key}}' 사용", + "get-attribute-text": "속성 '{{key}}' 사용", + "get-alarm-status-text": "알람 상태 사용", + "get-dashboard-state-text": "대시보드 상태 사용", + "get-dashboard-state-object-text": "대시보드 상태 엔티티 사용", + "when-dashboard-state-is-text": "대시보드 상태 ID가 '{{state}}'인 경우", + "when-dashboard-state-function-is-text": "f(대시보드 상태 ID)가 '{{state}}'인 경우", + "when-dashboard-state-object-function-is-text": "f(대시보드 상태 엔티티)가 '{{state}}'인 경우", + "set-attribute-to-value-text": "'{{key}}' 속성을 다음으로 설정: {{value}}", + "add-time-series-value-text": "'{{key}}' 시계열 값 추가: {{value}}", + "set-attribute-text": "'{{key}}' 속성 설정", + "add-time-series-text": "'{{key}}' 시계열 추가", + "action": "동작", + "value": "값", + "init-value-hint": "장치가 데이터를 보낼 때까지 설정될 값입니다.", + "method": "방법", + "method-name-required": "메서드 이름이 필요합니다.", + "request-timeout-ms": "RPC 요청 시간 제한 (ms)", + "request-timeout-required": "요청 시간 제한이 필요합니다.", + "min-request-timeout-error": "요청 시간 제한 값은 5000ms (5초) 이상이어야 합니다.", + "request-persistent": "RPC 요청 영구", + "persistent-polling-interval": "영구 폴링 간격 (ms)", + "persistent-polling-interval-hint": "영구 RPC 명령 응답을 가져오기 위한 폴링 간격 (ms)", + "persistent-polling-interval-required": "영구 폴링 간격이 필요합니다.", + "min-persistent-polling-interval-error": "영구 폴링 간격 값은 1000ms (1초) 이상이어야 합니다.", + "attribute-scope": "속성 범위", + "attribute-key": "속성 키", + "attribute-key-required": "속성 키를 입력하세요.", + "time-series-key": "시계열 키", + "time-series-key-required": "시계열 키가 필요합니다.", + "action-result-converter": "작업 결과 변환기", + "converter-none": "없음", + "converter-function": "함수", + "converter-constant": "상수", + "converter-value": "값", + "parse-value-function": "값 구문 분석 함수", + "state-when-result-is": "결과가 다음인 경우 '{{state}}'", + "parameters": "파라미터", + "convert-value-function": "값 변환 함수", + "error": { + "target-entity-is-not-set": "대상 엔티티가 설정되지 않았습니다!", + "failed-to-perform-action": "{{ actionLabel }} 작업을 수행하지 못했습니다.", + "invalid-attribute-scope": "{{scope}} 속성 범위는 {{entityType}} 엔티티에서 지원되지 않습니다." + } + }, + "widget-font": { + "font-settings": "글꼴 설정", + "font-family": "글꼴 패밀리", + "size": "크기", + "relative-font-size": "상대 글꼴 크기 (백분율)", + "font-style": "스타일", + "font-style-normal": "정상", + "font-style-italic": "이탤릭", + "font-style-oblique": "기울임", + "font-weight": "두께", + "font-weight-normal": "정상", + "font-weight-bold": "굵게", + "font-weight-bolder": "더 굵게", + "font-weight-lighter": "더 얇게", + "color": "색상", + "shadow-color": "그림자 색상", + "preview": "미리보기", + "line-height": "줄 높이", + "auto": "자동" + }, + "home": { + "no-data-available": "사용 가능한 데이터 없음" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "디스크", + "cpu-warning-text": "CPU 사용량이 높습니다. 시스템 오류를 방지하려면 시스템 성능을 최적화하세요.", + "cpu-critical-text": "CPU 사용량이 매우 높습니다. 시스템 오류를 방지하려면 시스템 성능을 최적화하세요.", + "ram-warning-text": "RAM 여유 공간이 부족합니다. 시스템 오류를 방지하려면 시스템 성능을 최적화하거나 RAM 크기를 늘리세요.", + "ram-critical-text": "RAM 여유 공간이 매우 부족합니다. 시스템 오류를 방지하려면 시스템 성능을 최적화하거나 RAM 크기를 늘리세요.", + "disk-warning-text": "디스크 공간이 부족합니다. 데이터 손실을 방지하려면 디스크 공간을 확보하거나 늘리세요.", + "disk-critical-text": "디스크 공간이 매우 부족합니다. 데이터 손실을 방지하려면 디스크 공간을 확보하거나 늘리세요." + }, + "cluster-info": { + "service-id": "서비스 ID", + "service-type": "서비스 유형", + "no-data": "데이터 없음" + }, + "transport-messages": { + "title": "전송 메시지", + "info": "장치에서 온 모든 메시지" + }, + "activity": { + "title": "활동" + }, + "documentation": { + "title": "문서", + "add-link": "링크 추가", + "add-link-title": "문서 링크 추가", + "name": "이름", + "name-required": "이름을 입력하세요.", + "link": "링크", + "link-required": "링크가 필요합니다.", + "columns": "열" + }, + "quick-links": { + "title": "빠른 링크", + "add-link": "링크 추가", + "add-link-title": "빠른 링크 추가", + "quick-link": "빠른 링크", + "quick-link-required": "빠른 링크가 필요합니다.", + "no-links-matching": "'{{name}}'과(와) 일치하는 링크가 없습니다.", + "columns": "열" + }, + "recent-dashboards": { + "title": "대시보드", + "last": "최근 본 항목", + "starred": "즐겨찾기", + "name": "이름", + "last-viewed": "최근 본 항목", + "no-last-viewed-dashboards": "아직 최근 본 대시보드 없음" + }, + "configured-features": { + "title": "구성된 기능", + "info": "구성이 필요한 기능 상태", + "email-feature": "이메일", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2단계 인증", + "feature-configured": "기능이 구성되었습니다.\n클릭하여 설정", + "feature-not-configured": "기능이 구성되지 않았습니다.\n클릭하여 설정" + }, + "version-info": { + "title": "버전", + "contact-us": "문의하기", + "current-version": "현재 버전", + "current": "현재", + "available-version": "사용 가능한 버전", + "available": "사용 가능", + "upgrade": "업그레이드", + "version-is-up-to-date": "버전이 최신 상태입니다" + }, + "usage-info": { + "title": "사용량", + "entities": "엔티티", + "api-calls": "API 호출" + }, + "functions": { + "title": "함수", + "pe-feature-tooltip": "ThingsBoard\nProfessional Edition에서만", + "switch-to-pe": "PE로 전환", + "alarms": "알람", + "dashboards": "대시보드", + "entities-and-relations": "엔티티 및 관계", + "profiles": "프로필", + "advanced-features": "고급 기능", + "notification-center": "알림 센터", + "api-usage": "Api 사용량", + "customers": "고객", + "customers-hierarchy": "고객 계층 구조", + "roles-and-permissions": "역할 및 권한", + "groups": "그룹", + "integrations": "통합", + "solution-templates": "솔루션 템플릿", + "scheduler": "스케줄러", + "white-labeling": "화이트 라벨링" + }, + "devices": { + "view-docs": "문서 보기", + "inactive": "비활성", + "active": "활성", + "total": "합계" + }, + "alarms": { + "critical": "심각한", + "assigned-to-me": "나에게 할당됨", + "total": "합계" + }, + "getting-started": { + "get-started": "시작하기", + "finish": "완료", + "done-welcome-title": "환영합니다", + "done-welcome-text": "잘 해결했습니다!", + "sys-admin": { + "step1": { + "title": "테넌트 및 테넌트 관리자 생성", + "content": "

테넌트는 장치와 자산을 소유하거나 생산하는 개인 또는 조직입니다. 테넌트는 여러 테넌트 관리자 사용자, 고객, 장치 및 자산을 가질 수 있습니다.

테넌트 관리자는 테넌트 계정 내에서 장치, 자산, 고객 및 대시보드를 생성하고 관리할 수 있습니다.

방법은 문서를 참조하세요:

", + "how-to-create-tenant": "테넌트 및 테넌트 관리자 생성 방법" + }, + "step2": { + "title": "기능 구성: 메일 서버", + "content": "

메일 서버 구성은 사용자 활성화, 비밀번호 복구 및 알림 전달에 필수적입니다.

방법은 문서를 참조하세요:

", + "how-to-configure-mail-server": "메일 서버 구성 방법" + }, + "step3": { + "title": "기능 구성: SMS 공급자", + "content": "

SMS 공급자를 구성하여 고객에게 SMS로 알림을 보냅니다.

방법은 문서를 참조하세요:

", + "how-to-configure-sms-provider": "SMS 공급자 구성 방법" + }, + "step4": { + "title": "기능 구성: 2FA", + "content": "

2단계 인증으로 플랫폼 계정의 보안을 강화하세요.

방법은 문서를 참조하세요:

", + "how-to-configure-2fa": "2FA 구성 방법" + }, + "step5": { + "title": "기능 구성: OAuth 2", + "content": "

OAuth 2.0을 통한 Single Sign-On 기능으로 테넌트 및 고객 사용자의 로그인을 간소화하세요.

방법은 문서를 참조하세요:

", + "how-to-configure-oauth2": "OAuth 2 구성 방법" + }, + "step6": { + "title": "기능 구성: Slack", + "content": "

설정한 알림 규칙에 따라 Slack을 통해 테넌트 및 고객 사용자에게 알림을 배포합니다.

방법은 문서를 참조하세요:

", + "how-to-configure-notifications": "Slack 구성 방법" + } + }, + "tenant-admin": { + "step1": { + "title": "장치 생성", + "content": "

UI를 통해 플랫폼에 첫 번째 장치를 프로비저닝해 보겠습니다. 방법은 문서를 참조하세요:

", + "how-to-create-device": "장치 생성 방법" + }, + "step2": { + "title": "장치 연결", + "content-before": "

장치를 연결하려면 장치 자격 증명을 가져와야 합니다. 이 가이드에서는 기본 자동 생성 자격 증명인 액세스 토큰을 사용하는 것을 권장합니다.

  • 장치 테이블로 이동
  • 장치 행을 클릭하여 장치 세부 정보 열기
  • \"액세스 토큰 복사\" 버튼 누르기

간단한 명령을 사용하여 HTTP를 통해 데이터를 게시합니다. $ACCESS_TOKEN을 장치 액세스 토큰으로 교체하는 것을 잊지 마세요:

", + "ubuntu": { + "install-curl": "Ubuntu용 cURL 설치:" + }, + "macos": { + "install-curl": "MacOS용 cURL 설치:" + }, + "windows": { + "install-curl": "Windows 10 b17063부터 cURL이 기본적으로 사용 가능합니다." + }, + "replace-access-token": "$ACCESS_TOKEN을 장치의 토큰으로 교체:", + "content-after": "

MQTT, CoAP 등 다른 프로토콜도 사용할 수 있습니다.

방법은 문서를 참조하세요:

", + "how-to-connect-device": "장치 연결 방법" + }, + "step3": { + "title": "대시보드 생성", + "content": "

자산, 장치 등의 엔티티에서 데이터를 시각화하는 대시보드를 만듭니다.

방법은 문서를 참조하세요:

", + "how-to-create-dashboard": "대시보드 생성 방법" + }, + "step4": { + "title": "알람 규칙 구성", + "alarm-rules": "알람 규칙", + "content": "

온도가 25°C에 도달하면 알림을 발생시켜 보겠습니다. 방법은 문서를 참조하세요:

", + "how-to-configure-alarm-rules": "알림 규칙 구성 방법" + }, + "step5": { + "title": "알람 생성", + "content-before": "

알림을 트리거하려면 26°C 이상의 새 텔레메트리 값을 보냅니다.

", + "replace-access-token": "$ACCESS_TOKEN을 장치의 토큰으로 교체:", + "content-after": "

방법은 문서를 참조하세요:

", + "how-to-create-alarm": "알림 생성 방법" + }, + "step6": { + "title": "고객 생성 및 대시보드 할당", + "content": "

최종 사용자 대시보드를 생성하면 고객 사용자는 자신의 장치만 볼 수 있으며 다른 고객의 데이터는 숨겨집니다.

방법은 문서를 참조하세요:

", + "how-to-create-customer-and-assign-dashboard": "고객 생성 및 대시보드 할당 방법" + } + } + }, + "api-usage": { + "api-usage": "Api 사용량", + "label": "라벨", + "state-name": "상태 이름", + "status": "상태", + "status-required": "상태는 필수 항목입니다.", + "limit": "최대 제한", + "limit-required": "최대 제한이 필요합니다.", + "current-number": "현재 수", + "current-number-required": "현재 수는 필수 항목입니다.", + "add-key": "키 추가", + "no-key": "키 없음", + "delete-key": "키 삭제", + "target-dashboard-state": "대상 대시보드 상태", + "go-to-main-state": "기본 보기로 이동" + }, + "unit-conversion": { + "hint": "저장된 값의 단위입니다. 변환할 원본 단위입니다. 소스 데이터에서 사용하는 기호를 입력하세요 (예: m, km, ft, in)." } }, + "color": { + "color": "색상" + }, "icon": { "icon": "아이콘", + "icons": "아이콘", + "custom": "사용자 지정", "select-icon": "선택된 아이콘", - "material-icons": "Material icons", - "show-all": "모든 아이콘 보기" + "material-icons": "머티리얼 아이콘", + "show-all": "모든 아이콘 보기", + "search-icon": "아이콘 검색", + "no-icons-found": "'{{iconSearch}}'에 대한 아이콘을 찾을 수 없음" + }, + "phone-input": { + "phone-input-label": "전화번호", + "phone-input-required": "전화번호는 필수 항목입니다.", + "phone-input-validation": "전화번호가 유효하지 않거나 불가능합니다.", + "phone-input-pattern": "잘못된 전화번호입니다. E.164 형식을 사용하세요 (예: {{phoneNumber}})", + "phone-input-hint": "E.164 형식의 전화번호 (예: {{phoneNumber}})" }, "custom": { "widget-action": { - "action-cell-button": "Action cell button", - "row-click": "On row click", - "polygon-click": "On polygon click", - "marker-click": "On marker click", - "tooltip-tag-action": "Tooltip tag action", - "node-selected": "On node selected", - "element-click": "On HTML element click", - "pie-slice-click": "On slice click", - "row-double-click": "On row double click" + "action-cell-button": "동작 셀 버튼", + "row-click": "행 클릭 시", + "cell-click": "셀 클릭 시", + "polygon-click": "다각형 클릭 시", + "marker-click": "마커 클릭 시", + "circle-click": "원 클릭 시", + "tooltip-tag-action": "툴팁 태그 동작", + "node-selected": "노드 선택 시", + "element-click": "HTML 요소 클릭 시", + "pie-slice-click": "슬라이스 클릭 시", + "row-double-click": "행 더블 클릭 시", + "cell-double-click": "셀 더블 클릭 시", + "card-click": "카드 클릭 시", + "click": "클릭 시" } }, + "paginator": { + "items-per-page": "페이지당 항목:", + "first-page-label": "첫 페이지", + "last-page-label": "마지막 페이지", + "next-page-label": "다음 페이지", + "previous-page-label": "이전 페이지", + "items-per-page-separator": "/" + }, "language": { + "auto": "자동", "language": "언어(Language)" } } diff --git a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json index 01d828b1be..7926c16961 100644 --- a/ui-ngx/src/assets/locale/locale.constant-nl_NL.json +++ b/ui-ngx/src/assets/locale/locale.constant-nl_NL.json @@ -803,7 +803,7 @@ "no-assets-matching": "Geen overeenkomende assets gevonden voor '{{entity}}'.", "asset-required": "Asset is verplicht", "name-starts-with": "Assetnaam-expressie", - "help-text": "Gebruik '%' indien nodig: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Gebruik '%' naar behoefte: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Assets zoeken", "import": "Assets importeren", "asset-file": "Assetbestand", @@ -910,9 +910,9 @@ "processing-timeouts": "${entityName} Verwerking time-outs", "rule-chain": "Rule chain", "rule-engine": "Rule Engine", - "rule-engine-daily-activity": "Dagelijkse activiteit Rule Engine", "rule-engine-executions": "Rule Engine-uitvoeringen", "rule-engine-hourly-activity": "Uurlijkse activiteit Rule Engine", + "rule-engine-daily-activity": "Dagelijkse activiteit Rule Engine", "rule-engine-monthly-activity": "Maandelijkse activiteit Rule Engine", "rule-engine-statistics": "Rule Engine-statistieken", "rule-node": "Rule node", @@ -936,6 +936,8 @@ "transport-data-points-hourly-activity": "Transportdatapunten: uurlijkse activiteit", "transport-data-points-daily-activity": "Transportdatapunten: dagelijkse activiteit", "transport-data-points-monthly-activity": "Transportdatapunten: maandelijkse activiteit", + "view-details": "Details bekijken", + "view-statistics": "Statistieken bekijken", "transport-messages": "Transportberichten", "transport-messages-hourly-activity": "Transportberichten: uurlijkse activiteit", "transport-data-point-hourly-activity": "Transportdatapunt: uurlijkse activiteit", @@ -1093,6 +1095,7 @@ "expression": "Expressie", "no-found": "Geen berekende velden gevonden", "list": "{ count, plural, =1 {Eén berekend veld} other {Lijst van # berekende velden} }", + "add": "Berekend veld toevoegen", "selected-fields": "{ count, plural, =1 {1 berekend veld} other {# berekende velden} } geselecteerd", "type": { "simple": "Eenvoudig", @@ -2122,7 +2125,7 @@ "remove-alias": "Apparaatalias verwijderen", "add-alias": "Apparaatalias toevoegen", "name-starts-with": "Apparaatnaam expressie", - "help-text": "Gebruik '%' indien nodig: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Gebruik '%' naar behoefte: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Apparatenlijst", "use-device-name-filter": "Filter gebruiken", "device-list-empty": "Geen apparaten geselecteerd.", @@ -3037,7 +3040,7 @@ "no-entities-text": "Geen entiteiten gevonden", "no-entity-types-matching": "Geen entiteittypes gevonden die overeenkomen met '{{entityType}}'.", "name-starts-with": "Naamexpressie", - "help-text": "Gebruik '%' indien nodig: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Gebruik '%' naar behoefte: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Gebruik filter", "entity-list-empty": "Geen entiteiten geselecteerd.", "entity-type-list-required": "Minstens één entiteittype moet worden geselecteerd.", @@ -3240,7 +3243,7 @@ "remove-alias": "Alias van entiteitweergave verwijderen", "add-alias": "Alias voor entiteitweergave toevoegen", "name-starts-with": "Naamexpressie van entiteitweergave", - "help-text": "Gebruik '%' waar nodig: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Gebruik '%' naar behoefte: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Lijst van entiteitweergaven", "use-entity-view-name-filter": "Filter gebruiken", "entity-view-list-empty": "Geen entiteitweergaven geselecteerd.", @@ -7463,7 +7466,7 @@ "handle-empty-result-function": "Leegresultaatfunctie afhandelen", "handle-error-function": "Foutfunctie afhandelen", "handle-non-mobile-fallback-function": "Non-Mobile-terugvalfunctie afhandelen", - "save-to-gallery": "Opslaan in galerij", + "save-to-gallery": "Opslaan in 'Afbeeldingsgalerij'", "provision-type": "Provisioningtype", "auto": "Auto", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-no_NO.json b/ui-ngx/src/assets/locale/locale.constant-no_NO.json index 7e6e464202..be53c9624d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-no_NO.json +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -78,6 +78,7 @@ "show-more": "Vis mer", "dont-show-again": "Ikke vis igjen", "see-documentation": "Se dokumentasjon", + "see-debug-events": "Se feilsøkingshendelser", "clear": "Tøm", "upload": "Last opp", "delete-anyway": "Slett likevel", @@ -485,6 +486,7 @@ "2fa": { "2fa": "Tofaktorautentisering", "available-providers": "Tilgjengelige leverandører", + "available-providers-required": "Minst én 2FA-leverandør må konfigureres.", "issuer-name": "Utstederens navn", "issuer-name-required": "Utstederens navn er påkrevd.", "max-verification-failures-before-user-lockout": "Maks antall mislykkede verifiseringer før brukerkonto låses", @@ -513,7 +515,9 @@ "verification-message-template-required": "Meldingsmal for verifisering er påkrevd.", "within-time": "Innen tid (sek)", "within-time-pattern": "Tiden må være et positivt heltall.", - "within-time-required": "Tid er påkrevd." + "within-time-required": "Tid er påkrevd.", + "force-2fa": "Håndhev tofaktorautentisering", + "enforce-for": "Håndhev for" }, "jwt": { "security-settings": "JWT sikkerhetsinnstillinger", @@ -549,6 +553,7 @@ }, "alarm": { "alarm": "Alarm", + "alarm-list": "Alarmliste", "alarms": "Alarmer", "all-alarms": "Alle alarmer", "select-alarm": "Velg alarm", @@ -649,7 +654,16 @@ "alarm-type": "Alarmtype", "enter-alarm-type": "Angi alarmtype", "no-alarm-types-matching": "Ingen alarmtyper som samsvarer med '{{entitySubtype}}' ble funnet.", - "alarm-type-list-empty": "Ingen alarmtyper valgt." + "alarm-type-list-empty": "Ingen alarmtyper valgt.", + "system-comments": { + "acked-by-user": "Alarmen ble kvittert av bruker {{userName}}", + "cleared-by-user": "Alarmen ble nullstilt av bruker {{userName}}", + "assigned-to-user": "Alarmen ble tilordnet av bruker {{userName}} til bruker {{assigneeName}}", + "unassigned-to-user": "Tilordningen av alarmen ble fjernet av bruker {{userName}}", + "unassigned-from-deleted-user": "Tilordningen av alarmen ble fjernet fordi brukeren {{userName}} - ble slettet", + "comment-deleted": "Bruker {{userName}} slettet kommentaren sin", + "severity-changed": "Alarmens alvorlighetsgrad ble oppdatert fra {{oldSeverity}} til {{newSeverity}}" + } }, "alarm-activity": { "add": "Legg til en kommentar...", @@ -677,8 +691,8 @@ "filter-type-entity-list": "Enhetsliste", "filter-type-entity-name": "Enhetsnavn", "filter-type-entity-type": "Enhetstype", - "filter-type-state-entity": "Enhet fra dashboard-tilstand", - "filter-type-state-entity-description": "Enhet hentet fra dashboard-tilstandsparametere", + "filter-type-state-entity": "Entitet fra dashbordtilstand", + "filter-type-state-entity-description": "Entitet hentet fra parametere for dashbordtilstand", "filter-type-asset-type": "Asset-type", "filter-type-asset-type-description": "Assets av typen '{{assetTypes}}'", "filter-type-asset-type-and-name-description": "Assets av typen '{{assetTypes}}' og med navn som begynner med '{{prefix}}'", @@ -709,17 +723,18 @@ "filter-type-required": "Filtertype er påkrevd.", "entity-filter-no-entity-matched": "Ingen enheter samsvarer med spesifisert filter.", "no-entity-filter-specified": "Ingen enhetsfilter spesifisert", - "root-state-entity": "Bruk dashboard-tilstandsenhet som rot", + "root-state-entity": "Bruk entitet for dashbordtilstand som rot", "last-level-relation": "Hent kun siste relasjonsnivå", "root-entity": "Rotenhet", "state-entity-parameter-name": "Parameter for tilstandsenhet", "default-state-entity": "Standard tilstandsenhet", "default-entity-parameter-name": "Som standard", - "max-relation-level": "Maks relasjonsnivå", + "query-options": "Spørringsalternativer", + "max-relation-level": "Maksimalt relasjonsnivå", "unlimited-level": "Ubegrenset nivå", - "state-entity": "Dashboard tilstandsenhet", - "all-entities": "Alle enheter", - "any-relation": "enhver" + "state-entity": "Dashbordtilstandsentitet", + "all-entities": "Alle entiteter", + "any-relation": "alle" }, "asset": { "asset": "Eiendel", @@ -753,6 +768,7 @@ "name-max-length": "Navnet må være kortere enn 256 tegn", "label-max-length": "Etiketten må være kortere enn 256 tegn", "description": "Beskrivelse", + "description-required": "Beskrivelse er påkrevd.", "type": "Type", "type-required": "Type er påkrevd.", "details": "Detaljer", @@ -787,7 +803,7 @@ "no-assets-matching": "Ingen eiendeler som samsvarer med '{{entity}}' ble funnet.", "asset-required": "Eiendel er påkrevd", "name-starts-with": "Navneuttrykk for eiendel", - "help-text": "Bruk '%' etter behov: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'.", + "help-text": "Bruk '%' etter behov: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Søk etter eiendeler", "import": "Importer eiendeler", "asset-file": "Eiendelsfil", @@ -866,6 +882,9 @@ "alarms-created-monthly-activity": "Månedlig aktivitet for opprettede alarmer", "data-points": "Datapunkter", "data-points-storage-days": "Lagringsdager for datapunkter", + "data-points-storage-days-hourly-activity": "Lagringsdager for datapunkter – timeaktivitet", + "data-points-storage-days-daily-activity": "Lagringsdager for datapunkter – daglig aktivitet", + "data-points-storage-days-monthly-activity": "Lagringsdager for datapunkter – månedlig aktivitet", "device-api": "Enhets-API", "email": "E-post", "email-messages": "E-postmeldinger", @@ -891,14 +910,15 @@ "processing-timeouts": "${entityName} Behandlingstidsavbrudd", "rule-chain": "Regelkjede", "rule-engine": "Regelmotor", - "rule-engine-daily-activity": "Daglig aktivitet for regelmotor", "rule-engine-executions": "Utførelser av regelmotor", "rule-engine-hourly-activity": "Timebasert aktivitet for regelmotor", + "rule-engine-daily-activity": "Daglig aktivitet for regelmotor", "rule-engine-monthly-activity": "Månedlig aktivitet for regelmotor", "rule-engine-statistics": "Statistikk for regelmotor", "rule-node": "Regelnode", "sms": "SMS", "sms-messages": "SMS-meldinger", + "sms-messages-hourly-activity": "SMS-meldinger – timeaktivitet", "sms-messages-daily-activity": "Daglig aktivitet for SMS-meldinger", "sms-messages-monthly-activity": "Månedlig aktivitet for SMS-meldinger", "successful": "${entityName} Vellykket", @@ -908,16 +928,48 @@ "telemetry-persistence-hourly-activity": "Timebasert aktivitet for telemetrilagring", "telemetry-persistence-monthly-activity": "Månedlig aktivitet for telemetrilagring", "transport": "Transport", - "transport-daily-activity": "Daglig aktivitet for transport", + "transport-msg-hourly-activity": "Transportmeldinger – timeaktivitet", + "transport-msg-daily-activity": "Transportmeldinger – daglig aktivitet", + "transport-msg-monthly-activity": "Transportmeldinger – månedlig aktivitet", + "transport-daily-activity": "Transport – daglig aktivitet", "transport-data-points": "Transportdatapunkter", - "transport-hourly-activity": "Timebasert aktivitet for transport", - "transport-messages": "Transportmeldinger", - "transport-monthly-activity": "Månedlig aktivitet for transport", + "transport-data-points-hourly-activity": "Transportdatapunkter – timeaktivitet", + "transport-data-points-daily-activity": "Transportdatapunkter – daglig aktivitet", + "transport-data-points-monthly-activity": "Transportdatapunkter – månedlig aktivitet", "view-details": "Vis detaljer", - "view-statistics": "Vis statistikk" + "view-statistics": "Vis statistikk", + "transport-messages": "Transportmeldinger", + "transport-messages-hourly-activity": "Transportmeldinger – timeaktivitet", + "transport-data-point-hourly-activity": "Transportdatapunkt – timeaktivitet", + "javascript-function-executions": "JavaScript-funksjonskjøringer", + "javascript-function-executions-hourly-activity": "JavaScript-funksjonskjøringer – timeaktivitet", + "javascript-function-executions-daily-activity": "JavaScript-funksjonskjøringer – daglig aktivitet", + "javascript-function-executions-monthly-activity": "JavaScript-funksjonskjøringer – månedlig aktivitet", + "tbel-function-executions": "TBEL-funksjonskjøringer", + "tbel-function-executions-hourly-activity": "TBEL-funksjonskjøringer – timeaktivitet", + "tbel-function-executions-daily-activity": "TBEL-funksjonskjøringer – daglig aktivitet", + "tbel-function-executions-monthly-activity": "TBEL-funksjonskjøringer – månedlig aktivitet", + "created-reports": "Opprettede rapporter", + "created-reports-hourly-activity": "Opprettede rapporter – timeaktivitet", + "created-reports-daily-activity": "Opprettede rapporter – daglig aktivitet", + "created-reports-monthly-activity": "Opprettede rapporter – månedlig aktivitet", + "emails": "E-poster", + "emails-hourly-activity": "E-poster – timeaktivitet", + "emails-daily-activity": "E-poster – daglig aktivitet", + "emails-monthly-activity": "E-poster – månedlig aktivitet", + "status": { + "enabled": "Aktivert", + "disabled": "Deaktivert", + "warning": "Advarsel" + } }, "api-limit": { - "cassandra-queries": "Cassandra-forespørsler", + "cassandra-write-queries-core": "REST API Cassandra-skrivespørringer", + "cassandra-read-queries-core": "REST API- og WS-telemetri Cassandra-lesespørringer", + "cassandra-write-queries-rule-engine": "Regelmotor-telemetri Cassandra-skrivespørringer", + "cassandra-read-queries-rule-engine": "Regelmotor-telemetri Cassandra-lesespørringer", + "cassandra-write-queries-monolith": "Monolith-telemetri Cassandra-skrivespørringer", + "cassandra-read-queries-monolith": "Monolith-telemetri Cassandra-lesespørringer", "entity-version-creation": "Oppretting av enhetsversjon", "entity-version-load": "Innlasting av enhetsversjon", "notification-requests": "Varslingsforespørsler", @@ -934,6 +986,40 @@ "edge-uplink-messages": "Edge-opplinkmeldinger", "edge-uplink-messages-per-edge": "Edge-opplinkmeldinger per edge" }, + "api-key": { + "api-key": "API-nøkkel", + "api-keys": "API-nøkler", + "delete-api-key-title": "Er du sikker på at du vil slette API-nøkkelen '{{name}}'?", + "delete-api-key-text": "Vær forsiktig, etter bekreftelsen vil nøkkelen ikke kunne gjenopprettes.", + "delete-api-keys-title": "Er du sikker på at du vil slette { count, plural, =1 {1 API-nøkkel} other {# API-nøkler} }?", + "delete-api-keys-text": "Vær forsiktig, etter bekreftelsen vil alle valgte nøkler ikke kunne gjenopprettes.", + "expiration-date": "Utløpsdato", + "date": "dato", + "description": "Beskrivelse", + "disable": "Deaktiver", + "edit-description": "Rediger beskrivelse", + "enable": "Aktiver API-nøkkel ", + "expiration-time": "Utløpstid", + "expiration-time-never": "Aldri", + "expiration-time-custom": "Egendefinert", + "generate": "Generer", + "generate-title": "Generer API-nøkkel", + "generate-text": "Merk: API-nøkkelen arver tillatelsene til brukeren den opprettes for.", + "generated-api-key-title": "API-nøkkel generert. La oss sjekke tilkoblingen!", + "generated-api-key-copy": "Sørg for å kopiere og lagre API-nøkkelen din nå, siden du ikke vil kunne se den igjen.", + "generated-api-key-command": "Bruk følgende instruksjoner for å sjekke tilkoblingen. Som resultat bør du motta informasjon om gjeldende bruker:", + "generated-api-key-insecure-url": "Å kjøre kommandoer over en usikker HTTP-tilkobling vil sende API-nøkkelen din ukryptert, noe som gjør den sårbar for avlytting.", + "list": "{ count, plural, =1 {Én API-nøkkel} other {Liste over # API-nøkler} }", + "manage": "Administrer", + "manage-api-keys": "Administrer API-nøkler", + "no-found": "Fant ingen API-nøkler", + "selected-api-keys": "{ count, plural, =1 {1 API-nøkkel} other {# API-nøkler} } valgt", + "search": "Søk etter API-nøkler", + "status": "Status", + "status-active": "Aktiv", + "status-inactive": "Inaktiv", + "status-expired": "Utløpt" + }, "audit-log": { "audit": "Revisjon", "audit-logs": "Revisjonslogger", @@ -987,7 +1073,11 @@ "type-provision-failure": "Klargjøring av enhet mislyktes", "type-timeseries-updated": "Telemetri oppdatert", "type-timeseries-deleted": "Telemetri slettet", - "type-sms-sent": "SMS sendt" + "type-sms-sent": "SMS sendt", + "any-type": "Enhver type", + "audit-log-filter-title": "Revisjonsloggfilter", + "filter-title": "Filter", + "filter-types": "Revisjonsloggtyper" }, "debug-settings": { "label": "Feilsøkingskonfigurasjon", @@ -1005,15 +1095,29 @@ "expression": "Uttrykk", "no-found": "Ingen beregnede felt funnet", "list": "{ count, plural, =1 {Ett beregnet felt} other {Liste med # beregnede felt} }", + "add": "Legg til beregnet felt", "selected-fields": "{ count, plural, =1 {1 beregnet felt} other {# beregnede felt} } valgt", "type": { "simple": "Enkel", - "script": "Skript" + "simple-hint": "Enkel aritmetisk beregning basert på inndataargumenter.", + "script": "Skript", + "script-hint": "Beregning over definerte argumenter ved hjelp av et TBEL-skript.", + "geofencing": "Geofencing", + "geofencing-hint": "Evaluering av entitetens GPS-posisjon og overganger mot konfigurerte geofencing-sonegrupper.", + "propagation": "Propagering", + "propagation-hint": "Propagering av data til overordnede eller underordnede entiteter basert på relasjonsretning og -type.", + "related-entities-aggregation": "Aggregering av relaterte entiteter", + "related-entities-aggregation-hint": "Aggregering av siste data fra relaterte entiteter.", + "time-series-data-aggregation": "Aggregering av tidsseriedata", + "time-series-data-aggregation-hint": "Aggregering av historiske data fra en gjeldende entitet." }, + "preview": "Forhåndsvisning", "arguments": "Argumenter", "decimals-by-default": "Desimaler som standard", "debugging": "Feilsøking av beregnet felt", + "calculated-field-details": "Detaljer for beregnet felt", "argument-name": "Argumentnavn", + "name": "Navn", "datasource": "Datakilde", "add-argument": "Legg til argument", "test-script-function": "Test skriptfunksjon", @@ -1025,8 +1129,9 @@ "argument-asset": "Eiendel", "argument-customer": "Kunde", "argument-tenant": "Gjeldende leietaker", + "argument-owner": "Nåværende eier", + "argument-relation-query": "Relaterte entiteter", "argument-type": "Argumenttype", - "see-debug-events": "Se feilsøkingshendelser", "attribute": "Attributt", "copy-argument-name": "Kopier argumentnavn", "timeseries-key": "Tidsserienøkkel", @@ -1039,12 +1144,14 @@ "shared-attributes": "Delte attributter", "attribute-key": "Attributtnøkkel", "default-value": "Standardverdi", + "default-value-required": "Standardverdi er påkrevd.", "limit": "Maksverdier", "time-window": "Tidsvindu", "customer-name": "Kundenavn", "asset-name": "Eiendelsnavn", "timeseries": "Tidsserie", "output": "Utdata", + "output-hint": "Definerer hvordan output behandles.", "create": "Opprett nytt beregnet felt", "file": "Fil for beregnet felt", "invalid-file-error": "Ugyldig filformat. Vennligst sørg for at filen er en gyldig JSON-fil.", @@ -1057,8 +1164,175 @@ "delete-multiple-title": "Er du sikker på at du vil slette { count, plural, =1 {1 beregnet felt} other {# beregnede felt} }?", "delete-multiple-text": "Vær forsiktig, etter bekreftelse vil alle valgte beregnede felt bli fjernet og all relatert data vil ikke kunne gjenopprettes.", "test-with-this-message": "Test med denne meldingen", + "use-latest-timestamp": "Bruk nyeste tidsstempel", + "entity-coordinates": "Entitetskoordinater", + "latitude-time-series-key": "Breddegrad tidsserienøkkel", + "latitude-time-series-key-required": "Breddegrad tidsserienøkkel er påkrevd.", + "longitude-time-series-key": "Lengdegrad tidsserienøkkel", + "longitude-time-series-key-required": "Lengdegrad tidsserienøkkel er påkrevd.", + "geofencing-zone-groups": "Geofencing sonegrupper", + "geofencing-zone-groups-settings": "Innstillinger for geofencing sonegruppe", + "target-zone": "Målsone", + "perimeter-key": "Perimeter-nøkkel", + "report-strategy": "Rapporteringsstrategi", + "no-zone-configured": "Minst én sone er påkrevd.", + "no-zone-configured-required": "Minst én sonegruppe må være konfigurert.", + "add-zone-group": "Legg til sonegruppe", + "report-transition-event-only": "Kun overgangshendelser", + "report-presence-status-only": "Kun tilstedeværelsesstatus", + "report-transition-event-and-presence": "Tilstedeværelsesstatus og overgangshendelser", + "perimeter-attribute-key": "Perimeter-attributtnøkkel", + "perimeter-attribute-key-required": "Perimeter-attributtnøkkel er påkrevd.", + "perimeter-attribute-key-pattern": "Perimeter-attributtnøkkel er ugyldig.", + "entity-zone-relationship": "Sti fra entitet til soner", + "direction": "Relasjonsretning", + "direction-from": "Fra entitet til sone", + "direction-to": "Fra sone til entitet", + "relation-type": "Relasjonstype", + "create-relation-with-matched-zones": "Opprett relasjoner for kildeentitet med samsvarende soner", + "relation-level": "Relasjonsnivå", + "fetch-last-available-level": "Hent kun siste tilgjengelige nivå", + "zone-group-refresh-interval": "Oppdateringsintervall for sonegrupper", + "copy-zone-group-name": "Kopier sonegruppens navn", + "open-details-page": "Åpne entitetsdetaljside", + "level": "Nivå", + "direction-level": "Retning", + "direction-up": "Opp", + "direction-up-parent": "Opp til overordnet", + "direction-down": "Ned", + "direction-down-child": "Ned til underordnet", + "add-level": "Legg til nivå", + "delete-level": "Slett nivå", + "no-level": "Ingen nivå konfigurert", + "levels-required": "Minst ett nivå må være konfigurert.", + "max-allowed-levels-error": "Relasjonsnivået overskrider maks tillatt nivå.", + "propagation-path-related-entities": "Propageringssti til relaterte entiteter", + "propagate-type": { + "arguments-only": "Kun argumenter", + "expression-result": "Beregningens resultat" + }, + "script": "Skript", + "data-propagate": "Data som skal propageres", + "output-key": "Output-nøkkel", + "copy-output-key": "Kopier output-nøkkel", + "aggregation-path-related-entities": "Aggregasjonssti til relaterte entiteter", + "deduplication-interval": "Dedupliseringsintervall", + "deduplication-interval-min": "Dedupliseringsintervallet bør være minst {{ sec }} sekunder.", + "deduplication-interval-hint": "Minimalt tidsrom mellom telemetriaggregasjoner.", + "deduplication-interval-required": "Dedupliseringsintervall er påkrevd.", + "calculated-field-filter-title": "Filter for beregnet felt", + "filter-title": "Filter", + "calculated-field-types": "Typer av beregnede felt", + "events": "Hendelser", + "any-type": "Enhver type", + "metrics": { + "metrics": "Metrikker", + "metrics-empty": "Minst én metrikk må være konfigurert.", + "metric-name": "Metrikknavn", + "metric-name-required": "Metrikknavn er påkrevd.", + "metric-name-pattern": "Metrikknavn er ugyldig.", + "metric-name-duplicate": "Metrikk med dette navnet finnes allerede.", + "metric-name-max-length": "Metrikknavnet bør være mindre enn 256 tegn.", + "metric-name-forbidden": "Metrikknavnet er reservert og kan ikke brukes.", + "copy-metric-name": "Kopier metrikknavn", + "argument-name": "Argumentnavn", + "aggregation": "Aggregering", + "aggregation-type": { + "avg": "Gjennomsnitt", + "min": "Minimum", + "max": "Maksimum", + "sum": "Sum", + "count": "Antall", + "count-unique": "Antall unike" + }, + "filtered": "Filtrert", + "value-source": "Verdi kilde", + "value-source-hint": "Definerer hvordan verdien for aggregering hentes.", + "value-source-type": { + "key": "Nøkkel", + "function": "Funksjon" + }, + "no-metrics-configured": "Minst én metrikk er påkrevd.", + "add-metric": "Legg til metrikk", + "max-metrics": "Maksimalt antall metrikker er nådd.", + "metric-settings": "Metrikkinnstillinger", + "filter": "Filter", + "filter-hint": "Aktiverer filtrering av entiteter under aggregering. Filterfunksjonen må returnere en boolsk verdi og kan bruke alle konfigurerte argumenter." + }, + "output-strategy": { + "strategy": "Strategi", + "process-right-away": "Behandle umiddelbart", + "process-rule-chains": "Behandle via Regelkjeder", + "save-time-series": "Lagre i tidsserie", + "save-database": "Lagre i database", + "save-latest-values": "Lagre i siste verdier", + "send-web-sockets": "Send til WebSockets", + "save-calculated-fields": "Send til beregnede felt", + "update-attribute-only-on-value-change": "Oppdater attributt kun ved verdiendring", + "send-attributes-updated-notification": "Send varsel om oppdaterte attributter", + "ttl": "Egendefinert TTL", + "ttl-required": "TTL er påkrevd", + "ttl-min": "Kun minimum TTL på 0 er tillatt", + "processing-parameters": "Behandlingsparametere", + "hint": { + "strategy": "Styrer om resultatet behandles umiddelbart eller sendes til en regelkjede for videre behandling.", + "processing-options": "Behandlingsalternativer", + "update-attribute-only-on-value-change": "Oppdaterer attributtet ved hver innkommende melding, uavhengig av om verdien har endret seg. Dette øker API-bruken og reduserer ytelsen.", + "update-attribute-only-on-value-change-enabled": "Oppdaterer attributtet kun når verdien endres. Hvis verdien er uendret, oppdateres ikke tidsstempler og varsler sendes ikke.", + "send-attributes-updated-notification": "Sender en «Attributter oppdatert»-hendelse til standard regelkjede.", + "save-time-series": "Lagrer tidsseriedata i ts_kv-tabellen i databasen.", + "save-database": "Lagrer attributtdata i databasen.", + "save-latest-values": "Oppdaterer tidsseriedata i ts_kv_latest-tabellen i databasen hvis den nye verdien er nyere.", + "send-web-sockets-attribute": "Varsler WebSocket-abonnementer om oppdateringer av attributtdata.", + "send-web-sockets-time-series": "Varsler WebSocket-abonnementer om oppdateringer av tidsseriedata.", + "save-calculated-fields-attribute": "Varsler beregnede felt om oppdateringer av attributtdata.", + "save-calculated-fields-time-series": "Varsler beregnede felt om oppdateringer av tidsseriedata.", + "ttl": "Definerer oppbevaringsperioden for tidsseriedata. Hvis deaktivert, brukes TTL fra Leietakerprofil." + } + }, + "aggregate-interval-type": "Aggregeringsintervalltype", + "aggregate-interval-value": "Aggregeringsintervallverdi", + "aggregate-interval-value-required": "Aggregeringsintervallverdi er påkrevd.", + "aggregate-interval-value-min": "Aggregeringsintervallverdi bør være minst { sec, plural, =0 {0 sekund} =1 {1 sekund} other {# sekunder} }.", + "aggregate-interval-value-step-multiple-of": "Aggregeringsintervallverdi må være en divisor eller et multiplum av 1 dag.", + "aggregate-period": { + "hour": "Time", + "day": "Dag", + "week": "Uke (man–søn)", + "week-sun-sat": "Uke (søn–lør)", + "month": "Måned", + "quarter": "Kvartal", + "year": "År", + "custom": "Egendefinert" + }, + "aggregate-period-hint-offset": "Aggregeringsintervallet ditt blir: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "Aggregeringsintervallet ditt blir: {{ interval }} og så videre.", + "entity-aggregation": { + "argument-hint": "Data hentes fra gjeldende entitet.", + "argument-title-hint": "Definerer inndataargumentene som brukes for aggregering.", + "argument-setting-hint": "Siste telemetri er den eneste tilgjengelige argumenttypen for dette beregnede feltet.", + "aggregation-interval": "Aggregeringsintervall", + "aggregation-interval-hint": "Definerer hvor ofte aggregering utføres. Eksempel: hver 1 time aggregerer data kl. 00:00, 01:00, 02:00 osv. Aggregeringsresultater lagres med tidsstempelet som tilsvarer starten på aggregeringsintervallet.", + "apply-offset": "Bruk forskyvning på aggregeringsintervall", + "apply-offset-hint": "Definerer hvor mye starten på hver aggregeringsperiode skal forskyves (f.eks. +10 minutter – 00:10, 01:10).", + "offset-value": "Forskyvningsverdi", + "offset-value-required": "Forskyvningsverdi er påkrevd.", + "offset-value-min": "Forskyvningsverdi må være et positivt heltall.", + "offset-value-max": "Forskyvningsverdi bør være mindre enn aggregeringsintervallverdien.", + "wait-delay": "Bruk ventetidsavbrudd for forsinket telemetri", + "wait-delay-hint": "Definerer hvor lenge det skal ventes på forsinket telemetri etter at intervallet er avsluttet. Hvis slik telemetri kommer inn, beregnes resultatet for intervallet på nytt.", + "duration": "Varighet", + "duration-required": "Varighet er påkrevd.", + "duration-min": "Varighet bør være minst 1 minutt.", + "duration-hint": "Hvor lenge det skal ventes på forsinkede data etter at intervallet er avsluttet.", + "produce-intermediate-result": "Produser mellomresultat", + "produce-intermediate-result-hint": "Beregner metrikker i løpet av gjeldende intervall for å produsere et mellomresultat. Oppdateringer skjer ikke oftere enn én gang hver {{ time }}." + }, "hint": { - "arguments-simple-with-rolling": "Beregnede felt av enkel type bør ikke inneholde nøkler med rullende tidsserietype.", + "arguments-simple-with-rolling": "Beregnet felt av typen «Enkel» skal ikke inneholde nøkler med tidsserie-rulleringstype.", + "arguments-propagate-arguments-with-rolling": "Typen «Tidsserie-rullering» er ikke kompatibel med «Kun argumenter»-propagering.", + "arguments-propagate-argument-entity-type": "Entitetstype er ikke kompatibel med «Kun argumenter»-propagering.", + "arguments-propagate-argument-must-current-entity": "Minst ett argument må være konfigurert med kildentitetstypen «Gjeldende entitet».", "arguments-empty": "Argumenter skal ikke være tomme.", "expression-required": "Uttrykk er påkrevd.", "expression-invalid": "Uttrykk er ugyldig", @@ -1068,13 +1342,316 @@ "argument-name-duplicate": "Et argument med dette navnet finnes allerede.", "argument-name-max-length": "Argumentnavn må være kortere enn 256 tegn.", "argument-name-forbidden": "Argumentnavnet er reservert og kan ikke brukes.", + "output-key-required": "Output-nøkkel er påkrevd.", + "output-key-pattern": "Output-nøkkel er ugyldig.", + "output-key-duplicate": "Nøkkel med dette navnet finnes allerede.", + "output-key-max-length": "Output-nøkkel bør være mindre enn 256 tegn.", + "output-key-forbidden": "Output-nøkkel er reservert og kan ikke brukes.", + "entity-type-required": "Entitetstype er påkrevd", + "name-required": "Navn er påkrevd.", + "name-pattern": "Navn er ugyldig.", + "name-duplicate": "Navn med dette navnet finnes allerede.", + "name-max-length": "Navn bør være mindre enn 256 tegn.", + "name-forbidden": "Navn er reservert og kan ikke brukes.", "argument-type-required": "Argumenttype er påkrevd.", "max-args": "Maksimalt antall argumenter er nådd.", "decimals-range": "Desimaler som standard må være et tall mellom 0 og 15.", "expression": "Standarduttrykket viser hvordan man konverterer temperatur fra Fahrenheit til Celsius.", - "arguments-entity-not-found": "Målenheten for argument ikke funnet." + "arguments-entity-not-found": "Målenheten for argument ikke funnet.", + "use-latest-timestamp": "Hvis aktivert, lagres den beregnede verdien ved å bruke det nyeste tidsstempelet fra argumentenes telemetri, i stedet for servertid.", + "entity-coordinates": "Angi tidsserienøklene som gir entitetens GPS-koordinater (breddegrad og lengdegrad).", + "geofencing-zone-groups": "Definer én eller flere geofencing-sonegrupper som skal sjekkes (f.eks. «allowedZones», «restrictedZones»). Hver gruppe må ha et unikt navn, som brukes som prefiks for telemetrinøkler i utdata fra beregnet felt.", + "perimeter-attribute-key": "Angi attributtnøkkelen som inneholder definisjonen av geofencing-sonens omkrets. Omkretsen hentes alltid fra attributter på serversiden for soneentiteten.", + "report-strategy": "Tilstedeværelsesstatus rapporterer om entiteten for øyeblikket er INNE i eller UTENFOR sonegruppen. Overgangshendelser rapporterer når entiteten GIKK INN I eller FORLOT sonegruppen.", + "create-relation-with-matched-zones": "Opprett og vedlikehold automatisk relasjoner mellom entiteten og sonene den for øyeblikket er inne i. Relasjoner fjernes når entiteten forlater en sone og opprettes når den går inn i en ny.", + "relation-type-required": "Relasjonstype er påkrevd.", + "relation-level-required": "Relasjonsnivå er påkrevd.", + "relation-level-min": "Minimumsverdi for relasjonsnivå er 1.", + "relation-level-max": "Maksimumsverdi for relasjonsnivå er {{max}}.", + "geofencing-empty": "Minst én sonegruppe må være konfigurert.", + "geofencing-entity-not-found": "Geofencing-målentitet ble ikke funnet.", + "max-geofencing-zone": "Maksimalt antall geofencing-soner er nådd.", + "zone-group-refresh-interval": "Definerer hvor ofte sonegrupper konfigurert via relaterte entiteter oppdateres.", + "zone-group-refresh-interval-required": "Oppdateringsintervall for sonegrupper er påkrevd.", + "zone-group-refresh-interval-min": "Oppdateringsintervallet for sonegrupper bør være minst {{ min }} sekunder.", + "propagation-path-related-entities": "Definerer en direkte sti på ett nivå til en relatert entitet basert på valgt retning og relasjonstype. Kun relasjoner mellom enhets-, eiendels-, kunde- og leietakerentiteter støttes. Maksimalt antall entiteter som løses via relasjonsstien er {{ max }}.", + "data-propagate": "Definerer dataene som skal propageres fra argumentene konfigurert nedenfor. «Kun argumenter» bruker de hentede dataene direkte, mens «Beregningens resultat» beregner en ny verdi fra disse dataene.", + "aggregation-path-related-entities": "Definerer en aggregeringssti på ett nivå via direkte relasjoner med overordnede eller underordnede entiteter, basert på retning og relasjonstype. Kun relasjoner mellom enhets-, eiendels-, kunde- og leietakerentiteter støttes. Maksimalt antall entiteter som løses via relasjonsstien er {{ max }}.", + "arguments-aggregation": "Definerer inndataargumentene som brukes for filtrering og aggregering.", + "setting-arguments-aggregation": "Data hentes fra relaterte entiteter konfigurert i aggregeringsstien.", + "metrics": "Definerer metrikker som aggregeres basert på konfigurerte argumenter.", + "entity-aggregation-metrics": "Definerer metrikker som aggregeres basert på konfigurerte argumenter over de angitte tidsintervallene.", + "import-invalid-calculated-field-type": "Kan ikke importere beregnet felt: Ugyldig struktur for beregnet felt.", + "simple-expression-title": "Aritmetisk uttrykk som definerer hvordan den beregnede verdien beregnes.", + "script-title": "TBEL-skript som definerer beregningslogikken og utdataverdiene.", + "simple-arguments": "Aritmetisk uttrykk som definerer hvordan den beregnede verdien beregnes.", + "script-arguments": "Definerer inndataargumentene som er tilgjengelige for skriptet." + } + }, + "alarm-rule": { + "alarm-rules-tab": "Alarmregler", + "alarm-rule": "Alarmregel", + "alarm-rules": "Alarmregler", + "alarm-rules-old": "Gammel", + "alarm-rules-actual": "Aktuell", + "severities": "Alvorlighetsgrader", + "cleared": "Tømmekondisjon", + "delete-title": "Er du sikker på at du vil slette alarmregelen '{{title}}'?", + "delete-text": "Vær forsiktig, etter bekreftelsen vil alarmregelen og alle tilknyttede data ikke kunne gjenopprettes.", + "delete-multiple-title": "Er du sikker på at du vil slette { count, plural, =1 {1 alarmregel} other {# alarmregler} }?", + "delete-multiple-text": "Vær forsiktig, etter bekreftelsen vil alle valgte alarmregler bli fjernet og alle tilknyttede data vil ikke kunne gjenopprettes.", + "create": "Opprett ny alarmregel", + "add": "Legg til alarmregel", + "copy": "Kopier konfigurasjon for alarmregel", + "details": "Detaljer for alarmregel", + "no-found": "Fant ingen alarmregler", + "list": "{ count, plural, =1 {Én alarmregel} other {Liste over # alarmregler} }", + "selected-fields": "{ count, plural, =1 {1 alarmregel} other {# alarmregler} } valgt", + "import": "Importer alarmregel", + "file": "Alarmregelfil", + "export": "Eksporter alarmregel", + "export-failed-error": "Kan ikke eksportere alarmregel: {{error}}", + "entity-type": "Entitetstype", + "entity-type-required": "Entitetstype er påkrevd.", + "alarm-type": "Alarmtype", + "alarm-type-hint": "Unik identifikator (f.eks. HighTempAlarm) innenfor omfanget til alarmens opphav (Enhet, Eiendel osv.) for å unngå konflikter.", + "alarm-type-required": "Alarmtype er påkrevd.", + "alarm-type-pattern": "Alarmtype er ugyldig.", + "alarm-type-max-length": "Alarmtype bør være mindre enn 256 tegn.", + "clear-alarm": "Nullstill alarm", + "value-argument": "Argument", + "value-argument-required": "Argument er påkrevd.", + "static-settings": "Statiske innstillinger", + "configuration": "Konfigurasjon", + "static-schedule": "Statisk", + "dynamic-schedule": "Dynamisk", + "operation-and": "OG", + "operation-or": "ELLER", + "condition-during": "I løpet av {{during}}", + "condition-during-dynamic": "I løpet av \"{{ attribute }}\"", + "condition-repeat-times": "Gjentas { count, plural, =1 {1 gang} other {# ganger} }", + "condition-repeat-times-dynamic": "Gjentas \"{{ attribute }}\" ganger", + "filter-preview": "Forhåndsvisning av filter", + "condition-settings": "Betingelsesinnstillinger", + "static": "Statisk", + "dynamic": "Dynamisk", + "argument-filters": "Argumentfiltre", + "argument-name": "Argumentnavn", + "value-type": "Verditype", + "general": "Generelt", + "filters": "Filtre", + "date-time-hint": "Argumentet må være i epoch-millisekunder. Eksempel: 1698839340000 tilsvarer 2023-11-01 12:49:00 UTC.", + "operation": "Operasjon", + "value-source": "Verdikilde", + "value": "Verdi", + "ignore-case": "Ignorer store/små bokstaver", + "condition": "Betingelse", + "script": "Skript", + "add-filter": "Legg til argumentfilter", + "edit-filter": "Argumentfilter", + "remove-filter": "Fjern argumentfilter", + "no-filter": "Minst ett filter er påkrevd.", + "conditions": { + "simple": "Enkel", + "duration": "Varighet", + "repeating": "Gjentakende" + }, + "schedule-title": "Tidsplan", + "edit-schedule": "Rediger alarmtidsplan", + "schedule-type": "Tidsplanleggertype", + "schedule-type-required": "Tidsplanleggertype er påkrevd.", + "schedule": { + "any-time": "Aktiv hele tiden", + "specific-time": "Aktiv på et bestemt tidspunkt", + "custom": "Egendefinert" + }, + "schedule-day": { + "monday": "Mandag", + "tuesday": "Tirsdag", + "wednesday": "Onsdag", + "thursday": "Torsdag", + "friday": "Fredag", + "saturday": "Lørdag", + "sunday": "Søndag" + }, + "schedule-days": "Dager", + "schedule-time": "Tid", + "schedule-time-from": "Fra", + "schedule-time-to": "Til", + "schedule-days-of-week-required": "Minst én ukedag må velges.", + "tbel": "TBEL", + "expression-type": { + "simple": "Enkel", + "script": "Skript" + }, + "operation-type": { + "and": "Og", + "or": "Eller" + }, + "filter-predicate-type": { + "string": "Streng", + "numeric": "Numerisk", + "boolean": "Boolsk", + "complex": "Kompleks" + }, + "alarm-rule-additional-info": "Tilleggsinformasjon", + "edit-alarm-rule-additional-info": "Rediger tilleggsinformasjon", + "alarm-rule-additional-info-placeholder": "Legg inn kommentarer og justeringer her for å vise dem i Alarminformasjon under Tilleggsinformasjon", + "alarm-rule-additional-info-hint": "Tips: bruk ${Argumentnavn} for å erstatte verdier for argumentene som brukes i alarmregelbetingelsen.", + "alarm-rule-additional-info-icon-hint": "Bruk Argumentnavn for å erstatte verdier for argumentene som brukes i alarmregelbetingelsen.", + "alarm-rule-mobile-dashboard": "Mobilt dashbord", + "alarm-rule-mobile-dashboard-hint": "Brukes av mobilapplikasjonen som et dashbord for alarmdetaljer.", + "alarm-rule-no-mobile-dashboard": "Ingen dashbord valgt", + "alarm-rule-condition": "Alarmregelbetingelse", + "enter-alarm-rule-condition-prompt": "Legg til betingelse", + "enter-alarm-rule-clear-condition-prompt": "Legg til tømmebetingelse", + "edit-alarm-rule-condition": "Alarmbetingelse", + "condition-type": "Betingelsestype", + "condition-type-hint": "\"Varighet\"- og \"Gjentakende\"-alternativene er ikke tilgjengelige når operasjonen \"Mangler i\" brukes i filteret.", + "select-alarm-severity": "Velg alarmaalvorlighetsgrad", + "add-create-alarm-rule-prompt": "Minst én utløserbetingelse er påkrevd.", + "add-create-alarm-rule": "Legg til utløserbetingelse", + "add-clear-alarm-rule": "Legg til tømmebetingelse", + "condition-duration": "Betingelsesvarighet", + "condition-duration-value": "Varighetsverdi", + "condition-duration-time-unit": "Tidsenhet", + "condition-duration-value-range": "Varighetsverdi må være i området fra 1 til 2147483647.", + "condition-duration-value-pattern": "Varighetsverdi må være heltall.", + "condition-duration-value-required": "Varighetsverdi er påkrevd.", + "condition-duration-time-unit-required": "Tidsenhet er påkrevd.", + "condition-repeating-value": "Antall hendelser", + "condition-repeating-value-hint": "Oppdatering av ethvert argument i alarmregelen vil telles som en hendelse", + "condition-repeating-value-range": "Antall hendelser må være i området fra 1 til 2147483647.", + "condition-repeating-value-pattern": "Antall hendelser må være heltall.", + "condition-repeating-value-required": "Antall hendelser er påkrevd.", + "create-conditions": "Utløserbetingelser", + "clear-condition": "Tømmebetingelse", + "no-clear-alarm-rule": "Ingen tømmebetingelse er konfigurert.", + "advanced-settings": "Avanserte innstillinger", + "propagate-alarm": "Propager alarm til relaterte entiteter", + "alarm-rule-relation-types-list": "Relasjonstyper", + "alarm-rule-relation-types-list-hint": "Definerer relasjonstyper for å filtrere relaterte entiteter. Hvis ikke angitt, propageres alarmen til alle relaterte entiteter.", + "propagate-alarm-to-owner": "Propager alarm til entitetseier (Kunde eller Leietaker)", + "propagate-alarm-to-tenant": "Propager alarm til Leietaker", + "alarm-rule-filter-title": "Alarmregelfilter", + "filter-title": "Filter", + "debugging": "Feilsøking av alarmregel", + "any-type": "Enhver type", + "enter-alarm-rule-type": "Angi alarmtype", + "no-alarm-rule-types-matching": "Fant ingen alarmtyper som samsvarer med '{{entitySubtype}}'.", + "alarm-rule-type-list-empty": "Ingen alarmtyper valgt.", + "alarm-rule-type-list": "Alarmtypeliste", + "alarm-rule-entity-list": "Entitetsliste", + "missing-for": "mangler i", + "time-unit": "Enhet", + "mode": "Modus", + "type": "Type", + "value-required": "Verdi er påkrevd.", + "min-value": "Verdien må være 1 eller større.", + "argument-in-use": "Argumentet brukes som generelt argument.", + "import-invalid-alarm-rule-type": "Kan ikke importere alarmregel: Ugyldig struktur for alarmregel.", + "no-filter-preview": "Ingen filter angitt", + "filter-operation": { + "and": "Og", + "or": "Eller" } }, + "ai-models": { + "ai-models": "AI-modeller", + "ai-model": "AI-modell", + "model": "Modell", + "name": "Navn", + "ai-provider": "AI-leverandør", + "no-found": "Fant ingen AI-modeller", + "list": "{ count, plural, =1 {Én modell} other {Liste over # modeller} }", + "selected-fields": "{ count, plural, =1 {1 modell} other {# modeller} } valgt", + "add": "Legg til modell", + "delete-model-title": "Er du sikker på at du vil slette modellen '{{modelName}}'?", + "delete-model-text": "Vær forsiktig, etter bekreftelsen vil modellen og alle tilknyttede data ikke kunne gjenopprettes.", + "delete-models-title": "Er du sikker på at du vil slette { count, plural, =1 {1 modell} other {# modeller} }?", + "delete-models-text": "Vær forsiktig, etter bekreftelsen vil alle valgte modeller bli fjernet og alle tilknyttede data vil ikke kunne gjenopprettes.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models", + "ollama": "Ollama" + }, + "name-required": "Navn er påkrevd.", + "name-max-length": "Navn må være 255 tegn eller mindre.", + "provider": "Leverandør", + "api-key": "API-nøkkel", + "api-key-required": "API-nøkkel er påkrevd.", + "api-key-open-ai-required": "API-nøkkel er påkrevd når du bruker den offisielle OpenAI API-en.", + "project-id": "Prosjekt-ID", + "project-id-required": "Prosjekt-ID er påkrevd", + "location": "Plassering", + "location-required": "Plassering er påkrevd.", + "service-account-key-file": "Nøkkelfil for tjenestekonto", + "service-account-key-file-required": "Nøkkelfil for tjenestekonto er påkrevd.", + "no-file": "Ingen fil valgt.", + "drop-file": "Slipp en fil eller klikk for å velge en fil som skal lastes opp.", + "personal-access-token": "Personlig tilgangstoken", + "personal-access-token-required": "Personlig tilgangstoken er påkrevd.", + "configuration": "Konfigurasjon", + "model-id": "Modell-ID", + "model-id-required": "Modell-ID er påkrevd.", + "deployment-name": "Distribusjonsnavn", + "deployment-name-required": "Distribusjonsnavn er påkrevd", + "set": "Angi", + "region": "Region", + "region-required": "Region er påkrevd.", + "access-key-id": "Tilgangsnøkkel-ID", + "access-key-id-required": "Tilgangsnøkkel-ID er påkrevd.", + "secret-access-key": "Hemmelig tilgangsnøkkel", + "secret-access-key-required": "Hemmelig tilgangsnøkkel er påkrevd.", + "temperature": "Temperatur", + "temperature-hint": "Justerer graden av tilfeldighet i modellens output. Høyere verdier øker tilfeldigheten, mens lavere verdier reduserer den.", + "temperature-min": "Må være 0 eller høyere.", + "top-p": "Top P", + "top-p-hint": "Oppretter en pool av de mest sannsynlige tokenene som modellen kan velge fra. Høyere verdier gir en større og mer mangfoldig pool, mens lavere verdier gir en mindre.", + "top-p-min-max": "Må være større enn 0 og opptil 1.", + "top-k": "Top K", + "top-k-hint": "Begrenser modellens valg til et fast sett med de \"K\" mest sannsynlige tokenene.", + "top-k-min": "Må være 0 eller høyere.", + "presence-penalty": "Tilstedeværelsesstraff", + "presence-penalty-hint": "Bruker en fast straff på sannsynligheten for et token hvis det allerede har dukket opp i teksten.", + "frequency-penalty": "Frekvensstraff", + "frequency-penalty-hint": "Bruker en straff på sannsynligheten for et token som øker basert på hvor ofte det forekommer i teksten.", + "max-output-tokens": "Maksimalt antall output-tokener", + "max-output-tokens-hint": "Angir maksimalt antall tokener som \nmodellen kan generere i ett enkelt svar.", + "context-length": "Kontekstlengde", + "context-length-hint": "Definerer størrelsen på kontekstvinduet i tokener. Denne verdien angir den totale minnegrensen for modellen, inkludert både brukerens inndata og det genererte svaret.", + "endpoint": "Endepunkt", + "endpoint-required": "Endepunkt er påkrevd.", + "baseurl": "Basis-URL", + "baseurl-required": "Basis-URL er påkrevd.", + "service-version": "Tjenesteversjon", + "check-connectivity": "Sjekk tilkobling", + "check-connectivity-success": "Testforespørselen var vellykket", + "check-connectivity-failed": "Testforespørselen mislyktes", + "no-model-matching": "Fant ingen modeller som samsvarer med '{{entity}}'.", + "model-required": "Modell er påkrevd.", + "no-model-text": "Fant ingen modeller.", + "authentication": "Autentisering", + "authentication-basic-hint": "Bruker standard HTTP Basic-autentisering. Brukernavn og passord kombineres, Base64-kodes og sendes i en \"Authorization\"-header med hver forespørsel til Ollama-serveren.", + "authentication-token-hint": "Bruker Bearer-tokenautentisering. Det angitte tokenet sendes direkte i en \"Authorization\" eader med hver forespørsel til Ollama-serveren.", + "authentication-type": { + "none": "Ingen", + "basic": "Basic", + "token": "Token" + }, + "username": "Brukernavn", + "username-required": "Brukernavn er påkrevd.", + "password": "Passord", + "password-required": "Passord er påkrevd.", + "token": "Token", + "token-required": "Token er påkrevd." + }, "confirm-on-exit": { "message": "Du har ulagrede endringer. Er du sikker på at du vil forlate denne siden?", "html-message": "Du har ulagrede endringer.
Er du sikker på at du vil forlate denne siden?", @@ -1083,6 +1660,7 @@ "contact": { "country": "Land", "country-required": "Land er påkrevd.", + "country-object-required": "Velg et gyldig land fra listen.", "city": "By", "state": "Stat / Provins", "postal-code": "Postnummer", @@ -1119,6 +1697,8 @@ "documentation": "Dokumentasjon", "time-left": "{{time}} igjen", "output": "Utdata", + "sort-asc": "Stigende", + "sort-desc": "Synkende", "suffix": { "s": "s", "ms": "ms" @@ -1255,6 +1835,8 @@ "mobile-order": "Rekkefølge i mobilapp", "mobile-hide": "Skjul dashbord i mobilapp", "update-image": "Oppdater dashbordbilde", + "update-new-version": "Last opp ny versjon", + "upload-file-to-update": "Last opp fil for å oppdatere", "take-screenshot": "Ta skjermbilde", "select-widget-title": "Velg widget", "select-widget-value": "{{title}}: velg widget", @@ -1543,7 +2125,7 @@ "remove-alias": "Fjern enhetsalias", "add-alias": "Legg til enhetsalias", "name-starts-with": "Enhetsnavnuttrykk", - "help-text": "Bruk '%' etter behov: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "Bruk '%' etter behov: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Enhetsliste", "use-device-name-filter": "Bruk filter", "device-list-empty": "Ingen enheter valgt.", @@ -1623,6 +2205,8 @@ "bootstrap-tab": "Bootstrap-klient", "bootstrap-server": "Bootstrap-server", "lwm2m-server": "LwM2M-server", + "client-reboot": "Utløser for registreringsoppdatering", + "bootstrap-reboot": "Utløser for Bootstrap-forespørsel", "client-publicKey-or-id": "Klient offentlig nøkkel eller ID", "client-publicKey-or-id-required": "Klient offentlig nøkkel eller ID er påkrevd.", "client-publicKey-or-id-tooltip-psk": "PSK-identifikatoren er en vilkårlig PSK-ID opptil 128 byte, som beskrevet i standarden [RFC7925].\nMÅ konverteres til en tegnstreng og kodes i UTF-8.", @@ -1670,7 +2254,6 @@ "unable-delete-device-alias-text": "Enhetsalias '{{deviceAlias}}' kan ikke slettes da det brukes av følgende widget(er):
{{widgetsList}}", "is-gateway": "Er gateway", "overwrite-activity-time": "Overstyr aktivitetstid for tilkoblet enhet", - "device-filter": "Enhetsfilter", "device-filter-title": "Enhetsfilter", "filter-title": "Filter", "device-state": "Enhetstilstand", @@ -1754,6 +2337,7 @@ "selected-options-limit": "Begrensning for valgte alternativer", "advanced-ui-settings": "Avanserte grensesnittinnstillinger", "disable-on-property": "Deaktiver ved egenskap", + "disable-on-property-none": "Ingen (feltet er alltid aktivert)", "display-condition-function": "Visningsbetingelsesfunksjon", "sub-label": "Underetikett", "vertical-divider-after": "Vertikal skillelinje etter", @@ -1787,7 +2371,8 @@ "array-item": "Array-element", "item-type": "Elementtype", "item-name": "Elementnavn", - "no-items": "Ingen elementer" + "no-items": "Ingen elementer", + "support-unit-conversion": "Støtt enhetskonvertering" }, "clear-form": "Tøm skjema", "clear-form-prompt": "Er du sikker på at du vil fjerne alle skjemaegenskaper?", @@ -1911,6 +2496,7 @@ "mqtt-use-json-format-for-default-downlink-topics-hint": "Når aktivert, vil plattformen bruke JSON til å sende attributter og RPC over temaene: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, osv. Påvirker ikke abonnementer via nye v2-temaer.", "mqtt-send-ack-on-validation-exception": "Send PUBACK ved valideringsfeil", "mqtt-send-ack-on-validation-exception-hint": "Som standard lukkes MQTT-økten ved valideringsfeil. Hvis aktivert, sendes isteden en bekreftelse.", + "mqtt-protocol-version": "Protokollversjon", "snmp-add-mapping": "Legg til SNMP-mapping", "snmp-mapping-not-configured": "Ingen mapping for OID til tidsserie/telemetri konfigurert", "snmp-timseries-or-attribute-name": "Navn på tidsserie/attributt for mapping", @@ -2121,6 +2707,7 @@ "short-id-required": "Kort server-ID er påkrevd.", "short-id-range": "Kort server-ID må være mellom {{ min }} og {{ max }}.", "short-id-pattern": "Kort server-ID må være et positivt heltall.", + "short-id-pattern-bs": "Kort server-ID må kun være null", "lifetime": "Klientens registreringsvarighet", "lifetime-required": "Klientens registreringsvarighet er påkrevd.", "lifetime-pattern": "Registreringsvarighet må være et positivt heltall.", @@ -2143,7 +2730,7 @@ "tqs": "TQS: Både TCP og SMS aktive; TCP i kømodus, SMS i standardmodus (ikke støttet siden LWM2M 1.1)", "sq": "SQ: SMS i kømodus (ikke støttet siden LWM2M 1.1)" }, - "binding-tooltip": "Liste over bindingsmodus i LwM2M serverobjektet - /1/x/7.\nAngir støttede bindingsmodi i klienten.\nBør matche verdien i “Supported Binding and Modes” i Enhetsobjektet (/3/0/16).\nKun én transportbinding brukes per økt.", + "binding-tooltip": "Dette er listen i den\"binding\"-ressursen til LwM2M-serverobjektet - /1/x/7.\nAngir de støttede binding-modusene i LwM2M-klienten.\nDenne verdien BØR være den samme som verdien i ressursen “Supported Binding and Modes” i Device Object (/3/0/16).\nSelv om flere transporter støttes, kan bare én transportbinding brukes under hele transportsesjonen.\nFor eksempel, når både UDP og SMS støttes, kan LwM2M-klienten og LwM2M-serveren velge å kommunisere enten over UDP eller SMS under hele transportsesjonen.", "bootstrap-server": "Bootstrap-server", "lwm2m-server": "LwM2M-server", "include-bootstrap-server": "Inkluder Bootstrap-serveroppdateringer", @@ -2171,6 +2758,9 @@ "add-lwm2m-server-config": "Legg til LwM2M-server", "no-config-servers": "Ingen servere konfigurert", "others-tab": "Andre innstillinger", + "ota-update": "OTA-oppdatering", + "use-object-19-for-ota-update": "Bruk Object 19 for OTA-filmetadata (sjekksum, størrelse, versjon, navn)", + "use-object-19-for-ota-update-hint": "Bruk Resource ObjectId = 19 for OTA-oppdateringer: FirmWare → InstanceId = 65534, SoftWare → InstanceId = 65535. Dataformatet er JSON innkapslet i Base64. Denne JSON-en inneholder OTA-filmetadata (filinfo): \"Checksum\" (SHA256). Ekstra felter: \"Title\" (OTA-navn), \"Version\" (OTA-versjon), \"File Name\" (filnavn for lagring av OTA på klienten), \"File Size\" (OTA-størrelse i byte).", "client-strategy": "Klientstrategi ved tilkobling", "client-strategy-label": "Strategi", "client-strategy-only-observe": "Kun Observer-forespørsel til klienten etter første tilkobling", @@ -2201,8 +2791,20 @@ "default-object-id": "Standard objektversjon (Attributt)", "default-object-id-ver": { "v1-0": "1.0", - "v1-1": "1.1" - } + "v1-1": "1.1", + "v1-2": "1.2" + }, + "observe-strategy": { + "observe-strategy": "Observe-strategi", + "single": "Enkel", + "single-description": "Én Observe-forespørsel per ressurs (høyere presisjon, mer nettverkstrafikk)", + "composite-all": "Kompositt alle", + "composite-all-description": "Alle ressurser observeres med én enkelt Composite Observe-forespørsel (mer effektivt, mindre fleksibelt)", + "composite-by-object": "Kompositt etter objekter", + "composite-by-object-description": "Ressurser grupperes etter objekttype og observeres ved hjelp av separate Composite Observe-forespørsler (balansert tilnærming)" + }, + "init-attr-tel-as-obs-strategy": "Initialiser attributter og telemetri ved hjelp av Observe-strategi", + "init-attr-tel-as-obs-strategy-hint": "Hvis false - attributter og telemetri initialiseres ved å lese verdiene én etter én.\\nHvis true - attributter og telemetri initialiseres ved å abonnere på verdiene ved hjelp av Observe-strategien." }, "snmp": { "add-communication-config": "Legg til kommunikasjonskonfigurasjon", @@ -2438,7 +3040,7 @@ "no-entities-text": "Ingen enheter funnet", "no-entity-types-matching": "Ingen enhetstyper som samsvarer med '{{entityType}}' ble funnet.", "name-starts-with": "Navnuttrykk", - "help-text": "Bruk '%' etter behov: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "Bruk '%' etter behov: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Bruk filter", "entity-list-empty": "Ingen enheter valgt.", "entity-type-list-required": "Minst én enhetstype må velges.", @@ -2518,12 +3120,16 @@ "type-rulenodes": "Regelnoder", "list-of-rulenodes": "{ count, plural, =1 {Én regelnode} other {Liste over # regelnoder} }", "rulenode-name-starts-with": "Regelnoder som starter med '{{prefix}}'", + "type-api-key": "API-nøkkel", + "type-api-keys": "API-nøkler", "type-current-customer": "Gjeldende kunde", "type-current-tenant": "Gjeldende leietaker", "type-current-user": "Gjeldende bruker", "type-current-user-owner": "Gjeldende brukereier", "type-calculated-field": "Beregnet felt", "type-calculated-fields": "Beregnete felt", + "type-ai-model": "AI-modell", + "type-ai-models": "AI-modeller", "type-widgets-bundle": "Widget-pakke", "type-widgets-bundles": "Widget-pakker", "list-of-widgets-bundles": "{ count, plural, =1 {Én widget-pakke} other {Liste over # widget-pakker} }", @@ -2537,6 +3143,7 @@ "details": "Enhetsdetaljer", "no-entities-prompt": "Ingen enheter funnet", "no-data": "Ingen data å vise", + "show-all-columns": "Vis alle", "columns-to-display": "Kolonner å vise", "type-api-usage-state": "API-bruksstatus", "type-edge": "Edge", @@ -2553,6 +3160,8 @@ "type-tb-resources": "Ressurser", "list-of-tb-resources": "{ count, plural, =1 {Én ressurs} other {Liste over # ressurser} }", "type-ota-package": "OTA-pakke", + "type-ota-packages": "OTA-pakker", + "list-of-ota-packages": "{ count, plural, =1 {Én OTA-pakke} other {Liste over # OTA-pakker} }", "type-rpc": "RPC", "type-queue": "Kø", "type-queue-stats": "Køstatistikk", @@ -2580,7 +3189,15 @@ "list-of-mobile-apps": "{ count, plural, =1 {Én mobilapplikasjon} other {Liste over # mobilapplikasjoner} }", "type-mobile-app-bundle": "Mobilpakke", "type-mobile-app-bundles": "Mobilpakker", - "list-of-mobile-app-bundles": "{ count, plural, =1 {Én mobilpakke} other {Liste over # mobilpakker} }" + "list-of-mobile-app-bundles": "{ count, plural, =1 {Én mobilpakke} other {Liste over # mobilpakker} }", + "limit-reached": "Grense nådd", + "limit-reached-text": "Du har nådd grensen på {{ entities }}. For å legge til flere, be systemadministratoren din om å øke grensen for {{ entity }}.", + "request-limit-increase": "Be om grenseøkning", + "request-sysadmin-text": "Er du systemadministratoren?", + "login-here": "Logg inn her", + "to-increase-limit": "for å øke grensen.", + "increase-limit-request-sent-title": "Vi har sendt en automatisk forespørsel til systemadministratoren din om å øke grensen", + "increase-limit-request-sent-text": "Gi dem litt tid til å vurdere forespørselen og oppdatere innstillingene. Du må kanskje oppdatere denne siden for å se endringene." }, "entity-field": { "created-time": "Opprettelsestid", @@ -2626,7 +3243,7 @@ "remove-alias": "Fjern alias for entitetsvisning", "add-alias": "Legg til alias for entitetsvisning", "name-starts-with": "Navnuttrykk for entitetsvisning", - "help-text": "Bruk '%' etter behov: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "Bruk '%' etter behov: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Liste over entitetsvisninger", "use-entity-view-name-filter": "Bruk filter", "entity-view-list-empty": "Ingen entitetsvisninger valgt.", @@ -2938,6 +3555,7 @@ "missing-key-filters-error": "Nøkkelfiltre mangler for filter '{{filter}}'.", "filter": "Filter", "editable": "Redigerbar", + "editable-hint": "Tillat brukeren å endre filterverdien i dashbord.", "no-filters-found": "Ingen filtre funnet.", "no-filter-text": "Ingen filter spesifisert", "add-filter-prompt": "Vennligst legg til filter", @@ -2977,6 +3595,8 @@ "filter-user-params": "Brukerparametere for filterpredikat", "user-parameters": "Brukerparametere", "display-label": "Etikett som vises", + "custom-label": "Egendefinert etikett", + "custom-label-hint": "Aktiver for å angi din egen etikett for filteret. Når deaktivert, genereres en etikett automatisk.", "order-priority": "Prioritet for feltrekkefølge", "key-filter": "Nøkkelfilter", "key-filters": "Nøkkelfiltre", @@ -3021,7 +3641,8 @@ "switch-to-dynamic-value": "Bytt til dynamisk verdi", "switch-to-default-value": "Bytt til standardverdi", "inherit-owner": "Arv fra eier", - "source-attribute-not-set": "Hvis kildeattributt ikke er angitt" + "source-attribute-not-set": "Hvis kildeattributt ikke er angitt", + "unit": "Enhet" }, "fullscreen": { "expand": "Utvid til fullskjerm", @@ -3406,6 +4027,7 @@ "power-button-background": "Bakgrunn for strømbryter", "value-box-background": "Bakgrunn for verdiboks", "value-units": "Verdi-enheter", + "enable-units-scale": "Aktiver enheter på skala", "filtration-mode": "Filtreringsmodus", "filtration-mode-hint": "Heltallsverdi som angir gjeldende filtreringsmodus.", "filtration-mode-update": "Oppdater filtreringsmodus", @@ -3652,17 +4274,19 @@ "two-factor-authentication": "Tofaktorautentisering", "passwords-mismatch-error": "Passordene må være like!", "password-again": "Gjenta passord", + "sign-in": "Vennligst logg inn", "username": "Brukernavn (epost)", "remember-me": "Husk meg", "forgot-password": "Glemt passord?", "password-reset": "Tilbakestilling av passord", - "expired-password-reset-message": "Dine legitimasjonsopplysninger er utløpt! Vennligst opprett et nytt passord.", + "expired-password-reset-message": "Passordet ditt er utløpt! \nVennligst angi et nytt passord.", "new-password": "Nytt passord", "new-password-again": "Bekreft nytt passord", "password-link-sent-message": "Tilbakestillingslenke er sendt", "email": "Epost", "invalid-email-format": "Ugyldig epostformat.", - "login-with": "Logg inn med {{name}}", + "sign-in-with": "Logg inn med {{name}}", + "sign-in-to-your-account": "Logg inn på kontoen din", "or": "eller", "error": "Innloggingsfeil", "verify-your-identity": "Bekreft din identitet", @@ -3681,7 +4305,51 @@ "activation-link-expired": "Aktiveringslenken er utløpt", "activation-link-expired-message": "Lenken for å aktivere profilen din er utløpt. Du kan gå tilbake til innloggingssiden for å motta en ny epost.", "reset-password-link-expired": "Lenken for tilbakestilling av passord er utløpt", - "reset-password-link-expired-message": "Lenken for å tilbakestille passordet ditt er utløpt. Du kan gå tilbake til innloggingssiden for å motta en ny epost." + "reset-password-link-expired-message": "Lenken for å tilbakestille passordet ditt er utløpt. Du kan gå tilbake til innloggingssiden for å motta en ny epost.", + "two-fa": "Tofaktorautentisering", + "two-fa-required": "Tofaktorautentisering er påkrevd", + "set-up-verification-method": "Konfigurer en verifiseringsmetode for å fortsette", + "set-up-verification-method-login": "Konfigurer en verifiseringsmetode eller logg inn", + "enable-authenticator-app": "Aktiver autentiseringsapp", + "enable-authenticator-app-description": "Angi sikkerhetskoden fra autentiseringsappen din", + "enable-authenticator-sms": "Aktiver SMS-autentisering", + "enable-authenticator-sms-description": "Angi en 6-sifret kode vi nettopp sendte til ", + "enable-authenticator-email": "Aktiver E-post-autentisering", + "enable-authenticator-email-description": "En sikkerhetskode er sendt til e-postadressen din på ", + "enter-key-manually": "eller angi denne 32-sifrede nøkkelen manuelt:", + "continue": "Fortsett", + "confirm": "Bekreft", + "authenticator-app-success": "Autentiseringsapp ble aktivert", + "authenticator-app-success-description": "Neste gang du logger inn, må du oppgi en tofaktorautentiseringskode", + "authenticator-sms-success": "SMS-autentisering ble aktivert", + "authenticator-sms-success-description": "Neste gang du logger inn, blir du bedt om å angi sikkerhetskoden som sendes til telefonnummeret", + "authenticator-email-success": "E-post-autentisering ble aktivert", + "authenticator-email-success-description": "Neste gang du logger inn, blir du bedt om å angi sikkerhetskoden som sendes til e-postadressen din", + "authenticator-backup-code-success": "Reservekode ble aktivert", + "authenticator-backup-code-success-description": "Neste gang du logger inn, blir du bedt om å angi sikkerhetskoden eller bruke én av reservekodene.", + "add-verification-method": "Legg til verifiseringsmetode", + "get-backup-code": "Hent reservekoder", + "copy-key": "Kopier nøkkel", + "send-code": "Send kode", + "email-label": "E-post", + "email-description": "Angi en e-postadresse som skal brukes som autentisering.", + "sms-description": "Angi et telefonnummer som skal brukes som autentisering.", + "backup-code-description": "Skriv ut kodene slik at du har dem tilgjengelig når du trenger dem for å logge inn på kontoen din. Du kan bruke hver reservekode én gang.", + "backup-code-warn": "Når du forlater denne siden, kan disse kodene ikke vises igjen. Oppbevar dem trygt ved å bruke alternativene nedenfor.", + "download-txt": "Last ned (txt)", + "print": "Skriv ut", + "verification-code": "6-sifret kode", + "verification-code-invalid": "Ugyldig format for verifiseringskode", + "verification-code-incorrect": "Verifiseringskoden er feil", + "verification-code-many-request": "For mange forespørsler om å sjekke verifiseringskode", + "scan-qr-code": "Skann denne QR-koden med verifiseringsappen din", + "phone-input": { + "phone-input-label": "Telefonnummer", + "phone-input-required": "Telefonnummer er påkrevd", + "phone-input-validation": "Telefonnummeret er ugyldig eller ikke mulig", + "phone-input-pattern": "Ugyldig telefonnummer. Skal være i E.164-format, f.eks. {{phoneNumber}}", + "phone-input-hint": "Telefonnummer i E.164-format, f.eks. {{phoneNumber}}" + } }, "mobile": { "add-application": "Legg til applikasjon", @@ -3721,6 +4389,8 @@ "mobile-package-max-length": "Applikasjonspakken må være under 256 tegn", "mobile-package-required": "Applikasjonspakke er påkrevd.", "mobile-package-pattern": "Ugyldig format for applikasjonspakke", + "mobile-package-title": "Applikasjonstittel", + "mobile-package-title-max-length": "Applikasjonstittel må være mindre enn 256", "no-application": "Ingen applikasjoner funnet", "no-bundles": "Ingen pakker funnet", "platform-type": "Plattformtype", @@ -3802,19 +4472,15 @@ "configuration-step": { "prepare-environment-title": "Forbered utviklingsmiljø", "prepare-environment-text": "Flutter ThingsBoard Mobile Application krever Flutter SDK. Følg instruksjonene for å sette opp Flutter SDK.", - "get-source-code-title": "Hent kildekode for appen", + "get-source-code-title": "Hent appens kildekode", "get-source-code-text": "Du kan hente kildekoden til Flutter ThingsBoard Mobile Application ved å klone den fra GitHub-repositoriet:", - "configure-api-title": "Konfigurer ThingsBoard API-endepunkt", - "configure-api-text": "Åpne prosjektet flutter_thingsboard_pe_app i editoren/IDE-en din. Rediger:", - "configure-api-hint": "Angi verdien av konstanten thingsBoardApiEndpoint slik at den samsvarer med API-endepunktet til din ThingsBoard-serverinstans. Ikke bruk \"localhost\" eller \"127.0.0.1\" som vertsnavn.", + "configure-app-settings-title": "Konfigurer appinnstillinger", + "configure-app-settings-text": "Last ned konfigurasjonsfilen og legg den i rotkatalogen til prosjektet du klonet i forrige steg.", + "download-file": "Last ned fil", "run-app-title": "Kjør appen", - "run-app-text": "Kjør appen som beskrevet i din IDE.\nHvis du bruker terminalen, kjør appen med følgende kommando:", - "more-information": "Detaljert informasjon finnes i vår Kom-i-gang dokumentasjon.", - "getting-started": "Kom i gang", - "configure-package-title": "Konfigurer applikasjonspakke", - "configure-package-text": "Du kan manuelt endre applikasjonspakken eller bruke tredjeparts CLI-verktøy.", - "configure-package-text-install": "For å installere Rename CLI-verktøyet, kjør følgende kommando:", - "configure-package-run-commands": "Kjør disse kommandoene i prosjektets rotmappe:" + "run-app-text": "Kjør appen som beskrevet i IDE-en din.\nHvis du bruker terminalen, kjør appen med følgende kommando:", + "more-information": "Detaljert informasjon finner du i dokumentasjonen vår for Kom i gang.", + "getting-started": "Kom i gang" } }, "notification": { @@ -3838,6 +4504,7 @@ "new-platform-version-trigger-settings": "Innstillinger for ny plattformversjonsutløser", "rate-limits-trigger-settings": "Innstillinger for utløser ved overskredet hastighetsgrense", "task-processing-failure-trigger-settings": "Innstillinger for utløser ved feil i oppgavebehandling", + "resources-shortage-trigger-settings": "Innstillinger for utløsning ved ressursmangel", "at-least-one-should-be-selected": "Minst én må velges", "basic-settings": "Grunnleggende innstillinger", "button-text": "Knappetekst", @@ -3852,6 +4519,7 @@ "create-new": "Opprett ny", "created": "Opprettet", "customize-messages": "Tilpass meldinger", + "cpu-threshold": "CPU-terskel", "delete-notification-text": "Vær forsiktig, etter bekreftelse vil varslingen ikke kunne gjenopprettes.", "delete-notification-title": "Er du sikker på at du vil slette varslingen?", "delete-notifications-text": "Vær forsiktig, etter bekreftelse vil varslingene ikke kunne gjenopprettes.", @@ -3918,6 +4586,7 @@ "input-fields-support-templatization": "Inndatafelt støtter templatmaler.", "link": "Lenke", "link-required": "Lenke er påkrevd", + "link-max-length": "Lenken må være mindre enn eller lik {{ length }} tegn", "link-type": { "dashboard": "Åpne dashbord", "link": "Åpne URL-lenke" @@ -3944,6 +4613,7 @@ "no-severity-found": "Ingen alvorlighetsgrad funnet", "no-severity-matching": "'{{severity}}' ble ikke funnet.", "no-template-matching": "Ingen ressurser som samsvarer med '{{template}}' ble funnet.", + "create-new-template": "Opprett en ny!", "not-found-slack-recipient": "Slack-mottaker ikke funnet", "notification": "Varsel", "notification-center": "Varslingssenter", @@ -3967,6 +4637,7 @@ "only-rule-chain-lifecycle-failures": "Kun regelkjedens livssyklusfeil", "only-rule-node-lifecycle-failures": "Kun regelnodens livssyklusfeil", "platform-users": "Plattformbrukere", + "ram-threshold": "RAM-terskel", "rate-limits": "Hastighetsgrenser", "rate-limits-hint": "Hvis feltet er tomt, vil utløseren gjelde for alle hastighetsgrenser", "recipient": "Mottaker", @@ -4032,6 +4703,7 @@ "start-from-scratch": "Start fra bunnen", "status": "Status", "stop-escalation-alarm-status-become": "Stopp eskalering når alarmstatus blir:", + "storage-threshold": "Lagringsterskel", "subject": "Emne", "subject-required": "Emne er påkrevd", "subject-max-length": "Emnet må være mindre enn eller lik {{ length }} tegn", @@ -4044,8 +4716,9 @@ "alarm-comment": "Alarmkommentar", "api-usage-limit": "API-bruksgrense", "device-activity": "Enhetsaktivitet", - "entities-limit": "Enhetsgrense", - "entity-action": "Enhetshandling", + "entities-limit": "Entitetsgrense", + "entities-limit-increase-request": "Forespørsel om økning av entitetsgrense", + "entity-action": "Entitetshandling", "general": "Generelt", "rule-engine-lifecycle-event": "Regelmotor livssyklushendelse", "rule-node": "Regelnode", @@ -4053,7 +4726,8 @@ "rate-limits": "Overskredet grense", "edge-communication-failure": "Edge kommunikasjonsfeil", "edge-connection": "Edge-tilkobling", - "task-processing-failure": "Feil ved oppgavebehandling" + "task-processing-failure": "Feil ved oppgavebehandling", + "resources-shortage": "Ressursmangel" }, "templates": "Maler", "notification-templates": "Varslinger / Maler", @@ -4077,6 +4751,7 @@ "edge-connection": "Edge-tilkobling", "edge-communication-failure": "Edge kommunikasjonsfeil", "task-processing-failure": "Feil ved oppgavebehandling", + "resources-shortage": "Ressursmangel", "trigger": "Utløser", "trigger-required": "Utløser er påkrevd" }, @@ -4118,6 +4793,7 @@ "checksum-copied-message": "Pakkesjekksummen er kopiert til utklippstavlen", "change-firmware": "Endring av fastvaren kan føre til oppdatering av { count, plural, =1 {1 enhet} other {# enheter} }.", "change-software": "Endring av programvaren kan føre til oppdatering av { count, plural, =1 {1 enhet} other {# enheter} }.", + "change-ota-setting-title": "Er du sikker på at du vil endre OTA-innstillinger?", "chose-compatible-device-profile": "Den opplastede pakken vil bare være tilgjengelig for enheter med valgt profil.", "chose-firmware-distributed-device": "Velg fastvare som skal distribueres til enhetene", "chose-software-distributed-device": "Velg programvare som skal distribueres til enhetene", @@ -4259,6 +4935,12 @@ "at-least": "Minst:", "character": "{ count, plural, =1 {1 tegn} other {# tegn} }", "digit": "{ count, plural, =1 {1 siffer} other {# sifre} }", + "password-tooltip-min-length": "Minst {{minimumLength}} tegn", + "password-tooltip-max-length": "Høyst {{maximumLength}} tegn", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} stor bokstav", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} liten bokstav", + "password-tooltip-digit": "{{minimumDigits}} tall", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} spesialtegn", "incorrect-password-try-again": "Feil passord. Prøv igjen", "lowercase-letter": "{ count, plural, =1 {1 liten bokstav} other {# små bokstaver} }", "new-passwords-not-match": "Nytt passord samsvarer ikke", @@ -4313,10 +4995,12 @@ "add-relation-filter": "Legg til relasjonsfilter", "any-relation": "Enhver relasjon", "relation-filters": "Relasjonsfiltre", + "relation-filter": "Relasjonsfilter", "additional-info": "Tilleggsinfo (JSON)", "invalid-additional-info": "Kan ikke tolke tilleggsinfo som gyldig JSON.", "no-relations-text": "Ingen relasjoner funnet", - "not": "Ikke" + "not": "Ikke", + "copy-type": "Kopier type" }, "resource": { "add": "Legg til ressurs", @@ -4353,7 +5037,8 @@ "jks": "JKS", "js-module": "JS-modul", "lwm2m-model": "LWM2M-modell", - "pkcs-12": "PKCS #12" + "pkcs-12": "PKCS #12", + "general": "Generelt" }, "resource-sub-type": "Undertype", "sub-type": { @@ -4361,7 +5046,12 @@ "scada-symbol": "Scada-symbol", "extension": "Utvidelse", "module": "Modul" - } + }, + "resource-is-in-use": "Ressursen brukes av andre entiteter", + "resources-are-in-use": "Ressursene brukes av andre entiteter", + "resource-is-in-use-text": "Ressursen '{{title}}' ble ikke slettet fordi den brukes av følgende entiteter:", + "resources-are-in-use-text": "Ikke alle ressurser ble slettet fordi de brukes av andre entiteter.
Du kan se refererte entiteter ved å klikke på Referanser-knappen i den tilsvarende ressursraden.
Hvis du fortsatt vil slette disse ressursene, velg dem i tabellen nedenfor og klikk på Slett valgte-knappen.", + "delete-resource-in-use-text": "Hvis du fortsatt vil slette ressursen, klikk på Slett likevel-knappen." }, "javascript": { "add": "Legg til JavaScript-ressurs", @@ -5261,7 +5951,7 @@ "time-series": "Tidsserie", "latest": "Siste verdier", "web-sockets": "WebSockets", - "calculated-fields": "Beregnete felt" + "calculated-fields-and-alarm-rules": "Beregnede felt og alarmregler" }, "save-attribute": { "processing-settings": "Behandlingsinnstillinger", @@ -5303,6 +5993,37 @@ "html-text-description": "Lar deg bruke HTML-tag'er for formatering, lenker og bilder i meldingskroppen.", "dynamic-text-description": "Lar deg bruke ren tekst eller HTML dynamisk basert på templatization-funksjon.", "after-template-evaluation-hint": "Etter evaluering må verdien være true for HTML og false for ren tekst." + }, + "ai": { + "ai-model": "AI-modell", + "model": "Modell", + "ai-model-hint": "Velg den forhåndskonfigurerte AI-modellen for å behandle forespørsler sendt av denne regelnoten, eller bruk \"Opprett ny\" for å konfigurere en ny.", + "prompt-settings": "Prompt-innstillinger", + "prompt-settings-hint": "Den valgfrie systemprompten angir AI-ens generelle rolle og begrensninger, mens brukerprompten definerer den spesifikke oppgaven som skal utføres. Begge feltene støtter også maler.", + "system-prompt": "Systemprompt", + "system-prompt-max-length": "Systemprompt må være 500000 tegn eller mindre.", + "system-prompt-blank": "Systemprompt kan ikke være tom.", + "user-prompt": "Brukerprompt", + "user-prompt-required": "Brukerprompt er påkrevd.", + "user-prompt-max-length": "Brukerprompt må være 500000 tegn eller mindre.", + "user-prompt-blank": "Brukerprompt kan ikke være tom.", + "response-format": "Svarformat", + "response-text": "Tekst", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Lar modellen generere vilkårlig tekst, som kan være et gyldig JSON-objekt eller ikke. Hvis output ikke er et gyldig JSON-objekt, pakkes den automatisk inn i et JSON-objekt under nøkkelen \"response\".", + "response-format-hint-JSON": "Modellen må generere et svar som er gyldig JSON. Hvis output ikke er et gyldig JSON-objekt, pakkes den automatisk inn i et JSON-objekt under nøkkelen \"response\".", + "response-format-hint-JSON_SCHEMA": "Modellen må generere en JSON som samsvarer med den spesifikke strukturen og datatypene som er definert i den angitte schemaen. Hvis output ikke er et gyldig JSON-objekt, pakkes den automatisk inn i et JSON-objekt under nøkkelen \"response\".", + "response-json-schema-hint": "Selv om enhver gyldig JSON Schema kan legges inn, støtter denne regelnoten kun et begrenset delsett av funksjonene. Se nodedokumentasjonen for detaljer.", + "response-json-schema-required": "JSON Schema er påkrevd", + "advanced-settings": "Avanserte innstillinger", + "timeout": "Tidsavbrudd", + "timeout-hint": "Maksimal tid å vente på et svar \nfra AI-modellen før forespørselen avsluttes.", + "timeout-required": "Tidsavbrudd er påkrevd", + "timeout-validation": "Må være fra 1 sekund til 10 minutter.", + "force-acknowledgement": "Tving kvittering", + "force-acknowledgement-hint": "Hvis aktivert, kvitteres den innkommende meldingen umiddelbart. Modellens svar legges deretter i kø som en separat, ny melding.", + "ai-resources": "AI-ressurser" } }, "timezone": { @@ -5425,7 +6146,8 @@ "bad-request-params": "Feil forespørselsparametere", "item-not-found": "Element ikke funnet", "too-many-requests": "For mange forespørsler", - "too-many-updates": "For mange oppdateringer" + "too-many-updates": "For mange oppdateringer", + "entities-limit-exceeded": "Entitetsgrense overskredet" }, "tenant": { "tenant": "Leietaker", @@ -5563,6 +6285,27 @@ "max-arguments-per-cf": "Maksimalt antall argumenter per beregnet felt", "max-arguments-per-cf-range": "Maksimalt antall argumenter per beregnet felt kan ikke være negativt", "max-arguments-per-cf-required": "Maksimalt antall argumenter per beregnet felt er påkrevd", + "max-related-level-per-argument": "Maksimalt relasjonsnivå per 'Relaterte entiteter'-argument", + "max-related-level-per-argument-range": "Maksimalt relasjonsnivå per 'Relaterte entiteter'-argument kan ikke være mindre enn '1'", + "max-related-level-per-argument-required": "Maksimalt relasjonsnivå per 'Relaterte entiteter'-argument er påkrevd", + "min-allowed-scheduled-update-interval": "Min tillatt oppdateringsintervall for 'Relaterte entiteter'-argumenter (sekunder)", + "min-allowed-scheduled-update-interval-range": "Min tillatt oppdateringsintervall kan ikke være negativt", + "min-allowed-deduplication-interval": "Min tillatt dedupliseringsintervall (sekunder)", + "min-allowed-deduplication-interval-range": "Min tillatt dedupliseringsintervallverdi kan ikke være negativ", + "min-allowed-deduplication-interval-required": "Min tillatt dedupliseringsintervall er påkrevd", + "intermediate-aggregation-interval": "Mellomliggende aggregeringsintervall (sekunder)", + "intermediate-aggregation-interval-range": "Mellomliggende aggregeringsintervallverdi kan ikke være mindre enn '1'", + "intermediate-aggregation-interval-required": "Mellomliggende aggregeringsintervall er påkrevd", + "reevaluation-check-interval": "Sjekkintervall for revurdering (sekunder)", + "reevaluation-check-interval-range": "Sjekkintervallverdi for revurdering kan ikke være mindre enn '1'", + "reevaluation-check-interval-required": "Sjekkintervall for revurdering er påkrevd", + "alarms-reevaluation-interval": "Revurderingsintervall for alarmer (sekunder)", + "alarms-reevaluation-interval-range": "Revurderingsintervallverdi for alarmer kan ikke være mindre enn '1'", + "alarms-reevaluation-interval-required": "Revurderingsintervall for alarmer er påkrevd", + "min-allowed-aggregation-interval": "Min tillatt aggregeringsintervall (sekunder)", + "min-allowed-aggregation-interval-range": "Min tillatt aggregeringsintervallverdi kan ikke være negativ", + "min-allowed-aggregation-interval-required": "Min tillatt aggregeringsintervall er påkrevd", + "min-allowed-scheduled-update-interval-required": "Min tillatt oppdateringsintervall er påkrevd", "max-state-size": "Maksimal tilstandsstørrelse i KB", "max-state-size-range": "Maksimal tilstandsstørrelse i KB kan ikke være negativ", "max-state-size-required": "Maksimal tilstandsstørrelse i KB er påkrevd", @@ -5624,7 +6367,10 @@ "too-small-value-zero": "Verdien må være større enn 0", "too-small-value-one": "Verdien må være større enn 1", "queue-size-is-limited-by-system-configuration": "Størrelsen på køen er også begrenset av systemkonfigurasjonen.", - "cassandra-tenant-limits-configuration": "Cassandra-spørring for leietaker", + "cassandra-write-tenant-core-limits-configuration": "REST API Cassandra-skrivespørringer", + "cassandra-read-tenant-core-limits-configuration": "REST API- og WS-telemetri Cassandra-lesespørringer", + "cassandra-write-tenant-rule-engine-limits-configuration": "Regelmotor-telemetri Cassandra-skrivespørringer", + "cassandra-read-tenant-rule-engine-limits-configuration": "Regelmotor-telemetri Cassandra-lesespørringer", "ws-limit-max-sessions-per-tenant": "Maksimalt antall økter per leietaker", "ws-limit-max-sessions-per-customer": "Maksimalt antall økter per kunde", "ws-limit-max-sessions-per-regular-user": "Maksimalt antall økter per vanlig bruker", @@ -5635,11 +6381,15 @@ "ws-limit-max-subscriptions-per-regular-user": "Maksimalt antall abonnementer per vanlig bruker", "ws-limit-max-subscriptions-per-public-user": "Maksimalt antall abonnementer per offentlig bruker", "ws-limit-updates-per-session": "WS-oppdateringer per økt", + "relation-search-entity-limit": "Entitetsgrense for relasjonssøk", + "relation-search-entity-limit-hint": "Begrenser antall entiteter som løses på siste nivå i relasjonsstien. Gjelder for 'Relaterte entiteter'-argumenter og propageringsfelter.", + "relation-search-entity-limit-required": "Entitetsgrense for relasjonssøk", + "relation-search-entity-limit-range": "Entitetsgrense for relasjonssøk kan ikke være mindre enn '1'", "rate-limits": { "add-limit": "Legg til grense", + "and-also-less-than": "og også mindre enn", "advanced-settings": "Avanserte innstillinger", "edit-limit": "Rediger grense", - "but-less-than": "men mindre enn", "calculated-field-debug-event-rate-limit": "Feilsøkingshendelser for beregnet felt", "edit-calculated-field-debug-event-rate-limit": "Rediger grense for feilsøkingshendelser for beregnet felt", "edit-transport-tenant-msg-title": "Rediger grense for transportmeldinger for leietaker", @@ -5657,7 +6407,10 @@ "edit-tenant-rest-limits-title": "Rediger REST-forespørsler for leietaker", "edit-customer-rest-limits-title": "Rediger REST-forespørsler for kunde", "edit-ws-limit-updates-per-session-title": "Rediger grense for WS-oppdateringer per økt", - "edit-cassandra-tenant-limits-configuration-title": "Rediger Cassandra-spørringsgrenser for leietaker", + "edit-cassandra-write-tenant-core-limits-configuration": "Rediger REST API Cassandra-skrivespørringer", + "edit-cassandra-read-tenant-core-limits-configuration": "Rediger REST API- og WS-telemetri Cassandra-lesespørringer", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "Rediger Regelmotor-telemetri Cassandra-skrivespørringer", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "Rediger Regelmotor-telemetri Cassandra-lesespørringer", "edit-tenant-entity-export-rate-limit-title": "Rediger grense for eksport av enhetsversjoner", "edit-tenant-entity-import-rate-limit-title": "Rediger grense for import av enhetsversjoner", "edit-tenant-notification-request-rate-limit-title": "Rediger grense for varslingsforespørsler", @@ -5679,7 +6432,8 @@ "per-seconds": "Per sekunder", "per-seconds-required": "Tidstakt er påkrevd.", "per-seconds-min": "Minimumsverdi er 1.", - "rate-limits": "Hastighetsgrenser", + "per-seconds-duplicate": "Duplisert tidsrate. Hvert tidsintervall må være unikt.", + "rate-limits": "Rategrenser", "remove-limit": "Fjern grense", "transport-tenant-msg": "Transportmeldinger for leietaker", "transport-tenant-telemetry-msg": "Transporttelemetrimedlinger for leietaker", @@ -5816,7 +6570,9 @@ "default-agg-interval": "Standard grupperingsintervall", "edit-intervals-list-hint": "Liste over tilgjengelige intervallalternativer kan spesifiseres.", "edit-grouping-intervals-list-hint": "Det er mulig å konfigurere listen over grupperingsintervaller og standardintervall.", - "all": "Alle" + "all": "Alle", + "save-current-settings-as-default": "Lagre gjeldende innstillinger som standard tidsvindu", + "hide-option-from-end-users": "Skjul alternativet for sluttbrukere" }, "tooltip": { "trigger": "Utløser", @@ -5827,12 +6583,125 @@ "date": "Dato", "show-date-time-interval": "Vis dato- og tidsintervall", "show-date-time-interval-hint": "Vis dato- og tidsintervall i henhold til dataaggregeringen.", + "hide-zero-tooltip-values": "Skjul nullverdier", + "show-stack-total": "Vis totalverdi i stablingsmodus", "background-color": "Bakgrunnsfarge", "background-blur": "Bakgrunnsuskarphet" }, "unit": { + "set-unit-conversion": "Angi enhetskonvertering", + "unit-settings": { + "unit-settings": "Enhetsinnstillinger", + "source-unit": "Kildeenhet", + "source-unit-hint": "Dette er enheten for den lagrede verdien. Enheten du konverterer fra. Angi symbolet som kildedataene dine bruker (f.eks. m, km, ft, in).", + "target-metric-unit": "Målenhet i metrisk system", + "target-metric-unit-hint": "Velg hvilken metrisk enhet (SI) du vil at kildeverdien skal konverteres til (f.eks. cm, mm, km).", + "target-imperial-unit": "Målenhet i imperialt system", + "target-imperial-unit-hint": "Velg hvilken imperial enhet du vil at kildeverdien skal konverteres til (f.eks. in, ft, yd).", + "target-hybrid-unit": "Målenhet i hybridsystem", + "target-hybrid-unit-hint": "Velg hvilken hybrid enhet du vil at kildeverdien skal konverteres til (f.eks. cm, in, km). Hybridenheter kombinerer metriske eller imperial enheter.", + "enable-unit-conversion": "Aktiver enhetskonvertering", + "enable-unit-conversion-hint": "Slå på for å aktivere konvertering. Når den er av, sendes kildeverdien videre uendret. Deaktivert hvis det bare finnes én enhet i den tilsvarende målegruppen (f.eks. Luminous flux, AQI)." + }, + "unit-system": "Enhetssystem", + "unit-system-type": { + "AUTO": "Auto", + "METRIC": "Metrisk", + "IMPERIAL": "Imperial", + "HYBRID": "Hybrid" + }, + "measures": { + "absorbed-dose-rate": "Absorbert doserate", + "acceleration": "Akselerasjon", + "acidity": "Surhetsgrad", + "air-quality-index": "Luftkvalitetsindeks", + "amount-of-substance": "Stoffmengde", + "angle": "Vinkel", + "angular-acceleration": "Vinkelakselerasjon", + "area": "Areal", + "area-density": "Arealetetthet", + "capacitance": "Kapasitans", + "catalytic-activity": "Katalytisk aktivitet", + "catalytic-concentration": "Katalytisk konsentrasjon", + "charge": "Elektrisk ladning", + "current-density": "Strømtetthet", + "data-transfer-rate": "Dataoverføringshastighet", + "density": "Tetthet", + "digital": "Digital", + "dimension-ratio": "Dimensjonsforhold", + "dynamic-viscosity": "Dynamisk viskositet", + "earthquake-magnitude": "Jordskjelvstyrke", + "electric-charge-density": "Elektrisk ladningstetthet", + "electric-current": "Elektrisk strøm", + "electric-dipole-moment": "Elektrisk dipolmoment", + "electric-field-strength": "Elektrisk feltstyrke", + "electric-flux": "Elektrisk fluks", + "electric-permittivity": "Elektrisk permittivitet", + "electric-polarizability": "Elektrisk polariserbarhet", + "electrical-conductance": "Elektrisk konduktans", + "electrical-conductivity": "Elektrisk ledningsevne", + "energy": "Energi", + "energy-density": "Energitetthet", + "force": "Kraft", + "frequency": "Frekvens", + "fuel-efficiency": "Drivstoffeffektivitet", + "heat-capacity": "Varmekapasitet", + "illuminance": "Belysningsstyrke", + "inductance": "Induktans", + "kinematic-viscosity": "Kinematisk viskositet", + "length": "Lengde", + "light-exposure": "Lyseksponering", + "linear-charge-density": "Lineær ladningstetthet", + "logarithmic-ratio": "Logaritmisk forhold", + "luminous-efficacy": "Lysvirkningsgrad", + "luminous-flux": "Lysfluks", + "luminous-intensity": "Lysintensitet", + "magnetic-field-gradient": "Magnetfeltgradient", + "magnetic-flux": "Magnetisk fluks", + "magnetic-flux-density": "Magnetisk flukstetthet", + "magnetic-moment": "Magnetisk moment", + "magnetic-permeability": "Magnetisk permeabilitet", + "mass": "Masse", + "mass-fraction": "Massefraksjon", + "molar-concentration": "Molarkonsentrasjon", + "molar-energy": "Molar energi", + "molar-heat-capacity": "Molar varmekapasitet", + "molar-mass": "Molarmasse", + "number-concentration": "Tallkonsentrasjon", + "parts-per-million": "Deler per million", + "power": "Effekt", + "power-density": "Effekttetthet", + "pressure": "Trykk", + "radiance": "Radianse", + "radiant-intensity": "Strålingsintensitet", + "radiation-dose": "Stråledose", + "radioactive-decay": "Radioaktivt henfall", + "radioactivity": "Radioaktivitet", + "radioactivity-concentration": "Radioaktivitetskonsentrasjon", + "reciprocal-length": "Omvendt lengde", + "resistance": "Motstand", + "reynolds-number": "Reynolds-tall", + "signal-level": "Signalnivå", + "solid-angle": "Romvinkel", + "specific-energy": "Spesifikk energi", + "specific-heat-capacity": "Spesifikk varmekapasitet", + "specific-humidity": "Spesifikk fuktighet", + "specific-volume": "Spesifikt volum", + "speed": "Hastighet", + "surface-charge-density": "Overflateladningstetthet", + "surface-tension": "Overflatespenning", + "temperature": "Temperatur", + "thermal-conductivity": "Varmeledningsevne", + "time": "Tid", + "torque": "Dreiemoment", + "turbidity": "Turbiditet", + "voltage": "Spenning", + "volume": "Volum", + "volume-flow": "Volumstrøm" + }, "millimeter": "Millimeter", "centimeter": "Centimeter", + "decimeter": "Desimeter", "angstrom": "Ångström", "nanometer": "Nanometer", "micrometer": "Mikrometer", @@ -5840,6 +6709,7 @@ "kilometer": "Kilometer", "inch": "Tommer", "foot": "Fot", + "foot-us": "Fot (US survey)", "yard": "Yard", "mile": "Mil", "nautical-mile": "Nautisk mil", @@ -5886,6 +6756,7 @@ "cubic-foot": "Kubikkfot", "cubic-yard": "Kubikkyard", "fluid-ounce": "Fluid unse", + "fluid-ounce-per-second": "Fluid ounce per sekund", "pint": "Pint", "quart": "Kvart", "gallon": "Gallon", @@ -5904,9 +6775,13 @@ "meter-per-second": "Meter per sekund", "kilometer-per-hour": "Kilometer i timen", "foot-per-second": "Fot per sekund", - "mile-per-hour": "Mil i timen", + "foot-per-minute": "Fot per minutt", + "mile-per-hour": "Mile per time", "knot": "Knop", + "inch-per-second": "Tomme per sekund", + "inch-per-hour": "Tomme per time", "millimeters-per-minute": "Millimeter per minutt", + "meter-per-minute": "Meter per minutt", "kilometer-per-hour-squared": "Kilometer i timen i kvadrat", "foot-per-second-squared": "Fot per sekund i kvadrat", "pascal": "Pascal", @@ -5923,6 +6798,7 @@ "newton-per-meter": "Newton per meter", "atmospheres": "Atmosfærer", "pounds-per-square-inch": "Pund per kvadrattomme", + "kilopound-per-square-inch": "Kilopound per kvadrattomme", "torr": "Torr", "inches-of-mercury": "Tommer kvikksølv", "pascal-per-square-meter": "Pascal per kvadratmeter", @@ -5939,11 +6815,17 @@ "kilojoule": "Kilojoule", "megajoule": "Megajoule", "gigajoule": "Gigajoule", - "watt-hour": "Wattime", - "kilowatt-hour": "Kilowattime", + "watt-hour": "Watt-time", + "watt-minute": "Watt-minutt", + "kilowatt-hour": "Kilowatt-time", + "milliwatt-hour": "Milliwatt-time", + "megawatt-hour": "Megawatt-time", + "gigawatt-hour": "Gigawatt-time", "electron-volts": "Elektronvolt", - "joules-per-coulomb": "Joule per Coulomb", - "british-thermal-unit": "Britisk termisk enhet", + "joules-per-coulomb": "Joule per coulomb", + "british-thermal-unit": "Britiske termiske enheter", + "thousand-british-thermal-unit": "Tusen britiske termiske enheter", + "million-british-thermal-unit": "Million britiske termiske enheter", "foot-pound": "Fot-pund", "calorie": "Kalori", "small-calorie": "Liten kalori", @@ -5974,10 +6856,20 @@ "watt-per-square-inch": "Watt per kvadrattomme", "kilowatt-per-square-inch": "Kilowatt per kvadrattomme", "horsepower": "Hestekraft", - "btu-per-hour": "Britiske termiske enheter/time", + "btu-per-hour": "Britiske termiske enheter per time", + "btu-per-second": "Britiske termiske enheter per sekund", + "btu-per-day": "Britiske termiske enheter per dag", + "mbtu-per-hour": "Tusen britiske termiske enheter per time", + "mbtu-per-second": "Tusen britiske termiske enheter per sekund", + "mbtu-per-day": "Tusen britiske termiske enheter per dag", + "mmbtu-per-hour": "Million britiske termiske enheter per time", + "mmbtu-per-second": "Million britiske termiske enheter per sekund", + "mmbtu-per-day": "Million britiske termiske enheter per dag", + "foot-pound-per-second": "Fot-pound per sekund", "coulomb": "Coulomb", "millicoulomb": "Millicoulomb", "microcoulomb": "Mikrocoulomb", + "nanocoulomb": "Nanocoulomb", "picocoulomb": "Picocoulomb", "coulomb-per-meter": "Coulomb per meter", "coulomb-per-cubic-meter": "Coulomb per kubikkmeter", @@ -6003,6 +6895,9 @@ "microampere": "Mikroampere", "milliampere": "Milliampere", "ampere": "Ampere", + "kiloampere": "Kiloampere", + "megaampere": "Megaampere", + "gigaampere": "Gigaampere", "microampere-per-square-centimeter": "Mikroampere per kvadratcentimeter", "ampere-per-square-meter": "Ampere per kvadratmeter", "ampere-per-meter": "Ampere per meter", @@ -6011,7 +6906,11 @@ "ampere-meter-squared": "Ampere-meter kvadrert", "nanovolt": "Nanovolt", "picovolt": "Picovolt", + "millivolt": "Millivolt", + "microvolt": "Mikrovolt", "volt": "Volt", + "kilovolt": "Kilovolt", + "megavolt": "Megavolt", "dbmV": "dBmV", "dbm": "dBm", "volt-meter": "Volt-meter", @@ -6026,10 +6925,12 @@ "kilohm": "Kiloohm", "megohm": "Megohm", "gigohm": "Gigohm", + "millihertz": "Millihertz", "hertz": "Hertz", "kilohertz": "Kilohertz", "megahertz": "Megahertz", "gigahertz": "Gigahertz", + "terahertz": "Terahertz", "rpm": "Omdreininger per minutt", "candela-per-square-meter": "Candela per kvadratmeter", "candela": "Candela", @@ -6115,6 +7016,9 @@ "millibars": "Millibar", "inch-of-mercury": "Tommer kvikksølv", "richter-scale": "Richterskala", + "nanosecond": "Nanosekund", + "microsecond": "Mikrosekund", + "millisecond": "Millisekund", "second": "Sekund", "minute": "Minutt", "hour": "Time", @@ -6130,6 +7034,7 @@ "gallons-per-minute": "Galloner per minutt", "cubic-foot-per-second": "Kubikkfot per sekund", "milliliters-per-minute": "Milliliter per minutt", + "cubic-decimeter-per-second": "Kubikkdesimeter per sekund", "bit": "Bit", "byte": "Byte", "kilobyte": "Kilobyte", @@ -6152,6 +7057,9 @@ "degree": "Grad", "radian": "Radian", "gradian": "Gradian", + "arcminute": "Bueminutt", + "arcsecond": "Buesekund", + "milliradian": "Milliradian", "revolution": "Revolusjon", "siemens": "Siemens", "millisiemens": "Millisiemens", @@ -6221,10 +7129,12 @@ "radian-per-second": "Radian per sekund", "radian-per-second-squared": "Radian per sekund i kvadrat", "revolutions-per-minute-per-second": "Vinkelakselerasjon", - "deg-per-second": "grader/s", - "degrees-brix": "Grader Brix", + "deg-per-second": "Grader per sekund", + "rotation-per-minute": "Rotasjon per minutt", + "degrees-brix": "Grader brix", "katal": "Katal", - "katal-per-cubic-metre": "Katal per kubikkmeter" + "katal-per-cubic-metre": "Katal per kubikkmeter", + "paris-inch": "Paris-tomme" }, "user": { "user": "Bruker", @@ -6316,7 +7226,8 @@ "export-relations": "Eksporter relasjoner", "export-attributes": "Eksporter attributter", "export-credentials": "Eksporter legitimasjon", - "export-calculated-fields": "Eksporter beregnede felt", + "export-calculated-fields": "Eksporter beregnede felt \nog alarmregler", + "export-alarm-rules": "Eksporter alarmregler", "entity-versions": "Entitetsversjoner", "versions": "Versjoner", "created-time": "Opprettet tidspunkt", @@ -6334,6 +7245,7 @@ "load-attributes": "Last inn attributter", "load-credentials": "Last inn legitimasjon", "load-calculated-fields": "Last inn beregnede felt", + "load-alarm-rules": "Last inn alarmregler", "compare-with-current": "Sammenlign med nåværende", "diff-entity-with-version": "Forskjell med entitetsversjon '{{versionName}}'", "previous-difference": "Forrige forskjell", @@ -6543,7 +7455,23 @@ "scan-qr-code": "Skann QR-kode", "make-phone-call": "Ring telefonsamtale", "get-location": "Hent telefonens posisjon", - "take-screenshot": "Ta skjermbilde" + "take-screenshot": "Ta skjermbilde", + "handle-provision-success-function": "Håndter funksjon for vellykket klargjøring", + "get-location-function": "Hent posisjonsfunksjon", + "process-launch-result-function": "Behandle funksjon for startresultat", + "get-phone-number-function": "Hent telefonnummerfunksjon", + "process-image-function": "Behandle bildefunksjon", + "process-qr-code-function": "Behandle QR-kodefunksjon", + "process-location-function": "Behandle posisjonsfunksjon", + "handle-empty-result-function": "Håndter funksjon for tomt resultat", + "handle-error-function": "Håndter feilfunksjon", + "handle-non-mobile-fallback-function": "Håndter fallback-funksjon for ikke-mobil", + "save-to-gallery": "Lagre i 'Bildegalleriet'", + "provision-type": "Klargjøringstype", + "auto": "Auto", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" }, "custom-action-function": "Egendefinert handlingsfunksjon", "custom-pretty-function": "Egendefinert handling (med HTML-mal) funksjon", @@ -6552,7 +7480,8 @@ "marker": "Markør", "polygon": "Polygon", "rectangle": "Rektangel", - "circle": "Sirkel" + "circle": "Sirkel", + "polyline": "Polylinje" }, "place-map-item": "Plasser kartobjekt", "map-item-tooltip": { @@ -6564,7 +7493,9 @@ "continue-draw-polygon": "Fortsett å tegne polygon", "finish-draw-polygon": "Fullfør tegning av polygon", "start-draw-circle": "Start å tegne sirkel", - "finish-draw-circle": "Fullfør tegning av sirkel" + "finish-draw-circle": "Fullfør tegning av sirkel", + "start-draw-polyline": "Start tegning av polylinje", + "finish-draw-polyline": "Fullfør tegning av polylinje" } }, "widgets-bundle": { @@ -7130,6 +8061,14 @@ "update-animation-delay": "Oppdater animasjonsforsinkelse" }, "chart-axis": { + "limit": "Grense", + "source": "Kilde", + "key-value": "Nøkkel / verdi", + "value-required": "Verdi er påkrevd.", + "entity-key-required": "Entitetsnøkkel er påkrevd.", + "key-required": "Nøkkel er påkrevd.", + "scale-limits": "Skaleringsgrenser", + "scale-appearance": "Skaleringsutseende", "scale": "Skala", "scale-min": "min", "scale-max": "maks", @@ -7674,7 +8613,10 @@ "add-radio-option": "Legg til radioknappealternativ", "radio-label-position": "Etikettplassering", "radio-label-position-before": "Før", - "radio-label-position-after": "Etter" + "radio-label-position-after": "Etter", + "save-image": "Lagre bilde", + "save-to-gallery": "Lagre automatisk bilder som tas opp i Bildegalleriet", + "public-image": "Gjør bildet tilgjengelig for alle uautoriserte brukere" }, "invalid-qr-code-text": "Ugyldig inputtekst for QR-kode. Input må være av typen streng", "qr-code": { @@ -7774,6 +8716,18 @@ "fill-area-opacity": "Opasitet for fylt område", "range-chart-style": "Stil for områdediagram" }, + "knob": { + "behavior": "Atferd", + "initial-value": "Startverdi", + "initial-value-hint": "Handling for å hente startverdien for dreieknappen.", + "on-value-change": "Ved verdiendring", + "on-value-change-hint": "Handling som utløses når dreieknappens verdi endres.", + "range": "Område", + "min": "min", + "max": "maks", + "value": "Verdi", + "fallback-initial-value": "Fallback-startverdi" + }, "rpc": { "value-settings": "Verdiinnstillinger", "initial-value": "Startverdi", @@ -7830,9 +8784,7 @@ "led-status-value-timeseries": "Enhetstidsserie som inneholder LED-statusverdi", "check-status-method": "RPC-metode for statuskontroll", "parse-led-status-value-function": "Parse LED-statusverdi-funksjon", - "knob-title": "Knappetittel", - "min-value": "Minimumsverdi", - "max-value": "Maksimumsverdi" + "knob-title": "Knappetittel" }, "maps": { "map-type": { @@ -7959,7 +8911,8 @@ "trips": "Turer", "markers": "Markører", "polygons": "Polygoner", - "circles": "Sirkler" + "circles": "Sirkler", + "polylines": "Polylinjer" }, "data-layer": { "source": "Kilde", @@ -8156,10 +9109,29 @@ "finish-circle-hint-with-entity": "Sirkel for '{{entityName}}': klikk for å avslutte og lagre sirkel", "finish-circle-hint": "Sirkel: klikk for å avslutte tegning" }, - "select-entity": "Velg enhet", + "polyline": { + "polyline-key": "Polylinjenøkkel", + "polyline-key-required": "Polylinjenøkkel er påkrevd", + "no-polylines": "Ingen polylinjer konfigurert", + "add-polylines": "Legg til polylinje", + "polyline-configuration": "Polylinjekonfigurasjon", + "remove-polyline": "Fjern polylinje", + "edit": "Rediger polylinje", + "cut": "Klipp polylinjeområde", + "rotate": "Roter polylinje", + "remove-polyline-for": "Fjern polylinje for '{{entityName}}'", + "draw-polyline": "Tegn polylinje", + "polyline-place-first-point-hint-with-entity": "Polylinje for '{{entityName}}': klikk for å plassere første punkt", + "polyline-place-first-point-hint": "Polylinje: klikk for å plassere første punkt", + "finish-polyline-hint-with-entity": "Polylinje for '{{entityName}}': klikk for å fullføre tegningen", + "finish-polyline-hint": "Polylinje: klikk for å fullføre tegningen", + "polyline-place-first-point-cut-hint": "Klikk for å plassere første punkt", + "finish-polyline-cut-hint": "Klikk på første markør for å fullføre og lagre" + }, + "select-entity": "Velg entitet", "select-entity-hint": "Tips: etter valg, klikk på kartet for å angi posisjon" }, - "select-entity": "Velg enhet", + "select-entity": "Velg entitet", "select-entity-hint": "Tips: etter valg, klikk på kartet for å angi posisjon", "tooltips": { "placeMarker": "Klikk for å plassere '{{entityName}}' enhet", @@ -8597,6 +9569,7 @@ "show-empty-space-hidden-action": "Vis tomt område i stedet for skjult cellehandling", "dont-reserve-space-hidden-action": "Ikke reserver plass til skjulte handlingsknapper", "display-timestamp": "Tidsstempel", + "timestamp-column-name": "Tidsstempel", "display-pagination": "Vis paginering", "default-page-size": "Standard sidestørrelse", "page-step-settings": "Sideinnstillinger", @@ -8658,7 +9631,9 @@ "alarm-column-error": "Minst én alarmkolonne må angis", "table-tabs": "Tabellfaner", "show-cell-actions-menu-mobile": "Vis rullegardinmeny for cellehandlinger i mobilmodus", - "disable-sorting": "Deaktiver sortering" + "disable-sorting": "Deaktiver sortering", + "sort-by": "Sorter faner etter", + "sort-timestamp-option": "Opprettet tid" }, "latest-chart": { "total": "Totalt", @@ -8687,7 +9662,11 @@ "radar-axis": "Radarakse", "axis-label": "Aksesetikett", "ticks-label": "Tikkeetikett", - "radar-chart-style": "Radar-diagramstil" + "radar-chart-style": "Radar-diagramstil", + "max-axes-scaling": "Maks akseskalering", + "max-axes-scaling-hint": "Velg om hver radarakse har sin egen maksverdi (Separat) eller deler den høyeste verdien på tvers av alle akser basert på widget-datasettet (Felles).", + "separate": "Separat", + "common": "Felles" }, "time-series-chart": { "chart": "Diagram", @@ -9109,7 +10088,7 @@ }, "step2": { "title": "Koble til enhet", - "content-before": "

For å koble til enheten må du hente legitimasjon. Vi anbefaler å bruke automatisk genererte legitimasjonsdata – tilgangstoken – i denne veiledningen.

  • Gå til enhetstabellen
  • Klikk på enheten for å åpne detaljene
  • Trykk på knappen «Kopier tilgangstoken»

Bruk enkle kommandoer for å publisere data via HTTP. Husk å erstatte $ACCESS_TOKEN med tilgangstokenet for enheten:

", + "content-before": "

For å koble til enheten må du hente legitimasjon. Vi anbefaler å bruke automatisk genererte legitimasjonsdata – tilgangstoken – i denne veiledningen.

  • Gå til enhetstabellen
  • Klikk på enheten for å åpne detaljene
  • Trykk på knappen \"Kopier tilgangstoken\"

Bruk enkle kommandoer for å publisere data via HTTP. Husk å erstatte $ACCESS_TOKEN med tilgangstokenet for enheten:

", "ubuntu": { "install-curl": "Installer cURL for Ubuntu:" }, @@ -9146,11 +10125,28 @@ "content": "

Ved å opprette dashbord for sluttbruker, kan kundebrukeren bare se sine egne enheter og data. Data fra andre kunder vil være skjult.

Følg dokumentasjonen for hvordan du gjør det:

" } } + }, + "api-usage": { + "api-usage": "API-bruk", + "label": "Etikett", + "state-name": "Tilstandsnavn", + "status": "Status", + "status-required": "Status er påkrevd.", + "limit": "Maksgrense", + "limit-required": "Maksgrense er påkrevd.", + "current-number": "Gjeldende antall", + "current-number-required": "Gjeldende antall er påkrevd.", + "add-key": "Legg til nøkkel", + "no-key": "Ingen nøkkel", + "delete-key": "Slett nøkkel", + "target-dashboard-state": "Måltilstand for dashbord", + "go-to-main-state": "Gå til standardvisning" } }, "icon": { "icon": "Ikon", "icons": "Ikoner", + "custom": "Egendefinert", "select-icon": "Velg ikon", "material-icons": "Materialikoner", "show-all": "Vis alle ikoner", @@ -9191,6 +10187,7 @@ "items-per-page-separator": "av" }, "language": { + "auto": "Auto", "language": "Språk" } -} +} \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json index 91ffd72b2f..a602a04529 100644 --- a/ui-ngx/src/assets/locale/locale.constant-pt_BR.json +++ b/ui-ngx/src/assets/locale/locale.constant-pt_BR.json @@ -2,19 +2,24 @@ "access": { "unauthorized": "Não autorizado", "unauthorized-access": "Acesso não autorizado", - "unauthorized-access-text": "Para acessar este recurso, você precisa se inscrever!", + "unauthorized-access-text": "Você precisa fazer login para acessar este recurso!", "access-forbidden": "Acesso proibido", - "access-forbidden-text": "Você não tem direito de acesso a esta localização!
Se ainda deseja acessar esta localização, tente inscrever-se com um nome de usuário diferente.", - "refresh-token-expired": "A sessão expirou", - "refresh-token-failed": "Impossível atualizar a sessão", + "access-forbidden-text": "Você não tem permissão de acesso a este local!
Tente fazer login com outro usuário se ainda deseja obter acesso a este local.", + "refresh-token-expired": "Sessão expirada", + "refresh-token-failed": "Não foi possível atualizar a sessão", "permission-denied": "Permissão negada", "permission-denied-text": "Você não tem permissão para executar esta operação!" }, + "account": { + "account": "Conta", + "notification-settings": "Configurações de notificação" + }, "action": { "activate": "Ativar", "suspend": "Suspender", "save": "Salvar", "saveAs": "Salvar como", + "move": "Mover", "cancel": "Cancelar", "ok": "OK", "delete": "Excluir", @@ -23,22 +28,22 @@ "no": "Não", "update": "Atualizar", "remove": "Remover", - "select": "Selecionar", "search": "Pesquisar", "clear-search": "Limpar pesquisa", "assign": "Atribuir", - "unassign": "Cancelar atribuição", + "unassign": "Desatribuir", "share": "Compartilhar", "make-private": "Tornar privado", "apply": "Aplicar", "apply-changes": "Aplicar alterações", "edit-mode": "Modo de edição", "enter-edit-mode": "Entrar no modo de edição", - "decline-changes": "Rejeitar alterações", + "decline-changes": "Recusar alterações", + "decline": "Recusar", "close": "Fechar", "back": "Voltar", "run": "Executar", - "sign-in": "Inscreva-se!", + "sign-in": "Entrar!", "edit": "Editar", "view": "Visualizar", "create": "Criar", @@ -52,59 +57,138 @@ "import": "Importar", "export": "Exportar", "share-via": "Compartilhar via {{provider}}", + "select": "Selecionar", "continue": "Continuar", "discard-changes": "Descartar alterações", - "download": "Download", - "done": "Concluído" + "download": "Baixar", + "next": "Próximo", + "next-with-label": "Próximo: {{label}}", + "read-more": "Ler mais", + "hide": "Ocultar", + "test": "Testar", + "done": "Concluir", + "print": "Imprimir", + "restore": "Restaurar", + "confirm": "Confirmar", + "more": "Mais", + "less": "Menos", + "skip": "Ignorar", + "send": "Enviar", + "reset": "Redefinir", + "show-more": "Mostrar mais", + "dont-show-again": "Não mostrar novamente", + "see-documentation": "Ver documentação", + "see-debug-events": "Ver eventos de depuração", + "clear": "Limpar", + "upload": "Carregar", + "delete-anyway": "Excluir mesmo assim", + "delete-selected": "Excluir selecionados", + "set": "Definir" }, "aggregation": { "aggregation": "Agregação", "function": "Função de agregação de dados", - "limit": "Valores máx.", + "limit": "Máximo de valores", "group-interval": "Intervalo de agrupamento", - "min": "Mín.", - "max": "Máx.", + "min": "Mín", + "max": "Máx", "avg": "Média", "sum": "Soma", "count": "Contagem", "none": "Nenhum" }, "admin": { + "settings": "Configurações", "general": "Geral", - "general-settings": "Configuração geral", - "outgoing-mail": "Servidor de e-mail", - "outgoing-mail-settings": "Configuração do servidor de saída de e-mail", - "system-settings": "Configuração do sistema", - "test-mail-sent": "O e-mail de teste foi enviado corretamente!", - "base-url": "URL de base", - "base-url-required": "O URL de base é obrigatório.", - "mail-from": "E-mail de", - "mail-from-required": "'E-mail de' é obrigatório.", + "general-settings": "Configurações gerais", + "home-settings": "Configurações da página inicial", + "home": "Início", + "outgoing-mail": "Servidor de email", + "outgoing-mail-settings": "Configurações do servidor de email de saída", + "system-settings": "Configurações do sistema", + "test-mail-sent": "Email de teste enviado com sucesso!", + "base-url": "URL base", + "base-url-required": "URL base é obrigatória.", + "prohibit-different-url": "Proibir o uso de hostname dos cabeçalhos de requisição do cliente", + "prohibit-different-url-hint": "Esta configuração deve ser habilitada em ambientes de produção. Pode causar problemas de segurança quando desabilitada", + "device-connectivity": { + "device-connectivity": "Conectividade do dispositivo", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "Se os campos de host ou porta estiverem vazios, o valor padrão do protocolo será utilizado.", + "host": "Host", + "port": "Porta", + "port-pattern": "A porta deve ser um número inteiro positivo.", + "port-range": "A porta deve estar no intervalo de 1 a 65535." + }, + "mail-from": "Remetente do email", + "mail-from-required": "Remetente do email é obrigatório.", "smtp-protocol": "Protocolo SMTP", "smtp-host": "Host SMTP", - "smtp-host-required": "O host SMTP é obrigatório.", + "smtp-host-required": "Host SMTP é obrigatório.", "smtp-port": "Porta SMTP", - "smtp-port-required": "É necessário informar a porta SMPT.", - "smtp-port-invalid": "Esta não parece ser uma porta SMTP válida.", - "timeout-msec": "Tempo limite (ms)", - "timeout-required": "O tempo limite é obrigatório.", - "timeout-invalid": "Este não parece ser um tempo limite válido.", - "enable-tls": "Habilitar TLS", - "tls-version": "Versão de TLS", - "enable-proxy": "Habilitar proxy", - "proxy-host": "Host de proxy", - "proxy-host-required": "O host de proxy é obrigatório.", + "smtp-port-required": "É necessário informar uma porta SMTP.", + "smtp-port-invalid": "Isso não parece uma porta SMTP válida.", + "timeout-msec": "Tempo limite (msec)", + "timeout-required": "Tempo limite é obrigatório.", + "timeout-invalid": "Isso não parece um tempo limite válido.", + "enable-tls": "Ativar TLS", + "tls-version": "Versão TLS", + "enable-proxy": "Ativar proxy", + "proxy-host": "Host do proxy", + "proxy-host-required": "Host do proxy é obrigatório.", "proxy-port": "Porta do proxy", - "proxy-port-required": "A porta do proxy é obrigatória.", - "proxy-port-range": "A porta do proxy deve estar em um intervalo de 1 a 65535.", + "proxy-port-required": "Porta do proxy é obrigatória.", + "proxy-port-range": "A porta do proxy deve estar no intervalo de 1 a 65535.", "proxy-user": "Usuário do proxy", "proxy-password": "Senha do proxy", - "send-test-mail": "Enviar e-mail de teste", - "security-settings": "Configuração de segurança", - "password-policy": "Política de senhas", + "change-password": "Alterar senha", + "send-test-mail": "Enviar email de teste", + "sms-provider": "Provedor de SMS", + "sms-provider-settings": "Configurações do provedor de SMS", + "sms-provider-type": "Tipo de provedor de SMS", + "sms-provider-type-required": "Tipo de provedor de SMS é obrigatório.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID é obrigatório", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key é obrigatório", + "aws-region": "Região AWS", + "aws-region-required": "Região AWS é obrigatória", + "number-from": "Número de telefone remetente", + "number-from-required": "Número de telefone remetente é obrigatório.", + "number-to": "Número de telefone destinatário", + "number-to-required": "Número de telefone destinatário é obrigatório.", + "phone-number-hint": "Número de telefone no formato E.164, ex.: +19995550123", + "phone-number-hint-twilio": "Número de telefone no formato E.164/SID do número de telefone/SID do serviço de mensagens, ex.: +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Número de telefone inválido. Deve estar no formato E.164, ex.: +19995550123.", + "phone-number-pattern-twilio": "Número de telefone inválido. Deve estar no formato E.164/SID do número de telefone/SID do serviço de mensagens, ex.: +19995550123/PNXXX/MGXXX.", + "sms-message": "Mensagem SMS", + "sms-message-required": "Mensagem SMS é obrigatória.", + "sms-message-max-length": "A mensagem SMS não pode ter mais de 1600 caracteres", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID é obrigatório", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token é obrigatório", + "send-test-sms": "Enviar SMS de teste", + "test-sms-sent": "SMS de teste enviado com sucesso!", + "security-settings": "Configurações de segurança", + "password-policy": "Política de senha", "minimum-password-length": "Comprimento mínimo da senha", - "minimum-password-length-required": "O comprimento mínimo da senha é obrigatório", - "minimum-password-length-range": "O comprimento mínimo da senha deve estar em um intervalo de 5 a 50.", + "minimum-password-length-required": "Comprimento mínimo da senha é obrigatório", + "minimum-password-length-range": "O comprimento mínimo da senha deve estar no intervalo de 6 a 50", + "maximum-password-length": "Comprimento máximo da senha", + "maximum-password-length-min": "O comprimento máximo da senha deve ser de pelo menos 6", + "maximum-password-length-less-min": "O comprimento máximo da senha deve ser maior que o comprimento mínimo", "minimum-uppercase-letters": "Número mínimo de letras maiúsculas", "minimum-uppercase-letters-range": "O número mínimo de letras maiúsculas não pode ser negativo", "minimum-lowercase-letters": "Número mínimo de letras minúsculas", @@ -113,36 +197,385 @@ "minimum-digits-range": "O número mínimo de dígitos não pode ser negativo", "minimum-special-characters": "Número mínimo de caracteres especiais", "minimum-special-characters-range": "O número mínimo de caracteres especiais não pode ser negativo", - "password-expiration-period-days": "Período de expiração de senha em dias", - "password-expiration-period-days-range": "O período de expiração de senha não pode ser negativo", + "password-expiration-period-days": "Período de expiração da senha em dias", + "password-expiration-period-days-range": "O período de expiração da senha em dias não pode ser negativo", "password-reuse-frequency-days": "Frequência de reutilização de senha em dias", - "password-reuse-frequency-days-range": "A frequência em dias de reutilização da senha não pode ser negativa", + "password-reuse-frequency-days-range": "A frequência de reutilização de senha em dias não pode ser negativa", + "allow-whitespace": "Permitir espaço em branco", + "force-reset-password-if-no-valid": "Forçar redefinição de senha se não for válida", + "force-reset-password-if-no-valid-hint": "Tenha cuidado ao habilitar este recurso: ele exigirá que usuários com senha inválida redefinam sua senha por email.", "general-policy": "Política geral", - "max-failed-login-attempts": "Número máximo de tentativas de login com falha antes de bloquear a conta", - "minimum-max-failed-login-attempts-range": "O número máximo de tentativas de login com falha não pode ser negativo", - "user-lockout-notification-email": "Em caso de bloqueio de conta do usuário, enviar notificação para o endereço de e-mail" + "max-failed-login-attempts": "Número máximo de tentativas de login falhas antes de bloquear a conta", + "minimum-max-failed-login-attempts-range": "O número máximo de tentativas de login falhas não pode ser negativo", + "user-lockout-notification-email": "Em caso de bloqueio de conta do usuário, enviar notificação por email", + "user-activation-token-ttl": "TTL do link de ativação do usuário em horas", + "user-activation-token-ttl-range": "O TTL do link de ativação do usuário deve estar no intervalo de 1 a 24 horas", + "password-reset-token-ttl": "TTL do link de redefinição de senha em horas", + "password-reset-token-ttl-range": "O TTL do link de redefinição de senha deve estar no intervalo de 1 a 24 horas", + "mobile-secret-key-length": "Comprimento da chave secreta do aplicativo móvel", + "mobile-secret-key-length-range": "O comprimento da chave secreta do aplicativo móvel deve ser positivo", + "domain-name": "Nome de domínio", + "domain-name-unique": "O nome de domínio e o protocolo precisam ser únicos.", + "domain-name-max-length": "O nome de domínio deve ter menos de 256 caracteres", + "error-verification-url": "O nome de domínio não deve conter os símbolos '/' e ':'. Exemplo: thingsboard.io", + "connection-settings": "Configurações de conexão", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI é obrigatório.", + "activate-user": "Ativar usuário", + "add-domain": "Adicionar domínio", + "delete-domain": "Excluir domínio", + "add-provider": "Adicionar provedor", + "delete-provider": "Excluir provedor", + "allow-user-creation": "Permitir criação de usuário", + "always-fullscreen": "Sempre em tela cheia", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI é obrigatório.", + "add-client": "Adicionar cliente OAuth 2.0", + "client-details": "Detalhes do cliente OAuth 2.0", + "client": "Cliente OAuth 2.0", + "clients": "Clientes OAuth 2.0", + "no-oauth2-clients": "Nenhum cliente OAuth 2.0 encontrado", + "search-oauth2-clients": "Pesquisar clientes OAuth 2.0", + "delete-client-title": "Tem certeza de que deseja excluir o cliente OAuth 2.0 '{{clientName}}'?", + "delete-client-text": "Atenção: após a confirmação, o cliente e todos os dados relacionados serão irrecuperáveis.", + "delete-mobile-app-title": "Tem certeza de que deseja excluir o aplicativo móvel '{{applicationName}}'?", + "delete-mobile-app-text": "Atenção: após a confirmação, o aplicativo móvel e todos os dados relacionados serão irrecuperáveis.", + "title": "Título", + "client-title-required": "Título é obrigatório", + "client-title-max-length": "O título deve ter menos de 100 caracteres", + "advanced-settings": "Configurações avançadas", + "domain-details": "Detalhes do domínio", + "no-domains": "Nenhum domínio encontrado", + "search-domains": "Pesquisar domínios", + "mobile-app-details": "Detalhes do aplicativo móvel", + "add-mobile-app": "Adicionar aplicativo móvel", + "no-mobile-apps": "Nenhum aplicativo móvel encontrado", + "search-mobile-apps": "Pesquisar aplicativos móveis", + "send-token": "Enviar token", + "create-new": "Criar novo", + "client-authentication-method": "Método de autenticação do cliente", + "client-id": "Client ID", + "client-id-required": "Client ID é obrigatório.", + "client-id-max-length": "O Client ID deve ter menos de 256 caracteres", + "client-secret": "Client secret", + "client-secret-required": "Client secret é obrigatório.", + "client-secret-max-length": "O Client secret deve ter menos de 2049 caracteres", + "custom-setting": "Configurações personalizadas", + "customer-name-pattern": "Padrão de nome do cliente", + "customer-name-pattern-max-length": "O padrão de nome do cliente deve ter menos de 256 caracteres", + "default-dashboard-name": "Nome do Dashboard padrão", + "default-dashboard-name-max-length": "O nome do Dashboard padrão deve ter menos de 256 caracteres", + "delete-domain-text": "Atenção: após a confirmação, o domínio e todos os dados do provedor ficarão indisponíveis.", + "delete-domain-title": "Tem certeza de que deseja excluir o domínio '{{domainName}}'?", + "delete-registration-text": "Atenção: após a confirmação, os dados do provedor ficarão indisponíveis.", + "delete-registration-title": "Tem certeza de que deseja excluir o provedor '{{name}}'?", + "email-attribute-key": "Chave de atributo de email", + "email-attribute-key-required": "Chave de atributo de email é obrigatória.", + "email-attribute-key-max-length": "A chave de atributo de email deve ter menos de 32 caracteres", + "first-name-attribute-key": "Chave de atributo de nome", + "first-name-attribute-key-max-length": "A chave de atributo de nome deve ter menos de 32 caracteres", + "general": "Geral", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "Chave de atributo de sobrenome", + "last-name-attribute-key-max-length": "A chave de atributo de sobrenome deve ter menos de 32 caracteres", + "login-button-icon": "Ícone do botão de login", + "login-button-label": "Rótulo do provedor", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "Rótulo é obrigatório.", + "login-provider": "Provedor de login", + "mapper": "Mapeador", + "new-domain": "Novo domínio", + "oauth2": "OAuth 2.0", + "password-max-length": "A senha deve ter menos de 256 caracteres", + "redirect-uri-template": "Redirect URI template", + "copy-redirect-uri": "Copiar Redirect URI", + "registration-id": "ID de registro", + "registration-id-required": "ID de registro é obrigatório.", + "registration-id-unique": "O ID de registro precisa ser único no sistema.", + "scope": "Scope", + "scope-required": "Scope é obrigatório.", + "tenant-name-pattern": "Padrão de nome do tenant", + "tenant-name-pattern-required": "Padrão de nome do tenant é obrigatório.", + "tenant-name-pattern-max-length": "O padrão de nome do tenant deve ter menos de 256 caracteres", + "tenant-name-strategy": "Estratégia de nome do tenant", + "type": "Tipo de mapeador", + "uri-pattern-error": "Formato de URI inválido.", + "url": "URL", + "url-pattern": "Formato de URL inválido.", + "url-required": "URL é obrigatória.", + "url-max-length": "A URL deve ter menos de 256 caracteres", + "user-info-uri": "URI de informações do usuário", + "user-info-uri-required": "URI de informações do usuário é obrigatório.", + "username-max-length": "O nome de usuário deve ter menos de 256 caracteres", + "user-name-attribute-name": "Chave de atributo do nome de usuário", + "user-name-attribute-name-required": "Chave de atributo do nome de usuário é obrigatória", + "protocol": "Protocolo", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Ativar configurações OAuth 2.0", + "disable": "Desativar configurações OAuth 2.0", + "edge": "Propagar para Edge", + "edge-enable": "Ativar propagação para Edge", + "edge-disable": "Desativar propagação para Edge", + "domains": "Domínios", + "mobile-apps": "Aplicativos móveis", + "mobile-package": "Pacote do aplicativo", + "mobile-package-placeholder": "Ex.: my.example.app", + "mobile-package-hint": "Para Android: seu ID de aplicativo único. Para iOS: identificador do bundle do produto.", + "mobile-package-unique": "O pacote do aplicativo deve ser único.", + "mobile-package-required": "Pacote do aplicativo é obrigatório.", + "mobile-package-max-length": "O pacote do aplicativo deve ter menos de 256 caracteres", + "mobile-package-spaces": "O pacote do aplicativo não deve conter espaços", + "mobile-app-secret": "Application secret", + "mobile-app-secret-hint": "String codificada em Base64 representando pelo menos 512 bits de dados.", + "mobile-app-secret-required": "Application secret é obrigatório.", + "mobile-app-secret-min-length": "Application secret deve conter pelo menos 512 bits de dados.", + "mobile-app-secret-base64": "Application secret deve estar no formato base64.", + "invalid-mobile-app-secret": "Application secret deve conter apenas caracteres alfanuméricos e ter entre 16 e 2048 caracteres.", + "copy-mobile-app-secret": "Copiar application secret", + "delete-mobile-app": "Excluir informações do aplicativo", + "providers": "Provedores", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Todas as plataformas", + "smtp-provider": "Provedor SMTP", + "allowed-platforms": "Plataformas permitidas", + "authentication": "Autenticação", + "basic": "Básico", + "provider": "Provedor", + "redirect-url": "Redirect URI", + "domain-name": "Nome de domínio", + "domain-name-required": "Nome de domínio é obrigatório", + "redirect-url-template": "Redirect URI template", + "microsoft-tenant-id": "ID do diretório (tenant)", + "microsoft-tenant-id-required": "ID do diretório (tenant) é obrigatório", + "token-uri": "Token URI", + "token-uri-required": "Token URI é obrigatório", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Personalizado", + "generate-access-token": "Gerar access token", + "update-access-token": "Atualizar access token", + "access-token-status": "Status do access token:", + "token-status-generated": "gerado", + "token-status-not-generated": "não gerado" + }, + "smpp-provider": { + "smpp-version": "Versão SMPP", + "smpp-host": "Host SMPP", + "smpp-host-required": "Host SMPP é obrigatório", + "smpp-port": "Porta SMPP", + "smpp-port-required": "Porta SMPP é obrigatória", + "system-id": "ID do sistema", + "system-id-required": "ID do sistema é obrigatório", + "password": "Senha", + "password-required": "Senha é obrigatória", + "type-settings": "Configurações de tipo", + "source-settings": "Configurações de origem", + "destination-settings": "Configurações de destino", + "additional-settings": "Configurações adicionais", + "system-type": "Tipo de sistema", + "bind-type": "Tipo de bind", + "service-type": "Tipo de serviço", + "source-address": "Endereço de origem", + "source-ton": "TON de origem", + "source-npi": "NPI de origem", + "destination-ton": "TON de destino (Type of Number)", + "destination-npi": "NPI de destino (Numbering Plan Identification)", + "address-range": "Intervalo de endereços", + "coding-scheme": "Esquema de codificação", + "bind-type-tx": "Transmissor", + "bind-type-rx": "Receptor", + "bind-type-trx": "Transceptor", + "ton-unknown": "Desconhecido", + "ton-international": "Internacional", + "ton-national": "Nacional", + "ton-network-specific": "Específico de rede", + "ton-subscriber-number": "Número do assinante", + "ton-alphanumeric": "Alfanumérico", + "ton-abbreviated": "Abreviado", + "npi-unknown": "0 - Desconhecido", + "npi-isdn": "1 - Plano de numeração ISDN/telefônico (E163/E164)", + "npi-data-numbering-plan": "3 - Plano de numeração de dados (X.121)", + "npi-telex-numbering-plan": "4 - Plano de numeração de telex (F.69)", + "npi-land-mobile": "6 - Móvel terrestre (E.212)", + "npi-national-numbering-plan": "8 - Plano de numeração nacional", + "npi-private-numbering-plan": "9 - Plano de numeração privado", + "npi-ermes-numbering-plan": "10 - Plano de numeração ERMES (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Internet (IP)", + "npi-wap-client-id": "18 - WAP Client Id (a ser definido pelo WAP Forum)", + "scheme-smsc": "0 - Alfabeto padrão SMSC (ASCII para código curto e longo e GSM para tarifa gratuita)", + "scheme-ia5": "1 - IA5 (ASCII para código curto e longo, Latin 9 para tarifa gratuita (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Octet não especificado (binário de 8 bits)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Octet não especificado (binário de 8 bits)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Cirílico (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latino/Hebraico (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Codificação pictográfica", + "scheme-music-codes": "10 - Códigos musicais (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Kanji JIS estendido (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Conjunto de caracteres gráficos coreanos (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Selecionar nome da fila", + "queue-name": "Nome", + "queue-name-required": "Nome da fila é obrigatório!", + "queues": "Filas", + "queue-partitions": "Partições", + "queue-submit-strategy": "Estratégia de envio", + "queue-processing-strategy": "Estratégia de processamento", + "queue-configuration": "Configuração da fila", + "repository-settings": "Configurações do repositório", + "repository": "Repositório", + "repository-url": "URL do repositório", + "repository-url-required": "URL do repositório é obrigatória.", + "default-branch": "Nome do branch padrão", + "repository-read-only": "Somente leitura", + "show-merge-commits": "Mostrar commits de merge", + "authentication-settings": "Configurações de autenticação", + "auth-method": "Método de autenticação", + "auth-method-username-password": "Senha / token de acesso", + "auth-method-username-password-hint": "Usuários do GitHub devem usar tokens de acesso com permissões de gravação no repositório.", + "auth-method-private-key": "Chave privada", + "password-access-token": "Senha / token de acesso", + "change-password-access-token": "Alterar senha / token de acesso", + "private-key": "Chave privada", + "drop-private-key-file-or": "Arraste e solte um arquivo de chave privada ou", + "passphrase": "Frase secreta", + "enter-passphrase": "Inserir frase secreta", + "change-passphrase": "Alterar frase secreta", + "check-access": "Verificar acesso", + "check-repository-access-success": "Acesso ao repositório verificado com sucesso!", + "delete-repository-settings-title": "Tem certeza de que deseja excluir as configurações do repositório?", + "delete-repository-settings-text": "Atenção: após a confirmação, as configurações do repositório serão removidas e o recurso de controle de versão ficará indisponível.", + "auto-commit-settings": "Configurações de auto-commit", + "auto-commit": "Auto-commit", + "auto-commit-entities": "Entidades com auto-commit", + "no-auto-commit-entities-prompt": "Nenhuma entidade configurada para auto-commit", + "delete-auto-commit-settings-title": "Tem certeza de que deseja excluir as configurações de auto-commit?", + "delete-auto-commit-settings-text": "Atenção: após a confirmação, as configurações de auto-commit serão removidas e o auto-commit ficará desativado para todas as entidades.", + "mobile-app": { + "mobile-app": "Aplicativo móvel", + "mobile-app-qr-code-widget-settings": "Configurações do widget de QR code do aplicativo móvel", + "applications": "Aplicativos", + "default": "Padrão", + "custom": "Personalizado", + "android": "Android", + "ios": "iOS", + "appearance": "Aparência", + "appearance-on-home-page": "Aparência na página inicial", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "badges": "Selos", + "label": "Rótulo", + "label-required": "Rótulo é obrigatório", + "label-max-length": "O rótulo deve ter no máximo 50 caracteres", + "right": "Direita", + "left": "Esquerda", + "set": "Definir", + "preview": "Pré-visualização", + "connect-mobile-app": "Conectar aplicativo móvel", + "use-system-settings": "Usar configurações do sistema" + }, + "2fa": { + "2fa": "Autenticação de dois fatores", + "available-providers": "Provedores disponíveis", + "available-providers-required": "Pelo menos um provedor 2FA deve ser configurado.", + "issuer-name": "Nome do emissor", + "issuer-name-required": "Nome do emissor é obrigatório.", + "max-verification-failures-before-user-lockout": "Máximo de falhas de verificação antes do bloqueio do usuário", + "max-verification-failures-before-user-lockout-pattern": "O máximo de falhas de verificação deve ser um número inteiro positivo.", + "number-of-checking-attempts": "Número de tentativas de verificação", + "number-of-checking-attempts-pattern": "O número de tentativas de verificação deve ser um número inteiro positivo.", + "number-of-checking-attempts-required": "Número de tentativas de verificação é obrigatório.", + "number-of-codes": "Número de códigos", + "number-of-codes-pattern": "O número de códigos deve ser um número inteiro positivo.", + "number-of-codes-required": "Número de códigos é obrigatório.", + "provider": "Provedor", + "retry-verification-code-period": "Período de reenvio do código de verificação", + "retry-verification-code-period-pattern": "O período mínimo é de 5 seg", + "retry-verification-code-period-required": "Período de reenvio do código de verificação é obrigatório.", + "total-allowed-time-for-verification": "Tempo total permitido para verificação", + "total-allowed-time-for-verification-pattern": "O tempo total mínimo permitido é de 60 seg", + "total-allowed-time-for-verification-required": "Tempo total permitido é obrigatório.", + "use-system-two-factor-auth-settings": "Usar configurações de autenticação de dois fatores do sistema", + "verification-code-check-rate-limit": "Limite de taxa de verificação do código", + "verification-code-lifetime": "Tempo de vida do código de verificação", + "verification-code-lifetime-pattern": "O tempo de vida do código de verificação deve ser um número inteiro positivo.", + "verification-code-lifetime-required": "Tempo de vida do código de verificação é obrigatório.", + "verification-message-template": "Modelo de mensagem de verificação", + "verification-limitations": "Limitações de verificação", + "verification-message-template-pattern": "A mensagem de verificação precisa conter o padrão: ${code}", + "verification-message-template-required": "Modelo de mensagem de verificação é obrigatório.", + "within-time": "Dentro do tempo", + "within-time-pattern": "O tempo deve ser um número inteiro positivo.", + "within-time-required": "Tempo é obrigatório.", + "force-2fa": "Impor autenticação de dois fatores", + "enforce-for": "Impor para" + }, + "jwt": { + "security-settings": "Configurações de segurança JWT", + "issuer-name": "Nome do emissor", + "issuer-name-required": "Nome do emissor é obrigatório.", + "signings-key": "Chave de assinatura", + "signings-key-hint": "String codificada em Base64 representando pelo menos 512 bits de dados.", + "signings-key-required": "Chave de assinatura é obrigatória.", + "signings-key-min-length": "A chave de assinatura deve conter pelo menos 512 bits de dados.", + "signings-key-base64": "A chave de assinatura deve estar no formato base64.", + "expiration-time": "Tempo de expiração do token (seg)", + "expiration-time-required": "Tempo de expiração do token é obrigatório.", + "expiration-time-max": "O tempo máximo permitido é de 2147483647 segundos (68 anos).", + "expiration-time-min": "O tempo mínimo é de 60 segundos (1 minuto).", + "refresh-expiration-time": "Tempo de expiração do token de atualização (seg)", + "refresh-expiration-time-required": "Tempo de expiração do token de atualização é obrigatório.", + "refresh-expiration-time-max": "O tempo máximo permitido é de 2147483647 segundos (68 anos).", + "refresh-expiration-time-min": "O tempo mínimo é de 900 segundos (15 minutos).", + "refresh-expiration-time-less-token": "O tempo do token de atualização deve ser maior que o tempo do token.", + "generate-key": "Gerar chave", + "info-header": "Todos os usuários precisarão fazer login novamente", + "info-message": "A alteração da chave de assinatura JWT fará com que todos os tokens emitidos se tornem inválidos. Todos os usuários precisarão fazer login novamente. Isso também afetará scripts que utilizam a API REST/Websockets." + }, + "resources": "Recursos", + "notifications": "Notificações", + "notifications-settings": "Configurações de notificações", + "slack-api-token": "Token de API do Slack", + "slack": "Slack", + "slack-settings": "Configurações do Slack", + "mobile-settings": "Configurações móveis", + "firebase-service-account-file": "Arquivo JSON de credenciais da conta de serviço do Firebase", + "select-firebase-service-account-file": "Arraste e solte o arquivo de credenciais da conta de serviço do Firebase ou " }, "alarm": { "alarm": "Alarme", + "alarm-list": "Lista de alarmes", "alarms": "Alarmes", + "all-alarms": "Todos os alarmes", "select-alarm": "Selecionar alarme", - "no-alarms-matching": "Nenhum alarme encontrado que coincida com '{{entity}}'.", - "alarm-required": "O alarme é obrigatório", + "no-alarms-matching": "Nenhum alarme correspondente a '{{entity}}' foi encontrado.", + "alarm-required": "Alarme é obrigatório", + "alarm-filter": "Filtro de alarme", + "filter": "Filtro", "alarm-status": "Status do alarme", - "alarm-status-list": "Lista de status de alarmes", + "alarm-status-list": "Lista de status de alarme", "any-status": "Qualquer status", "search-status": { "ANY": "Qualquer", "ACTIVE": "Ativo", "CLEARED": "Limpo", - "ACK": "Confirmado", - "UNACK": "Não confirmado" + "ACK": "Reconhecido", + "UNACK": "Não reconhecido" }, "display-status": { - "ACTIVE_UNACK": "Não confirmado ativo", - "ACTIVE_ACK": "Confirmado ativo", - "CLEARED_UNACK": "Não confirmado limpo", - "CLEARED_ACK": "Confirmado limpo" + "ACTIVE_UNACK": "Ativo não reconhecido", + "ACTIVE_ACK": "Ativo reconhecido", + "CLEARED_UNACK": "Limpo não reconhecido", + "CLEARED_ACK": "Limpo reconhecido" }, "no-alarms-prompt": "Nenhum alarme encontrado", "created-time": "Hora de criação", @@ -151,46 +584,102 @@ "originator": "Originador", "originator-type": "Tipo de originador", "details": "Detalhes", + "originator-label": "Rótulo do originador", + "assign": "Atribuir", + "assignments": "Atribuições", + "assignee": "Responsável", + "assignee-id": "Id do responsável", + "assignee-first-name": "Nome do responsável", + "assignee-last-name": "Sobrenome do responsável", + "assignee-email": "Email do responsável", + "unassigned": "Não atribuído", + "user-deleted": "Usuário excluído", + "assignee-not-set": "Todos", "status": "Status", "alarm-details": "Detalhes do alarme", "start-time": "Hora de início", + "assign-time": "Hora de atribuição", "end-time": "Hora de término", - "ack-time": "Horário de confirmação", - "clear-time": "Horário de limpeza", - "alarm-severity-list": "Lista de severidade de alarmes", + "ack-time": "Hora de reconhecimento", + "clear-time": "Hora de limpeza", + "duration": "Duração", + "alarm-severity": "Severidade do alarme", + "alarm-severity-list": "Lista de severidades de alarme", "any-severity": "Qualquer severidade", - "severity-critical": "Crítica", - "severity-major": "Principal", + "severity-critical": "Crítico", + "severity-major": "Maior", "severity-minor": "Menor", "severity-warning": "Aviso", "severity-indeterminate": "Indeterminado", - "acknowledge": "Confirmar", + "acknowledge": "Reconhecer", "clear": "Limpar", + "delete": "Excluir", "search": "Pesquisar alarmes", - "selected-alarms": "{ count, plural, =1 {1 alarm} other {# alarms} } selecionado(s)", - "no-data": "Nenhum dado para exibição", - "polling-interval": "Intervalo seletivo de alarmes (s)", - "polling-interval-required": "O intervalo de chamada seletiva de alarmes é obrigatório.", - "min-polling-interval-message": "É permitido no mínimo 1 segundo de intervalo de chamada seletiva.", - "aknowledge-alarms-title": "Confirmar { count, plural, =1 {1 alarm} other {# alarms} }", - "aknowledge-alarms-text": "Tem certeza de que deseja confirmar { count, plural, =1 {1 alarm} other {# alarms} }?", - "aknowledge-alarm-title": "Confirmar alarme", - "aknowledge-alarm-text": "Tem certeza de que deseja confirmar o alarme?", - "clear-alarms-title": "Limpar { count, plural, =1 {1 alarm} other {# alarms} }", - "clear-alarms-text": "Tem certeza de que deseja limpar { count, plural, =1 {1 alarm} other {# alarms} }?", + "selected-alarms": "{ count, plural, =1 {1 alarme} other {# alarmes} } selecionado(s)", + "no-data": "Sem dados para exibir", + "polling-interval": "Intervalo de consulta de alarmes (seg)", + "polling-interval-required": "O intervalo de consulta de alarmes é obrigatório.", + "min-polling-interval-message": "É permitido no mínimo 1 seg de intervalo de consulta.", + "aknowledge-alarms-title": "Reconhecer { count, plural, =1 {1 alarme} other {# alarmes} }", + "aknowledge-alarms-text": "Tem certeza de que deseja reconhecer { count, plural, =1 {1 alarme} other {# alarmes} }?", + "aknowledge-alarm-title": "Reconhecer alarme", + "aknowledge-alarm-text": "Tem certeza de que deseja reconhecer o alarme?", + "selected-alarms-are-acknowledged": "Os alarmes selecionados já estão reconhecidos", + "clear-alarms-title": "Limpar { count, plural, =1 {1 alarme} other {# alarmes} }", + "clear-alarms-text": "Tem certeza de que deseja limpar { count, plural, =1 {1 alarme} other {# alarmes} }?", "clear-alarm-title": "Limpar alarme", - "clear-alarm-text": "Tem certeza de que deseja limpar alarme?", - "alarm-status-filter": "Filtro de status de alarmes", - "alarm-filter": "Filtro de alarmes", - "max-count-load": "Número máximo de alarmes a serem carregados (0 - ilimitado)", - "max-count-load-required": "O número máximo de alarmes é obrigatório.", + "clear-alarm-text": "Tem certeza de que deseja limpar o alarme?", + "delete-alarms-title": "Excluir { count, plural, =1 {1 alarme} other {# alarmes} }", + "delete-alarms-text": "Tem certeza de que deseja excluir { count, plural, =1 {1 alarme} other {# alarmes} }?", + "selected-alarms-are-cleared": "Os alarmes selecionados já estão limpos", + "alarm-status-filter": "Filtro de status de alarme", + "alarm-filter-title": "Filtro de alarme", + "assigned": "Atribuído", + "filter-title": "Filtro", + "max-count-load": "Número máximo de alarmes a carregar (0 - ilimitado)", + "max-count-load-required": "O número máximo de alarmes a carregar é obrigatório.", "max-count-load-error-min": "O valor mínimo é 0.", - "fetch-size": "Tamanho da busca", - "fetch-size-required": "O tamanho da busca é obrigatório.", + "fetch-size": "Tamanho de busca", + "fetch-size-required": "O tamanho de busca é obrigatório.", "fetch-size-error-min": "O valor mínimo é 10.", - "alarm-type-list": "Lista de tipos de alarmes", + "alarm-types": "Tipos de alarme", + "alarm-type-list": "Lista de tipos de alarme", "any-type": "Qualquer tipo", - "search-propagated-alarms": "Pesquisar alarmes propagados" + "assigned-to-current-user": "Atribuído ao usuário atual", + "assigned-to-me": "Atribuído a mim", + "search-propagated-alarms": "Pesquisar alarmes propagados", + "comments": "Comentários de alarme", + "show-more": "Mostrar mais", + "additional-info": "Informações adicionais", + "alarm-type": "Tipo de alarme", + "enter-alarm-type": "Inserir tipo de alarme", + "no-alarm-types-matching": "Nenhum tipo de alarme correspondente a '{{entitySubtype}}' foi encontrado.", + "alarm-type-list-empty": "Nenhum tipo de alarme selecionado.", + "system-comments": { + "acked-by-user": "O alarme foi reconhecido pelo usuário {{userName}}", + "cleared-by-user": "O alarme foi limpo pelo usuário {{userName}}", + "assigned-to-user": "O alarme foi atribuído pelo usuário {{userName}} ao usuário {{assigneeName}}", + "unassigned-to-user": "O alarme foi desatribuído pelo usuário {{userName}}", + "unassigned-from-deleted-user": "O alarme foi desatribuído porque o usuário {{userName}} - foi excluído", + "comment-deleted": "O usuário {{userName}} excluiu seu comentário", + "severity-changed": "A severidade do alarme foi atualizada de {{oldSeverity}} para {{newSeverity}}" + } + }, + "alarm-activity": { + "add": "Adicionar um comentário...", + "alarm-comment": "Comentário de alarme", + "comments": "Comentários", + "delete-alarm-comment": "Deseja excluir este comentário?", + "refresh": "Atualizar", + "oldest-first": "Mais antigos primeiro", + "newest-first": "Mais recentes primeiro", + "activity": "Atividade", + "export": "Exportar para CSV", + "author": "Autor", + "created-date": "Data de criação", + "edited-date": "Data de edição", + "text": "Texto", + "system": "Sistema" }, "alias": { "add": "Adicionar alias", @@ -198,43 +687,52 @@ "name": "Nome do alias", "name-required": "O nome do alias é obrigatório", "duplicate-alias": "Já existe um alias com o mesmo nome.", - "filter-type-single-entity": "Entidade individual", + "filter-type-single-entity": "Entidade única", "filter-type-entity-list": "Lista de entidades", "filter-type-entity-name": "Nome da entidade", + "filter-type-entity-type": "Tipo de entidade", "filter-type-state-entity": "Entidade do estado do dashboard", "filter-type-state-entity-description": "Entidade obtida dos parâmetros de estado do dashboard", "filter-type-asset-type": "Tipo de ativo", "filter-type-asset-type-description": "Ativos do tipo '{{assetTypes}}'", - "filter-type-asset-type-and-name-description": "Ativos do tipo '{{assetTypes}}' cujo nome começa com '{{prefix}}'", + "filter-type-asset-type-and-name-description": "Ativos do tipo '{{assetTypes}}' e com nome começando com '{{prefix}}'", "filter-type-device-type": "Tipo de dispositivo", "filter-type-device-type-description": "Dispositivos do tipo '{{deviceTypes}}'", - "filter-type-device-type-and-name-description": "Dispositivos do tipo '{{deviceTypes}}' cujo nome começa com '{{prefix}}'", - "filter-type-entity-view-type": "Tipo de exibições de entidades", - "filter-type-entity-view-type-description": "Exibições de entidades do tipo '{{entityViewTypes}}'", - "filter-type-entity-view-type-and-name-description": "Exibições de entidades do tipo '{{entityViewTypes}}' cujo nome começa com '{{prefix}}'", + "filter-type-device-type-and-name-description": "Dispositivos do tipo '{{deviceTypes}}' e com nome começando com '{{prefix}}'", + "filter-type-entity-view-type": "Tipo de visualização de entidade", + "filter-type-entity-view-type-description": "Visualizações de entidade do tipo '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Visualizações de entidade do tipo '{{entityViewTypes}}' e com nome começando com '{{prefix}}'", + "filter-type-edge-type": "Tipo de edge", + "filter-type-edge-type-description": "Edges do tipo '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges do tipo '{{edgeTypes}}' e com nome começando com '{{prefix}}'", "filter-type-relations-query": "Consulta de relações", - "filter-type-relations-query-description": "{{entities}} que tem relação de {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-relations-query-description": "{{entities}} que têm relação {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Consulta de pesquisa de edge", + "filter-type-edge-search-query-description": "Edges com tipos {{edgeTypes}} que têm relação {{relationType}} {{direction}} {{rootEntity}}", "filter-type-asset-search-query": "Consulta de pesquisa de ativo", - "filter-type-asset-search-query-description": "Ativos dos tipos {{assetTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query-description": "Ativos com tipos {{assetTypes}} que têm relação {{relationType}} {{direction}} {{rootEntity}}", "filter-type-device-search-query": "Consulta de pesquisa de dispositivo", - "filter-type-device-search-query-description": "Dispositivos dos tipos {{deviceTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", - "filter-type-entity-view-search-query": "Consulta de pesquisa de exibição de entidade", - "filter-type-entity-view-search-query-description": "Exibições de entidades dos tipos {{entityViewTypes}} que têm relação de {{relationType}} {{direction}} {{rootEntity}}", - "entity-filter": "Filtro de entidades", - "resolve-multiple": "Resolver como entidades múltiplas", + "filter-type-device-search-query-description": "Dispositivos com tipos {{deviceTypes}} que têm relação {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Consulta de pesquisa de visualização de entidade", + "filter-type-entity-view-search-query-description": "Visualizações de entidade com tipos {{entityViewTypes}} que têm relação {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Estado de uso de API", + "entity-filter": "Filtro de entidade", + "resolve-multiple": "Resolver como múltiplas entidades", + "resolve-multiple-hint": "Ativar para renderizar dados de todas as entidades filtradas simultaneamente. \nSe desativado, o widget exibe dados somente da entidade selecionada.", "filter-type": "Tipo de filtro", "filter-type-required": "O tipo de filtro é obrigatório.", - "entity-filter-no-entity-matched": "Nenhuma entidade encontrada que coincida com o filtro especificado.", + "entity-filter-no-entity-matched": "Nenhuma entidade correspondente ao filtro especificado foi encontrada.", "no-entity-filter-specified": "Nenhum filtro de entidade especificado", - "root-state-entity": "Usar entidade de estado do dashboard como raiz", - "last-level-relation": "Procurar apenas último nível de relação", + "root-state-entity": "Usar entidade do estado do dashboard como raiz", + "last-level-relation": "Buscar somente relação do último nível", "root-entity": "Entidade raiz", - "state-entity-parameter-name": "Nome do parâmetro de entidade de estado", + "state-entity-parameter-name": "Nome do parâmetro da entidade de estado", "default-state-entity": "Entidade de estado padrão", - "default-entity-parameter-name": "Predefinido", - "max-relation-level": "Nível máx. de relação", + "default-entity-parameter-name": "Por padrão", + "query-options": "Opções de consulta", + "max-relation-level": "Nível máximo de relação", "unlimited-level": "Nível ilimitado", - "state-entity": "Entidade de estado do dashboard", + "state-entity": "Entidade do estado do dashboard", "all-entities": "Todas as entidades", "any-relation": "qualquer" }, @@ -244,29 +742,33 @@ "management": "Gerenciamento de ativos", "view-assets": "Visualizar ativos", "add": "Adicionar ativo", - "assign-to-customer": "Atribuir a cliente", - "assign-asset-to-customer": "Atribuir ativo(s) a cliente", + "asset-type-max-length": "O tipo de ativo deve ter menos de 256 caracteres", + "assign-to-customer": "Atribuir ao cliente", + "assign-asset-to-customer": "Atribuir ativo(s) ao cliente", "assign-asset-to-customer-text": "Selecione os ativos a serem atribuídos ao cliente", "no-assets-text": "Nenhum ativo encontrado", - "assign-to-customer-text": "Selecione o cliente para atribuir o(s) ativo(s)", + "assign-to-customer-text": "Selecione o cliente ao qual atribuir o(s) ativo(s)", "public": "Público", "assignedToCustomer": "Atribuído ao cliente", "make-public": "Tornar ativo público", "make-private": "Tornar ativo privado", - "unassign-from-customer": "Remover atribuição a cliente", + "unassign-from-customer": "Desatribuir do cliente", "delete": "Excluir ativo", "asset-public": "Ativo é público", "asset-type": "Tipo de ativo", "asset-type-required": "O tipo de ativo é obrigatório.", "select-asset-type": "Selecionar tipo de ativo", - "enter-asset-type": "Inserir tipo de ativo", + "enter-asset-type": "Inserir perfil do ativo", "any-asset": "Qualquer ativo", - "no-asset-types-matching": "Nenhum tipo de ativo encontrado que coincida com '{{entitySubtype}}'.", + "no-asset-types-matching": "Nenhum tipo de ativo correspondente a '{{entitySubtype}}' foi encontrado.", "asset-type-list-empty": "Nenhum tipo de ativo selecionado.", - "asset-types": "Tipos de ativos", + "asset-types": "Tipos de ativo", "name": "Nome", "name-required": "O nome é obrigatório.", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "label-max-length": "O rótulo deve ter menos de 256 caracteres", "description": "Descrição", + "description-required": "A descrição é obrigatória.", "type": "Tipo", "type-required": "O tipo é obrigatório.", "details": "Detalhes", @@ -274,54 +776,72 @@ "add-asset-text": "Adicionar novo ativo", "asset-details": "Detalhes do ativo", "assign-assets": "Atribuir ativos", - "assign-assets-text": "Atribuir { count, plural, =1 {1 asset} other {# assets} } a cliente", + "assign-assets-text": "Atribuir { count, plural, =1 {1 ativo} other {# ativos} } ao cliente", + "assign-asset-to-edge-title": "Atribuir ativo(s) ao Edge", + "assign-asset-to-edge-text": "Selecione os ativos a serem atribuídos ao Edge", "delete-assets": "Excluir ativos", - "unassign-assets": "Remover atribuição de ativos", - "unassign-assets-action-title": "Remover atribuição de { count, plural, =1 {1 asset} other {# assets} } a cliente", + "unassign-assets": "Desatribuir ativos", + "unassign-assets-action-title": "Desatribuir { count, plural, =1 {1 ativo} other {# ativos} } do cliente", "assign-new-asset": "Atribuir novo ativo", "delete-asset-title": "Tem certeza de que deseja excluir o ativo '{{assetName}}'?", - "delete-asset-text": "Cuidado, após confirmar, não será possível recuperar o ativo e nenhum dado associado.", - "delete-assets-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 asset} other {# assets} }?", - "delete-assets-action-title": "Excluir { count, plural, =1 {1 asset} other {# assets} }", - "delete-assets-text": "Cuidado, após confirmar, todos os ativos selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "delete-asset-text": "Atenção: após a confirmação, o ativo e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-assets-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 ativo} other {# ativos} }?", + "delete-assets-action-title": "Excluir { count, plural, =1 {1 ativo} other {# ativos} }", + "delete-assets-text": "Atenção: após a confirmação, todos os ativos selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", "make-public-asset-title": "Tem certeza de que deseja tornar o ativo '{{assetName}}' público?", - "make-public-asset-text": "Após confirmar, o ativo e todos os dados associados a ele se tornarão públicos e poderão ser acessados por outros.", + "make-public-asset-text": "Após a confirmação, o ativo e todos os seus dados serão tornados públicos e acessíveis por outros.", "make-private-asset-title": "Tem certeza de que deseja tornar o ativo '{{assetName}}' privado?", - "make-private-asset-text": "Após confirmar, o ativo e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", - "unassign-asset-title": "Tem certeza de que deseja remover a atribuição do ativo '{{assetName}}'?", - "unassign-asset-text": "Após confirmar, a atribuição do ativo será removida e ele não poderá ser acessado pelo cliente.", - "unassign-asset": "Remover atribuição de ativo", - "unassign-assets-title": "Tem certeza de que deseja remover a atribuição de { count, plural, =1 {1 asset} other {# assets} }?", - "unassign-assets-text": "Após confirmar, a atribuição de todos os ativos selecionados será removida e eles não poderão ser acessados pelo cliente.", + "make-private-asset-text": "Após a confirmação, o ativo e todos os seus dados serão tornados privados e não estarão acessíveis por outros.", + "unassign-asset-title": "Tem certeza de que deseja desatribuir o ativo '{{assetName}}'?", + "unassign-asset-text": "Após a confirmação, o ativo será desatribuído e não estará acessível pelo cliente.", + "unassign-asset": "Desatribuir ativo", + "unassign-assets-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 ativo} other {# ativos} }?", + "unassign-assets-text": "Após a confirmação, todos os ativos selecionados serão desatribuídos e não estarão acessíveis pelo cliente.", "copyId": "Copiar ID do ativo", "idCopiedMessage": "O ID do ativo foi copiado para a área de transferência", "select-asset": "Selecionar ativo", - "no-assets-matching": "Nenhum ativo encontrado que coincida com '{{entity}}'.", - "asset-required": "O ativo é obrigatório", - "name-starts-with": "O nome do ativo começa com", + "no-assets-matching": "Nenhum ativo correspondente a '{{entity}}' foi encontrado.", + "asset-required": "O ativo é obrigatório.", + "name-starts-with": "Expressão do nome do ativo", + "help-text": "Use '%' conforme necessário: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", + "search": "Pesquisar ativos", "import": "Importar ativos", - "asset-file": "Arquivo de ativos", - "search": "Pesquisar ativo", - "selected-assets": "{ count, plural, =1 {1 asset} other {# assets} } selecionado(s)", - "label": "Etiqueta" + "asset-file": "Arquivo de ativo", + "label": "Rótulo", + "assign-asset-to-edge": "Atribuir ativo(s) ao edge", + "unassign-asset-from-edge": "Desatribuir ativo", + "unassign-asset-from-edge-title": "Tem certeza de que deseja desatribuir o ativo '{{assetName}}'?", + "unassign-asset-from-edge-text": "Após a confirmação, o ativo será desatribuído e não estará acessível pelo edge.", + "unassign-assets-from-edge-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 ativo} other {# ativos} }?", + "unassign-assets-from-edge-text": "Após a confirmação, todos os ativos selecionados serão desatribuídos e não estarão acessíveis pelo edge.", + "selected-assets": "{ count, plural, =1 {1 ativo} other {# ativos} } selecionado(s)" }, "attribute": { "attributes": "Atributos", - "latest-telemetry": "Última telemetria", - "attributes-scope": "Escopo de atributos de entidade", + "latest-telemetry": "Telemetria mais recente", + "no-latest-telemetry": "Sem telemetria mais recente", + "attributes-scope": "Escopo de atributos da entidade", "scope-telemetry": "Telemetria", - "scope-latest-telemetry": "Última telemetria", + "scope-latest-telemetry": "Telemetria mais recente", "scope-client": "Atributos do cliente", "scope-server": "Atributos do servidor", "scope-shared": "Atributos compartilhados", + "scope-client-short": "Cliente", + "scope-server-short": "Servidor", + "scope-shared-short": "Compartilhado", + "scope-latest-short": "Mais recente", + "scope-any": "Qualquer", "add": "Adicionar atributo", "key": "Chave", - "last-update-time": "Horário da última atualização", - "key-required": "A chave de atributo é obrigatória.", + "key-max-length": "A chave deve ter menos de 256 caracteres", + "last-update-time": "Hora da última atualização", + "key-required": "A chave do atributo é obrigatória.", "value": "Valor", "value-required": "O valor do atributo é obrigatório.", - "delete-attributes-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 attribute} other {# attributes} }?", - "delete-attributes-text": "Cuidado, após confirmar, todos os atributos selecionados serão removidos.", + "telemetry-key-required": "A chave de telemetria é obrigatória", + "telemetry-value-required": "O valor de telemetria é obrigatório", + "delete-attributes-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 atributo} other {# atributos} }?", + "delete-attributes-text": "Atenção: após a confirmação, todos os atributos selecionados serão removidos.", "delete-attributes": "Excluir atributos", "enter-attribute-value": "Inserir valor do atributo", "show-on-widget": "Mostrar no widget", @@ -330,15 +850,180 @@ "prev-widget": "Widget anterior", "add-to-dashboard": "Adicionar ao dashboard", "add-widget-to-dashboard": "Adicionar widget ao dashboard", - "selected-attributes": "{ count, plural, =1 {1 attribute} other {# attributes} } selecionado(s)", - "selected-telemetry": "{ count, plural, =1 {1 telemetry unit} other {# telemetry units} } selecionado(s)", + "selected-attributes": "{ count, plural, =1 {1 atributo} other {# atributos} } selecionado(s)", + "selected-telemetry": "{ count, plural, =1 {1 unidade de telemetria} other {# unidades de telemetria} } selecionada(s)", "no-attributes-text": "Nenhum atributo encontrado", - "no-telemetry-text": "Nenhuma telemetria encontrada" + "no-telemetry-text": "Nenhuma telemetria encontrada", + "copy-key": "Copiar chave", + "add-telemetry": "Adicionar telemetria", + "copy-value": "Copiar valor", + "delete-timeseries": { + "start-time": "Hora de início", + "ends-on": "Termina em", + "strategy": "Estratégia", + "delete-strategy": "Estratégia de exclusão", + "all-data": "Excluir todos os dados", + "all-data-except-latest-value": "Excluir todos os dados exceto o valor mais recente", + "latest-value": "Excluir o valor mais recente", + "all-data-for-time-period": "Excluir todos os dados do período", + "rewrite-latest-value": "Reescrever o valor mais recente" + } + }, + "api-usage": { + "api-features": "Funcionalidades de API", + "api-usage": "Uso de API", + "alarm": "Alarme", + "alarms-created": "Alarmes criados", + "queue-stats": "Estatísticas de fila", + "processing-failures-and-timeouts": "Falhas de processamento e tempos limite", + "exceptions": "Exceções", + "alarms-created-daily-activity": "Atividade diária de alarmes criados", + "alarms-created-hourly-activity": "Atividade horária de alarmes criados", + "alarms-created-monthly-activity": "Atividade mensal de alarmes criados", + "data-points": "Pontos de dados", + "data-points-storage-days": "Dias de armazenamento de pontos de dados", + "data-points-storage-days-hourly-activity": "Atividade horária de dias de armazenamento de pontos de dados", + "data-points-storage-days-daily-activity": "Atividade diária de dias de armazenamento de pontos de dados", + "data-points-storage-days-monthly-activity": "Atividade mensal de dias de armazenamento de pontos de dados", + "device-api": "API do dispositivo", + "email": "Email", + "email-messages": "Mensagens de email", + "email-messages-daily-activity": "Atividade diária de mensagens de email", + "email-messages-monthly-activity": "Atividade mensal de mensagens de email", + "executions": "Execuções", + "scripts": "Scripts", + "scripts-hourly-activity": "Atividade horária de scripts", + "scripts-daily-activity": "Atividade diária de scripts", + "scripts-monthly-activity": "Atividade mensal de scripts", + "javascript": "JavaScript", + "javascript-executions": "Execuções de JavaScript", + "tbel": "TBEL", + "tbel-executions": "Execuções de TBEL", + "latest-error": "Último erro", + "messages": "Mensagens", + "notifications": "Notificações", + "notifications-email-sms": "Notificações (Email/SMS)", + "notifications-hourly-activity": "Atividade horária de notificações", + "permanent-failures": "${entityName} Falhas permanentes", + "permanent-timeouts": "${entityName} Tempos Limite permanentes", + "processing-failures": "${entityName} Falhas de processamento", + "processing-timeouts": "${entityName} Tempos Limite de processamento", + "rule-chain": "Cadeia de regras", + "rule-engine": "Motor de regras", + "rule-engine-executions": "Execuções do motor de regras", + "rule-engine-hourly-activity": "Atividade horária do motor de regras", + "rule-engine-daily-activity": "Atividade diária do motor de regras", + "rule-engine-monthly-activity": "Atividade mensal do motor de regras", + "rule-engine-statistics": "Estatísticas do motor de regras", + "rule-node": "Nó de regra", + "sms": "SMS", + "sms-messages": "Mensagens SMS", + "sms-messages-hourly-activity": "Atividade horária de mensagens SMS", + "sms-messages-daily-activity": "Atividade diária de mensagens SMS", + "sms-messages-monthly-activity": "Atividade mensal de mensagens SMS", + "successful": "${entityName} Bem-sucedido", + "telemetry": "Telemetria", + "telemetry-persistence": "Persistência de telemetria", + "telemetry-persistence-daily-activity": "Atividade diária de persistência de telemetria", + "telemetry-persistence-hourly-activity": "Atividade horária de persistência de telemetria", + "telemetry-persistence-monthly-activity": "Atividade mensal de persistência de telemetria", + "transport": "Transporte", + "transport-msg-hourly-activity": "Atividade horária de mensagens de transporte", + "transport-msg-daily-activity": "Atividade diária de mensagens de transporte", + "transport-msg-monthly-activity": "Atividade mensal de mensagens de transporte", + "transport-daily-activity": "Atividade diária de transporte", + "transport-data-points": "Pontos de dados de transporte", + "transport-data-points-hourly-activity": "Atividade horária de pontos de dados de transporte", + "transport-data-points-daily-activity": "Atividade diária de pontos de dados de transporte", + "transport-data-points-monthly-activity": "Atividade mensal de pontos de dados de transporte", + "view-details": "Visualizar detalhes", + "view-statistics": "Visualizar estatísticas", + "transport-messages": "Mensagens de transporte", + "transport-messages-hourly-activity": "Atividade horária de mensagens de transporte", + "transport-data-point-hourly-activity": "Atividade horária de ponto de dados de transporte", + "javascript-function-executions": "Execuções de funções JavaScript", + "javascript-function-executions-hourly-activity": "Atividade horária de execuções de funções JavaScript", + "javascript-function-executions-daily-activity": "Atividade diária de execuções de funções JavaScript", + "javascript-function-executions-monthly-activity": "Atividade mensal de execuções de funções JavaScript", + "tbel-function-executions": "Execuções de funções TBEL", + "tbel-function-executions-hourly-activity": "Atividade horária de execuções de funções TBEL", + "tbel-function-executions-daily-activity": "Atividade diária de execuções de funções TBEL", + "tbel-function-executions-monthly-activity": "Atividade mensal de execuções de funções TBEL", + "created-reports": "Relatórios criados", + "created-reports-hourly-activity": "Atividade horária de relatórios criados", + "created-reports-daily-activity": "Atividade diária de relatórios criados", + "created-reports-monthly-activity": "Atividade mensal de relatórios criados", + "emails": "Emails", + "emails-hourly-activity": "Atividade horária de emails", + "emails-daily-activity": "Atividade diária de emails", + "emails-monthly-activity": "Atividade mensal de emails", + "status": { + "enabled": "Ativado", + "disabled": "Desativado", + "warning": "Aviso" + } + }, + "api-limit": { + "cassandra-write-queries-core": "Consultas de escrita Cassandra da API Rest", + "cassandra-read-queries-core": "Consultas de leitura Cassandra da API REST e telemetria WS", + "cassandra-write-queries-rule-engine": "Consultas de escrita Cassandra de telemetria do motor de regras", + "cassandra-read-queries-rule-engine": "Consultas de leitura Cassandra de telemetria do motor de regras", + "cassandra-write-queries-monolith": "Consultas de escrita Cassandra de telemetria monolítica", + "cassandra-read-queries-monolith": "Consultas de leitura Cassandra de telemetria monolítica", + "entity-version-creation": "Criação de versão de entidade", + "entity-version-load": "Carregamento de versão de entidade", + "notification-requests": "Requisições de notificação", + "notification-requests-per-rule": "Requisições de notificação por regra", + "rest-api-requests": "Requisições de API REST", + "rest-api-requests-per-customer": "Requisições de API REST por cliente", + "transport-messages": "Mensagens de transporte", + "transport-messages-per-device": "Mensagens de transporte por dispositivo", + "transport-messages-per-gateway": "Mensagens de transporte por gateway", + "transport-messages-per-gateway-device": "Mensagens de transporte por dispositivo de gateway", + "ws-updates-per-session": "Atualizações WS por sessão", + "edge-events": "Eventos de edge", + "edge-events-per-edge": "Eventos de edge por edge", + "edge-uplink-messages": "Mensagens uplink de edge", + "edge-uplink-messages-per-edge": "Mensagens uplink de edge por edge" + }, + "api-key": { + "api-key": "Chave de API", + "api-keys": "Chaves de API", + "delete-api-key-title": "Tem certeza de que deseja excluir a chave de API '{{name}}'?", + "delete-api-key-text": "Atenção: após a confirmação, a chave se tornará irrecuperável.", + "delete-api-keys-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 chave de API} other {# chaves de API} }?", + "delete-api-keys-text": "Atenção: após a confirmação, todas as chaves selecionadas se tornarão irrecuperáveis.", + "expiration-date": "Data de expiração", + "date": "data", + "description": "Descrição", + "disable": "Desativar", + "edit-description": "Editar descrição", + "enable": "Ativar chave de API ", + "expiration-time": "Tempo de expiração", + "expiration-time-never": "Nunca", + "expiration-time-custom": "Personalizado", + "generate": "Gerar", + "generate-title": "Gerar chave de API", + "generate-text": "Nota: A chave de API herda as permissões do usuário para o qual foi criada.", + "generated-api-key-title": "Chave de API gerada. Vamos verificar a conectividade!", + "generated-api-key-copy": "Certifique-se de copiar e salvar sua chave de API agora, pois não será possível visualizá-la novamente.", + "generated-api-key-command": "Use as instruções a seguir para verificar a conectividade. Como resultado, você receberá as informações do usuário atual:", + "generated-api-key-insecure-url": "A execução de comandos por uma conexão HTTP não segura enviará sua chave de API sem criptografia, tornando-a vulnerável à interceptação.", + "list": "{ count, plural, =1 {Uma chave de API} other {Lista de # chaves de API} }", + "manage": "Gerenciar", + "manage-api-keys": "Gerenciar chaves de API", + "no-found": "Nenhuma chave de API encontrada", + "selected-api-keys": "{ count, plural, =1 {1 chave de API} other {# chaves de API} } selecionada(s)", + "search": "Pesquisar chaves de API", + "status": "Status", + "status-active": "Ativo", + "status-inactive": "Inativo", + "status-expired": "Expirado" }, "audit-log": { "audit": "Auditoria", "audit-logs": "Logs de auditoria", - "timestamp": "Carimbo de data/hora", + "timestamp": "Timestamp", "entity-type": "Tipo de entidade", "entity-name": "Nome da entidade", "user": "Usuário", @@ -352,8 +1037,10 @@ "type-attributes-deleted": "Atributos excluídos", "type-rpc-call": "Chamada RPC", "type-credentials-updated": "Credenciais atualizadas", - "type-assigned-to-customer": "Atribuído ao cliente", - "type-unassigned-from-customer": "Atribuição a cliente removida", + "type-assigned-to-customer": "Atribuído ao Cliente", + "type-unassigned-from-customer": "Desatribuído do Cliente", + "type-assigned-to-edge": "Atribuído ao Edge", + "type-unassigned-from-edge": "Desatribuído do Edge", "type-activated": "Ativado", "type-suspended": "Suspenso", "type-credentials-read": "Credenciais lidas", @@ -361,66 +1048,695 @@ "type-relation-add-or-update": "Relação atualizada", "type-relation-delete": "Relação excluída", "type-relations-delete": "Todas as relações excluídas", - "type-alarm-ack": "Confirmado", - "type-alarm-clear": "Limpo", + "type-alarm-ack": "Alarme reconhecido", + "type-alarm-clear": "Alarme limpo", + "type-alarm-delete": "Alarme excluído", + "type-alarm-assign": "Alarme atribuído", + "type-alarm-unassign": "Alarme desatribuído", + "type-added-comment": "Comentário adicionado", + "type-updated-comment": "Comentário atualizado", + "type-deleted-comment": "Comentário excluído", "type-login": "Login", "type-logout": "Logout", "type-lockout": "Bloqueio", - "status-success": "Êxito", + "status-success": "Sucesso", "status-failure": "Falha", - "audit-log-details": "Detalhes da trilha de auditoria", + "audit-log-details": "Detalhes do log de auditoria", "no-audit-logs-prompt": "Nenhum log encontrado", "action-data": "Dados da ação", "failure-details": "Detalhes da falha", "search": "Pesquisar logs de auditoria", "clear-search": "Limpar pesquisa", - "type-assigned-from-tenant": "Atribuído do locatário", - "type-assigned-to-tenant": "Atribuído a locatário" + "type-assigned-from-tenant": "Atribuído do Tenant", + "type-assigned-to-tenant": "Atribuído ao Tenant", + "type-provision-success": "Dispositivo provisionado", + "type-provision-failure": "Falha no provisionamento do dispositivo", + "type-timeseries-updated": "Telemetria atualizada", + "type-timeseries-deleted": "Telemetria excluída", + "type-sms-sent": "SMS enviado", + "any-type": "Qualquer tipo", + "audit-log-filter-title": "Filtro de log de auditoria", + "filter-title": "Filtro", + "filter-types": "Tipos de log de auditoria" + }, + "debug-settings": { + "label": "Configuração de depuração", + "on-failure": "Somente falhas (24/7)", + "all-messages": "Todas as mensagens ({{time}})", + "failures": "Falhas", + "entity": "entidade", + "hint": { + "main-limited": "Não serão registradas mais de {{msg}} mensagens de depuração de {{entity}} a cada {{time}}.", + "on-failure": "Registrar apenas mensagens de erro.", + "all-messages": "Registrar todas as mensagens de depuração." + } + }, + "calculated-fields": { + "expression": "Expressão", + "no-found": "Nenhum campo calculado encontrado", + "list": "{ count, plural, =1 {Um campo calculado} other {Lista de # campos calculados} }", + "add": "Adicionar campo calculado", + "selected-fields": "{ count, plural, =1 {1 campo calculado} other {# campos calculados} } selecionado(s)", + "type": { + "simple": "Simples", + "simple-hint": "Cálculo aritmético simples baseado em argumentos de entrada.", + "script": "Script", + "script-hint": "Cálculo sobre argumentos definidos usando um script TBEL.", + "geofencing": "Geofencing", + "geofencing-hint": "Avaliação da posição GPS da entidade e transições em relação aos grupos de zonas de geofencing configurados.", + "propagation": "Propagação", + "propagation-hint": "Propagação de dados para entidades pai ou filho com base na direção e tipo de relação.", + "related-entities-aggregation": "Agregação de entidades relacionadas", + "related-entities-aggregation-hint": "Agregação dos dados mais recentes de entidades relacionadas.", + "time-series-data-aggregation": "Agregação de dados de série temporal", + "time-series-data-aggregation-hint": "Agregação de dados históricos de uma entidade atual." + }, + "preview": "Pré-visualização", + "arguments": "Argumentos", + "decimals-by-default": "Casas decimais padrão", + "debugging": "Depuração de campo calculado", + "calculated-field-details": "Detalhes do campo calculado", + "argument-name": "Nome do argumento", + "name": "Nome", + "datasource": "Fonte de dados", + "add-argument": "Adicionar argumento", + "test-script-function": "Testar função do script", + "no-arguments": "É necessário pelo menos um argumento.", + "argument-settings": "Configurações do argumento", + "argument-current": "Entidade atual", + "argument-current-tenant": "Tenant atual", + "argument-device": "Dispositivo", + "argument-asset": "Ativo", + "argument-customer": "Cliente", + "argument-tenant": "Tenant atual", + "argument-owner": "Proprietário atual", + "argument-relation-query": "Entidades relacionadas", + "argument-type": "Tipo de argumento", + "attribute": "Atributo", + "copy-argument-name": "Copiar nome do argumento", + "timeseries-key": "Chave de série temporal", + "device-name": "Nome do dispositivo", + "latest-telemetry": "Telemetria mais recente", + "rolling": "Série temporal deslizante", + "attribute-scope": "Escopo do atributo", + "server-attributes": "Atributos do servidor", + "client-attributes": "Atributos do cliente", + "shared-attributes": "Atributos compartilhados", + "attribute-key": "Chave do atributo", + "default-value": "Valor padrão", + "default-value-required": "O valor padrão é obrigatório.", + "limit": "Máximo de valores", + "time-window": "Janela de tempo", + "customer-name": "Nome do cliente", + "asset-name": "Nome do ativo", + "timeseries": "Série temporal", + "output": "Saída", + "output-hint": "Define como a saída é processada.", + "create": "Criar novo campo calculado", + "file": "Arquivo de campo calculado", + "invalid-file-error": "Formato de arquivo inválido. Certifique-se de que o arquivo é um arquivo JSON válido.", + "import": "Importar campo calculado", + "export": "Exportar campo calculado", + "export-failed-error": "Não foi possível exportar o campo calculado: {{error}}", + "output-type": "Tipo de saída", + "delete-title": "Tem certeza de que deseja excluir o campo calculado '{{title}}'?", + "delete-text": "Atenção: após a confirmação, o campo calculado e todos os dados relacionados serão irrecuperáveis.", + "delete-multiple-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 campo calculado} other {# campos calculados} }?", + "delete-multiple-text": "Atenção: após a confirmação, todos os campos calculados selecionados serão removidos e todos os dados relacionados serão irrecuperáveis.", + "test-with-this-message": "Testar com esta mensagem", + "use-latest-timestamp": "Usar timestamp mais recente", + "entity-coordinates": "Coordenadas da entidade", + "latitude-time-series-key": "Chave de série temporal de latitude", + "latitude-time-series-key-required": "A chave de série temporal de latitude é obrigatória.", + "longitude-time-series-key": "Chave de série temporal de longitude", + "longitude-time-series-key-required": "A chave de série temporal de longitude é obrigatória.", + "geofencing-zone-groups": "Grupos de zonas de geofencing", + "geofencing-zone-groups-settings": "Configurações do grupo de zonas de geofencing", + "target-zone": "Zona alvo", + "perimeter-key": "Chave do perímetro", + "report-strategy": "Estratégia de relatório", + "no-zone-configured": "É necessária pelo menos uma zona.", + "no-zone-configured-required": "Pelo menos um grupo de zonas deve ser configurado.", + "add-zone-group": "Adicionar grupo de zonas", + "report-transition-event-only": "Somente eventos de transição", + "report-presence-status-only": "Somente status de presença", + "report-transition-event-and-presence": "Status de presença e eventos de transição", + "perimeter-attribute-key": "Chave do atributo de perímetro", + "perimeter-attribute-key-required": "A chave do atributo de perímetro é obrigatória.", + "perimeter-attribute-key-pattern": "A chave do atributo de perímetro é inválida.", + "entity-zone-relationship": "Caminho da entidade para as zonas", + "direction": "Direção da relação", + "direction-from": "Da entidade para a zona", + "direction-to": "Da zona para a entidade", + "relation-type": "Tipo de relação", + "create-relation-with-matched-zones": "Criar relações para a entidade de origem com as zonas correspondentes", + "relation-level": "Nível de relação", + "fetch-last-available-level": "Buscar apenas o último nível disponível", + "zone-group-refresh-interval": "Intervalo de atualização dos grupos de zonas", + "copy-zone-group-name": "Copiar nome do grupo de zonas", + "open-details-page": "Abrir página de detalhes da entidade", + "level": "Nível", + "direction-level": "Direção", + "direction-up": "Acima", + "direction-up-parent": "Acima para o pai", + "direction-down": "Abaixo", + "direction-down-child": "Abaixo para o filho", + "add-level": "Adicionar nível", + "delete-level": "Excluir nível", + "no-level": "Nenhum nível configurado", + "levels-required": "Pelo menos um nível deve ser configurado.", + "max-allowed-levels-error": "O nível de relação excede o máximo permitido.", + "propagation-path-related-entities": "Caminho de propagação para entidades relacionadas", + "propagate-type": { + "arguments-only": "Somente argumentos", + "expression-result": "Resultado do cálculo" + }, + "script": "Script", + "data-propagate": "Dados a propagar", + "output-key": "Chave de saída", + "copy-output-key": "Copiar chave de saída", + "aggregation-path-related-entities": "Caminho de agregação para entidades relacionadas", + "deduplication-interval": "Intervalo de deduplicação", + "deduplication-interval-min": "O intervalo de deduplicação deve ser de pelo menos {{ sec }} segundos.", + "deduplication-interval-hint": "Tempo mínimo entre agregações de telemetria.", + "deduplication-interval-required": "O intervalo de deduplicação é obrigatório.", + "calculated-field-filter-title": "Filtro de campo calculado", + "filter-title": "Filtro", + "calculated-field-types": "Tipos de campo calculado", + "events": "Eventos", + "any-type": "Qualquer tipo", + "metrics": { + "metrics": "Métricas", + "metrics-empty": "Pelo menos uma métrica deve ser configurada.", + "metric-name": "Nome da métrica", + "metric-name-required": "O nome da métrica é obrigatório.", + "metric-name-pattern": "O nome da métrica é inválido.", + "metric-name-duplicate": "Já existe uma métrica com esse nome.", + "metric-name-max-length": "O nome da métrica deve ter menos de 256 caracteres.", + "metric-name-forbidden": "O nome da métrica é reservado e não pode ser usado.", + "copy-metric-name": "Copiar nome da métrica", + "argument-name": "Nome do argumento", + "aggregation": "Agregação", + "aggregation-type": { + "avg": "Média", + "min": "Mínimo", + "max": "Máximo", + "sum": "Soma", + "count": "Contagem", + "count-unique": "Contagem única" + }, + "filtered": "Filtrado", + "value-source": "Fonte do valor", + "value-source-hint": "Define como o valor para agregação é obtido.", + "value-source-type": { + "key": "Chave", + "function": "Função" + }, + "no-metrics-configured": "É necessária pelo menos uma métrica.", + "add-metric": "Adicionar métrica", + "max-metrics": "Número máximo de métricas atingido.", + "metric-settings": "Configurações da métrica", + "filter": "Filtro", + "filter-hint": "Habilita a filtragem de entidades durante a agregação. A função de filtro deve retornar um valor booleano e pode usar todos os argumentos configurados." + }, + "output-strategy": { + "strategy": "Estratégia", + "process-right-away": "Processar imediatamente", + "process-rule-chains": "Processar via Cadeias de regras", + "save-time-series": "Salvar em série temporal", + "save-database": "Salvar no banco de dados", + "save-latest-values": "Salvar nos valores mais recentes", + "send-web-sockets": "Enviar para WebSockets", + "save-calculated-fields": "Enviar para Campos calculados", + "update-attribute-only-on-value-change": "Atualizar atributo somente na mudança de valor", + "send-attributes-updated-notification": "Enviar notificação de atributos atualizados", + "ttl": "TTL personalizado", + "ttl-required": "O TTL é obrigatório", + "ttl-min": "Somente TTL mínimo de 0 é permitido", + "processing-parameters": "Parâmetros de processamento", + "hint": { + "strategy": "Controla se o resultado é processado imediatamente ou enviado a uma cadeia de regras para processamento adicional.", + "processing-options": "Opções de processamento", + "update-attribute-only-on-value-change": "Atualiza o atributo em cada mensagem recebida, independentemente de o valor ter mudado. Isso aumenta o uso da API e reduz o desempenho.", + "update-attribute-only-on-value-change-enabled": "Atualiza o atributo somente quando o valor muda. Se o valor não mudar, os timestamps não são atualizados e as notificações não são enviadas.", + "send-attributes-updated-notification": "Envia um evento de Atributos Atualizados para a cadeia de regras padrão.", + "save-time-series": "Salva os dados de série temporal na tabela ts_kv do banco de dados.", + "save-database": "Salva os dados de atributos no banco de dados.", + "save-latest-values": "Atualiza os dados de série temporal na tabela ts_kv_latest do banco de dados se o novo valor for mais recente.", + "send-web-sockets-attribute": "Notifica as assinaturas WebSocket sobre atualizações nos dados de atributos.", + "send-web-sockets-time-series": "Notifica as assinaturas WebSocket sobre atualizações nos dados de série temporal.", + "save-calculated-fields-attribute": "Notifica os campos calculados sobre atualizações nos dados de atributos.", + "save-calculated-fields-time-series": "Notifica os campos calculados sobre atualizações nos dados de série temporal.", + "ttl": "Define o período de retenção dos dados de série temporal. Se desabilitado, o TTL do Perfil do tenant é usado." + } + }, + "aggregate-interval-type": "Tipo de intervalo de agregação", + "aggregate-interval-value": "Valor do intervalo de agregação", + "aggregate-interval-value-required": "O valor do intervalo de agregação é obrigatório.", + "aggregate-interval-value-min": "O valor do intervalo de agregação deve ser de pelo menos { sec, plural, =0 {0 segundo} =1 {1 segundo} other {# segundos} }.", + "aggregate-interval-value-step-multiple-of": "O valor do intervalo de agregação deve ser divisor ou múltiplo de 1 dia.", + "aggregate-period": { + "hour": "Hora", + "day": "Dia", + "week": "Semana (Seg - Dom)", + "week-sun-sat": "Semana (Dom - Sáb)", + "month": "Mês", + "quarter": "Trimestre", + "year": "Ano", + "custom": "Personalizado" + }, + "aggregate-period-hint-offset": "Seu intervalo de agregação será: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "Seu intervalo de agregação será: {{ interval }} e assim por diante.", + "entity-aggregation": { + "argument-hint": "Os dados serão buscados da entidade atual.", + "argument-title-hint": "Define os argumentos de entrada usados para agregação.", + "argument-setting-hint": "A telemetria mais recente é o único tipo de argumento disponível para este campo calculado.", + "aggregation-interval": "Intervalo de agregação", + "aggregation-interval-hint": "Define com que frequência a agregação é realizada. Exemplo: a cada 1 hora agrega dados às 00:00, 01:00, 02:00, etc. Os resultados da agregação são armazenados com o timestamp correspondente ao início do intervalo de agregação.", + "apply-offset": "Aplicar deslocamento ao intervalo de agregação", + "apply-offset-hint": "Define quanto deslocar o início de cada período de agregação (ex.: +10 minutos - 00:10, 01:10).", + "offset-value": "Valor do deslocamento", + "offset-value-required": "O valor do deslocamento é obrigatório.", + "offset-value-min": "O valor do deslocamento deve ser um número inteiro positivo.", + "offset-value-max": "O valor do deslocamento deve ser menor que o valor do intervalo de agregação.", + "wait-delay": "Aplicar tempo de espera para telemetria atrasada", + "wait-delay-hint": "Define quanto tempo aguardar pela telemetria atrasada após o término do intervalo. Se tal telemetria chegar, o resultado para aquele intervalo será recalculado.", + "duration": "Duração", + "duration-required": "A duração é obrigatória.", + "duration-min": "A duração deve ser de pelo menos 1 minuto.", + "duration-hint": "Quanto tempo aguardar pelos dados atrasados após o término do intervalo.", + "produce-intermediate-result": "Produzir resultado intermediário", + "produce-intermediate-result-hint": "Calcula métricas durante o intervalo atual para produzir um resultado intermediário. As atualizações ocorrem no máximo uma vez a cada {{ time }}." + }, + "hint": { + "arguments-simple-with-rolling": "O campo calculado do tipo simples não deve conter chaves com tipo de série temporal deslizante.", + "arguments-propagate-arguments-with-rolling": "O tipo 'Série temporal deslizante' é incompatível com a propagação 'Somente argumentos'.", + "arguments-propagate-argument-entity-type": "O tipo de entidade é incompatível com a propagação 'Somente argumentos'.", + "arguments-propagate-argument-must-current-entity": "Pelo menos um argumento deve ser configurado com o tipo de entidade de origem 'Entidade atual'.", + "arguments-empty": "Pelo menos um argumento deve ser especificado.", + "expression-required": "A expressão é obrigatória.", + "expression-invalid": "A expressão é inválida", + "expression-max-length": "O comprimento da expressão deve ter menos de 255 caracteres.", + "argument-name-required": "O nome do argumento é obrigatório.", + "argument-name-pattern": "O nome do argumento é inválido.", + "argument-name-duplicate": "Já existe um argumento com esse nome.", + "argument-name-max-length": "O nome do argumento deve ter menos de 256 caracteres.", + "argument-name-forbidden": "O nome do argumento é reservado e não pode ser usado.", + "output-key-required": "A chave de saída é obrigatória.", + "output-key-pattern": "A chave de saída é inválida.", + "output-key-duplicate": "Já existe uma chave com esse nome.", + "output-key-max-length": "A chave de saída deve ter menos de 256 caracteres.", + "output-key-forbidden": "A chave de saída é reservada e não pode ser usada.", + "entity-type-required": "O tipo de entidade é obrigatório", + "name-required": "O nome é obrigatório.", + "name-pattern": "O nome é inválido.", + "name-duplicate": "Já existe um nome com esse nome.", + "name-max-length": "O nome deve ter menos de 256 caracteres.", + "name-forbidden": "O nome é reservado e não pode ser usado.", + "argument-type-required": "O tipo de argumento é obrigatório.", + "max-args": "Número máximo de argumentos atingido.", + "decimals-range": "As casas decimais padrão devem ser um número entre 0 e 15.", + "expression": "A expressão padrão demonstra como transformar uma temperatura de Fahrenheit para Celsius.", + "arguments-entity-not-found": "Entidade alvo do argumento não encontrada.", + "use-latest-timestamp": "Se habilitado, o valor calculado será persistido usando o timestamp mais recente da telemetria dos argumentos, em vez do horário do servidor.", + "entity-coordinates": "Especifique as chaves de série temporal que fornecem as coordenadas GPS da entidade (latitude e longitude).", + "geofencing-zone-groups": "Defina um ou mais grupos de zonas de geofencing para verificar (ex.: 'allowedZones', 'restrictedZones'). Cada grupo deve ter um nome único, que é usado como prefixo para as chaves de telemetria de saída do campo calculado.", + "perimeter-attribute-key": "Defina a chave do atributo que contém a definição do perímetro da zona de geofencing. O perímetro é sempre obtido dos atributos do lado do servidor da entidade de zona.", + "report-strategy": "O status de presença informa se a entidade está atualmente DENTRO ou FORA do grupo de zonas. Os eventos de transição informam quando a entidade ENTROU ou SAIU do grupo de zonas.", + "create-relation-with-matched-zones": "Criar e manter automaticamente relações entre a entidade e as zonas em que ela está atualmente. As relações são removidas quando a entidade sai de uma zona e criadas quando ela entra em uma nova.", + "relation-type-required": "O tipo de relação é obrigatório.", + "relation-level-required": "O nível de relação é obrigatório.", + "relation-level-min": "O valor mínimo do nível de relação é 1.", + "relation-level-max": "O valor máximo do nível de relação é {{max}}.", + "geofencing-empty": "Pelo menos um grupo de zonas deve ser configurado.", + "geofencing-entity-not-found": "Entidade alvo do geofencing não encontrada.", + "max-geofencing-zone": "Número máximo de zonas de geofencing atingido.", + "zone-group-refresh-interval": "Define com que frequência os grupos de zonas configurados via entidades relacionadas são atualizados.", + "zone-group-refresh-interval-required": "O intervalo de atualização dos grupos de zonas é obrigatório.", + "zone-group-refresh-interval-min": "O intervalo de atualização do grupo de zonas deve ser de pelo menos {{ min }} segundos.", + "propagation-path-related-entities": "Define um caminho direto de nível único para uma entidade relacionada com base na direção selecionada e no tipo de relação. Apenas relações entre entidades de dispositivo, ativo, cliente e tenant são suportadas. O máximo de entidades resolvidas pelo caminho de relação é {{ max }}.", + "data-propagate": "Define os dados a serem propagados a partir dos argumentos configurados abaixo. 'Somente argumentos' usa os dados recuperados diretamente, enquanto 'Resultado do cálculo' calcula um novo valor a partir desses dados.", + "aggregation-path-related-entities": "Define um caminho de agregação de nível único via relações diretas com entidades pai ou filho, com base na direção e no tipo de relação. Apenas relações entre entidades de dispositivo, ativo, cliente e tenant são suportadas. O máximo de entidades resolvidas pelo caminho de relação é {{ max }}.", + "arguments-aggregation": "Define os argumentos de entrada usados para filtragem e agregação.", + "setting-arguments-aggregation": "Os dados serão buscados das entidades relacionadas configuradas no caminho de agregação.", + "metrics": "Define as métricas agregadas com base nos argumentos configurados.", + "entity-aggregation-metrics": "Define as métricas agregadas com base nos argumentos configurados ao longo dos intervalos de tempo especificados.", + "import-invalid-calculated-field-type": "Não foi possível importar o campo calculado: Estrutura de campo calculado inválida.", + "simple-expression-title": "Expressão aritmética que define como o valor calculado é computado.", + "script-title": "Script TBEL que define a lógica de cálculo e os valores de saída.", + "simple-arguments": "Expressão aritmética que define como o valor calculado é computado.", + "script-arguments": "Define os argumentos de entrada disponíveis para o script." + } + }, + "alarm-rule": { + "alarm-rules-tab": "Regras de alarme", + "alarm-rule": "Regra de alarme", + "alarm-rules": "Regras de alarme", + "alarm-rules-old": "Antigas", + "alarm-rules-actual": "Atuais", + "severities": "Severidades", + "cleared": "Condição de limpeza", + "delete-title": "Tem certeza de que deseja excluir a regra de alarme '{{title}}'?", + "delete-text": "Atenção: após a confirmação, a regra de alarme e todos os dados relacionados serão irrecuperáveis.", + "delete-multiple-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 regra de alarme} other {# regras de alarme} }?", + "delete-multiple-text": "Atenção: após a confirmação, todas as regras de alarme selecionadas serão removidas e todos os dados relacionados serão irrecuperáveis.", + "create": "Criar nova regra de alarme", + "add": "Adicionar regra de alarme", + "copy": "Copiar configuração da regra de alarme", + "details": "Detalhes da regra de alarme", + "no-found": "Nenhuma regra de alarme encontrada", + "list": "{ count, plural, =1 {Uma regra de alarme} other {Lista de # regras de alarme} }", + "selected-fields": "{ count, plural, =1 {1 regra de alarme} other {# regras de alarme} } selecionada(s)", + "import": "Importar regra de alarme", + "file": "Arquivo de regra de alarme", + "export": "Exportar regra de alarme", + "export-failed-error": "Não foi possível exportar a regra de alarme: {{error}}", + "entity-type": "Tipo de entidade", + "entity-type-required": "O tipo de entidade é obrigatório.", + "alarm-type": "Tipo de alarme", + "alarm-type-hint": "Identificador único (ex.: HighTempAlarm) no escopo do originador do alarme (Dispositivo, Ativo, etc.) para evitar conflitos.", + "alarm-type-required": "O tipo de alarme é obrigatório.", + "alarm-type-pattern": "O tipo de alarme é inválido.", + "alarm-type-max-length": "O tipo de alarme deve ter menos de 256 caracteres.", + "clear-alarm": "Limpar alarme", + "value-argument": "Argumento", + "value-argument-required": "O argumento é obrigatório.", + "static-settings": "Configurações estáticas", + "configuration": "Configuração", + "static-schedule": "Estático", + "dynamic-schedule": "Dinâmico", + "operation-and": "E", + "operation-or": "OU", + "condition-during": "Durante {{during}}", + "condition-during-dynamic": "Durante \"{{ attribute }}\"", + "condition-repeat-times": "Repete { count, plural, =1 {1 vez} other {# vezes} }", + "condition-repeat-times-dynamic": "Repete \"{{ attribute }}\" vezes", + "filter-preview": "Pré-visualização do filtro", + "condition-settings": "Configurações da condição", + "static": "Estático", + "dynamic": "Dinâmico", + "argument-filters": "Filtros de argumento", + "argument-name": "Nome do argumento", + "value-type": "Tipo de valor", + "general": "Geral", + "filters": "Filtros", + "date-time-hint": "O argumento deve estar em milissegundos epoch. Exemplo: 1698839340000 equivale a 2023-11-01 12:49:00 UTC.", + "operation": "Operação", + "value-source": "Fonte do valor", + "value": "Valor", + "ignore-case": "Ignorar maiúsculas/minúsculas", + "condition": "Condição", + "script": "Script", + "add-filter": "Adicionar filtro de argumento", + "edit-filter": "Filtro de argumento", + "remove-filter": "Remover filtro de argumento", + "no-filter": "É necessário pelo menos um filtro.", + "conditions": { + "simple": "Simples", + "duration": "Duração", + "repeating": "Repetição" + }, + "schedule-title": "Agendamento", + "edit-schedule": "Editar agendamento do alarme", + "schedule-type": "Tipo de agendador", + "schedule-type-required": "O tipo de agendador é obrigatório.", + "schedule": { + "any-time": "Ativo o tempo todo", + "specific-time": "Ativo em um horário específico", + "custom": "Personalizado" + }, + "schedule-day": { + "monday": "Segunda-feira", + "tuesday": "Terça-feira", + "wednesday": "Quarta-feira", + "thursday": "Quinta-feira", + "friday": "Sexta-feira", + "saturday": "Sábado", + "sunday": "Domingo" + }, + "schedule-days": "Dias", + "schedule-time": "Horário", + "schedule-time-from": "De", + "schedule-time-to": "Até", + "schedule-days-of-week-required": "Pelo menos um dia da semana deve ser selecionado.", + "tbel": "TBEL", + "expression-type": { + "simple": "Simples", + "script": "Script" + }, + "operation-type": { + "and": "E", + "or": "Ou" + }, + "filter-predicate-type": { + "string": "String", + "numeric": "Numérico", + "boolean": "Booleano", + "complex": "Complexo" + }, + "alarm-rule-additional-info": "Informações adicionais", + "edit-alarm-rule-additional-info": "Editar informações adicionais", + "alarm-rule-additional-info-placeholder": "Forneça seus comentários e ajustes aqui para exibi-los nos detalhes do Alarme em Informações adicionais", + "alarm-rule-additional-info-hint": "Dica: use ${Argument name} para substituir os valores dos argumentos usados na condição da regra de alarme.", + "alarm-rule-additional-info-icon-hint": "Use o nome do argumento para substituir os valores dos argumentos usados na condição da regra de alarme.", + "alarm-rule-mobile-dashboard": "Dashboard móvel", + "alarm-rule-mobile-dashboard-hint": "Usado pelo aplicativo móvel como dashboard de detalhes do alarme.", + "alarm-rule-no-mobile-dashboard": "Nenhum dashboard selecionado", + "alarm-rule-condition": "Condição da regra de alarme", + "enter-alarm-rule-condition-prompt": "Adicionar condição", + "enter-alarm-rule-clear-condition-prompt": "Adicionar condição de limpeza", + "edit-alarm-rule-condition": "Condição do alarme", + "condition-type": "Tipo de condição", + "condition-type-hint": "As opções \"Duração\" e \"Repetição\" não estão disponíveis quando a operação \"Ausente por\" é usada no filtro.", + "select-alarm-severity": "Selecionar severidade do alarme", + "add-create-alarm-rule-prompt": "É necessária pelo menos uma condição de acionamento.", + "add-create-alarm-rule": "Adicionar condição de acionamento", + "add-clear-alarm-rule": "Adicionar condição de limpeza", + "condition-duration": "Duração da condição", + "condition-duration-value": "Valor da duração", + "condition-duration-time-unit": "Unidade de tempo", + "condition-duration-value-range": "O valor da duração deve estar no intervalo de 1 a 2147483647.", + "condition-duration-value-pattern": "O valor da duração deve ser um número inteiro.", + "condition-duration-value-required": "O valor da duração é obrigatório.", + "condition-duration-time-unit-required": "A unidade de tempo é obrigatória.", + "condition-repeating-value": "Contagem de eventos", + "condition-repeating-value-hint": "A atualização de qualquer argumento da regra de alarme será contada como evento", + "condition-repeating-value-range": "A contagem de eventos deve estar no intervalo de 1 a 2147483647.", + "condition-repeating-value-pattern": "A contagem de eventos deve ser um número inteiro.", + "condition-repeating-value-required": "A contagem de eventos é obrigatória.", + "create-conditions": "Condições de acionamento", + "clear-condition": "Condição de limpeza", + "no-clear-alarm-rule": "Nenhuma condição de limpeza configurada.", + "advanced-settings": "Configurações avançadas", + "propagate-alarm": "Propagar alarme para entidades relacionadas", + "alarm-rule-relation-types-list": "Tipos de relação", + "alarm-rule-relation-types-list-hint": "Define os tipos de relação para filtrar as entidades relacionadas. Se não definido, o alarme será propagado para todas as entidades relacionadas.", + "propagate-alarm-to-owner": "Propagar alarme para o proprietário da entidade (Cliente ou Tenant)", + "propagate-alarm-to-tenant": "Propagar alarme para o Tenant", + "alarm-rule-filter-title": "Filtro de regra de alarme", + "filter-title": "Filtro", + "debugging": "Depuração de regra de alarme", + "any-type": "Qualquer tipo", + "enter-alarm-rule-type": "Inserir tipo de alarme", + "no-alarm-rule-types-matching": "Nenhum tipo de alarme correspondente a '{{entitySubtype}}' foi encontrado.", + "alarm-rule-type-list-empty": "Nenhum tipo de alarme selecionado.", + "alarm-rule-type-list": "Lista de tipos de alarme", + "alarm-rule-entity-list": "Lista de entidades", + "missing-for": "ausente por", + "time-unit": "Unidade", + "mode": "Modo", + "type": "Tipo", + "value-required": "O valor é obrigatório.", + "min-value": "O valor deve ser 1 ou maior.", + "argument-in-use": "O argumento está sendo usado como argumento geral.", + "import-invalid-alarm-rule-type": "Não foi possível importar a regra de alarme: Estrutura de regra de alarme inválida.", + "no-filter-preview": "Nenhum filtro especificado", + "filter-operation": { + "and": "E", + "or": "Ou" + } + }, + "ai-models": { + "ai-models": "Modelos de IA", + "ai-model": "Modelo de IA", + "model": "Modelo", + "name": "Nome", + "ai-provider": "Provedor de IA", + "no-found": "Nenhum modelo de IA encontrado", + "list": "{ count, plural, =1 {Um modelo} other {Lista de # modelos} }", + "selected-fields": "{ count, plural, =1 {1 modelo} other {# modelos} } selecionado(s)", + "add": "Adicionar modelo", + "delete-model-title": "Tem certeza de que deseja excluir o modelo '{{modelName}}'?", + "delete-model-text": "Atenção: após a confirmação, o modelo e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-models-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 modelo} other {# modelos} }?", + "delete-models-text": "Atenção: após a confirmação, todos os modelos selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models", + "ollama": "Ollama" + }, + "name-required": "O nome é obrigatório.", + "name-max-length": "O nome deve ter 255 caracteres ou menos.", + "provider": "Provedor", + "api-key": "Chave de API", + "api-key-required": "A chave de API é obrigatória.", + "api-key-open-ai-required": "A chave de API é obrigatória ao usar a API oficial do OpenAI.", + "project-id": "ID do projeto", + "project-id-required": "O ID do projeto é obrigatório", + "location": "Localização", + "location-required": "A localização é obrigatória.", + "service-account-key-file": "Arquivo de chave da conta de serviço", + "service-account-key-file-required": "O arquivo de chave da conta de serviço é obrigatório.", + "no-file": "Nenhum arquivo selecionado.", + "drop-file": "Solte um arquivo ou clique para selecionar um arquivo para upload.", + "personal-access-token": "Token de acesso pessoal", + "personal-access-token-required": "O token de acesso pessoal é obrigatório.", + "configuration": "Configuração", + "model-id": "ID do modelo", + "model-id-required": "O ID do modelo é obrigatório.", + "deployment-name": "Nome do deployment", + "deployment-name-required": "O nome do deployment é obrigatório", + "set": "Definir", + "region": "Região", + "region-required": "A região é obrigatória.", + "access-key-id": "ID da chave de acesso", + "access-key-id-required": "O ID da chave de acesso é obrigatório.", + "secret-access-key": "Chave de acesso secreta", + "secret-access-key-required": "A chave de acesso secreta é obrigatória.", + "temperature": "Temperatura", + "temperature-hint": "Ajusta o nível de aleatoriedade na saída do modelo. Valores mais altos aumentam a aleatoriedade, enquanto valores mais baixos a diminuem.", + "temperature-min": "Deve ser 0 ou maior.", + "top-p": "Top P", + "top-p-hint": "Cria um conjunto dos tokens mais prováveis para o modelo escolher. Valores mais altos criam um conjunto maior e mais diversificado, enquanto valores mais baixos criam um conjunto menor.", + "top-p-min-max": "Deve ser maior que 0 e até 1.", + "top-k": "Top K", + "top-k-hint": "Restringe as escolhas do modelo a um conjunto fixo dos \"K\" tokens mais prováveis.", + "top-k-min": "Deve ser 0 ou maior.", + "presence-penalty": "Penalidade de presença", + "presence-penalty-hint": "Aplica uma penalidade fixa à probabilidade de um token se ele já apareceu no texto.", + "frequency-penalty": "Penalidade de frequência", + "frequency-penalty-hint": "Aplica uma penalidade à probabilidade de um token que aumenta com base na sua frequência no texto.", + "max-output-tokens": "Máximo de tokens de saída", + "max-output-tokens-hint": "Define o número máximo de tokens que o \nmodelo pode gerar em uma única resposta.", + "context-length": "Comprimento do contexto", + "context-length-hint": "Define o tamanho da janela de contexto em tokens. Este valor define o limite total de memória para o modelo, incluindo tanto a entrada do usuário quanto a resposta gerada.", + "endpoint": "Endpoint", + "endpoint-required": "O Endpoint é obrigatório.", + "baseurl": "URL base", + "baseurl-required": "A URL base é obrigatória.", + "service-version": "Versão do serviço", + "check-connectivity": "Verificar conectividade", + "check-connectivity-success": "A solicitação de teste foi bem-sucedida", + "check-connectivity-failed": "A solicitação de teste falhou", + "no-model-matching": "Nenhum modelo correspondente a '{{entity}}' foi encontrado.", + "model-required": "O modelo é obrigatório.", + "no-model-text": "Nenhum modelo encontrado.", + "authentication": "Autenticação", + "authentication-basic-hint": "Usa autenticação HTTP Basic padrão. O nome de usuário e a senha serão combinados, codificados em Base64 e enviados em um cabeçalho \"Authorization\" a cada solicitação ao servidor Ollama.", + "authentication-token-hint": "Usa autenticação por token Bearer. O token fornecido será enviado diretamente em um cabeçalho \"Authorization\" a cada solicitação ao servidor Ollama.", + "authentication-type": { + "none": "Nenhuma", + "basic": "Basic", + "token": "Token" + }, + "username": "Nome de usuário", + "username-required": "O nome de usuário é obrigatório.", + "password": "Senha", + "password-required": "A senha é obrigatória.", + "token": "Token", + "token-required": "O token é obrigatório." }, "confirm-on-exit": { - "message": "Existem alterações sem salvar. Tem certeza de que deseja sair desta página?", - "html-message": "Existem alterações sem salvar.
Tem certeza de que deseja sair desta página?", - "title": "Alterações sem salvar" + "message": "Há alterações não salvas. Tem certeza de que deseja sair desta página?", + "html-message": "Há alterações não salvas.
Tem certeza de que deseja sair desta página?", + "title": "Alterações não salvas" }, "contact": { "country": "País", + "country-required": "O país é obrigatório.", + "country-object-required": "Selecione um país válido da lista.", "city": "Cidade", - "state": "Estado", + "state": "Estado / Província", "postal-code": "CEP / Código postal", - "postal-code-invalid": "Formato de código postal / CEP inválido.", + "postal-code-invalid": "Formato de CEP / Código postal inválido.", "address": "Endereço", "address2": "Endereço 2", "phone": "Telefone", - "email": "E-mail", - "no-address": "Em endereço" + "email": "Email", + "no-address": "Sem endereço", + "no-country-found": "Nenhum país encontrado.", + "no-country-matching": "Nenhum país correspondente a '{{country}}' foi encontrado.", + "state-max-length": "O comprimento do estado deve ser menor que 256", + "phone-max-length": "O número de telefone deve ter menos de 256 caracteres", + "city-max-length": "A cidade especificada deve ter menos de 256 caracteres" }, "common": { + "name": "Nome", + "type": "Tipo", + "general": "Geral", "username": "Nome de usuário", "password": "Senha", + "data": "Dados", + "timestamp": "Timestamp", "enter-username": "Inserir nome de usuário", "enter-password": "Inserir senha", "enter-search": "Inserir pesquisa", "created-time": "Hora de criação", - "loading": "Carregando..." + "disabled": "Desativado", + "loading": "Carregando...", + "proceed": "Prosseguir", + "open-details-page": "Abrir página de detalhes", + "not-found": "Não encontrado", + "value": "Valor", + "documentation": "Documentação", + "time-left": "{{time}} restante", + "output": "Saída", + "sort-asc": "Crescente", + "sort-desc": "Decrescente", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "O nome é obrigatório.", + "name-pattern": "O nome é inválido.", + "name-max-length": "O nome deve ter menos de 256 caracteres.", + "title-required": "O título é obrigatório.", + "title-pattern": "O título é inválido.", + "title-max-length": "O título deve ter menos de 256 caracteres.", + "key-required": "A chave é obrigatória.", + "key-pattern": "A chave é inválida.", + "key-max-length": "A chave deve ter menos de 256 caracteres." + }, + "required-fields": "Campos obrigatórios ausentes" }, "content-type": { "json": "Json", "text": "Texto", "binary": "Binário (Base64)" }, + "color": { + "color": "Cor" + }, "customer": { "customer": "Cliente", "customers": "Clientes", "management": "Gerenciamento de clientes", - "dashboard": "Dashboard de clientes", - "dashboards": "Dashboards de clientes", + "dashboard": "Dashboard do cliente", + "dashboards": "Dashboards do cliente", "devices": "Dispositivos do cliente", - "entity-views": "Exibições de entidades do cliente", + "entity-views": "Visualizações de entidade do cliente", "assets": "Ativos do cliente", "public-dashboards": "Dashboards públicos", "public-devices": "Dispositivos públicos", "public-assets": "Ativos públicos", - "public-entity-views": "Exibições de entidades públicas", + "public-entity-views": "Visualizações de entidade públicas", "add": "Adicionar cliente", "delete": "Excluir cliente", "manage-customer-users": "Gerenciar usuários do cliente", @@ -429,446 +1745,1467 @@ "manage-public-devices": "Gerenciar dispositivos públicos", "manage-public-dashboards": "Gerenciar dashboards públicos", "manage-customer-assets": "Gerenciar ativos do cliente", + "manage-customer-edges": "Gerenciar edges do cliente", "manage-public-assets": "Gerenciar ativos públicos", "add-customer-text": "Adicionar novo cliente", "no-customers-text": "Nenhum cliente encontrado", "customer-details": "Detalhes do cliente", - "delete-customer-title": "Tem certeza de que deseja excluir o ativo cliente '{{customerTitle}}'?", - "delete-customer-text": "Cuidado, após confirmar, não será possível recuperar o cliente e nenhum dado associado.", - "delete-customers-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 customer} other {# customers} }?", - "delete-customers-action-title": "Excluir { count, plural, =1 {1 customer} other {# customers} }", - "delete-customers-text": "Cuidado, após confirmar, todos os clientes selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "delete-customer-title": "Tem certeza de que deseja excluir o cliente '{{customerTitle}}'?", + "delete-customer-text": "Cuidado, após a confirmação o cliente e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-customers-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 cliente} other {# clientes} }?", + "delete-customers-action-title": "Excluir { count, plural, =1 {1 cliente} other {# clientes} }", + "delete-customers-text": "Cuidado, após a confirmação todos os clientes selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", "manage-users": "Gerenciar usuários", "manage-assets": "Gerenciar ativos", "manage-devices": "Gerenciar dispositivos", "manage-dashboards": "Gerenciar dashboards", "title": "Título", "title-required": "O título é obrigatório.", + "title-max-length": "O título deve ter menos de 256 caracteres", "description": "Descrição", "details": "Detalhes", "events": "Eventos", - "copyId": "Copiar ID de cliente", + "copyId": "Copiar ID do cliente", "idCopiedMessage": "O ID do cliente foi copiado para a área de transferência", "select-customer": "Selecionar cliente", - "no-customers-matching": "Nenhum cliente encontrado que coincida com '{{entity}}'.", - "customer-required": "O cliente é obrigatório", - "select-default-customer": "Selecionar cliente predefinido", - "default-customer": "Cliente predefinido", - "default-customer-required": "O cliente predefinido é necessário para depurar o dashboard no nível de locatário", + "no-customers-matching": "Nenhum cliente correspondente a '{{entity}}' foi encontrado.", + "customer-required": "O cliente é obrigatório.", + "select-default-customer": "Selecionar cliente padrão", + "default-customer": "Cliente padrão", + "default-customer-required": "O cliente padrão é obrigatório para depurar o dashboard no nível do Tenant", "search": "Pesquisar clientes", - "selected-customers": "{ count, plural, =1 {1 customer} other {# customers} } selecionado(s)" + "selected-customers": "{ count, plural, =1 {1 cliente} other {# clientes} } selecionado(s)", + "edges": "Instâncias de edge do cliente", + "manage-edges": "Gerenciar edges" + }, + "css-size": { + "size-value-required": "O valor de tamanho é obrigatório", + "invalid-size-value": "Valor de tamanho inválido" + }, + "date": { + "last-update-n-ago": "Última atualização N atrás", + "last-update-n-ago-text": "Última atualização {{ agoText }}", + "custom-date": "Data personalizada", + "format": "Formato", + "preview": "Visualização", + "auto": "Automático", + "time-granularity-formats": "Formatos de granularidade de tempo", + "unit-year": "Anos", + "unit-month": "Meses", + "unit-day": "Dias", + "unit-hour": "Horas", + "unit-minute": "Minutos", + "unit-second": "Segundos", + "unit-millisecond": "Milissegundos" }, "datetime": { - "date-from": "Data de", - "time-from": "Hora de", - "date-to": "Data até", - "time-to": "Hora até" + "date-from": "Data de início", + "time-from": "Hora de início", + "date-to": "Data de término", + "time-to": "Hora de término", + "from": "De", + "to": "Até" }, "dashboard": { "dashboard": "Dashboard", "dashboards": "Dashboards", "management": "Gerenciamento de dashboards", - "view-dashboards": "Visualizar dashboards", + "view-dashboards": "Visualizar Dashboards", "add": "Adicionar dashboard", - "assign-dashboard-to-customer": "Atribuir dashboard(s) a cliente", + "assign-dashboard-to-customer": "Atribuir Dashboard(s) ao Cliente", "assign-dashboard-to-customer-text": "Selecione os dashboards a serem atribuídos ao cliente", - "assign-to-customer-text": "Selecione o cliente para atribuir o(s) dashboard(s)", - "assign-to-customer": "Atribuir a cliente", - "unassign-from-customer": "Remover atribuição a cliente", - "make-public": "Tornar dashboards público", - "make-private": "Tornar dashboards privado", + "assign-to-customer-text": "Selecione o cliente ao qual atribuir o(s) dashboard(s)", + "assign-to-customer": "Atribuir ao cliente", + "unassign-from-customer": "Cancelar atribuição ao cliente", + "make-public": "Tornar dashboard público", + "make-private": "Tornar dashboard privado", "manage-assigned-customers": "Gerenciar clientes atribuídos", "assigned-customers": "Clientes atribuídos", - "assign-to-customers": "Atribuir Dashboard(s) a Clientes", - "assign-to-customers-text": "Selecione os clientes para atribuir o(s) dashboard(s)", - "unassign-from-customers": "Remover Atribuição de Dashboard(s) a Clientes", - "unassign-from-customers-text": "Selecione os clientes para remover a atribuição do(s) dashboard(s)", + "assign-to-customers": "Atribuir Dashboard(s) aos Clientes", + "assign-to-customers-text": "Selecione os clientes aos quais atribuir o(s) dashboard(s)", + "unassign-from-customers": "Cancelar atribuição de Dashboard(s) dos Clientes", + "unassign-from-customers-text": "Selecione os clientes para cancelar a atribuição do(s) dashboard(s)", "no-dashboards-text": "Nenhum dashboard encontrado", - "no-widgets": "Nenhum widgets configurado", + "no-widgets": "Nenhum widget configurado", "add-widget": "Adicionar novo widget", + "add-widget-button-text": "Adicionar widget", "title": "Título", + "image": "Imagem do dashboard", + "mobile-app-settings": "Configurações do aplicativo móvel", + "mobile-order": "Ordem do dashboard no aplicativo móvel", + "mobile-hide": "Ocultar dashboard no aplicativo móvel", + "update-image": "Atualizar imagem do dashboard", + "update-new-version": "Carregar nova versão", + "upload-file-to-update": "Carregar arquivo para atualizar", + "take-screenshot": "Tirar captura de tela", "select-widget-title": "Selecionar widget", - "select-widget-subtitle": "Lista de tipos de widget disponíveis", + "select-widget-value": "{{title}}: selecionar widget", + "select-widget-subtitle": "Lista de tipos de widgets disponíveis", "delete": "Excluir dashboard", "title-required": "O título é obrigatório.", + "title-max-length": "O título deve ter menos de 256 caracteres", "description": "Descrição", "details": "Detalhes", "dashboard-details": "Detalhes do dashboard", "add-dashboard-text": "Adicionar novo dashboard", "assign-dashboards": "Atribuir dashboards", "assign-new-dashboard": "Atribuir novo dashboard", - "assign-dashboards-text": "Atribuir { count, plural, =1 {1 dashboard} other {# dashboards} } a clientes", - "unassign-dashboards-action-text": "Remover atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} } a clientes", + "assign-dashboards-text": "Atribuir { count, plural, =1 {1 dashboard} other {# dashboards} } aos clientes", + "unassign-dashboards-action-text": "Cancelar atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} } dos clientes", "delete-dashboards": "Excluir dashboards", - "unassign-dashboards": "Remover atribuição de dashboards", - "unassign-dashboards-action-title": "Remover atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} } a cliente", + "unassign-dashboards": "Cancelar atribuição de dashboards", + "unassign-dashboards-action-title": "Cancelar atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} } do cliente", "delete-dashboard-title": "Tem certeza de que deseja excluir o dashboard '{{dashboardTitle}}'?", - "delete-dashboard-text": "Cuidado, após confirmar, não será possível recuperar o dashboard e nenhum dado associado.", + "delete-dashboard-text": "Cuidado, após a confirmação o dashboard e todos os dados relacionados se tornarão irrecuperáveis.", "delete-dashboards-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 dashboard} other {# dashboards} }?", "delete-dashboards-action-title": "Excluir { count, plural, =1 {1 dashboard} other {# dashboards} }", - "delete-dashboards-text": "Cuidado, após confirmar, todos os dashboards selecionados serão removidos e não será possível recuperar nenhum dado associado.", - "unassign-dashboard-title": "Tem certeza de que deseja remover a atribuição do dashboard '{{dashboardTitle}}'?", - "unassign-dashboard-text": "Após confirmar, a atribuição do dashboard será removida e ele não poderá ser acessado pelo cliente.", - "unassign-dashboard": "Remover atribuição de dashboard", - "unassign-dashboards-title": "Tem certeza de que deseja remover a atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} }?", - "unassign-dashboards-text": "Após confirmar, a atribuição de todos os dashboards selecionados será removida e eles não poderão ser acessados pelo cliente.", - "public-dashboard-title": "Agora o dashboard é público", - "public-dashboard-text": "Agora o dashboard {{dashboardTitle}} é público e pode ser acessado pelo próximo link público:", - "public-dashboard-notice": "Observação: Não se esqueça de tornar dispositivos relacionados públicos para poder acessar seus dados.", + "delete-dashboards-text": "Cuidado, após a confirmação todos os dashboards selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "unassign-dashboard-title": "Tem certeza de que deseja cancelar a atribuição do dashboard '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Após a confirmação o dashboard terá sua atribuição cancelada e não estará mais acessível pelo cliente.", + "unassign-dashboard": "Cancelar atribuição do dashboard", + "unassign-dashboards-title": "Tem certeza de que deseja cancelar a atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-text": "Após a confirmação todos os dashboards selecionados terão suas atribuições canceladas e não estarão mais acessíveis pelo cliente.", + "public-dashboard-title": "O dashboard agora é público", + "public-dashboard-text": "Seu dashboard {{dashboardTitle}} agora é público e acessível via link público:", + "public-dashboard-notice": "Observação: Não se esqueça de tornar os dispositivos relacionados públicos para acessar seus dados.", "make-private-dashboard-title": "Tem certeza de que deseja tornar o dashboard '{{dashboardTitle}}' privado?", - "make-private-dashboard-text": "Após confirmar, o dashboard e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", - "make-private-dashboard": "Tornar dashboards privado", - "socialshare-text": "'{{dashboardTitle}}' da plataforma ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' da plataforma ThingsBoard", + "make-private-dashboard-text": "Após a confirmação o dashboard será tornado privado e não estará mais acessível por outros.", + "make-private-dashboard": "Tornar dashboard privado", + "socialshare-text": "'{{dashboardTitle}}' desenvolvido com ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' desenvolvido com ThingsBoard", "select-dashboard": "Selecionar dashboard", - "no-dashboards-matching": "Nenhum dashboard encontrado que coincida com '{{entity}}'.", + "no-dashboards-matching": "Nenhum dashboard correspondente a '{{entity}}' foi encontrado.", "dashboard-required": "O dashboard é obrigatório.", "select-existing": "Selecionar dashboard existente", "create-new": "Criar novo dashboard", - "new-dashboard-title": "Novo título de dashboard", + "new-dashboard-title": "Título do novo dashboard", "open-dashboard": "Abrir dashboard", "set-background": "Definir plano de fundo", "background-color": "Cor de fundo", - "background-image": "Imagem de fundo", - "background-size-mode": "Modo de tamanho de fundo", + "background-image": "Imagem do plano de fundo", + "background-size-mode": "Modo de tamanho do plano de fundo", "no-image": "Nenhuma imagem selecionada", + "empty-image": "Sem imagem", "drop-image": "Solte uma imagem ou clique para selecionar um arquivo para carregar.", + "maximum-upload-file-size": "Tamanho máximo do arquivo para upload: {{ size }}", + "cannot-upload-file": "Não é possível carregar o arquivo", "settings": "Configurações", - "columns-count": "Contagem de colunas", - "columns-count-required": "A contagem de colunas é obrigatória.", - "min-columns-count-message": "Somente é permitida uma contagem de no mínimo 10 colunas.", - "max-columns-count-message": "Somente é permitida uma contagem de no máximo 1000 colunas.", + "move-all-widgets": "Mover todos os widgets", + "move-by": "Mover por", + "cols": "colunas", + "rows": "linhas", + "layout": "Layout", + "layout-type-default": "Padrão", + "layout-type-scada": "SCADA", + "layout-type-divider": "Divisor", + "layout-settings-type": "Configurações de layout: ponto de interrupção {{ type }}", + "columns-count": "Quantidade de colunas", + "columns-count-required": "A quantidade de colunas é obrigatória.", + "min-columns-count-message": "A quantidade mínima permitida de colunas é 10.", + "max-columns-count-message": "A quantidade máxima permitida de colunas é 1000.", + "min-layout-width": "Largura mínima do layout", + "columns-suffix": "colunas", "widgets-margins": "Margem entre widgets", "margin-required": "O valor da margem é obrigatório.", - "min-margin-message": "Somente 0 é permitido como valor mínimo de margem.", - "max-margin-message": "Somente 50 é permitido como valor máximo de margem.", + "min-margin-message": "O valor mínimo permitido para a margem é 0.", + "max-margin-message": "O valor máximo permitido para a margem é 50.", "horizontal-margin": "Margem horizontal", "horizontal-margin-required": "O valor da margem horizontal é obrigatório.", - "min-horizontal-margin-message": "Somente 0 é permitido como valor mínimo de margem horizontal.", - "max-horizontal-margin-message": "Somente 50 é permitido como valor máximo de margem horizontal.", + "min-horizontal-margin-message": "O valor mínimo permitido para a margem horizontal é 0.", + "max-horizontal-margin-message": "O valor máximo permitido para a margem horizontal é 50.", "vertical-margin": "Margem vertical", "vertical-margin-required": "O valor da margem vertical é obrigatório.", - "min-vertical-margin-message": "Somente 0 é permitido como valor mínimo de margem vertical.", - "max-vertical-margin-message": "Somente 50 é permitido como valor máximo de margem vertical.", - "autofill-height": "Altura do layout de preenchimento automático", - "mobile-layout": "Configuração de layout móvel", - "mobile-row-height": "Altura de linha móvel, px", - "mobile-row-height-required": "O valor da altura de linha móvel é obrigatório.", - "min-mobile-row-height-message": "Somente 5 pixels é permitido como valor mínimo da altura de linha móvel.", - "max-mobile-row-height-message": "Somente 200 pixels é permitido como valor máximo da altura de linha móvel.", - "display-title": "Exibir título de dashboard", - "toolbar-always-open": "Manter barra de ferramentas aberto", + "min-vertical-margin-message": "O valor mínimo permitido para a margem vertical é 0.", + "max-vertical-margin-message": "O valor máximo permitido para a margem vertical é 50.", + "apply-outer-margin": "Aplicar margem às bordas do layout", + "autofill-height": "Preencher automaticamente a altura do layout", + "mobile-layout": "Configurações de layout móvel", + "mobile-row-height": "Altura da linha no layout móvel", + "mobile-row-height-required": "O valor da altura da linha no layout móvel é obrigatório.", + "min-mobile-row-height-message": "O valor mínimo permitido para a altura da linha no layout móvel é 5 pixels.", + "max-mobile-row-height-message": "O valor máximo permitido para a altura da linha no layout móvel é 200 pixels.", + "row-height": "Altura da linha", + "row-height-required": "O valor da altura da linha é obrigatório.", + "min-row-height-message": "O valor mínimo permitido para a altura da linha é 5 pixels.", + "max-row-height-message": "O valor máximo permitido para a altura da linha é 200 pixels.", + "display-first-in-mobile-view": "Exibir primeiro na visualização móvel", + "title-settings": "Configurações do título", + "display-title": "Exibir título do dashboard", "title-color": "Cor do título", + "toolbar-settings": "Configurações da barra de ferramentas", + "hide-toolbar": "Ocultar barra de ferramentas", + "toolbar-always-open": "Manter barra de ferramentas aberta", "display-dashboards-selection": "Exibir seleção de dashboards", "display-entities-selection": "Exibir seleção de entidades", "display-filters": "Exibir filtros", - "display-dashboard-timewindow": "Exibir timewindow", + "display-dashboard-timewindow": "Exibir janela de tempo", "display-dashboard-export": "Exibir exportação", + "display-update-dashboard-image": "Exibir atualização de imagem do dashboard", + "dashboard-logo-settings": "Configurações do logotipo do dashboard", + "display-dashboard-logo": "Exibir logotipo no modo de tela cheia do dashboard", + "dashboard-logo-image": "Imagem do logotipo do dashboard", + "advanced-settings": "Configurações avançadas", + "dashboard-css": "CSS do dashboard", "import": "Importar dashboard", "export": "Exportar dashboard", - "export-failed-error": "Impossível exportar dashboard: {{error}}", + "export-failed-error": "Não foi possível exportar o dashboard: {{error}}", + "export-prompt": "Incorporar imagens e recursos do dashboard", "create-new-dashboard": "Criar novo dashboard", - "dashboard-file": "Arquivo de dashboard", - "invalid-dashboard-file-error": "Impossível importar dashboard: Estrutura de dados do dashboard inválida", - "dashboard-import-missing-aliases-title": "Configurar aliases usados por dashboard importado", + "dashboard-file": "Arquivo do dashboard", + "invalid-dashboard-file-error": "Não foi possível importar o dashboard: Estrutura de dados do dashboard inválida.", + "dashboard-import-missing-aliases-title": "Configurar aliases usados pelo dashboard importado", "create-new-widget": "Criar novo widget", "import-widget": "Importar widget", - "widget-file": "Arquivo de widget", - "invalid-widget-file-error": "Impossível importar widget: Estrutura de dados do widget inválida.", - "widget-import-missing-aliases-title": "Configurar aliases usados por widget importado", - "open-toolbar": "Abrir barra de ferramentas de dashboard", + "widget-file": "Arquivo do widget", + "invalid-widget-file-error": "Não foi possível importar o widget: Estrutura de dados do widget inválida.", + "widget-import-missing-aliases-title": "Configurar aliases usados pelo widget importado", + "open-toolbar": "Abrir barra de ferramentas do dashboard", "close-toolbar": "Fechar barra de ferramentas", "configuration-error": "Erro de configuração", - "alias-resolution-error-title": "Erro de configuração de aliases de dashboard", - "invalid-aliases-config": "Impossível encontrar dispositivos que coincidam com algum dos filtros de aliases.
Para resolver esta questão, encontre em contato com seu administrador.", + "alias-resolution-error-title": "Erro de configuração de aliases do dashboard", + "invalid-aliases-config": "Não foi possível encontrar dispositivos correspondentes a alguns dos filtros de aliases.
Entre em contato com o administrador para resolver este problema.", "select-devices": "Selecionar dispositivos", "assignedToCustomer": "Atribuído ao cliente", "assignedToCustomers": "Atribuído aos clientes", "public": "Público", + "copyId": "Copiar ID do dashboard", + "idCopiedMessage": "O ID do dashboard foi copiado para a área de transferência", "public-link": "Link público", "copy-public-link": "Copiar link público", - "public-link-copied-message": "O link público da dashboard foi copiado para a área de transferência", - "manage-states": "Gerenciar estados de dashboards", - "states": "Estados de dashboards", - "search-states": "Pesquisar estados de dashboards", - "selected-states": "{ count, plural, =1 {1 dashboard state} other {# dashboard states} } selecionado(s)", - "edit-state": "Editar estado de dashboard", - "delete-state": "Excluir estado de dashboard", - "add-state": "Adicionar estado de dashboard", + "public-link-copied-message": "O link público do dashboard foi copiado para a área de transferência", + "manage-states": "Gerenciar estados do dashboard", + "states": "Estados do dashboard", + "states-short": "Estados", + "search-states": "Pesquisar estados do dashboard", + "selected-states": "{ count, plural, =1 {1 estado do dashboard} other {# estados do dashboard} } selecionado(s)", + "edit-state": "Editar estado do dashboard", + "delete-state": "Excluir estado do dashboard", + "add-state": "Adicionar estado do dashboard", "no-states-text": "Nenhum estado encontrado", "state": "Estado do dashboard", "state-name": "Nome", "state-name-required": "O nome do estado do dashboard é obrigatório.", "state-id": "ID do estado", "state-id-required": "O ID do estado do dashboard é obrigatório.", - "state-id-exists": "Já existe um estado de dashboard com o mesmo ID.", + "state-id-exists": "Já existe um estado do dashboard com o mesmo ID.", "is-root-state": "Estado raiz", - "delete-state-title": "Excluir estado de dashboard", - "delete-state-text": "Tem certeza de que deseja excluir o estado de dashboard com o nome '{{stateName}}'?", + "delete-state-title": "Excluir estado do dashboard", + "delete-state-text": "Tem certeza de que deseja excluir o estado do dashboard com o nome '{{stateName}}'?", "show-details": "Mostrar detalhes", "hide-details": "Ocultar detalhes", - "select-state": "Selecionar estado alvo", + "select-state": "Selecionar estado de destino", "state-controller": "Controlador de estado", - "search": "Selecionar dashboards", - "selected-dashboards": "{ count, plural, =1 {1 dashboard} other {# dashboards} } selecionado(s)" + "state-controller-default": "estático (obsoleto)", + "search": "Pesquisar dashboards", + "selected-dashboards": "{ count, plural, =1 {1 dashboard} other {# dashboards} } selecionado(s)", + "home-dashboard": "Dashboard inicial", + "home-dashboard-hide-toolbar": "Ocultar barra de ferramentas do dashboard inicial", + "unassign-dashboard-from-edge-text": "Após a confirmação o dashboard terá sua atribuição cancelada e não estará mais acessível pelo edge.", + "unassign-dashboards-from-edge-title": "Tem certeza de que deseja cancelar a atribuição de { count, plural, =1 {1 dashboard} other {# dashboards} }?", + "unassign-dashboards-from-edge-text": "Após a confirmação todos os dashboards selecionados terão suas atribuições canceladas e não estarão mais acessíveis pelo edge.", + "assign-dashboard-to-edge": "Atribuir Dashboard(s) ao Edge", + "assign-dashboard-to-edge-text": "Selecione os dashboards a serem atribuídos ao edge", + "non-existent-dashboard-state-error": "O estado do dashboard com ID \"{{ stateId }}\" não foi encontrado", + "edit-mode": "Modo de edição", + "duplicate-state-action": "Duplicar estado", + "breakpoint-value": "Ponto de interrupção ({{ value }})", + "breakpoints-id": { + "default": "Padrão", + "xs": "Móvel (xs)", + "sm": "Tablet (sm)", + "md": "Laptop (md)", + "lg": "Desktop (lg)", + "xl": "Desktop (xl)" + }, + "view-format-type-grid": "Grade", + "view-format-type-list": "Lista", + "view-format": "Formato de visualização" }, "datakey": { "settings": "Configurações", + "general": "Geral", "advanced": "Avançado", - "label": "Etiqueta", + "key": "Chave", + "keys": "Chaves", + "label": "Rótulo", "color": "Cor", - "units": "Símbolo especial a ser exibido ao lado do valor", - "decimals": "Número de dígitos após ponto de flutuação", + "units": "Símbolo especial para exibir ao lado do valor", + "decimals": "Número de dígitos após o ponto flutuante", "data-generation-func": "Função de geração de dados", "use-data-post-processing-func": "Usar função de pós-processamento de dados", - "configuration": "Configuração de dados-chave", - "timeseries": "Intervalos de tempo", + "configuration": "Configuração da chave de dados", + "timeseries": "Séries temporais", "attributes": "Atributos", - "entity-field": "Campo de entidade", - "alarm": "Campos de alarmes", - "timeseries-required": "Os intervalos de tempo de entidade são obrigatórios.", - "timeseries-or-attributes-required": "Os intervalos de tempo/atributos de entidade são obrigatórios.", - "alarm-fields-timeseries-or-attributes-required": "Os campos de alarmes ou intervalos de tempo/atributos de entidade são obrigatórios.", - "maximum-timeseries-or-attributes": "No máximo { count, plural, =1 {1 timeseries/attribute is allowed.} other {# timeseries/attributes are allowed} }", - "alarm-fields-required": "Os campos de alarmes são obrigatórios.", + "entity-field": "Campo da entidade", + "alarm": "Campos de alarme", + "timeseries-required": "As séries temporais da entidade são obrigatórias.", + "timeseries-or-attributes-required": "Séries temporais/atributos da entidade são obrigatórios.", + "alarm-fields-timeseries-or-attributes-required": "Campos de alarme ou séries temporais/atributos da entidade são obrigatórios.", + "maximum-timeseries-or-attributes": "Máximo { count, plural, =1 {1 série temporal/atributo permitido.} other {# séries temporais/atributos permitidos} }", + "alarm-fields-required": "Os campos de alarme são obrigatórios.", "function-types": "Tipos de função", + "function-type": "Tipo de função", "function-types-required": "Os tipos de função são obrigatórios.", - "maximum-function-types": "No máximo { count, plural, =1 {1 function type is allowed.} other {# function types are allowed} }", - "time-description": "carimbo de data/horário do valor atual;", + "data-keys": "Chaves de dados", + "data-key": "Chave de dados", + "data-keys-required": "As chaves de dados são obrigatórias.", + "data-key-required": "A chave de dados é obrigatória.", + "alarm-keys": "Chaves de dados de alarme", + "alarm-key": "Chave de dados de alarme", + "alarm-key-functions": "Funções de chave de alarme", + "alarm-key-function": "Função de chave de alarme", + "latest-keys": "Chaves de dados mais recentes", + "latest-key": "Chave de dados mais recente", + "latest-key-functions": "Funções de chave mais recente", + "latest-key-function": "Função de chave mais recente", + "timeseries-keys": "Chaves de dados de séries temporais", + "timeseries-key": "Chave de dados de série temporal", + "timeseries-key-functions": "Funções de chave de séries temporais", + "timeseries-key-function": "Função de chave de série temporal", + "maximum-function-types": "Máximo { count, plural, =1 {1 tipo de função permitido.} other {# tipos de função permitidos} }", + "time-description": "timestamp do valor atual;", "value-description": "o valor atual;", "prev-value-description": "resultado da chamada de função anterior;", - "time-prev-description": "carimbo de data/horário do valor anterior;", - "prev-orig-value-description": "valor original anterior;" + "time-prev-description": "timestamp do valor anterior;", + "prev-orig-value-description": "valor anterior original;", + "aggregation": "Agregação", + "aggregation-type-hint-common": "Por motivos de desempenho, o cálculo de valores agregados está disponível apenas para intervalos de tempo fixos como \"dia atual\", \"mês atual\", etc., e não está disponível para intervalos de janela deslizante como 'últimos 30 minutos' ou 'últimas 24 horas'.", + "aggregation-type-none-hint": "Obter o valor mais recente.", + "aggregation-type-min-hint": "Encontrar o valor mínimo entre os pontos de dados dentro de uma janela de tempo selecionada.", + "aggregation-type-max-hint": "Encontrar o valor máximo entre os pontos de dados dentro de uma janela de tempo selecionada.", + "aggregation-type-avg-hint": "Calcular um valor médio entre os pontos de dados dentro de uma janela de tempo selecionada.", + "aggregation-type-sum-hint": "Somar todos os valores dos pontos de dados dentro de uma janela de tempo selecionada.", + "aggregation-type-count-hint": "Número total de pontos de dados dentro de uma janela de tempo selecionada.", + "delta-calculation": "Cálculo delta", + "enable-delta-calculation": "Habilitar cálculo delta", + "enable-delta-calculation-hint": "Quando habilitado, o valor da chave de dados é calculado com base nos valores agregados para uma janela de tempo selecionada e um período de comparação especificado. Por motivos de desempenho, o cálculo delta está disponível apenas para janelas de tempo históricas e não para valores em tempo real. Por exemplo, é possível calcular o delta entre o consumo de energia de ontem comparado ao consumo de energia do dia anterior.", + "delta-calculation-result": "Resultado do cálculo delta", + "delta-calculation-result-previous-value": "Valor anterior", + "delta-calculation-result-delta-absolute": "Delta (absoluto)", + "delta-calculation-result-delta-percent": "Delta (percentual)", + "source": "Fonte", + "latest": "Mais recente", + "latest-value": "Valor mais recente", + "delta": "delta", + "percent": "percentual", + "absolute": "absoluto" }, "datasource": { "type": "Tipo de fonte de dados", "name": "Nome", - "add-datasource-prompt": "Adicione a fonte de dados" + "label": "Rótulo", + "add-datasource-prompt": "Adicione uma fonte de dados" }, "details": { "details": "Detalhes", "edit-mode": "Modo de edição", "edit-json": "Editar JSON", - "toggle-edit-mode": "Alternar o modo de edição" + "toggle-edit-mode": "Alternar modo de edição" }, "device": { "device": "Dispositivo", - "device-required": "O nome do dispositivo é obrigatório.", + "device-required": "O dispositivo é obrigatório.", "devices": "Dispositivos", "management": "Gerenciamento de dispositivos", - "view-devices": "Exibir Dispositivos", + "view-devices": "Visualizar dispositivos", "device-alias": "Alias do dispositivo", + "device-type-max-length": "O tipo de dispositivo deve ter menos de 256 caracteres", "aliases": "Aliases de dispositivos", "no-alias-matching": "'{{alias}}' não encontrado.", "no-aliases-found": "Nenhum alias encontrado.", "no-key-matching": "'{{key}}' não encontrado.", - "no-keys-found": "Nenhuma chave encontrado.", + "no-keys-found": "Nenhuma chave encontrada.", "create-new-alias": "Criar um novo!", "create-new-key": "Criar um novo!", - "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
Os aliases de dispositivos devem ser exclusivos no dashboard.", - "configure-alias": "Configurar alias de '{{alias}}'", - "no-devices-matching": "Nenhum dispositivo encontrado que coincida com '{{entity}}'.", + "duplicate-alias-error": "Alias duplicado encontrado '{{alias}}'.
Os aliases de dispositivos devem ser únicos no dashboard.", + "configure-alias": "Configurar alias '{{alias}}'", + "no-devices-matching": "Nenhum dispositivo correspondente a '{{entity}}' foi encontrado.", "alias": "Alias", "alias-required": "O alias do dispositivo é obrigatório.", "remove-alias": "Remover alias do dispositivo", "add-alias": "Adicionar alias do dispositivo", - "name-starts-with": "O nome do dispositivo começa com", + "name-starts-with": "Expressão de nome do dispositivo", + "help-text": "Use '%' conforme necessário: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Lista de dispositivos", "use-device-name-filter": "Usar filtro", "device-list-empty": "Nenhum dispositivo selecionado.", - "device-name-filter-required": "O filtro do nome do dispositivo é obrigatório.", - "device-name-filter-no-device-matched": "Nenhum dispositivo encontrado que comece com '{{device}}'.", - "add": "Adicionar Dispositivo", - "assign-to-customer": "Atribuir a cliente", - "assign-device-to-customer": "Atribuir Dispositivo(s) a Cliente", + "device-name-filter-required": "O filtro de nome de dispositivo é obrigatório.", + "device-name-filter-no-device-matched": "Nenhum dispositivo iniciando com '{{device}}' foi encontrado.", + "add": "Adicionar dispositivo", + "assign-to-customer": "Atribuir ao cliente", + "assign-device-to-customer": "Atribuir dispositivo(s) ao cliente", "assign-device-to-customer-text": "Selecione os dispositivos a serem atribuídos ao cliente", "make-public": "Tornar dispositivo público", "make-private": "Tornar dispositivo privado", "no-devices-text": "Nenhum dispositivo encontrado", - "assign-to-customer-text": "Selecione cliente para atribuir o(s) dispositivo(s)", + "assign-to-customer-text": "Selecione o cliente ao qual atribuir o(s) dispositivo(s)", "device-details": "Detalhes do dispositivo", "add-device-text": "Adicionar novo dispositivo", "credentials": "Credenciais", "manage-credentials": "Gerenciar credenciais", "delete": "Excluir dispositivo", "assign-devices": "Atribuir dispositivos", - "assign-devices-text": "Atribuir { count, plural, =1 {1 device} other {# devices} } a cliente", + "assign-devices-text": "Atribuir { count, plural, =1 {1 dispositivo} other {# dispositivos} } ao cliente", "delete-devices": "Excluir dispositivos", - "unassign-from-customer": "Remover atribuição a cliente", - "unassign-devices": "Remover atribuição de dispositivos", - "unassign-devices-action-title": "Remover atribuição de { count, plural, =1 {1 device} other {# devices} } a cliente", + "unassign-from-customer": "Cancelar atribuição ao cliente", + "unassign-devices": "Cancelar atribuição de dispositivos", + "unassign-devices-action-title": "Cancelar atribuição de { count, plural, =1 {1 dispositivo} other {# dispositivos} } ao cliente", + "unassign-device-from-edge-title": "Tem certeza de que deseja cancelar a atribuição do dispositivo '{{deviceName}}'?", + "unassign-device-from-edge-text": "Após a confirmação, a atribuição do dispositivo será cancelada e ele não estará acessível pelo edge.", + "unassign-devices-from-edge": "Cancelar atribuição de dispositivos do edge", "assign-new-device": "Atribuir novo dispositivo", "make-public-device-title": "Tem certeza de que deseja tornar o dispositivo '{{deviceName}}' público?", - "make-public-device-text": "Após confirmar, o dispositivo e todos os dados associados a ele se tornarão públicos e poderão ser acessados por outros.", + "make-public-device-text": "Após a confirmação, o dispositivo e todos os seus dados serão tornados públicos e acessíveis por outros.", "make-private-device-title": "Tem certeza de que deseja tornar o dispositivo '{{deviceName}}' privado?", - "make-private-device-text": "Após confirmar, o dispositivo e todos os dados associados a ele se tornarão privados e não poderão ser acessados por outros.", - "view-credentials": "Exibir credenciais", + "make-private-device-text": "Após a confirmação, o dispositivo e todos os seus dados serão tornados privados e não estarão acessíveis por outros.", + "view-credentials": "Visualizar credenciais", "delete-device-title": "Tem certeza de que deseja excluir o dispositivo '{{deviceName}}'?", - "delete-device-text": "Cuidado, após confirmar, não será possível recuperar o dispositivo e nenhum dado associado.", - "delete-devices-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 device} other {# devices} }?", - "delete-devices-action-title": "Excluir { count, plural, =1 {1 device} other {# devices} }", - "delete-devices-text": "Cuidado, após confirmar, todos os dispositivos selecionados serão removidos e não será possível recuperar nenhum dado associado.", - "unassign-device-title": "Tem certeza de que deseja remover a atribuição do dispositivo '{{deviceName}}'?", - "unassign-device-text": "Após confirmar, a atribuição do dispositivo será removida e ele não poderá ser acessado pelo cliente.", - "unassign-device": "Remover atribuição de dispositivo", - "unassign-devices-title": "Tem certeza de que deseja remover a atribuição de { count, plural, =1 {1 device} other {# devices} }?", - "unassign-devices-text": "Após confirmar, a atribuição de todos os dispositivos selecionados será removida e eles não poderão ser acessados pelo cliente.", + "delete-device-text": "Atenção: após a confirmação, o dispositivo e todos os dados relacionados serão irrecuperáveis.", + "delete-devices-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 dispositivo} other {# dispositivos} }?", + "delete-devices-action-title": "Excluir { count, plural, =1 {1 dispositivo} other {# dispositivos} }", + "delete-devices-text": "Atenção: após a confirmação, todos os dispositivos selecionados serão removidos e todos os dados relacionados serão irrecuperáveis.", + "unassign-device-title": "Tem certeza de que deseja cancelar a atribuição do dispositivo '{{deviceName}}'?", + "unassign-device-text": "Após a confirmação, a atribuição do dispositivo será cancelada e ele não estará acessível pelo cliente.", + "unassign-device": "Cancelar atribuição do dispositivo", + "unassign-devices-title": "Tem certeza de que deseja cancelar a atribuição de { count, plural, =1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-text": "Após a confirmação, a atribuição de todos os dispositivos selecionados será cancelada e eles não estarão acessíveis pelo cliente.", "device-credentials": "Credenciais do dispositivo", - "credentials-type": "Tipo de credencial", + "loading-device-credentials": "Carregando credenciais do dispositivo...", + "credentials-type": "Tipo de credenciais", "access-token": "Token de acesso", "access-token-required": "O token de acesso é obrigatório.", - "access-token-invalid": "O token de acesso deve ter de 1 a 32 caracteres.", - "secret": "Segredo", - "secret-required": "O segredo é obrigatório.", - "device-type": "Tipo de dispositivo", + "access-token-invalid": "O comprimento do token de acesso deve ser de 1 a 32 caracteres.", + "certificate-pem-format": "Certificado no formato PEM", + "certificate-pem-format-required": "O certificado é obrigatório.", + "copy-access-token": "Copiar token de acesso", + "copy-certificate": "Copiar certificado", + "copy-client-id": "Copiar Client ID", + "copy-user-name": "Copiar nome de usuário", + "copy-password": "Copiar senha", + "generate-client-id": "Gerar Client ID", + "generate-user-name": "Gerar nome de usuário", + "generate-password": "Gerar senha", + "generate-access-token": "Gerar token de acesso", + "lwm2m-security-config": { + "identity": "Identidade do cliente", + "identity-required": "A identidade do cliente é obrigatória.", + "identity-tooltip": "O identificador PSK é um identificador PSK arbitrário de até 128 bytes, conforme descrito no padrão [RFC7925].\nO identificador PSK DEVE ser primeiro convertido em uma string de caracteres e depois codificado em octetos usando UTF-8.", + "client-key": "Chave do cliente", + "client-key-required": "A chave do cliente é obrigatória.", + "client-key-tooltip-prk": "A chave pública ou id RPK deve estar no padrão [RFC7250] e codificada no formato Base64!", + "client-key-tooltip-psk": "A chave PSK deve estar no padrão [RFC4279] e no formato HexDec: 32, 64, 128 caracteres!", + "endpoint": "Nome do Endpoint do cliente", + "endpoint-required": "O nome do Endpoint do cliente é obrigatório.", + "client-public-key": "Chave pública do cliente", + "client-public-key-hint": "Se a chave pública do cliente estiver vazia, o certificado confiável será utilizado", + "client-public-key-tooltip": "A chave pública X509 deve estar no formato X509v3 codificado em DER e suportar exclusivamente o algoritmo EC, e então ser codificada no formato Base64!", + "mode": "Modo de configuração de segurança", + "client-tab": "Configuração de segurança do cliente", + "client-certificate": "Certificado do cliente", + "bootstrap-tab": "Bootstrap do cliente", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "client-reboot": "Gatilho de atualização de registro", + "bootstrap-reboot": "Gatilho de Bootstrap-Request", + "client-publicKey-or-id": "Chave pública ou Id do cliente", + "client-publicKey-or-id-required": "A chave pública ou Id do cliente é obrigatória.", + "client-publicKey-or-id-tooltip-psk": "O identificador PSK é um identificador PSK arbitrário de até 128 bytes, conforme descrito no padrão [RFC7925].\nO identificador PSK DEVE ser primeiro convertido em uma string de caracteres e depois codificado em octetos usando UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "A chave pública ou id RPK deve estar no padrão [RFC7250] e codificada no formato Base64!", + "client-publicKey-or-id-tooltip-x509": "A chave pública X509 deve estar no formato X509v3 codificado em DER e suportar exclusivamente o algoritmo EC, e então ser codificada no formato Base64", + "client-secret-key": "Chave secreta do cliente", + "client-secret-key-required": "A chave secreta do cliente é obrigatória.", + "client-secret-key-tooltip-psk": "A chave PSK deve estar no padrão [RFC4279] e no formato HexDec: 32, 64, 128 caracteres!", + "client-secret-key-tooltip-prk": "A chave secreta RPK deve estar no formato PKCS_8 (codificação DER, padrão [RFC5958]) e então ser codificada no formato Base64!", + "client-secret-key-tooltip-x509": "A chave secreta X509 deve estar no formato PKCS_8 (codificação DER, padrão [RFC5958]) e então ser codificada no formato Base64!" + }, + "client-id": "Client ID", + "client-id-pattern": "Contém caractere inválido.", + "user-name": "Nome de usuário", + "user-name-required": "O nome de usuário é obrigatório.", + "client-id-or-user-name-necessary": "Client ID e/ou nome de usuário são necessários", + "password": "Senha", + "secret": "Secret", + "secret-required": "O secret é obrigatório.", + "device-type": "Perfil do dispositivo", "device-type-required": "O tipo de dispositivo é obrigatório.", "select-device-type": "Selecionar tipo de dispositivo", - "enter-device-type": "Inserir tipo de dispositivo", + "enter-device-type": "Inserir perfil do dispositivo", "any-device": "Qualquer dispositivo", - "no-device-types-matching": "Nenhum tipo de dispositivo encontrado que coincida com '{{entitySubtype}}'.", - "device-type-list-empty": "Nenhum tipo de dispositivo selecionado.", - "device-types": "Tipos de dispositivos", + "no-device-types-matching": "Nenhum perfil de dispositivo correspondente a '{{entitySubtype}}' foi encontrado.", + "device-type-list-empty": "Nenhum perfil de dispositivo selecionado!", + "device-profile-type-list-empty": "Pelo menos um perfil de dispositivo deve ser selecionado.", + "device-types": "Tipos de dispositivo", "name": "Nome", "name-required": "O nome é obrigatório.", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "label-max-length": "O rótulo deve ter menos de 256 caracteres", "description": "Descrição", - "label": "Etiqueta", + "label": "Rótulo", "events": "Eventos", "details": "Detalhes", - "copyId": "Copiar ID de dispositivo", + "copyId": "Copiar Id do dispositivo", "copyAccessToken": "Copiar token de acesso", - "idCopiedMessage": "O ID do dispositivo foi copiado para a área de transferência", - "accessTokenCopiedMessage": "O token de acesso foi copiado para a área de transferência", + "copy-mqtt-authentication": "Copiar credenciais MQTT", + "idCopiedMessage": "Id do dispositivo foi copiado para a área de transferência", + "accessTokenCopiedMessage": "Token de acesso do dispositivo foi copiado para a área de transferência", + "mqtt-authentication-copied-message": "Autenticação MQTT do dispositivo foi copiada para a área de transferência", "assignedToCustomer": "Atribuído ao cliente", - "unable-delete-device-alias-title": "Impossível excluir alias do dispositivo", - "unable-delete-device-alias-text": "Não é possível excluir o alias do dispositivo '{{deviceAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", + "unable-delete-device-alias-title": "Não é possível excluir o alias do dispositivo", + "unable-delete-device-alias-text": "O alias do dispositivo '{{deviceAlias}}' não pode ser excluído pois é utilizado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", "is-gateway": "É gateway", + "overwrite-activity-time": "Sobrescrever tempo de atividade do dispositivo conectado", + "device-filter-title": "Filtro de dispositivo", + "filter-title": "Filtro", + "device-state": "Estado do dispositivo", + "state": "Estado", + "any": "Qualquer", + "active": "Ativo", + "inactive": "Inativo", "public": "Público", "device-public": "O dispositivo é público", "select-device": "Selecionar dispositivo", "import": "Importar dispositivo", "device-file": "Arquivo de dispositivo", "search": "Pesquisar dispositivos", - "selected-devices": "{ count, plural, =1 {1 device} other {# devices} } selecionado(s)" + "selected-devices": "{ count, plural, =1 {1 dispositivo} other {# dispositivos} } selecionado(s)", + "device-configuration": "Configuração do dispositivo", + "transport-configuration": "Configuração de transporte", + "wizard": { + "device-details": "Detalhes do dispositivo" + }, + "unassign-devices-from-edge-title": "Tem certeza de que deseja cancelar a atribuição de { count, plural, =1 {1 dispositivo} other {# dispositivos} }?", + "unassign-devices-from-edge-text": "Após a confirmação, a atribuição de todos os dispositivos selecionados será cancelada e eles não estarão acessíveis pelo edge.", + "time": "Tempo", + "connectivity": { + "check-connectivity": "Verificar conectividade", + "device-created-check-connectivity": "Dispositivo criado. Vamos verificar a conectividade!", + "loading-check-connectivity-command": "Carregando comandos de verificação de conectividade...", + "use-following-instructions": "Use as instruções a seguir para enviar telemetria em nome do dispositivo usando shell", + "execute-following-command": "Execute o seguinte comando", + "install-curl-windows": "A partir do Windows 10 b17063, o cURL está disponível por padrão", + "install-curl-macos": "A partir do Mac OS X 10.2 6C115 (Jaguar), o cURL está disponível por padrão", + "install-mqtt-windows": "Use as instruções para baixar, instalar, configurar e executar o mosquitto_pub", + "install-coap-client": "Use as instruções para baixar, instalar, configurar e executar o coap-client", + "install-necessary-client-tools": "Instalar as ferramentas de cliente necessárias", + "mqtts-x509-command": "Use a seguinte documentação para conectar o dispositivo via MQTT com autorização X509", + "coaps-x509-command": "Use a seguinte documentação para conectar o dispositivo via CoAP over DTLS com autorização X509", + "snmp-command": "Use a seguinte documentação para conectar o dispositivo por meio do SNMP.", + "sparkplug-command": "Use a seguinte documentação para conectar o dispositivo por meio do MQTT Sparkplug.", + "lwm2m-command": "Use a seguinte documentação para conectar o dispositivo por meio do LWM2M." + } + }, + "dynamic-form": { + "property": { + "properties": "Propriedades", + "property": "Propriedade", + "id": "Id", + "name": "Nome", + "type": "Tipo", + "type-text": "Texto", + "type-password": "Senha", + "type-textarea": "Área de texto", + "type-number": "Número", + "type-switch": "Chave", + "type-select": "Seleção", + "type-radios": "Botões de rádio", + "type-datetime": "Data/Hora", + "type-image": "Imagem", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Cor", + "type-color-settings": "Configurações de cor", + "type-font": "Fonte", + "type-units": "Unidades", + "type-icon": "Ícone", + "type-fieldset": "Conjunto de campos", + "type-array": "Array", + "type-html-section": "Seção HTML", + "group-title": "Título do grupo", + "no-properties": "Nenhuma propriedade configurada", + "add-property": "Adicionar propriedade", + "property-settings": "Configurações da propriedade", + "remove-property": "Remover propriedade", + "default-value": "Valor padrão", + "value-required": "Valor obrigatório", + "number-settings": "Configurações de número", + "min": "Mín", + "max": "Máx", + "step": "Passo", + "selected-options-limit": "Limite de opções selecionadas", + "advanced-ui-settings": "Configurações avançadas de interface", + "disable-on-property": "Desabilitar na propriedade", + "disable-on-property-none": "Nenhum (campo sempre habilitado)", + "display-condition-function": "Função de condição de exibição", + "sub-label": "Sub-rótulo", + "vertical-divider-after": "Divisor vertical após", + "input-field-suffix": "Sufixo do campo de entrada", + "property-row-classes": "Classes de linha da propriedade", + "property-field-classes": "Classes de campo da propriedade", + "not-unique-property-ids-error": "Os IDs de propriedade devem ser únicos!", + "enable-multiple-select": "Habilitar seleção múltipla", + "allow-empty-select-option": "Permitir opção vazia", + "select-options": "Opções de seleção", + "not-unique-select-option-value-error": "Os valores das opções de seleção devem ser únicos!", + "value": "Valor", + "label": "Rótulo", + "add-option": "Adicionar opção", + "no-options": "Nenhuma opção configurada", + "remove-option": "Remover opção", + "textarea-rows": "Linhas da área de texto", + "help-id": "Id de ajuda", + "buttons-direction": "Direção dos botões", + "direction-row": "Linha", + "direction-column": "Coluna", + "radio-button-options": "Opções de botão de rádio", + "datetime-type": "Tipo do campo Data/Hora", + "datetime-type-date": "Data", + "datetime-type-time": "Hora", + "datetime-type-datetime": "Data/Hora", + "enable-clear-button": "Habilitar botão de limpar", + "html-section-settings": "Configurações de seção HTML", + "html-section-classes": "Classes de seção HTML", + "html-section-content": "Conteúdo da seção HTML", + "array-item": "Item do array", + "item-type": "Tipo do item", + "item-name": "Nome do item", + "no-items": "Nenhum item", + "support-unit-conversion": "Suportar conversão de unidades" + }, + "clear-form": "Limpar formulário", + "clear-form-prompt": "Tem certeza de que deseja remover todas as propriedades do formulário?", + "import-form": "Importar formulário de JSON", + "export-form": "Exportar formulário para JSON", + "json-file": "Arquivo JSON", + "json-content": "Conteúdo JSON", + "invalid-form-json-file-error": "Não foi possível importar o formulário do JSON: Estrutura de dados JSON do formulário inválida." + }, + "asset-profile": { + "asset-profile": "Perfil do ativo", + "asset-profiles": "Perfis do ativo", + "all-asset-profiles": "Todos", + "add": "Adicionar perfil do ativo", + "edit": "Editar perfil do ativo", + "asset-profile-details": "Detalhes do perfil do ativo", + "no-asset-profiles-text": "Nenhum perfil do ativo encontrado", + "search": "Pesquisar perfis do ativo", + "selected-asset-profiles": "{ count, plural, =1 {1 perfil do ativo} other {# perfis do ativo} } selecionado(s)", + "no-asset-profiles-matching": "Nenhum perfil do ativo correspondente a '{{entity}}' foi encontrado.", + "asset-profile-required": "Perfil do ativo é obrigatório", + "idCopiedMessage": "ID do perfil do ativo copiado para a área de transferência", + "set-default": "Definir perfil do ativo como padrão", + "delete": "Excluir perfil do ativo", + "copyId": "Copiar ID do perfil do ativo", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "new-device-profile-name": "Nome do perfil do ativo", + "new-device-profile-name-required": "O nome do perfil do ativo é obrigatório.", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "image": "Imagem do perfil do ativo", + "description": "Descrição", + "default": "Padrão", + "default-rule-chain": "Cadeia de regras padrão", + "default-edge-rule-chain": "Cadeia de regras padrão do edge", + "default-edge-rule-chain-hint": "Usada no edge como cadeia de regras para processar dados recebidos de ativos deste perfil do ativo", + "mobile-dashboard": "Dashboard móvel", + "mobile-dashboard-hint": "Usado pelo aplicativo móvel como dashboard de detalhes do ativo", + "select-queue-hint": "Selecione em uma lista suspensa.", + "delete-asset-profile-title": "Tem certeza de que deseja excluir o perfil do ativo '{{assetProfileName}}'?", + "delete-asset-profile-text": "Atenção: após a confirmação, o perfil do ativo e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-asset-profiles-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 perfil do ativo} other {# perfis do ativo} }?", + "delete-asset-profiles-text": "Atenção: após a confirmação, todos os perfis do ativo selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "set-default-asset-profile-title": "Tem certeza de que deseja definir o perfil do ativo '{{assetProfileName}}' como padrão?", + "set-default-asset-profile-text": "Após a confirmação, o perfil do ativo será marcado como padrão e será usado para novos ativos sem perfil especificado.", + "no-asset-profiles-found": "Nenhum perfil do ativo encontrado.", + "create-new-asset-profile": "Criar um novo!", + "create-asset-profile": "Criar novo perfil do ativo", + "import": "Importar perfil do ativo", + "export": "Exportar perfil do ativo", + "export-failed-error": "Não foi possível exportar o perfil do ativo: {{error}}", + "asset-profile-file": "Arquivo do perfil do ativo", + "invalid-asset-profile-file-error": "Não foi possível importar o perfil do ativo: Estrutura de dados do perfil do ativo inválida." + }, + "device-profile": { + "device-profile": "Perfil do dispositivo", + "device-profiles": "Perfis do dispositivo", + "all-device-profiles": "Todos", + "add": "Adicionar perfil do dispositivo", + "edit": "Editar perfil do dispositivo", + "device-profile-details": "Detalhes do perfil do dispositivo", + "no-device-profiles-text": "Nenhum perfil do dispositivo encontrado", + "search": "Pesquisar perfis do dispositivo", + "selected-device-profiles": "{ count, plural, =1 {1 perfil do dispositivo} other {# perfis do dispositivo} } selecionado(s)", + "no-device-profiles-matching": "Nenhum perfil do dispositivo correspondente a '{{entity}}' foi encontrado.", + "device-profile-required": "Perfil do dispositivo é obrigatório", + "idCopiedMessage": "ID do perfil do dispositivo copiado para a área de transferência", + "set-default": "Definir perfil do dispositivo como padrão", + "delete": "Excluir perfil do dispositivo", + "copyId": "Copiar ID do perfil do dispositivo", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "type": "Tipo de perfil", + "type-required": "O tipo de perfil é obrigatório.", + "type-default": "Padrão", + "image": "Imagem do perfil do dispositivo", + "transport-type": "Tipo de transporte", + "transport-type-required": "O tipo de transporte é obrigatório.", + "transport-type-default": "Padrão", + "transport-type-default-hint": "Suporta transporte básico MQTT, HTTP e CoAP", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Habilita configurações avançadas de transporte MQTT", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Habilita configurações avançadas de transporte CoAP", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Tipo de transporte LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Especificar configuração de transporte SNMP", + "transport-type-http": "HTTP", + "description": "Descrição", + "default": "Padrão", + "profile-configuration": "Configuração do perfil", + "transport-configuration": "Configuração de transporte", + "default-rule-chain": "Cadeia de regras padrão", + "default-edge-rule-chain": "Cadeia de regras padrão do edge", + "default-edge-rule-chain-hint": "Usada no edge como cadeia de regras para processar dados recebidos de dispositivos deste perfil do dispositivo", + "mobile-dashboard": "Dashboard móvel", + "mobile-dashboard-hint": "Usado pelo aplicativo móvel como dashboard de detalhes do dispositivo", + "select-queue-hint": "Selecione em uma lista suspensa.", + "delete-device-profile-title": "Tem certeza de que deseja excluir o perfil do dispositivo '{{deviceProfileName}}'?", + "delete-device-profile-text": "Atenção: após a confirmação, o perfil do dispositivo e todos os dados relacionados, incluindo atualizações OTA associadas, se tornarão irrecuperáveis.", + "delete-device-profiles-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 perfil do dispositivo} other {# perfis do dispositivo} }?", + "delete-device-profiles-text": "Atenção: após a confirmação, todos os perfis do dispositivo selecionados serão removidos e todos os dados relacionados, incluindo atualizações OTA associadas, se tornarão irrecuperáveis.", + "set-default-device-profile-title": "Tem certeza de que deseja definir o perfil do dispositivo '{{deviceProfileName}}' como padrão?", + "set-default-device-profile-text": "Após a confirmação, o perfil do dispositivo será marcado como padrão e será usado para novos dispositivos sem perfil especificado.", + "no-device-profiles-found": "Nenhum perfil do dispositivo encontrado.", + "create-new-device-profile": "Criar um novo!", + "mqtt-device-topic-filters": "Filtros de tópico MQTT do dispositivo", + "mqtt-device-topic-filters-unique": "Os filtros de tópico MQTT do dispositivo devem ser únicos.", + "mqtt-device-topic-filters-spark-plug": "Nó MQTT Sparkplug B Edge of Network (EoN).", + "mqtt-device-topic-filters-spark-plug-hint": "Permitir conexões de nós EoN com formato de payload e tópico Sparkplug B.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "Métricas SparkPlug para armazenar como atributos.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Nomes das métricas SparkPlug que serão armazenadas como atributos do dispositivo. Todas as outras métricas serão armazenadas como telemetria do dispositivo.", + "mqtt-device-payload-type": "Payload MQTT do dispositivo", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Habilitar compatibilidade com outros formatos de payload.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Quando habilitado, a plataforma usará o formato de payload Protobuf por padrão. Se o parsing falhar, a plataforma tentará usar o formato de payload JSON. Útil para compatibilidade retroativa durante atualizações de firmware. Por exemplo, a versão inicial do firmware usa Json, enquanto a nova versão usa Protobuf. Durante o processo de atualização de firmware de um conjunto de dispositivos, é necessário suportar Protobuf e JSON simultaneamente. O modo de compatibilidade introduz uma leve degradação de desempenho, portanto recomenda-se desabilitar este modo assim que todos os dispositivos estiverem atualizados.", + "mqtt-use-json-format-for-default-downlink-topics": "Usar formato Json para tópicos de downlink padrão", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Quando habilitado, a plataforma usará o formato de payload Json para enviar atributos e RPC pelos seguintes tópicos: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. Esta configuração não afeta as assinaturas de atributos e rpc enviadas usando os novos tópicos (v2): v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Onde $request_id é um identificador de solicitação inteiro.", + "mqtt-send-ack-on-validation-exception": "Enviar PUBACK em caso de falha de validação de mensagem PUBLISH", + "mqtt-send-ack-on-validation-exception-hint": "Por padrão, a plataforma encerrará a sessão MQTT em caso de falha de validação de mensagem. Quando habilitado, a plataforma enviará confirmação de publicação em vez de encerrar a sessão.", + "mqtt-protocol-version": "Versão do protocolo", + "snmp-add-mapping": "Adicionar mapeamento SNMP", + "snmp-mapping-not-configured": "Nenhum mapeamento de OID para série temporal/telemetria configurado", + "snmp-timseries-or-attribute-name": "Nome da série temporal/atributo para mapeamento", + "snmp-timseries-or-attribute-type": "Tipo da série temporal/atributo para mapeamento", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "O tipo de payload é obrigatório.", + "coap-device-type": "Tipo de dispositivo CoAP", + "coap-device-payload-type": "Payload do dispositivo CoAP", + "coap-device-type-required": "O tipo de dispositivo CoAP é obrigatório.", + "coap-device-type-default": "Padrão", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Suporte a curingas de nível único [+] e de múltiplos níveis [#].", + "telemetry-topic-filter": "Filtro de tópico de telemetria", + "telemetry-topic-filter-required": "O filtro de tópico de telemetria é obrigatório.", + "attributes-topic-filter": "Filtro de tópico de publicação de atributos", + "attributes-subscribe-topic-filter": "Filtro de tópico de assinatura de atributos", + "attributes-topic-filter-required": "O filtro de tópico de publicação de atributos é obrigatório.", + "attributes-subscribe-topic-filter-required": "O tópico de assinatura de atributos é obrigatório", + "telemetry-proto-schema": "Schema proto de telemetria", + "telemetry-proto-schema-required": "O schema proto de telemetria é obrigatório.", + "attributes-proto-schema": "Schema proto de atributos", + "attributes-proto-schema-required": "O schema proto de atributos é obrigatório.", + "rpc-response-proto-schema": "Schema proto de resposta RPC", + "rpc-response-proto-schema-required": "O schema proto de resposta RPC é obrigatório.", + "rpc-response-topic-filter": "Filtro de tópico de resposta RPC", + "rpc-response-topic-filter-required": "O filtro de tópico de resposta RPC é obrigatório.", + "rpc-request-proto-schema": "Schema proto de requisição RPC", + "rpc-request-proto-schema-required": "O schema proto de requisição RPC é obrigatório.", + "rpc-request-proto-schema-hint": "A mensagem de requisição RPC deve sempre ter os campos: string method = 1; int32 requestId = 2; e params = 3 de qualquer tipo de dado.", + "not-valid-pattern-topic-filter": "Filtro de tópico padrão inválido", + "not-valid-single-character": "Uso inválido de caractere curinga de nível único", + "not-valid-multi-character": "Uso inválido de caractere curinga de múltiplos níveis", + "single-level-wildcards-hint": "[+] é adequado para qualquer nível de filtro de tópico. Ex.: v1/devices/+/telemetry ou +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] pode substituir o próprio filtro de tópico e deve ser o último símbolo do tópico. Ex.: # ou v1/devices/me/#.", + "alarm-rules": "Regras de alarme", + "alarm-rules-with-count": "Regras de alarme ({{count}})", + "no-alarm-rules": "Nenhuma regra de alarme configurada", + "add-alarm-rule": "Adicionar regra de alarme", + "edit-alarm-rule": "Editar regra de alarme", + "alarm-type": "Tipo de alarme", + "alarm-type-required": "O tipo de alarme é obrigatório.", + "alarm-type-unique": "O tipo de alarme deve ser único nas regras de alarme do perfil do dispositivo.", + "alarm-type-max-length": "O tipo de alarme deve ter menos de 256 caracteres", + "create-alarm-pattern": "Criar alarme {{alarmType}}", + "create-alarm-rules": "Criar regras de alarme", + "no-create-alarm-rules": "Nenhuma condição de criação configurada", + "add-create-alarm-rule-prompt": "Adicione uma regra de criação de alarme", + "clear-alarm-rule": "Limpar regra de alarme", + "no-clear-alarm-rule": "Nenhuma condição de limpeza configurada", + "add-create-alarm-rule": "Adicionar condição de criação", + "add-clear-alarm-rule": "Adicionar condição de limpeza", + "select-alarm-severity": "Selecionar severidade do alarme", + "alarm-severity-required": "A severidade do alarme é obrigatória.", + "condition-duration": "Duração da condição", + "condition-duration-value": "Valor da duração", + "condition-duration-time-unit": "Unidade de tempo", + "condition-duration-value-range": "O valor da duração deve estar entre 1 e 2147483647.", + "condition-duration-value-pattern": "O valor da duração deve ser um número inteiro.", + "condition-duration-value-required": "O valor da duração é obrigatório.", + "condition-duration-time-unit-required": "A unidade de tempo é obrigatória.", + "advanced-settings": "Configurações avançadas", + "alarm-rule-additional-info": "Informações adicionais", + "edit-alarm-rule-additional-info": "Editar informações adicionais", + "alarm-rule-additional-info-placeholder": "Insira seus comentários e ajustes aqui para exibi-los nos detalhes do Alarme em Informações adicionais", + "alarm-rule-additional-info-hint": "Dica: use ${keyName} para substituir valores dos atributos ou chaves de telemetria usados na condição da regra de alarme.", + "alarm-rule-mobile-dashboard": "Dashboard móvel", + "alarm-rule-mobile-dashboard-hint": "Usado pelo aplicativo móvel como dashboard de detalhes do alarme", + "alarm-rule-no-mobile-dashboard": "Nenhum dashboard selecionado", + "propagate-alarm": "Propagar alarme para entidades relacionadas", + "alarm-rule-relation-types-list": "Tipos de relação", + "alarm-rule-relation-types-list-hint": "Define os tipos de relação para filtrar as entidades relacionadas. Se não definido, o alarme será propagado para todas as entidades relacionadas.", + "propagate-alarm-to-owner": "Propagar alarme para o proprietário da entidade (Cliente ou Tenant)", + "propagate-alarm-to-tenant": "Propagar alarme para o Tenant", + "alarm-rule-condition": "Condição da regra de alarme", + "enter-alarm-rule-condition-prompt": "Adicione uma condição de regra de alarme", + "edit-alarm-rule-condition": "Editar condição da regra de alarme", + "device-provisioning": "Provisionamento de dispositivo", + "provision-strategy": "Estratégia de provisionamento", + "provision-strategy-required": "A estratégia de provisionamento é obrigatória.", + "provision-strategy-disabled": "Desabilitado", + "provision-strategy-created-new": "Permitir criar novos dispositivos", + "provision-strategy-check-pre-provisioned": "Verificar dispositivos pré-provisionados", + "provision-device-key": "Chave de provisionamento do dispositivo", + "provision-device-key-required": "A chave de provisionamento do dispositivo é obrigatória.", + "copy-provision-key": "Copiar chave de provisionamento", + "provision-key-copied-message": "Chave de provisionamento copiada para a área de transferência", + "provision-device-secret": "Segredo de provisionamento do dispositivo", + "provision-device-secret-required": "O segredo de provisionamento do dispositivo é obrigatório.", + "copy-provision-secret": "Copiar segredo de provisionamento", + "provision-secret-copied-message": "Segredo de provisionamento copiado para a área de transferência", + "provision-strategy-x509": { + "certificate-chain": "Cadeia de certificados X509", + "certificate-chain-hint": "A estratégia de certificados X.509 é usada para provisionar dispositivos por certificados de cliente em comunicação TLS bidirecional.", + "allow-create-new-devices": "Criar novos dispositivos", + "allow-create-new-devices-hint": "Se selecionado, novos dispositivos serão criados e o certificado de cliente será usado como credencial do dispositivo.", + "certificate-value": "Certificado em formato PEM", + "certificate-value-required": "O certificado em formato PEM é obrigatório", + "cn-regex-variable": "Variável de expressão regular CN", + "cn-regex-variable-required": "A variável de expressão regular CN é obrigatória", + "cn-regex-variable-hint": "Necessário para obter o nome do dispositivo a partir do nome comum do certificado X509 do dispositivo." + }, + "condition": "Condição", + "condition-type": "Tipo de condição", + "condition-type-simple": "Simples", + "condition-type-duration": "Duração", + "condition-during": "Durante {{during}}", + "condition-during-dynamic": "Durante \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Repetição", + "condition-type-required": "O tipo de condição é obrigatório.", + "condition-repeating-value": "Contagem de eventos", + "condition-repeating-value-range": "A contagem de eventos deve estar entre 1 e 2147483647.", + "condition-repeating-value-pattern": "A contagem de eventos deve ser um número inteiro.", + "condition-repeating-value-required": "A contagem de eventos é obrigatória.", + "condition-repeat-times": "Repete { count, plural, =1 {1 vez} other {# vezes} }", + "condition-repeat-times-dynamic": "Repete \"{ attribute }\" ({ count, plural, =1 {1 vez} other {# vezes} })", + "schedule-type": "Tipo de agendador", + "schedule-type-required": "O tipo de agendador é obrigatório.", + "schedule": "Agendamento", + "edit-schedule": "Editar agendamento de alarme", + "schedule-any-time": "Ativo o tempo todo", + "schedule-specific-time": "Ativo em um horário específico", + "schedule-custom": "Personalizado", + "schedule-day": { + "monday": "Segunda-feira", + "tuesday": "Terça-feira", + "wednesday": "Quarta-feira", + "thursday": "Quinta-feira", + "friday": "Sexta-feira", + "saturday": "Sábado", + "sunday": "Domingo" + }, + "schedule-days": "Dias", + "schedule-time": "Hora", + "schedule-time-from": "De", + "schedule-time-to": "Até", + "schedule-days-of-week-required": "Pelo menos um dia da semana deve ser selecionado.", + "create-device-profile": "Criar novo perfil do dispositivo", + "import": "Importar perfil do dispositivo", + "export": "Exportar perfil do dispositivo", + "export-failed-error": "Não foi possível exportar o perfil do dispositivo: {{error}}", + "device-profile-file": "Arquivo do perfil do dispositivo", + "invalid-device-profile-file-error": "Não foi possível importar o perfil do dispositivo: Estrutura de dados do perfil do dispositivo inválida.", + "power-saving-mode": "Modo de economia de energia", + "power-saving-mode-type": { + "default": "Usar modo de economia de energia do perfil do dispositivo", + "psm": "Modo de economia de energia (PSM)", + "drx": "Recepção descontínua (DRX)", + "edrx": "Recepção descontínua estendida (eDRX)" + }, + "edrx-cycle": "Ciclo eDRX", + "edrx-cycle-required": "O ciclo eDRX é obrigatório.", + "edrx-cycle-pattern": "O ciclo eDRX deve ser um número inteiro positivo.", + "edrx-cycle-min": "O número mínimo do ciclo eDRX é {{ min }} segundos.", + "paging-transmission-window": "Janela de transmissão de paginação", + "paging-transmission-window-required": "A janela de transmissão de paginação é obrigatória.", + "paging-transmission-window-pattern": "A janela de transmissão de paginação deve ser um número inteiro positivo.", + "paging-transmission-window-min": "O número mínimo da janela de transmissão de paginação é {{ min }} segundos.", + "psm-activity-timer": "Temporizador de atividade PSM", + "psm-activity-timer-required": "O temporizador de atividade PSM é obrigatório.", + "psm-activity-timer-pattern": "O temporizador de atividade PSM deve ser um número inteiro positivo.", + "psm-activity-timer-min": "O número mínimo do temporizador de atividade PSM é {{ min }} segundos.", + "lwm2m": { + "object-list": "Lista de objetos", + "object-list-empty": "Nenhum objeto selecionado.", + "no-objects-found": "Nenhum objeto encontrado.", + "no-objects-matching": "Nenhum objeto correspondente a '{{object}}' foi encontrado.", + "model-tab": "Modelo LWM2M", + "add-new-instances": "Adicionar novas instâncias", + "instances-list": "Lista de instâncias", + "instances-list-required": "A lista de instâncias é obrigatória.", + "instance-id-pattern": "O ID da instância deve ser um número inteiro positivo.", + "instance-id-max": "Valor máximo do ID da instância: {{max}}.", + "instance": "Instância", + "resource-label": "#ID Nome do recurso", + "observe-label": "Observar", + "attribute-label": "Atributo", + "telemetry-label": "Telemetria", + "edit-observe-select": "Para editar observar, selecione telemetria ou atributo", + "edit-attributes-select": "Para editar atributos, selecione telemetria ou atributo", + "no-attributes-set": "Nenhum atributo definido", + "key-name": "Nome da chave", + "key-name-required": "O nome da chave é obrigatório", + "attribute-name": "Nome do atributo", + "attribute-name-required": "O nome do atributo é obrigatório.", + "attribute-value": "Valor do atributo", + "attribute-value-required": "O valor do atributo é obrigatório.", + "attribute-value-pattern": "O valor do atributo deve ser um número inteiro positivo.", + "edit-attributes": "Editar atributos: {{ name }}", + "view-attributes": "Visualizar atributos: {{ name }}", + "add-attribute": "Adicionar atributo", + "edit-attribute": "Editar atributo", + "view-attribute": "Visualizar atributo", + "remove-attribute": "Remover atributo", + "delete-server-text": "Atenção: após a confirmação, a configuração do servidor se tornará irrecuperável.", + "delete-server-title": "Tem certeza de que deseja excluir o servidor?", + "mode": "Modo de configuração de segurança", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Servidor Bootstrap (ShortId...)", + "lwm2m-server-legend": "Servidor LwM2M (ShortId...)", + "server": "Servidor", + "short-id": "ID curto do servidor", + "short-id-tooltip": "ID curto do servidor. Usado como link para associar a instância do objeto servidor.\nEste identificador identifica exclusivamente cada servidor LwM2M configurado para o cliente LwM2M.\nO recurso DEVE ser definido quando o recurso Bootstrap-Server tem o valor 'false'.\nOs valores ID:0 e ID:65535 NÃO DEVEM ser usados para identificar o servidor LwM2M.", + "short-id-tooltip-bootstrap": "ID curto do servidor. Usado como link para associar a instância do objeto servidor.\nEste identificador identifica exclusivamente cada servidor LwM2M configurado para o cliente LwM2M.\nO recurso DEVE ser definido quando o recurso Bootstrap-Server tem o valor 'false'.", + "short-id-required": "O ID curto do servidor é obrigatório.", + "short-id-range": "O ID curto do servidor deve estar entre {{ min }} e {{ max }}.", + "short-id-pattern": "O ID curto do servidor deve ser um número inteiro positivo.", + "short-id-pattern-bs": "O ID curto do servidor deve ser apenas nulo", + "lifetime": "Tempo de vida do registro do cliente", + "lifetime-required": "O tempo de vida do registro do cliente é obrigatório.", + "lifetime-pattern": "O tempo de vida do registro do cliente deve ser um número inteiro positivo.", + "default-min-period": "Período mínimo entre duas notificações (s)", + "default-min-period-tooltip": "O valor padrão que o cliente LwM2M deve usar para o Período Mínimo de uma Observação na ausência deste parâmetro em uma Observação.", + "default-min-period-required": "O período mínimo é obrigatório.", + "default-min-period-pattern": "O período mínimo deve ser um número inteiro positivo.", + "notification-storing": "Armazenamento de notificações quando desabilitado ou offline", + "binding": "Vinculação", + "binding-type": { + "u": "U: O cliente é acessível via vinculação UDP a qualquer momento.", + "m": "M: O cliente é acessível via vinculação MQTT a qualquer momento.", + "h": "H: O cliente é acessível via vinculação HTTP a qualquer momento.", + "t": "T: O cliente é acessível via vinculação TCP a qualquer momento.", + "s": "S: O cliente é acessível via vinculação SMS a qualquer momento.", + "n": "N: O cliente DEVE enviar a resposta a tal solicitação pela vinculação Non-IP (suportado desde LWM2M 1.1).", + "uq": "UQ: Conexão UDP em modo fila (não suportado desde LWM2M 1.1)", + "uqs": "UQS: conexões UDP e SMS ativas; UDP em modo fila, SMS em modo padrão (não suportado desde LWM2M 1.1)", + "tq": "TQ: Conexão TCP em modo fila (não suportado desde LWM2M 1.1)", + "tqs": "TQS: conexões TCP e SMS ativas; TCP em modo fila, SMS em modo padrão (não suportado desde LWM2M 1.1)", + "sq": "SQ: Conexão SMS em modo fila (não suportado desde LWM2M 1.1)" + }, + "binding-tooltip": "Esta é a lista no recurso \"binding\" do objeto de servidor LwM2M - /1/x/7.\nIndica os modos de vinculação suportados no cliente LwM2M.\nEste valor DEVE ser o mesmo que o valor no recurso \"Supported Binding and Modes\" no objeto Device (/3/0/16).\nEmbora múltiplos transportes sejam suportados, apenas uma vinculação de transporte pode ser usada durante toda a Sessão de Transporte.\nComo exemplo, quando UDP e SMS são ambos suportados, o cliente LwM2M e o servidor LwM2M podem optar por se comunicar por UDP ou SMS durante toda a Sessão de Transporte.", + "bootstrap-server": "Servidor Bootstrap", + "lwm2m-server": "Servidor LwM2M", + "include-bootstrap-server": "Incluir atualizações do servidor Bootstrap", + "bootstrap-update-title": "Você já tem um servidor Bootstrap configurado. Tem certeza de que deseja excluir as atualizações?", + "bootstrap-update-text": "Atenção: após a confirmação, os dados de configuração do servidor Bootstrap se tornarão irrecuperáveis.", + "server-host": "Host", + "server-host-required": "O host é obrigatório.", + "server-port": "Porta", + "server-port-required": "A porta é obrigatória.", + "server-port-pattern": "A porta deve ser um número inteiro positivo.", + "server-port-range": "A porta deve estar entre 1 e 65535.", + "server-public-key": "Chave pública do servidor", + "server-public-key-required": "A chave pública do servidor é obrigatória.", + "client-hold-off-time": "Tempo de espera", + "client-hold-off-time-required": "O tempo de espera é obrigatório.", + "client-hold-off-time-pattern": "O tempo de espera deve ser um número inteiro positivo.", + "client-hold-off-time-tooltip": "Tempo de espera do cliente para uso exclusivo com um servidor Bootstrap", + "account-after-timeout": "Conta após o tempo limite", + "account-after-timeout-required": "A conta após o tempo limite é obrigatória.", + "account-after-timeout-pattern": "A conta após o tempo limite deve ser um número inteiro positivo.", + "account-after-timeout-tooltip": "Conta do servidor Bootstrap após o valor de tempo limite fornecido por este recurso.", + "server-type": "Tipo de servidor", + "add-new-server-title": "Adicionar nova configuração de servidor", + "add-server-config": "Adicionar configuração de servidor", + "add-lwm2m-server-config": "Adicionar servidor LwM2M", + "no-config-servers": "Nenhum servidor configurado", + "others-tab": "Outras configurações", + "ota-update": "Atualização OTA", + "use-object-19-for-ota-update": "Usar Objeto 19 para metadados de arquivo OTA (checksum, tamanho, versão, nome)", + "use-object-19-for-ota-update-hint": "Usar Recurso ObjectId = 19 para atualizações OTA: FirmWare → InstanceId = 65534, SoftWare → InstanceId = 65535. O formato de dados é JSON encapsulado em Base64. Este JSON contém metadados do arquivo OTA (informações do arquivo): \"Checksum\" (SHA256). Campos adicionais: \"Title\" (nome OTA), \"Version\" (versão OTA), \"File Name\" (nome do arquivo para armazenar OTA no cliente), \"File Size\" (tamanho OTA em bytes).", + "client-strategy": "Estratégia do cliente ao conectar", + "client-strategy-label": "Estratégia", + "client-strategy-only-observe": "Apenas Observe Request para o cliente após a conexão inicial", + "client-strategy-read-all": "Ler todos os recursos e Observe Request para o cliente após o registro", + "fw-update": "Atualização de firmware", + "fw-update-strategy": "Estratégia de atualização de firmware", + "fw-update-strategy-data": "Enviar atualização de firmware como arquivo binário usando Objeto 19 e Recurso 0 (Data)", + "fw-update-strategy-package": "Enviar atualização de firmware como arquivo binário usando Objeto 5 e Recurso 0 (Package)", + "fw-update-strategy-package-uri": "Gerar automaticamente URL CoAP única para baixar o pacote e enviar atualização de firmware como Objeto 5 e Recurso 1 (Package URI)", + "sw-update": "Atualização de software", + "sw-update-strategy": "Estratégia de atualização de software", + "sw-update-strategy-package": "Enviar arquivo binário usando Objeto 9 e Recurso 2 (Package)", + "sw-update-strategy-package-uri": "Gerar automaticamente URL CoAP única para baixar o pacote e enviar atualização de software usando Objeto 9 e Recurso 3 (Package URI)", + "fw-update-resource": "Recurso CoAP de atualização de firmware", + "fw-update-resource-required": "O recurso CoAP de atualização de firmware é obrigatório.", + "sw-update-resource": "Recurso CoAP de atualização de software", + "sw-update-resource-required": "O recurso CoAP de atualização de software é obrigatório.", + "config-json-tab": "Perfil de dispositivo de configuração Json", + "attributes-name": { + "min-period": "Período mínimo", + "max-period": "Período máximo", + "greater-than": "Maior que", + "less-than": "Menor que", + "step": "Passo", + "min-evaluation-period": "Período mínimo de avaliação", + "max-evaluation-period": "Período máximo de avaliação" + }, + "default-object-id": "Versão padrão do objeto (Atributo)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1", + "v1-2": "1.2" + }, + "observe-strategy": { + "observe-strategy": "Estratégia de observação", + "single": "Individual", + "single-description": "Uma solicitação Observe por recurso (maior precisão, mais tráfego de rede)", + "composite-all": "Composto total", + "composite-all-description": "Todos os recursos são observados com uma única solicitação Composite Observe (mais eficiente, menos flexível)", + "composite-by-object": "Composto por objetos", + "composite-by-object-description": "Os recursos são agrupados por tipo de objeto e observados usando solicitações Composite Observe separadas (abordagem equilibrada)" + }, + "init-attr-tel-as-obs-strategy": "Inicializar atributos e telemetria usando a estratégia Observe", + "init-attr-tel-as-obs-strategy-hint": "Se falso - atributos e telemetria são inicializados lendo seus valores um por um.\\nSe verdadeiro - atributos e telemetria são inicializados assinando seus valores usando a estratégia Observe." + }, + "snmp": { + "add-communication-config": "Adicionar configuração de comunicação", + "add-mapping": "Adicionar mapeamento", + "authentication-passphrase": "Frase de autenticação", + "authentication-passphrase-required": "A frase de autenticação é obrigatória.", + "authentication-protocol": "Protocolo de autenticação", + "authentication-protocol-required": "O protocolo de autenticação é obrigatório.", + "communication-configs": "Configurações de comunicação", + "community": "String de comunidade", + "community-required": "A string de comunidade é obrigatória.", + "context-name": "Nome do contexto", + "data-key": "Chave de dados", + "data-key-required": "A chave de dados é obrigatória.", + "data-type": "Tipo de dados", + "data-type-required": "O tipo de dados é obrigatório.", + "engine-id": "ID do motor", + "host": "Host", + "host-required": "O host é obrigatório.", + "oid": "OID", + "oid-pattern": "Formato OID inválido.", + "oid-required": "O OID é obrigatório.", + "please-add-communication-config": "Adicione uma configuração de comunicação", + "please-add-mapping-config": "Adicione uma configuração de mapeamento", + "port": "Porta", + "port-format": "Formato de porta inválido.", + "port-required": "A porta é obrigatória.", + "privacy-passphrase": "Frase de privacidade", + "privacy-passphrase-required": "A frase de privacidade é obrigatória.", + "privacy-protocol": "Protocolo de privacidade", + "privacy-protocol-required": "O protocolo de privacidade é obrigatório.", + "protocol-version": "Versão do protocolo", + "protocol-version-required": "A versão do protocolo é obrigatória.", + "querying-frequency": "Frequência de consulta, ms", + "querying-frequency-invalid-format": "A frequência de consulta deve ser um número inteiro positivo.", + "querying-frequency-required": "A frequência de consulta é obrigatória.", + "retries": "Tentativas", + "retries-invalid-format": "O número de tentativas deve ser um número inteiro positivo.", + "retries-required": "O número de tentativas é obrigatório.", + "scope": "Escopo", + "scope-required": "O escopo é obrigatório.", + "security-name": "Nome de segurança", + "security-name-required": "O nome de segurança é obrigatório.", + "timeout-ms": "Tempo limite, ms", + "timeout-ms-invalid-format": "O tempo limite deve ser um número inteiro positivo.", + "timeout-ms-required": "O tempo limite é obrigatório.", + "user-name": "Nome de usuário", + "user-name-required": "O nome de usuário é obrigatório." + } }, "dialog": { - "close": "Fechar diálogo" + "close": "Fechar diálogo", + "error-message-title": "Mensagem de erro:", + "error-details-title": "Detalhes do erro" }, "direction": { "column": "Coluna", "row": "Linha" }, + "edge": { + "edge": "Edge", + "edge-instances": "Instâncias do edge", + "instances": "Instâncias", + "edge-file": "Arquivo do edge", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "label-max-length": "O rótulo deve ter menos de 256 caracteres", + "type-max-length": "O tipo deve ter menos de 256 caracteres", + "management": "Gerenciamento do edge", + "no-edges-matching": "Nenhum edge correspondente a '{{entity}}' foi encontrado.", + "add": "Adicionar edge", + "no-edges-text": "Nenhum edge encontrado", + "edge-details": "Detalhes do edge", + "add-edge-text": "Adicionar novo edge", + "delete": "Excluir edge", + "delete-edge-title": "Tem certeza de que deseja excluir o edge '{{edgeName}}'?", + "delete-edge-text": "Atenção: após a confirmação, o edge e todos os dados relacionados serão irrecuperáveis.", + "delete-edges-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 edge} other {# edges} }?", + "delete-edges-text": "Atenção: após a confirmação, todos os edges selecionados serão removidos e todos os dados relacionados serão irrecuperáveis.", + "name": "Nome", + "name-starts-with": "Nome do edge começa com", + "name-required": "O nome é obrigatório.", + "description": "Descrição", + "details": "Detalhes", + "events": "Eventos", + "copy-id": "Copiar ID do edge", + "id-copied-message": "ID do edge copiado para a área de transferência", + "sync": "Sincronizar edge", + "edge-required": "Edge é obrigatório", + "edge-type": "Tipo do edge", + "edge-type-required": "O tipo do edge é obrigatório.", + "event-action": "Ação do evento", + "entity-id": "ID da entidade", + "select-edge-type": "Selecionar tipo do edge", + "assign-to-customer": "Atribuir ao cliente", + "assign-to-customer-text": "Selecione o cliente ao qual deseja atribuir o(s) edge(s)", + "assign-edge-to-customer": "Atribuir edge(s) ao cliente", + "assign-edge-to-customer-text": "Selecione os edges a serem atribuídos ao cliente", + "assignedToCustomer": "Atribuído ao cliente", + "edge-public": "Edge é público", + "assigned-to-customer": "Atribuído a: {{customerTitle}}", + "unassign-from-customer": "Desatribuir do cliente", + "unassign-edge-title": "Tem certeza de que deseja desatribuir o edge '{{edgeName}}'?", + "unassign-edge-text": "Após a confirmação, o edge será desatribuído e não estará mais acessível pelo cliente.", + "unassign-edges-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 edge} other {# edges} }?", + "unassign-edges-text": "Após a confirmação, todos os edges selecionados serão desatribuídos e não estarão mais acessíveis pelo cliente.", + "make-public": "Tornar edge público", + "make-public-edge-title": "Tem certeza de que deseja tornar o edge '{{edgeName}}' público?", + "make-public-edge-text": "Após a confirmação, o edge e todos os seus dados serão tornados públicos e acessíveis por outros.", + "make-private": "Tornar edge privado", + "public": "Público", + "make-private-edge-title": "Tem certeza de que deseja tornar o edge '{{edgeName}}' privado?", + "make-private-edge-text": "Após a confirmação, o edge e todos os seus dados serão tornados privados e não estarão mais acessíveis por outros.", + "import": "Importar edge", + "install-connect-instructions": "Instruções de instalação e conexão", + "install-connect-instructions-edge-created": "Edge criado! Veja as instruções de instalação e conexão", + "loading-edge-instructions": "Carregando instruções do edge...", + "label": "Rótulo", + "load-entity-error": "Falha ao carregar os dados. A entidade foi excluída.", + "assign-new-edge": "Atribuir novo edge", + "unassign-from-edge": "Desatribuir do edge", + "edge-key": "Chave do edge", + "copy-edge-key": "Copiar chave do edge", + "edge-key-copied-message": "Chave do edge copiada para a área de transferência", + "edge-secret": "Segredo do edge", + "copy-edge-secret": "Copiar segredo do edge", + "edge-secret-copied-message": "Segredo do edge copiado para a área de transferência", + "manage-assets": "Gerenciar ativos", + "manage-devices": "Gerenciar dispositivos", + "manage-entity-views": "Gerenciar visualizações de entidade", + "manage-dashboards": "Gerenciar dashboards", + "manage-rulechains": "Gerenciar cadeias de regras", + "assets": "Ativos do edge", + "devices": "Dispositivos do edge", + "entity-views": "Visualizações de entidade do edge", + "dashboard": "Dashboard do edge", + "dashboards": "Dashboards do edge", + "rulechain-templates": "Modelos de cadeia de regras", + "edge-rulechain-templates": "Modelos de cadeia de regras do edge", + "rulechains": "Cadeias de regras do edge", + "search": "Pesquisar edges", + "selected-edges": "{ count, plural, =1 {1 edge} other {# edges} } selecionado(s)", + "any-edge": "Qualquer edge", + "no-edge-types-matching": "Nenhum tipo de edge correspondente a '{{entitySubtype}}' foi encontrado.", + "edge-type-list-empty": "Nenhum tipo de edge selecionado.", + "edge-types": "Tipos de edge", + "enter-edge-type": "Inserir tipo do edge", + "deployed": "Implantado", + "pending": "Pendente", + "downlinks": "Downlinks", + "no-downlinks-prompt": "Nenhum downlink encontrado", + "sync-process-started-successfully": "Processo de sincronização iniciado com sucesso!", + "missing-related-rule-chains-title": "O edge possui cadeia(s) de regras relacionada(s) ausente(s)", + "missing-related-rule-chains-text": "A(s) cadeia(s) de regras atribuída(s) ao edge utiliza(m) nós de regra que encaminham mensagem(s) para cadeia(s) de regras que não estão atribuídas a este edge.

Lista de cadeia(s) de regras ausente(s):
{{missingRuleChains}}", + "widget-datasource-error": "Este widget suporta apenas a fonte de dados da entidade EDGE", + "upgrade-instructions": "Instruções de atualização", + "connected": "Conectado", + "disconnected": "Desconectado" + }, + "edge-event": { + "type-dashboard": "Dashboard", + "type-asset": "Ativo", + "type-device": "Dispositivo", + "type-device-profile": "Perfil do dispositivo", + "type-asset-profile": "Perfil do ativo", + "type-entity-view": "Visualização de entidade", + "type-alarm": "Alarme", + "type-rule-chain": "Cadeia de regras", + "type-rule-chain-metadata": "Metadados da cadeia de regras", + "type-edge": "Edge", + "type-user": "Usuário", + "type-tenant": "Tenant", + "type-tenant-profile": "Perfil do tenant", + "type-customer": "Cliente", + "type-relation": "Relação", + "type-widgets-bundle": "Conjunto de widgets", + "type-widgets-type": "Tipo de widgets", + "type-admin-settings": "Configurações de administração", + "type-ota-package": "Pacote OTA", + "type-queue": "Fila", + "action-type-added": "Adicionado", + "action-type-deleted": "Excluído", + "action-type-updated": "Atualizado", + "action-type-post-attributes": "Publicar atributos", + "action-type-attributes-updated": "Atributos atualizados", + "action-type-attributes-deleted": "Atributos excluídos", + "action-type-timeseries-updated": "Séries temporais atualizadas", + "action-type-credentials-updated": "Credenciais atualizadas", + "action-type-assigned-to-customer": "Atribuído ao cliente", + "action-type-unassigned-from-customer": "Desatribuído do cliente", + "action-type-relation-add-or-update": "Adição ou atualização de relação", + "action-type-relation-deleted": "Relação excluída", + "action-type-rpc-call": "Chamada RPC", + "action-type-alarm-ack": "Confirmação de alarme", + "action-type-alarm-clear": "Limpeza de alarme", + "action-type-alarm-assigned": "Alarme atribuído", + "action-type-alarm-unassigned": "Alarme desatribuído", + "action-type-assigned-to-edge": "Atribuído ao edge", + "action-type-unassigned-from-edge": "Desatribuído do edge", + "action-type-credentials-request": "Solicitação de credenciais", + "action-type-entity-merge-request": "Solicitação de mesclagem de entidade" + }, "error": { - "unable-to-connect": "Impossível conectar ao servidor! Verifique sua conexão à Internet.", - "unhandled-error-code": "Código de erro sem tratamento: {{errorCode}}", + "unable-to-connect": "Não foi possível conectar ao servidor! Verifique sua conexão com a internet.", + "unhandled-error-code": "Código de erro não tratado: {{errorCode}}", "unknown-error": "Erro desconhecido" }, "entity": { "entity": "Entidade", "entities": "Entidades", - "aliases": "Aliases de entidades", + "entities-count": "Contagem de entidades", + "alarms-count": "Contagem de alarmes", + "aliases": "Aliases de entidade", + "aliases-short": "Aliases", "entity-alias": "Alias de entidade", - "unable-delete-entity-alias-title": "Impossível excluir alias de entidade", - "unable-delete-entity-alias-text": "Não é possível excluir o alias de entidade '{{entityAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", - "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
Os aliases de entidades devem ser exclusivos no dashboard.", + "unable-delete-entity-alias-title": "Não é possível excluir o alias de entidade", + "unable-delete-entity-alias-text": "O alias de entidade '{{entityAlias}}' não pode ser excluído pois é utilizado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", + "duplicate-alias-error": "Alias duplicado encontrado '{{alias}}'.
Os aliases de entidade devem ser únicos no dashboard.", "missing-entity-filter-error": "Filtro ausente para o alias '{{alias}}'.", - "configure-alias": "Configurar alias de '{{alias}}'", + "configure-alias": "Configurar alias '{{alias}}'", "alias": "Alias", "alias-required": "O alias de entidade é obrigatório.", "remove-alias": "Remover alias de entidade", "add-alias": "Adicionar alias de entidade", + "edit-alias": "Editar alias de entidade", "entity-list": "Lista de entidades", "entity-type": "Tipo de entidade", - "entity-types": "Tipos de entidades", - "entity-type-list": "Lista de tipos de entidades", + "entity-types": "Tipos de entidade", + "entity-type-list": "Lista de tipos de entidade", "any-entity": "Qualquer entidade", + "add-entity-type": "Adicionar tipo de entidade", "enter-entity-type": "Inserir tipo de entidade", - "no-entities-matching": "Nenhuma entidade encontrada que coincida com '{{entity}}'.", - "no-entity-types-matching": "Nenhum tipo de entidade encontrado que coincida com '{{entityType}}'.", - "name-starts-with": "O nome começa com", + "no-entities-matching": "Nenhuma entidade correspondente a '{{entity}}' foi encontrada.", + "no-entities-text": "Nenhuma entidade encontrada", + "no-entity-types-matching": "Nenhum tipo de entidade correspondente a '{{entityType}}' foi encontrado.", + "name-starts-with": "Expressão de nome", + "help-text": "Use '%' conforme necessário: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Usar filtro", "entity-list-empty": "Nenhuma entidade selecionada.", - "entity-name-filter-required": "O filtro do nome de entidade é obrigatório.", - "entity-name-filter-no-entity-matched": "Nenhuma entidade encontrada que comece com '{{entity}}'.", - "all-subtypes": "Tudo", + "entity-type-list-required": "Pelo menos um tipo de entidade deve ser selecionado.", + "entity-name-filter-required": "O filtro de nome de entidade é obrigatório.", + "entity-name-filter-no-entity-matched": "Nenhuma entidade começando com '{{entity}}' foi encontrada.", + "all-subtypes": "Todos", "select-entities": "Selecionar entidades", "no-aliases-found": "Nenhum alias encontrado.", "no-alias-matching": "'{{alias}}' não encontrado.", - "create-new-alias": "Criar um novo!", + "create-new-alias": "Criar novo!", + "create-new": "Criar novo", "key": "Chave", "key-name": "Nome da chave", - "no-keys-found": "Nenhuma chave encontrado.", + "no-keys-found": "Nenhuma chave encontrada.", "no-key-matching": "'{{key}}' não encontrado.", - "create-new-key": "Criar um novo!", + "create-new-key": "Criar novo!", "type": "Tipo", "type-required": "O tipo de entidade é obrigatório.", "type-device": "Dispositivo", "type-devices": "Dispositivos", - "list-of-devices": "{ count, plural, =1 {One device} other {List of # devices} }", + "list-of-devices": "{ count, plural, =1 {Um dispositivo} other {Lista de # dispositivos} }", "device-name-starts-with": "Dispositivos cujos nomes começam com '{{prefix}}'", + "type-device-profile": "Perfil do dispositivo", + "type-device-profiles": "Perfis do dispositivo", + "clear-selected-profiles": "Limpar perfis selecionados", + "list-of-device-profiles": "{ count, plural, =1 {Um perfil do dispositivo} other {Lista de # perfis do dispositivo} }", + "device-profile-name-starts-with": "Perfis do dispositivo cujos nomes começam com '{{prefix}}'", + "type-asset-profile": "Perfil do ativo", + "type-asset-profiles": "Perfis do ativo", + "list-of-asset-profiles": "{ count, plural, =1 {Um perfil do ativo} other {Lista de # perfis do ativo} }", + "asset-profile-name-starts-with": "Perfis do ativo cujos nomes começam com '{{prefix}}'", "type-asset": "Ativo", "type-assets": "Ativos", - "list-of-assets": "{ count, plural, =1 {One asset} other {List of # assets} }", + "list-of-assets": "{ count, plural, =1 {Um ativo} other {Lista de # ativos} }", "asset-name-starts-with": "Ativos cujos nomes começam com '{{prefix}}'", - "type-entity-view": "Exibição de entidade", - "type-entity-views": "Exibições de entidades", - "list-of-entity-views": "{ count, plural, =1 {One entity view} other {List of # entity views} }", - "entity-view-name-starts-with": "Exibições de entidades cujos nomes começam com '{{prefix}}'", + "type-entity-view": "Visualização de entidade", + "type-entity-views": "Visualizações de entidade", + "list-of-entity-views": "{ count, plural, =1 {Uma visualização de entidade} other {Lista de # visualizações de entidade} }", + "entity-view-name-starts-with": "Visualizações de entidade cujos nomes começam com '{{prefix}}'", "type-rule": "Regra", "type-rules": "Regras", - "list-of-rules": "{ count, plural, =1 {One rule} other {List of # rules} }", + "list-of-rules": "{ count, plural, =1 {Uma regra} other {Lista de # regras} }", "rule-name-starts-with": "Regras cujos nomes começam com '{{prefix}}'", - "type-plugin": "Plug-in", - "type-plugins": "Plug-ins", - "list-of-plugins": "{ count, plural, =1 {One plugin} other {List of # plugins} }", - "plugin-name-starts-with": "Plug-ins cujos nomes começam com '{{prefix}}'", - "type-tenant": "Locatário", - "type-tenants": "Locatários", - "list-of-tenants": "{ count, plural, =1 {One tenant} other {List of # tenants} }", - "tenant-name-starts-with": "Locatários cujos nomes começam com '{{prefix}}'", + "type-plugin": "Plugin", + "type-plugins": "Plugins", + "list-of-plugins": "{ count, plural, =1 {Um plugin} other {Lista de # plugins} }", + "plugin-name-starts-with": "Plugins cujos nomes começam com '{{prefix}}'", + "type-tenant": "Tenant", + "type-tenants": "Tenants", + "list-of-tenants": "{ count, plural, =1 {Um tenant} other {Lista de # tenants} }", + "tenant-name-starts-with": "Tenants cujos nomes começam com '{{prefix}}'", + "type-tenant-profile": "Perfil do tenant", + "type-tenant-profiles": "Perfis do tenant", + "list-of-tenant-profiles": "{ count, plural, =1 {Um perfil do tenant} other {Lista de # perfis do tenant} }", + "tenant-profile-name-starts-with": "Perfis do tenant cujos nomes começam com '{{prefix}}'", "type-customer": "Cliente", "type-customers": "Clientes", - "list-of-customers": "{ count, plural, =1 {One customer} other {List of # customers} }", + "list-of-customers": "{ count, plural, =1 {Um cliente} other {Lista de # clientes} }", "customer-name-starts-with": "Clientes cujos nomes começam com '{{prefix}}'", "type-user": "Usuário", "type-users": "Usuários", - "list-of-users": "{ count, plural, =1 {One user} other {List of # users} }", + "list-of-users": "{ count, plural, =1 {Um usuário} other {Lista de # usuários} }", "user-name-starts-with": "Usuários cujos nomes começam com '{{prefix}}'", "type-dashboard": "Dashboard", "type-dashboards": "Dashboards", - "list-of-dashboards": "{ count, plural, =1 {One dashboard} other {List of # dashboards} }", + "list-of-dashboards": "{ count, plural, =1 {Um dashboard} other {Lista de # dashboards} }", "dashboard-name-starts-with": "Dashboards cujos nomes começam com '{{prefix}}'", "type-alarm": "Alarme", "type-alarms": "Alarmes", - "list-of-alarms": "{ count, plural, =1 {One alarms} other {List of # alarms} }", + "list-of-alarms": "{ count, plural, =1 {Um alarme} other {Lista de # alarmes} }", "alarm-name-starts-with": "Alarmes cujos nomes começam com '{{prefix}}'", "type-rulechain": "Cadeia de regras", "type-rulechains": "Cadeias de regras", - "list-of-rulechains": "{ count, plural, =1 {One rule chain} other {List of # rule chains} }", + "list-of-rulechains": "{ count, plural, =1 {Uma cadeia de regras} other {Lista de # cadeias de regras} }", "rulechain-name-starts-with": "Cadeias de regras cujos nomes começam com '{{prefix}}'", "type-rulenode": "Nó de regra", - "type-rulenodes": "Nós de regras", - "list-of-rulenodes": "{ count, plural, =1 {One rule node} other {List of # rule nodes} }", - "rulenode-name-starts-with": "Nós de regras cujos nomes começam com '{{prefix}}'", + "type-rulenodes": "Nós de regra", + "list-of-rulenodes": "{ count, plural, =1 {Um nó de regra} other {Lista de # nós de regra} }", + "rulenode-name-starts-with": "Nós de regra cujos nomes começam com '{{prefix}}'", + "type-api-key": "Chave de API", + "type-api-keys": "Chaves de API", "type-current-customer": "Cliente atual", - "type-current-tenant": "Locatário atual", + "type-current-tenant": "Tenant atual", "type-current-user": "Usuário atual", - "type-current-user-owner": "Proprietário de usuário atual", + "type-current-user-owner": "Proprietário do usuário atual", + "type-calculated-field": "Campo calculado", + "type-calculated-fields": "Campos calculados", + "type-ai-model": "Modelo de IA", + "type-ai-models": "Modelos de IA", + "type-widgets-bundle": "Conjunto de widgets", + "type-widgets-bundles": "Conjuntos de widgets", + "list-of-widgets-bundles": "{ count, plural, =1 {Um conjunto de widgets} other {Lista de # conjuntos de widgets} }", + "type-widget": "Widget", + "type-widgets": "Widgets", + "list-of-widgets": "{ count, plural, =1 {Um widget} other {Lista de # widgets} }", "search": "Pesquisar entidades", - "selected-entities": "{ count, plural, =1 {1 entity} other {# entities} } selecionado(s)", + "selected-entities": "{ count, plural, =1 {1 entidade} other {# entidades} } selecionada(s)", "entity-name": "Nome da entidade", - "entity-label": "Etiqueta de entidade", - "details": "Detalhes de entidades", + "entity-label": "Rótulo da entidade", + "details": "Detalhes da entidade", "no-entities-prompt": "Nenhuma entidade encontrada", - "no-data": "Nenhum dado para exibição", - "columns-to-display": "Colunas para Exibição" + "no-data": "Nenhum dado para exibir", + "show-all-columns": "Mostrar todos", + "columns-to-display": "Colunas a exibir", + "type-api-usage-state": "Estado de uso da API", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, =1 {Um edge} other {Lista de # edges} }", + "edge-name-starts-with": "Edges cujos nomes começam com '{{prefix}}'", + "version-conflict": { + "message": "Deseja sobrescrever a versão existente ou descartar as alterações e carregar a versão mais recente?", + "link": "Você pode baixar sua versão do {{entityType}} usando este", + "overwrite": "Sobrescrever versão", + "discard": "Descartar alterações" + }, + "type-tb-resource": "Recurso", + "type-tb-resources": "Recursos", + "list-of-tb-resources": "{ count, plural, =1 {Um recurso} other {Lista de # recursos} }", + "type-ota-package": "Pacote OTA", + "type-ota-packages": "Pacotes OTA", + "list-of-ota-packages": "{ count, plural, =1 {Um pacote OTA} other {Lista de # pacotes OTA} }", + "type-rpc": "RPC", + "type-queue": "Fila", + "type-queue-stats": "Estatísticas da fila", + "type-queues-stats": "Estatísticas das filas", + "type-notification": "Notificação", + "type-notification-rule": "Regra de notificação", + "type-notification-rules": "Regras de notificação", + "list-of-notification-rules": "{ count, plural, =1 {Uma regra de notificação} other {Lista de # regras de notificação} }", + "type-notification-target": "Destinatário de notificação", + "type-notification-targets": "Destinatários de notificação", + "list-of-notification-targets": "{ count, plural, =1 {Um destinatário de notificação} other {Lista de # destinatários de notificação} }", + "type-notification-request": "Solicitação de notificação", + "type-notification-template": "Modelo de notificação", + "type-notification-templates": "Modelos de notificação", + "list-of-notification-templates": "{ count, plural, =1 {Um modelo de notificação} other {Lista de # modelos de notificação} }", + "link": "link", + "type-oauth2-client": "Cliente OAuth 2.0", + "type-oauth2-clients": "Clientes OAuth 2.0", + "list-of-oauth2-clients": "{ count, plural, =1 {Um cliente OAuth 2.0} other {Lista de # clientes OAuth 2.0} }", + "type-domain": "Domínio", + "type-domains": "Domínios", + "list-of-domains": "{ count, plural, =1 {Um domínio} other {Lista de # domínios} }", + "type-mobile-app": "Aplicativo móvel", + "type-mobile-apps": "Aplicativos móveis", + "list-of-mobile-apps": "{ count, plural, =1 {Um aplicativo móvel} other {Lista de # aplicativos móveis} }", + "type-mobile-app-bundle": "Pacote móvel", + "type-mobile-app-bundles": "Pacotes móveis", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Um pacote móvel} other {Lista de # pacotes móveis} }", + "limit-reached": "Limite atingido", + "limit-reached-text": "Você atingiu o limite de {{ entities }}. Para adicionar mais, solicite ao Administrador do sistema que aumente o limite de {{ entity }}.", + "request-limit-increase": "Solicitar aumento do limite", + "request-sysadmin-text": "Você é o Administrador do sistema?", + "login-here": "Faça login aqui", + "to-increase-limit": "para aumentar o limite.", + "increase-limit-request-sent-title": "Enviamos uma solicitação automática ao Administrador do sistema para aumentar o limite", + "increase-limit-request-sent-text": "Aguarde enquanto a solicitação é revisada e as configurações são atualizadas. Pode ser necessário atualizar esta página para ver as alterações." }, "entity-field": { - "created-time": "Hora de criação", + "created-time": "Data de criação", "name": "Nome", "type": "Tipo", "first-name": "Nome", "last-name": "Sobrenome", - "email": "E-mail", + "email": "Email", "title": "Título", "country": "País", "state": "Estado", @@ -877,287 +3214,321 @@ "address2": "Endereço 2", "zip": "CEP", "phone": "Telefone", - "label": "Etiqueta" + "label": "Rótulo", + "queue-name": "Nome da fila", + "service-id": "ID do serviço", + "owner-name": "Nome do proprietário", + "owner-type": "Tipo do proprietário" }, "entity-view": { - "entity-view": "Exibição de entidade", - "entity-view-required": "A exibição de entidade é obrigatória.", - "entity-views": "Exibições de entidades", - "management": "Habilitar gerenciamento de exibições", - "view-entity-views": "Visualizar exibições de entidades", - "entity-view-alias": "Alias de exibição de entidade", - "aliases": "Aliases de exibições de entidades", + "entity-view": "Visualização de entidade", + "entity-view-required": "Visualização de entidade é obrigatória.", + "entity-views": "Visualizações de entidade", + "management": "Gerenciamento de visualização de entidade", + "view-entity-views": "Visualizar visualizações de entidade", + "entity-view-alias": "Alias de visualização de entidade", + "aliases": "Aliases de visualização de entidade", "no-alias-matching": "'{{alias}}' não encontrado.", "no-aliases-found": "Nenhum alias encontrado.", "no-key-matching": "'{{key}}' não encontrado.", - "no-keys-found": "Nenhuma chave encontrado.", + "no-keys-found": "Nenhuma chave encontrada.", "create-new-alias": "Criar um novo!", "create-new-key": "Criar um novo!", - "duplicate-alias-error": "Alias '{{alias}}' duplicado encontrado.
Os aliases de exibições de entidades devem ser exclusivos no dashboard.", - "configure-alias": "Configurar alias de '{{alias}}'", - "no-entity-views-matching": "Nenhuma exibição de entidade encontrada que coincida com '{{entity}}'.", + "duplicate-alias-error": "Alias duplicado encontrado '{{alias}}'.
Os aliases de visualização de entidade devem ser únicos no dashboard.", + "configure-alias": "Configurar alias '{{alias}}'", + "no-entity-views-matching": "Nenhuma visualização de entidade correspondente a '{{entity}}' foi encontrada.", "public": "Público", "alias": "Alias", - "alias-required": "O alias de exibição de entidade é obrigatório.", - "remove-alias": "Remover alias de exibição de entidade", - "add-alias": "Adicionar alias de exibição de entidade", - "name-starts-with": "O nome da exibição de entidade começa com", - "entity-view-list": "Lista de exibições de entidades", + "alias-required": "O alias de visualização de entidade é obrigatório.", + "remove-alias": "Remover alias de visualização de entidade", + "add-alias": "Adicionar alias de visualização de entidade", + "name-starts-with": "Expressão de nome de visualização de entidade", + "help-text": "Use '%' conforme necessário: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", + "entity-view-list": "Lista de visualizações de entidade", "use-entity-view-name-filter": "Usar filtro", - "entity-view-list-empty": "Nenhuma exibição de entidade selecionada.", - "entity-view-name-filter-required": "O filtro de nomes de exibições de entidades é obrigatório.", - "entity-view-name-filter-no-entity-view-matched": "Nenhuma exibição de entidade encontrada que comece com '{{entityView}}'.", - "add": "Adicionar exibição de entidade", - "entity-view-public": "A exibição de entidade é pública", - "assign-to-customer": "Atribuir a cliente", - "assign-entity-view-to-customer": "Atribuir Exibição(ões) de entidade(s) a Cliente", - "assign-entity-view-to-customer-text": "Selecione as exibições de entidades a serem atribuídas ao cliente", - "no-entity-views-text": "Nenhuma exibição de entidade encontrada", - "assign-to-customer-text": "Selecione o cliente para atribuir a(s) exibição(ões) de entidade(s)", - "entity-view-details": "Detalhes de exibições de entidades", - "add-entity-view-text": "Adicionar nova exibição de entidade", - "delete": "Excluir exibição de entidade", - "assign-entity-views": "Atribuir exibições de entidades", - "assign-entity-views-text": "Atribuir { count, plural, =1 {1 entity view} other {# entity views} } a cliente", - "delete-entity-views": "Excluir exibições de entidades", - "unassign-from-customer": "Remover atribuição a cliente", - "unassign-entity-views": "Remover atribuição de exibições de entidades", - "unassign-entity-views-action-title": "Remover atribuição de { count, plural, =1 {1 entity view} other {# entity views} } a cliente", - "assign-new-entity-view": "Atribuir nova exibição de entidade", - "delete-entity-view-title": "Tem certeza de que deseja excluir a exibição de entidade '{{entityViewName}}'?", - "delete-entity-view-text": "Cuidado, após confirmar, não será possível recuperar a exibição de entidade e todos os dados associados.", - "delete-entity-views-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 entity view} other {# entity views} }?", - "delete-entity-views-action-title": "Excluir { count, plural, =1 {1 entity view} other {# entity views} }", - "delete-entity-views-text": "Cuidado, após confirmar, todas as exibições de entidades selecionadas serão removidas e não será possível recuperar nenhum dado associado.", - "unassign-entity-view-title": "Tem certeza de que deseja remover a atribuição da exibição de entidade '{{entityViewName}}'?", - "unassign-entity-view-text": "Após confirmar, a atribuição da exibição de entidade será removida e ela não poderá ser acessada pelo cliente.", - "unassign-entity-view": "Remover atribuição de exibição de entidade", - "unassign-entity-views-title": "Tem certeza de que deseja remover a atribuição de { count, plural, =1 {1 entity view} other {# entity views} }?", - "unassign-entity-views-text": "Após confirmar, a atribuição de todas as exibições de entidade selecionadas será removida e elas não poderão ser acessadas pelo cliente.", - "entity-view-type": "Tipo de exibições de entidades", - "entity-view-type-required": "O tipo de exibição de entidade é obrigatório.", - "select-entity-view-type": "Selecionar tipo de exibição de entidade", - "enter-entity-view-type": "Inserir tipo de exibição de entidade", - "any-entity-view": "Qualquer exibição de entidade", - "no-entity-view-types-matching": "Nenhum tipo de exibição de entidade encontrado que coincida com '{{entitySubtype}}'.", - "entity-view-type-list-empty": "Nenhum tipo de exibição de entidade selecionado.", - "entity-view-types": "Tipos de exibição de entidade", - "created-time": "Hora de criação", + "entity-view-list-empty": "Nenhuma visualização de entidade selecionada.", + "entity-view-name-filter-required": "O filtro de nome de visualização de entidade é obrigatório.", + "entity-view-name-filter-no-entity-view-matched": "Nenhuma visualização de entidade começando com '{{entityView}}' foi encontrada.", + "add": "Adicionar visualização de entidade", + "entity-view-public": "A visualização de entidade é pública", + "assign-to-customer": "Atribuir ao cliente", + "assign-entity-view-to-customer": "Atribuir visualização(ões) de entidade ao cliente", + "assign-entity-view-to-customer-text": "Selecione as visualizações de entidade para atribuir ao cliente", + "no-entity-views-text": "Nenhuma visualização de entidade encontrada", + "assign-to-customer-text": "Selecione o cliente para atribuir a(s) visualização(ões) de entidade", + "entity-view-details": "Detalhes da visualização de entidade", + "add-entity-view-text": "Adicionar nova visualização de entidade", + "delete": "Excluir visualização de entidade", + "assign-entity-views": "Atribuir visualizações de entidade", + "assign-entity-views-text": "Atribuir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} } ao cliente", + "delete-entity-views": "Excluir visualizações de entidade", + "make-public": "Tornar visualização de entidade pública", + "make-private": "Tornar visualização de entidade privada", + "unassign-from-customer": "Desatribuir do cliente", + "unassign-entity-views": "Desatribuir visualizações de entidade", + "unassign-entity-views-action-title": "Desatribuir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} } do cliente", + "assign-new-entity-view": "Atribuir nova visualização de entidade", + "delete-entity-view-title": "Tem certeza de que deseja excluir a visualização de entidade '{{entityViewName}}'?", + "delete-entity-view-text": "Atenção: após a confirmação, a visualização de entidade e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-entity-views-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} }?", + "delete-entity-views-action-title": "Excluir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} }", + "delete-entity-views-text": "Atenção: após a confirmação, todas as visualizações de entidade selecionadas serão removidas e todos os dados relacionados se tornarão irrecuperáveis.", + "make-public-entity-view-title": "Tem certeza de que deseja tornar a visualização de entidade '{{entityViewName}}' pública?", + "make-public-entity-view-text": "Após a confirmação, a visualização de entidade e todos os seus dados serão tornados públicos e acessíveis por outros.", + "make-private-entity-view-title": "Tem certeza de que deseja tornar a visualização de entidade '{{entityViewName}}' privada?", + "make-private-entity-view-text": "Após a confirmação, a visualização de entidade e todos os seus dados serão tornados privados e não serão acessíveis por outros.", + "unassign-entity-view-title": "Tem certeza de que deseja desatribuir a visualização de entidade '{{entityViewName}}'?", + "unassign-entity-view-text": "Após a confirmação, a visualização de entidade será desatribuída e não estará acessível ao cliente.", + "unassign-entity-view": "Desatribuir visualização de entidade", + "unassign-entity-views-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} }?", + "unassign-entity-views-text": "Após a confirmação, todas as visualizações de entidade selecionadas serão desatribuídas e não estarão acessíveis ao cliente.", + "entity-view-type": "Tipo de visualização de entidade", + "entity-view-type-required": "O tipo de visualização de entidade é obrigatório.", + "select-entity-view-type": "Selecionar tipo de visualização de entidade", + "enter-entity-view-type": "Inserir tipo de visualização de entidade", + "any-entity-view": "Qualquer visualização de entidade", + "no-entity-view-types-matching": "Nenhum tipo de visualização de entidade correspondente a '{{entitySubtype}}' foi encontrado.", + "entity-view-type-list-empty": "Nenhum tipo de visualização de entidade selecionado.", + "entity-view-types": "Tipos de visualização de entidade", + "created-time": "Data de criação", "name": "Nome", - "name-required": "O nome é obrigatório.", + "name-required": "Nome é obrigatório.", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "type-max-length": "O tipo de visualização de entidade deve ter menos de 256 caracteres", "description": "Descrição", "events": "Eventos", "details": "Detalhes", - "copyId": "Copiar ID de exibição de entidade", - "idCopiedMessage": "O ID de exibição de entidade foi copiado para a área de transferência", + "copyId": "Copiar Id da visualização de entidade", + "idCopiedMessage": "O Id da visualização de entidade foi copiado para a área de transferência", "assignedToCustomer": "Atribuído ao cliente", - "unable-entity-view-device-alias-title": "Impossível excluir alias de exibição de entidade", - "unable-entity-view-device-alias-text": "Não é possível excluir o alias do dispositivo '{{entityViewAlias}}' porque ele é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", - "select-entity-view": "Selecionar exibição de entidade", - "make-public": "Tornar exibição de entidade pública", - "make-private": "Tornar exibição de entidade privada", - "start-date": "Data de início", + "unable-entity-view-device-alias-title": "Não é possível excluir o alias de visualização de entidade", + "unable-entity-view-device-alias-text": "O alias de dispositivo '{{entityViewAlias}}' não pode ser excluído pois é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", + "select-entity-view": "Selecionar visualização de entidade", "start-ts": "Hora de início", - "end-date": "Data de término", "end-ts": "Hora de término", - "date-limits": "Limites de datas", + "date-limits": "Limites de data", "client-attributes": "Atributos do cliente", "shared-attributes": "Atributos compartilhados", "server-attributes": "Atributos do servidor", - "timeseries": "Intervalos de tempo", + "timeseries": "Séries temporais", "client-attributes-placeholder": "Atributos do cliente", "shared-attributes-placeholder": "Atributos compartilhados", "server-attributes-placeholder": "Atributos do servidor", - "timeseries-placeholder": "Intervalos de tempo", - "target-entity": "Entidade alvo", + "timeseries-placeholder": "Séries temporais", + "target-entity": "Entidade de destino", "attributes-propagation": "Propagação de atributos", - "attributes-propagation-hint": "A exibição de entidade copiará automaticamente atributos especificados da entidade alvo sempre que essa exibição de entidade for salva ou atualizada. Por questões de desempenho, os atributos da entidade alvo não são propagados para a exibição de entidade em cada alteração de atributo. A propagação automática pode ser habilitada configurando o nó de regra \"copiar para exibição\" na cadeia de regras e vinculando mensagens de \"Pós-Atributos\" e \"Atributos Atualizados\" ao novo nó de regra.", - "timeseries-data": "Dados de intervalos de tempo", - "timeseries-data-hint": "Configurar chaves de dados de intervalos de tempo da entidade alvo que estará acessível para a exibição de entidade. Esses dados de intervalo de tempo são apenas de leitura.", - "make-public-entity-view-title": "Tem certeza de que deseja tornar a exibição de entidade '{{entityViewName}}' pública?", - "make-public-entity-view-text": "Após confirmar, a exibição de entidade e todos os dados associados a ela se tornarão públicos e poderão ser acessados por outros.", - "make-private-entity-view-title": "Tem certeza de que deseja tornar a exibição de entidade '{{entityViewName}}' privada?", - "make-private-entity-view-text": "Após confirmar, a exibição de entidade e todos os dados associados a ela se tornarão privados e não poderão ser acessados por outros.", - "search": "Pesquisar exibições de entidades", - "selected-entity-views": "{ count, plural, =1 {1 entity view} other {# entity views} } selecionado(s)" + "attributes-propagation-hint": "A visualização de entidade copiará automaticamente os atributos especificados da entidade de destino cada vez que você salvar ou atualizar esta visualização de entidade. Por razões de desempenho, os atributos da entidade de destino não são propagados para a visualização de entidade a cada alteração de atributo. Você pode habilitar a propagação automática configurando o nó de regra \"copy to view\" na sua cadeia de regras e vinculando as mensagens \"Post attributes\" e \"Attributes Updated\" ao novo nó de regra.", + "timeseries-data": "Dados de séries temporais", + "timeseries-data-hint": "Configure as chaves de dados de séries temporais da entidade de destino que estarão acessíveis à visualização de entidade. Esses dados de séries temporais são somente leitura.", + "search": "Pesquisar visualizações de entidade", + "selected-entity-views": "{ count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} } selecionada(s)", + "assign-entity-view-to-edge": "Atribuir visualização(ões) de entidade ao Edge", + "assign-entity-view-to-edge-text": "Selecione as visualizações de entidade para atribuir ao edge", + "unassign-entity-view-from-edge-title": "Tem certeza de que deseja desatribuir a visualização de entidade '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Após a confirmação, a visualização de entidade será desatribuída e não estará acessível ao edge.", + "unassign-entity-views-from-edge-action-title": "Desatribuir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} } do edge", + "unassign-entity-view-from-edge": "Desatribuir visualização de entidade", + "unassign-entity-views-from-edge-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 visualização de entidade} other {# visualizações de entidade} }?", + "unassign-entity-views-from-edge-text": "Após a confirmação, todas as visualizações de entidade selecionadas serão desatribuídas e não estarão acessíveis ao edge." }, "event": { "event-type": "Tipo de evento", + "events-filter": "Filtro de eventos", + "clean-events": "Limpar eventos", "type-error": "Erro", "type-lc-event": "Evento de ciclo de vida", "type-stats": "Estatísticas", "type-debug-rule-node": "Depuração", "type-debug-rule-chain": "Depuração", + "type-debug-calculated-field": "Depuração", + "arguments": "Argumentos", + "result": "Resultado", "no-events-prompt": "Nenhum evento encontrado", "error": "Erro", "alarm": "Alarme", - "event-time": "Tempo de evento", + "event-time": "Hora do evento", "server": "Servidor", "body": "Corpo", "method": "Método", "type": "Tipo", - "message-id": "ID de mensagem", - "message-type": "Tipo de Mensagem", - "data-type": "Tipo de Dados", - "relation-type": "Tipo de Relação", "metadata": "Metadados", + "message": "Mensagem", + "message-id": "Id da mensagem", + "copy-message-id": "Copiar Id da mensagem", + "message-type": "Tipo de mensagem", + "data-type": "Tipo de dados", + "relation-type": "Tipo de relação", "data": "Dados", "event": "Evento", "status": "Status", - "success": "Êxito", + "success": "Sucesso", "failed": "Falhou", "messages-processed": "Mensagens processadas", - "errors-occurred": "Erros", - "all-events": "Tudo", - "entity-type": "Tipo de entidade" + "max-messages-processed": "Máximo de mensagens processadas", + "min-messages-processed": "Mínimo de mensagens processadas", + "errors-occurred": "Erros ocorridos", + "max-errors-occurred": "Máximo de erros ocorridos", + "min-errors-occurred": "Mínimo de erros ocorridos", + "min-value": "O valor mínimo é 0.", + "all-events": "Todos", + "has-error": "Contém erro", + "entity-id": "Id da entidade", + "copy-entity-id": "Copiar Id da entidade", + "entity-type": "Tipo de entidade", + "clear-filter": "Limpar filtro", + "clear-request-title": "Limpar todos os eventos", + "clear-request-text": "Tem certeza de que deseja limpar todos os eventos?", + "started": "Iniciado", + "updated": "Atualizado", + "stopped": "Parado" }, "extension": { "extensions": "Extensões", - "selected-extensions": "{ count, plural, =1 {1 extension} other {# extensions} } selecionado(s)", + "selected-extensions": "{ count, plural, =1 {1 extensão} other {# extensões} } selecionada(s)", "type": "Tipo", "key": "Chave", "value": "Valor", - "id": "ID", - "extension-id": "ID da extensão", + "id": "Id", + "extension-id": "Id da extensão", "extension-type": "Tipo de extensão", "transformer-json": "JSON *", - "unique-id-required": "O ID de extensão atual já existe.", + "unique-id-required": "O id da extensão atual já existe.", "delete": "Excluir extensão", "add": "Adicionar extensão", "edit": "Editar extensão", "delete-extension-title": "Tem certeza de que deseja excluir a extensão '{{extensionId}}'?", - "delete-extension-text": "Cuidado, após confirmar, não será possível recuperar a extensão e nenhum dado associado.", - "delete-extensions-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 extension} other {# extensions} }?", - "delete-extensions-text": "Cuidado, após confirmar, todas as extensões selecionadas serão removidas.", + "delete-extension-text": "Atenção: após a confirmação, a extensão e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-extensions-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 extensão} other {# extensões} }?", + "delete-extensions-text": "Atenção: após a confirmação, todas as extensões selecionadas serão removidas.", "converters": "Conversores", - "converter-id": "ID do conversor", + "converter-id": "Id do conversor", "configuration": "Configuração", - "converter-configurations": "Configurações de conversores", + "converter-configurations": "Configurações do conversor", "token": "Token de segurança", "add-converter": "Adicionar conversor", "add-config": "Adicionar configuração de conversor", - "device-name-expression": "Expressão do nome do dispositivo", - "device-type-expression": "Expressão do tipo de dispositivo", + "device-name-expression": "Expressão de nome do dispositivo", + "device-type-expression": "Expressão de tipo do dispositivo", "custom": "Personalizado", - "to-double": "Duplicar", + "to-double": "Para Double", "transformer": "Transformador", - "json-required": "O json do transformador é obrigatório", - "json-parse": "Impossível analisar json do transformador", + "json-required": "O JSON do transformador é obrigatório.", + "json-parse": "Não foi possível analisar o JSON do transformador.", "attributes": "Atributos", "add-attribute": "Adicionar atributo", "add-map": "Adicionar elemento de mapeamento", - "timeseries": "Intervalos de tempo", - "add-timeseries": "Adicionar intervalos de tempo", - "field-required": "O campo é obrigatório", + "timeseries": "Séries temporais", + "add-timeseries": "Adicionar séries temporais", + "field-required": "Campo é obrigatório", "brokers": "Brokers", "add-broker": "Adicionar broker", "host": "Host", "port": "Porta", - "port-range": "A porta deve estar em um intervalo de 1 a 65535.", + "port-range": "A porta deve estar no intervalo de 1 a 65535.", "ssl": "Ssl", "credentials": "Credenciais", "username": "Nome de usuário", "password": "Senha", - "retry-interval": "Intervalo de repetição em milissegundos", + "retry-interval": "Intervalo de nova tentativa em milissegundos", "anonymous": "Anônimo", "basic": "Básico", "pem": "PEM", - "ca-cert": "Arquivo de certificado da Autoridade de Certificação *", + "ca-cert": "Arquivo de certificado CA *", "private-key": "Arquivo de chave privada *", "cert": "Arquivo de certificado *", "no-file": "Nenhum arquivo selecionado.", - "drop-file": "Solte um arquivo ou clique para selecionar um arquivo para carregar.", + "drop-file": "Arraste um arquivo ou clique para selecionar um arquivo para upload.", "mapping": "Mapeamento", "topic-filter": "Filtro de tópico", "converter-type": "Tipo de conversor", "converter-json": "Json", - "json-name-expression": "Expressão json do nome do dispositivo", - "topic-name-expression": "Expressão de tópico do nome do dispositivo", - "json-type-expression": "Expressão json do tipo de dispositivo", - "topic-type-expression": "Expressão de tópico do tipo de dispositivo", - "attribute-key-expression": "Expressão da chave de atributo", - "attr-json-key-expression": "Expressão json da chave de atributo", - "attr-topic-key-expression": "Expressão de tópico da chave de atributo", - "request-id-expression": "Expressão de ID da solicitação", - "request-id-json-expression": "Expressão json de ID da solicitação", - "request-id-topic-expression": "Expressão de tópico de ID da solicitação", + "json-name-expression": "Expressão JSON de nome do dispositivo", + "topic-name-expression": "Expressão de tópico de nome do dispositivo", + "json-type-expression": "Expressão JSON de tipo do dispositivo", + "topic-type-expression": "Expressão de tópico de tipo do dispositivo", + "attribute-key-expression": "Expressão de chave de atributo", + "attr-json-key-expression": "Expressão JSON de chave de atributo", + "attr-topic-key-expression": "Expressão de tópico de chave de atributo", + "request-id-expression": "Expressão de id de solicitação", + "request-id-json-expression": "Expressão JSON de id de solicitação", + "request-id-topic-expression": "Expressão de tópico de id de solicitação", "response-topic-expression": "Expressão de tópico de resposta", - "value-expression": "Expressão do valor", + "value-expression": "Expressão de valor", "topic": "Tópico", - "timeout": "Tempo limite em milissegundos", - "converter-json-required": "Json do conversor é obrigatório.", - "converter-json-parse": "Impossível analisar json do conversor", - "filter-expression": "Expressão do filtro", + "timeout": "Tempo Limite em milissegundos", + "converter-json-required": "O JSON do conversor é obrigatório.", + "converter-json-parse": "Não foi possível analisar o JSON do conversor.", + "filter-expression": "Expressão de filtro", "connect-requests": "Solicitações de conexão", "add-connect-request": "Adicionar solicitação de conexão", "disconnect-requests": "Solicitações de desconexão", "add-disconnect-request": "Adicionar solicitação de desconexão", "attribute-requests": "Solicitações de atributo", "add-attribute-request": "Adicionar solicitação de atributo", - "attribute-updates": "Atualizações de atributos", + "attribute-updates": "Atualizações de atributo", "add-attribute-update": "Adicionar atualização de atributo", - "server-side-rpc": "RPC no lado do servidor", - "add-server-side-rpc-request": "Adicionar solicitação de RPC no lado do servidor", - "device-name-filter": "Filtro do nome do dispositivo", + "server-side-rpc": "RPC do lado do servidor", + "add-server-side-rpc-request": "Adicionar solicitação RPC do lado do servidor", + "device-name-filter": "Filtro de nome do dispositivo", "attribute-filter": "Filtro de atributo", "method-filter": "Filtro de método", "request-topic-expression": "Expressão de tópico de solicitação", "response-timeout": "Tempo limite de resposta em milissegundos", "topic-expression": "Expressão de tópico", - "client-scope": "Escopo de cliente", + "client-scope": "Escopo do cliente", "add-device": "Adicionar dispositivo", "opc-server": "Servidores", "opc-add-server": "Adicionar servidor", - "opc-add-server-prompt": "Adicione servidor", - "opc-application-name": "Nome do aplicativo", - "opc-application-uri": "Uri do aplicativo", - "opc-scan-period-in-seconds": "Período de digitalização em segundos", + "opc-add-server-prompt": "Adicione um servidor", + "opc-application-name": "Nome da aplicação", + "opc-application-uri": "URI da aplicação", + "opc-scan-period-in-seconds": "Período de varredura em segundos", "opc-security": "Segurança", "opc-identity": "Identidade", "opc-keystore": "Keystore", "opc-type": "Tipo", "opc-keystore-type": "Tipo", - "opc-keystore-location": "Localização*", + "opc-keystore-location": "Localização *", "opc-keystore-password": "Senha", "opc-keystore-alias": "Alias", - "opc-keystore-key-password": "Senha principal", - "opc-device-node-pattern": "Padrão de nó de dispositivo", - "opc-device-name-pattern": "Padrão de nome de dispositivo", - "modbus-server": "Servidores/subordinados", - "modbus-add-server": "Adicionar servidor/subordinado", - "modbus-add-server-prompt": "Adicione servidor/subordinado", + "opc-keystore-key-password": "Senha da chave", + "opc-device-node-pattern": "Padrão de nó do dispositivo", + "opc-device-name-pattern": "Padrão de nome do dispositivo", + "modbus-server": "Servidores/escravos", + "modbus-add-server": "Adicionar servidor/escravo", + "modbus-add-server-prompt": "Adicione um servidor/escravo", "modbus-transport": "Transporte", "modbus-tcp-reconnect": "Reconectar automaticamente", - "modbus-rtu-over-tcp": "RTU sobre TCP", + "modbus-rtu-over-tcp": "RTU over TCP", "modbus-port-name": "Nome da porta serial", "modbus-encoding": "Codificação", "modbus-parity": "Paridade", "modbus-baudrate": "Taxa de transmissão", "modbus-databits": "Bits de dados", "modbus-stopbits": "Bits de parada", - "modbus-databits-range": "Os bits de dados devem estar em um intervalo de 7 a 8.", - "modbus-stopbits-range": "Os bits de parada devem estar em um intervalo de 1 a 2.", + "modbus-databits-range": "Os bits de dados devem estar no intervalo de 7 a 8.", + "modbus-stopbits-range": "Os bits de parada devem estar no intervalo de 1 a 2.", "modbus-unit-id": "ID da unidade", - "modbus-unit-id-range": "O ID da unidade deve estar em um intervalo de 1 a 247.", + "modbus-unit-id-range": "O ID da unidade deve estar no intervalo de 1 a 247.", "modbus-device-name": "Nome do dispositivo", - "modbus-poll-period": "Período de sondagem (ms)", - "modbus-attributes-poll-period": "Atributos do período de sondagem (ms)", - "modbus-timeseries-poll-period": "Intervalos de tempo do período de sondagem (ms)", - "modbus-poll-period-range": "O período de sondagem deve ser um valor positivo.", - "modbus-tag": "Etiqueta", + "modbus-poll-period": "Período de polling (ms)", + "modbus-attributes-poll-period": "Período de polling de atributos (ms)", + "modbus-timeseries-poll-period": "Período de polling de séries temporais (ms)", + "modbus-poll-period-range": "O período de polling deve ser um valor positivo.", + "modbus-tag": "Tag", "modbus-function": "Função", - "modbus-register-address": "Endereço do registro", - "modbus-register-address-range": "O endereço do registro deve estar em um intervalo de 0 a 65535.", - "modbus-register-bit-index": "Índice de bits", - "modbus-register-bit-index-range": "O índice de bits deve estar em um intervalo de 0 a 15.", - "modbus-register-count": "Contagem de registro", - "modbus-register-count-range": "A contagem de registro deve ser um valor positivo.", - "modbus-byte-order": "Ordem de byte", + "modbus-register-address": "Endereço do registrador", + "modbus-register-address-range": "O endereço do registrador deve estar no intervalo de 0 a 65535.", + "modbus-register-bit-index": "Índice de bit", + "modbus-register-bit-index-range": "O índice de bit deve estar no intervalo de 0 a 15.", + "modbus-register-count": "Contagem de registradores", + "modbus-register-count-range": "A contagem de registradores deve ser um valor positivo.", + "modbus-byte-order": "Ordem de bytes", "sync": { "status": "Status", "sync": "Sincronizar", - "not-sync": "Não sincronizar", - "last-sync-time": "Horário da última sincronização", + "not-sync": "Não sincronizado", + "last-sync-time": "Último horário de sincronização", "not-available": "Não disponível" }, "export-extensions-configuration": "Exportar configuração de extensões", @@ -1168,6 +3539,9 @@ "file": "Arquivo de extensões", "invalid-file-error": "Arquivo de extensão inválido" }, + "feature": { + "advanced-features": "Recursos avançados" + }, "filter": { "add": "Adicionar filtro", "edit": "Editar filtro", @@ -1175,16 +3549,20 @@ "name-required": "O nome do filtro é obrigatório.", "duplicate-filter": "Já existe um filtro com o mesmo nome.", "filters": "Filtros", - "unable-delete-filter-title": "Impossível excluir filtro", - "unable-delete-filter-text": "Não é possível excluir o filtro '{{filter}}' porque ele é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", - "duplicate-filter-error": "Filtro '{{filter}}' duplicado encontrado.
Os filtros devem ser exclusivos no dashboard.", - "missing-key-filters-error": "Filtros chave ausentes para o filtro '{{filter}}'.", + "unable-delete-filter-title": "Não é possível excluir o filtro", + "unable-delete-filter-text": "O filtro '{{filter}}' não pode ser excluído pois é usado pelo(s) seguinte(s) widget(s):
{{widgetsList}}", + "duplicate-filter-error": "Filtro duplicado encontrado '{{filter}}'.
Os filtros devem ser únicos no dashboard.", + "missing-key-filters-error": "Filtros de chave ausentes para o filtro '{{filter}}'.", "filter": "Filtro", "editable": "Editável", + "editable-hint": "Permitir que o usuário altere o valor do filtro nos dashboards.", "no-filters-found": "Nenhum filtro encontrado.", + "no-filter-text": "Nenhum filtro especificado", + "add-filter-prompt": "Adicione um filtro", "no-filter-matching": "'{{filter}}' não encontrado.", "create-new-filter": "Criar um novo!", - "filter-required": "O filtro é obrigatório.", + "create-new": "Criar novo", + "filter-required": "Filtro é obrigatório.", "operation": { "operation": "Operação", "equal": "igual", @@ -1198,219 +3576,1387 @@ "greater-or-equal": "maior ou igual", "less-or-equal": "menor ou igual", "and": "e", - "or": "ou" + "or": "ou", + "in": "em", + "not-in": "não em" }, - "ignore-case": "Ignorar caixa", + "ignore-case": "ignorar maiúsculas/minúsculas", "value": "Valor", "remove-filter": "Remover filtro", + "duplicate-filter-action": "Duplicar filtro", + "preview": "Pré-visualização do filtro", "no-filters": "Nenhum filtro configurado", "add-filter": "Adicionar filtro", "add-complex-filter": "Adicionar filtro complexo", "add-complex": "Adicionar complexo", "complex-filter": "Filtro complexo", "edit-complex-filter": "Editar filtro complexo", - "edit-filter-user-params": "Editar parâmetros de usuário de predicado de filtro", - "user-parameters": "Parâmetros de usuário", - "display-label": "Etiqueta para exibição", - "order-priority": "Prioridade de ordem de campo", - "key-filter": "Filtro chave", - "key-filters": "Filtros chave", + "edit-filter-user-params": "Editar parâmetros de usuário do predicado do filtro", + "filter-user-params": "Parâmetros de usuário do predicado do filtro", + "user-parameters": "Parâmetros do usuário", + "display-label": "Rótulo de exibição", + "custom-label": "Rótulo personalizado", + "custom-label-hint": "Habilite para definir seu próprio rótulo para o filtro. Quando desabilitado, um rótulo será gerado automaticamente.", + "order-priority": "Ordem de exibição", + "key-filter": "Filtro de chave", + "key-filters": "Filtros de chave", "key-name": "Nome da chave", "key-name-required": "O nome da chave é obrigatório.", "key-type": { "key-type": "Tipo de chave", "attribute": "Atributo", - "timeseries": "Intervalos de tempo", - "entity-field": "Campo de entidade" + "timeseries": "Séries temporais", + "entity-field": "Campo de entidade", + "constant": "Constante", + "client-attribute": "Atributo do cliente", + "server-attribute": "Atributo do servidor", + "shared-attribute": "Atributo compartilhado" }, "value-type": { "value-type": "Tipo de valor", - "string": "Cadeia de caracteres", + "string": "String", "numeric": "Numérico", - "boolean": "Booliano", - "date-time": "Data/Hora" - }, - "value-type-required": "O tipo de valor chave é obrigatório.", - "key-value-type-change-title": "Tem certeza de que deseja alterar o tipo de valor chave?", - "key-value-type-change-message": "Se confirmar o novo tipo de valor, todos os filtros chave inseridos serão removidos.", - "no-key-filters": "Nenhum filtro chave configurado", - "add-key-filter": "Adicionar filtro chave", - "remove-key-filter": "Remover filtro chave", - "edit-key-filter": "Editar filtro chave", + "boolean": "Booleano", + "date-time": "Data e hora" + }, + "value-type-required": "O tipo de valor da chave é obrigatório.", + "key-value-type-change-title": "Tem certeza de que deseja alterar o tipo de valor da chave?", + "key-value-type-change-message": "Se confirmar o novo tipo de valor, todos os filtros de chave inseridos serão removidos.", + "no-key-filters": "Nenhum filtro de chave configurado", + "add-key-filter": "Adicionar filtro de chave", + "remove-key-filter": "Remover filtro de chave", + "edit-key-filter": "Editar filtro de chave", "date": "Data", "time": "Hora", - "current-tenant": "Locatário atual", + "current-tenant": "Tenant atual", "current-customer": "Cliente atual", "current-user": "Usuário atual", + "current-device": "Dispositivo atual", "default-value": "Valor padrão", + "default-comma-separated-values": "Valores padrão separados por vírgula", "dynamic-source-type": "Tipo de fonte dinâmica", - "no-dynamic-value": "Nenhum valor dinâmico", - "source-attribute": "Atributo da fonte", + "dynamic-value": "Valor dinâmico", + "no-dynamic-value": "Sem valor dinâmico", + "source-attribute": "Atributo de origem", "switch-to-dynamic-value": "Alternar para valor dinâmico", - "switch-to-default-value": "Alternar para valor padrão" + "switch-to-default-value": "Alternar para valor padrão", + "inherit-owner": "Herdar do proprietário", + "source-attribute-not-set": "Se o atributo de origem não estiver definido", + "unit": "Unidade" }, "fullscreen": { - "expand": "Expandir para tela inteira", - "exit": "Sair de tela inteira", - "toggle": "Alternar modo de tela inteira", - "fullscreen": "Tela inteira" + "expand": "Expandir para tela cheia", + "exit": "Sair da tela cheia", + "toggle": "Alternar modo de tela cheia", + "fullscreen": "Tela cheia" }, "function": { "function": "Função" }, "gateway": { - "create-new-gateway": "Criar um novo gateway", - "create-new-gateway-text": "Tem certeza de que deseja criar um novo gateway com o nome: '{{gatewayName}}'?", - "gateway": "Gateway", - "gateway-exists": "Já existe um dispositivo com o mesmo nome.", "gateway-name": "Nome do gateway", "gateway-name-required": "O nome do gateway é obrigatório.", - "gateway-saved": "A configuração do gateway foi salva corretamente.", + "gateways": "Gateways", + "create-new-gateway": "Criar um novo gateway", + "create-new-gateway-text": "Tem certeza de que deseja criar um novo gateway com o nome: '{{gatewayName}}'?", + "launch-command": "Comando de inicialização", "no-gateway-found": "Nenhum gateway encontrado.", "no-gateway-matching": " '{{item}}' não encontrado." }, "grid": { "delete-item-title": "Tem certeza de que deseja excluir este item?", - "delete-item-text": "Cuidado, após confirmar, não será possível recuperar este item e nenhum dado associado.", - "delete-items-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 item} other {# items} }?", - "delete-items-action-title": "Excluir { count, plural, =1 {1 item} other {# items} }", - "delete-items-text": "Cuidado, após confirmar, todos os itens selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "delete-item-text": "Atenção: após a confirmação, este item e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-items-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 item} other {# itens} }?", + "delete-items-action-title": "Excluir { count, plural, =1 {1 item} other {# itens} }", + "delete-items-text": "Atenção: após a confirmação, todos os itens selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", "add-item-text": "Adicionar novo item", "no-items-text": "Nenhum item encontrado", "item-details": "Detalhes do item", "delete-item": "Excluir item", "delete-items": "Excluir itens", - "scroll-to-top": "Rolar para o início" + "scroll-to-top": "Rolar para o topo" }, "help": { - "goto-help-page": "Ir para a página de Ajuda" + "goto-help-page": "Ir para a página de ajuda", + "show-help": "Mostrar ajuda" }, "home": { - "home": "Página Inicial", + "home": "Início", "profile": "Perfil", - "logout": "Logout", + "logout": "Sair", "menu": "Menu", "avatar": "Avatar", - "open-user-menu": "Abrir menu de usuário" + "open-user-menu": "Abrir menu do usuário" + }, + "file-input": { + "browse-file": "Procurar arquivo", + "browse-files": "Procurar arquivos" + }, + "image": { + "gallery": "Galeria de imagens", + "search": "Pesquisar imagem", + "selected-images": "{ count, plural, =1 {1 imagem} other {# imagens} } selecionada(s)", + "created-time": "Data de criação", + "name": "Nome", + "name-required": "Nome é obrigatório.", + "resolution": "Resolução", + "size": "Tamanho", + "system": "Sistema", + "download-image": "Baixar imagem", + "export-image": "Exportar imagem para JSON", + "import-image": "Importar imagem de JSON", + "upload-image": "Fazer upload de imagem", + "edit-image": "Editar imagem", + "image-details": "Detalhes da imagem", + "no-images": "Nenhuma imagem encontrada", + "delete-image": "Excluir imagem", + "delete-image-title": "Tem certeza de que deseja excluir a imagem '{{imageTitle}}'?", + "delete-image-text": "Atenção: após a confirmação, a imagem se tornará irrecuperável.", + "delete-images-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 imagem} other {# imagens} }?", + "delete-images-text": "Atenção: após a confirmação, todas as imagens selecionadas serão removidas e todos os dados relacionados se tornarão irrecuperáveis.", + "list-mode": "Visualização em lista", + "grid-mode": "Visualização em grade", + "image-preview": "Pré-visualização da imagem", + "update-image": "Atualizar imagem", + "export-failed-error": "Não foi possível exportar a imagem: {{error}}", + "image-json-file": "Arquivo JSON da imagem", + "invalid-image-json-file-error": "Não foi possível importar a imagem do JSON: Estrutura de dados JSON da imagem inválida.", + "image-is-in-use": "A imagem está sendo usada por outras entidades", + "images-are-in-use": "As imagens estão sendo usadas por outras entidades", + "image-is-in-use-text": "A imagem '{{title}}' não foi excluída porque é usada pelas seguintes entidades:", + "images-are-in-use-text": "Nem todas as imagens foram excluídas porque são usadas por outras entidades.
Você pode visualizar as entidades referenciadas clicando no botão Referências na linha da imagem correspondente.
Se ainda quiser excluir essas imagens, selecione-as na tabela abaixo e clique no botão Excluir selecionadas.", + "delete-image-in-use-text": "Se ainda quiser excluir a imagem, clique no botão Excluir mesmo assim.", + "system-entities": "Entidades do sistema:", + "entities": "entidades:", + "references": "Referências", + "include-system-images": "Incluir imagens do sistema", + "clear-image": "Limpar imagem", + "no-image": "Sem imagem", + "no-image-selected": "Nenhuma imagem selecionada", + "browse-from-gallery": "Procurar na galeria", + "set-link": "Definir link", + "image-link": "Link da imagem", + "link": "Link", + "copy-image-link": "Copiar link da imagem", + "embed-image": "Incorporar imagem", + "embed-to-html": "Incorporar em HTML", + "embed-to-html-hint": "Este recurso tornará o link disponível para qualquer usuário não autenticado.", + "embed-to-html-text": "Usando o seguinte trecho de código, você pode incorporar uma imagem nos componentes baseados em HTML puro.
Esses componentes incluem widgets de cartão HTML, funções de conteúdo de célula, etc.", + "embed-to-angular-template": "Incorporar em template Angular HTML", + "embed-to-angular-template-text": "Usando o seguinte trecho de código, você pode incorporar uma imagem no template Angular HTML que será usado para componentes.
Esses componentes incluem o widget Markdown, seção HTML no editor de widgets, ações personalizadas, etc." + }, + "image-input": { + "drop-images-or": "Arraste e solte imagens ou", + "drag-and-drop": "Arrastar e soltar", + "or": "ou", + "browse": "Procurar", + "no-images": "Nenhuma imagem selecionada", + "images": "imagens" }, "import": { "no-file": "Nenhum arquivo selecionado", - "drop-file": "Solte um arquivo JSON ou clique para selecionar um arquivo para carregar.", - "drop-file-csv": "Solte um arquivo CSV ou clique para selecionar um arquivo para carregar.", + "drop-file": "Solte um arquivo JSON ou clique para selecionar um arquivo para enviar.", + "drop-json-file-or": "Arraste e solte um arquivo JSON ou", + "drop-file-csv": "Solte um arquivo CSV ou clique para selecionar um arquivo para enviar.", + "drop-file-csv-or": "Arraste e solte um arquivo CSV ou", "column-value": "Valor", "column-title": "Título", - "column-example": "Exemplo de dados de valor", + "column-example": "Dados de valor de exemplo", "column-key": "Chave de atributo/telemetria", - "csv-delimiter": "Delimitador de CSV", - "csv-first-line-header": "A primeira linha contém nomes de coluna", + "credentials": "Credenciais", + "csv-delimiter": "Delimitador CSV", + "csv-first-line-header": "A primeira linha contém nomes de colunas", "csv-update-data": "Atualizar atributos/telemetria", - "import-csv-number-columns-error": "Um arquivo deve conter no mínimo duas colunas", + "details": "Detalhes", + "import-csv-number-columns-error": "O arquivo deve conter pelo menos duas colunas", "import-csv-invalid-format-error": "Formato de arquivo inválido. Linha: '{{line}}'", "column-type": { "name": "Nome", "type": "Tipo", - "label": "Etiqueta", + "label": "Rótulo", "column-type": "Tipo de coluna", "client-attribute": "Atributo do cliente", "shared-attribute": "Atributo compartilhado", "server-attribute": "Atributo do servidor", - "timeseries": "Intervalos de tempo", + "timeseries": "Série temporal", "entity-field": "Campo de entidade", "access-token": "Token de acesso", + "x509": "X.509", + "mqtt": { + "client-id": "Client ID MQTT", + "user-name": "Nome de usuário MQTT", + "password": "Senha MQTT" + }, + "lwm2m": { + "client-endpoint": "Nome do cliente do Endpoint LwM2M", + "security-config-mode": "Modo de configuração de segurança LwM2M", + "client-identity": "Identidade do cliente LwM2M", + "client-key": "Chave do cliente LwM2M", + "client-cert": "Chave pública do cliente LwM2M", + "bootstrap-server-security-mode": "Modo de segurança do servidor bootstrap LwM2M", + "bootstrap-server-secret-key": "Chave secreta do servidor bootstrap LwM2M", + "bootstrap-server-public-key-id": "Chave pública ou ID do servidor bootstrap LwM2M", + "lwm2m-server-security-mode": "Modo de segurança do servidor LwM2M", + "lwm2m-server-secret-key": "Chave secreta do servidor LwM2M", + "lwm2m-server-public-key-id": "Chave pública ou ID do servidor LwM2M" + }, + "snmp": { + "host": "Host SNMP", + "port": "Porta SNMP", + "version": "Versão SNMP (v1, v2c ou v3)", + "community-string": "String de comunidade SNMP" + }, "isgateway": "É Gateway", - "description": "Descrição" + "activity-time-from-gateway-device": "Tempo de atividade do dispositivo gateway", + "description": "Descrição", + "routing-key": "Chave do edge", + "secret": "Segredo do edge" }, "stepper-text": { "select-file": "Selecionar um arquivo", - "configuration": "Importar configuração", - "column-type": "Selecionar tipos de colunas", - "creat-entities": "Criar novas entidades" + "configuration": "Configuração de importação", + "column-type": "Selecionar tipo de colunas", + "creat-entities": "Criando novas entidades" }, "message": { - "create-entities": "{{count}} novas entidades foram criadas corretamente.", - "update-entities": "{{count}} novas entidades foram atualizadas corretamente.", + "create-entities": "{{count}} novas entidades foram criadas com sucesso.", + "update-entities": "{{count}} entidades foram atualizadas com sucesso.", "error-entities": "Ocorreu um erro ao criar {{count}} entidades." } }, + "scada": { + "symbols": "Símbolos SCADA", + "search": "Pesquisar símbolo", + "selected-symbols": "{ count, plural, =1 {1 símbolo} other {# símbolos} } selecionado(s)", + "download-symbol": "Baixar símbolo SCADA", + "export-symbol": "Exportar símbolo SCADA para JSON", + "import-symbol": "Importar símbolo SCADA de JSON", + "upload-symbol": "Carregar símbolo SCADA", + "update-symbol": "Atualizar símbolo SCADA", + "edit-symbol": "Editar símbolo SCADA", + "symbol-details": "Detalhes do símbolo SCADA", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Nenhum símbolo encontrado", + "show-hidden-elements": "Mostrar elementos ocultos", + "hide-hidden-elements": "Ocultar elementos ocultos", + "delete-symbol": "Excluir símbolo SCADA", + "delete-symbol-title": "Tem certeza de que deseja excluir o símbolo SCADA '{{imageTitle}}'?", + "delete-symbol-text": "Atenção: após a confirmação, o símbolo SCADA não poderá ser recuperado.", + "delete-symbols-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 símbolo SCADA} other {# símbolos SCADA} }?", + "delete-symbols-text": "Atenção: após a confirmação, todos os símbolos SCADA selecionados serão removidos e todos os dados relacionados não poderão ser recuperados.", + "include-system-symbols": "Incluir símbolos do sistema", + "symbol-preview": "Visualização do símbolo", + "general": "Geral", + "tags": "Tags", + "properties": "Propriedades", + "title": "Título", + "description": "Descrição", + "search-tags": "Pesquisar tags", + "widget-size": "Tamanho do widget", + "cols": "colunas", + "rows": "linhas", + "state-render-function": "Função de renderização de estado", + "preview": "Visualização", + "preview-widget-action-text": "Ação do widget '{{type}}' invocada com sucesso!", + "no-symbol": "Nenhum símbolo SCADA", + "no-symbol-selected": "Nenhum símbolo SCADA selecionado", + "clear-symbol": "Limpar símbolo SCADA", + "browse-symbol-from-gallery": "Procurar símbolo SCADA na galeria", + "zoom-in": "Aproximar", + "zoom-out": "Afastar", + "create-widget": "Criar widget", + "create-widget-from-symbol": "Criar widget a partir do símbolo SCADA", + "hidden": "oculto", + "tag": { + "tag": "Tag", + "on-click-action": "Ação ao clicar", + "no-tags": "Nenhuma tag configurada", + "delete-tag-text": "Tem certeza de que deseja excluir a tag
{{tag}} do elemento {{elementType}}?", + "update-tag": "Atualizar tag", + "enter-tag": "Inserir tag", + "tag-settings": "Configurações de tag", + "remove-tag": "Remover tag", + "add-tag": "Adicionar tag" + }, + "behavior": { + "behavior": "Comportamento", + "id": "Id", + "name": "Nome", + "type": "Tipo", + "no-behaviors": "Nenhum comportamento configurado", + "add-behavior": "Adicionar comportamento", + "type-action": "Ação", + "type-value": "Valor", + "type-widget-action": "Ação do widget", + "behavior-settings": "Configurações de comportamento", + "remove-behavior": "Remover comportamento", + "hint": "Dica", + "group-title": "Título do grupo", + "value-type": "Tipo de valor", + "default-value": "Valor padrão", + "true-label": "Rótulo verdadeiro", + "false-label": "Rótulo falso", + "state-label": "Rótulo de estado", + "default-payload": "Payload padrão", + "not-unique-behavior-ids-error": "Os IDs de comportamento devem ser únicos!", + "default-settings": "Configurações padrão" + }, + "symbol": { + "symbol": "Símbolo SCADA", + "fluid-presence": "Presença de fluido", + "fluid-presence-hint": "Indica se há fluido presente no tubo.", + "fluid-present": "Fluido presente", + "present": "Presente", + "absent": "Ausente", + "flow-presence": "Presença de fluxo", + "flow-presence-hint": "Indica se o fluido está fluindo no tubo.", + "flow-present": "Fluxo presente", + "flow-direction": "Direção do fluxo", + "flow-direction-hint": "Indica a direção do fluxo de fluido.", + "forward": "Avançar", + "reverse": "Reverso", + "flow-animation-speed": "Velocidade de animação do fluxo", + "flow-animation-speed-hint": "Valor decimal que indica a velocidade de animação do fluxo. 1 - velocidade normal, 0 - sem animação, < 1 - animação mais lenta, > 1 - animação mais rápida.", + "leak": "Vazamento", + "leak-hint": "Indica se há vazamento presente no tubo.", + "leak-present": "Vazamento presente", + "fluid-color": "Cor do fluido", + "pipe-color": "Cor do tubo", + "horizontal-pipe": "Tubo horizontal", + "vertical-pipe": "Tubo vertical", + "horizontal-fluid-color": "Cor do fluido horizontal", + "vertical-fluid-color": "Cor do fluido vertical", + "left-pipe": "Tubo esquerdo", + "right-pipe": "Tubo direito", + "top-pipe": "Tubo superior", + "bottom-pipe": "Tubo inferior", + "left-fluid-color": "Cor do fluido esquerdo", + "right-fluid-color": "Cor do fluido direito", + "top-fluid-color": "Cor do fluido superior", + "bottom-fluid-color": "Cor do fluido inferior", + "display": "Exibição", + "display-format": "Formato de exibição", + "value": "Valor", + "decimals": "Decimais", + "units": "Unidades", + "flow-meter-value-hint": "Valor decimal exibido no display do medidor de fluxo", + "value-hint": "Valor decimal que indica o valor atual", + "running": "Em execução", + "running-hint": "Indica se o componente está em estado de execução.", + "warning-state": "Estado de aviso", + "warning": "Aviso", + "warning-click": "Clique de aviso", + "warning-state-hint": "Indica se o componente está em estado de aviso.", + "critical-state": "Estado crítico", + "critical": "Crítico", + "critical-click": "Clique crítico", + "critical-state-hint": "Indica se o componente está em estado crítico.", + "critical-state-animation": "Animação de estado crítico", + "critical-state-animation-hint": "Se deve habilitar a animação piscante quando o componente está em estado crítico.", + "warning-critical-state-animation": "Animação de estado de aviso/crítico", + "warning-critical-state-animation-hint": "Se deve habilitar a animação piscante quando o componente está em estado de aviso ou crítico.", + "animation": "Animação", + "broken": "Com falha", + "broken-hint": "Indica se o componente está com falha.", + "on-display-click": "Ao clicar no display", + "on-display-click-hint": "Ação invocada quando o usuário clica no display.", + "pipe": "Tubo", + "default-border-color": "Cor padrão da borda", + "active-border-color": "Cor da borda ativa", + "warning-border-color": "Cor da borda de aviso", + "critical-border-color": "Cor da borda crítica", + "background-color": "Cor de fundo", + "rotation-animation-speed": "Velocidade de animação de rotação", + "rotation-animation-speed-hint": "Valor decimal que indica a velocidade de animação de rotação. 1 - velocidade normal, 0 - sem animação, < 1 - animação mais lenta, > 1 - animação mais rápida.", + "on-click": "Ao clicar", + "on-click-hint": "Ação invocada quando o usuário clica no componente.", + "connectors-positions": "Posições dos conectores", + "right-connector": "Conector direito", + "right-top-connector": "Conector superior direito", + "right-bottom-connector": "Conector inferior direito", + "left-connector": "Conector esquerdo", + "left-top-connector": "Conector superior esquerdo", + "left-bottom-connector": "Conector inferior esquerdo", + "top-left-connector": "Conector esquerdo superior", + "top-right-connector": "Conector direito superior", + "top-connector": "Conector superior", + "bottom-connector": "Conector inferior", + "running-color": "Cor em execução", + "stopped-color": "Cor parado", + "stopped": "Parado", + "warning-color": "Cor de aviso", + "critical-color": "Cor crítica", + "opened": "Aberto", + "opened-hint": "Indica se o componente está em estado aberto.", + "open": "Abrir", + "open-hint": "Ação invocada quando o usuário clica para abrir o componente.", + "close": "Fechar", + "close-hint": "Ação invocada quando o usuário clica para fechar o componente.", + "close-state-animation": "Animação de estado fechado", + "close-state-animation-hint": "Se deve habilitar a animação piscante quando o componente está em estado fechado.", + "opened-color": "Cor aberto", + "closed-color": "Cor fechado", + "opened-rotation-angle": "Ângulo de rotação aberto", + "closed-rotation-angle": "Ângulo de rotação fechado", + "tank-capacity": "Capacidade do tanque", + "tank-capacity-hint": "Valor decimal que indica a capacidade total do tanque.", + "current-volume": "Volume atual", + "current-volume-hint": "Valor decimal que indica o volume ocupado atualmente.", + "tank-color": "Cor do tanque", + "value-box": "Caixa de valor", + "value-text": "Texto de valor", + "scale": "Escala", + "transparent-mode": "Modo transparente", + "major-ticks": "Marcações principais", + "intervals": "Intervalos", + "major-ticks-color": "Cor das marcações principais", + "normal": "Normal", + "minor-ticks": "Marcações secundárias", + "minor-ticks-color": "Cor das marcações secundárias", + "temperature": "Temperatura", + "temperature-hint": "Valor decimal que indica a temperatura atual.", + "update-temperature": "Atualizar temperatura", + "update-temperature-hint": "Ação invocada quando o usuário clica para alterar a temperatura atual.", + "run": "Executar", + "run-hint": "Ação invocada quando o usuário clica para executar o componente.", + "stop": "Parar", + "stop-hint": "Ação invocada quando o usuário clica para parar o componente.", + "temperature-step": "Incremento de passo de temperatura", + "heat-pump-color": "Cor da bomba de calor", + "power-button-background": "Fundo do botão de energia", + "value-box-background": "Fundo da caixa de valor", + "value-units": "Unidades de valor", + "enable-units-scale": "Habilitar unidades na escala", + "filtration-mode": "Modo de filtração", + "filtration-mode-hint": "Valor inteiro que indica o modo de filtração atual.", + "filtration-mode-update": "Estado de atualização do modo de filtração", + "filtration-mode-update-hint": "Ação invocada quando o usuário clica para alterar o modo de filtração atual.", + "filter-mode": "Filtro", + "waste-mode": "Descarte", + "backwash-mode": "Retrolavagem", + "recirculate-mode": "Recircular", + "rinse-mode": "Enxaguar", + "closed-mode": "Fechado", + "sand-filter-color": "Cor do filtro de areia", + "mode-box-background": "Fundo da caixa de modo", + "border-color": "Cor da borda", + "label-color": "Cor do rótulo", + "water-leak-hint": "Indica se há vazamento.", + "default-color": "Cor padrão", + "leak-color": "Cor de vazamento", + "full-value": "Valor cheio", + "full-value-hint": "Valor decimal que indica o valor cheio.", + "label": "Rótulo", + "icon": "Ícone", + "button-color": "Cor do botão", + "on-label": "Texto do rótulo 'Ligado'", + "off-label": "Texto do rótulo 'Desligado'", + "arrow-presence": "Presença de seta", + "arrow-presence-hint": "Indica se há seta presente no conector.", + "arrow-present": "Seta presente", + "arrow-direction": "Direção do fluxo", + "arrow-direction-hint": "Indica a direção do fluxo.", + "flow-animation": "Presença de fluxo", + "flow-animation-hint": "Indica se o fluido está fluindo no conector.", + "flow": "Fluxo", + "flow-line": "Linha", + "flow-line-style": "Estilo de linha", + "flow-style-hint": "Defina os valores de Traço e Espaço de modo que a soma seja divisível por 100 sem resto para sincronização perfeita da animação.", + "flow-dash-cap": "Cap do traço", + "dash-cap-butt": "Plano", + "dash-cap-round": "Arredondado", + "dash-cap-square": "Quadrado", + "dash": "Traço", + "gap": "Espaço", + "main-line": "Linha principal", + "line": "Linha", + "line-color": "Cor da linha", + "arrow-color": "Cor da seta", + "target-value": "Valor alvo", + "target-value-hint": "Indica o ponto alvo na escala.", + "min-max-value": "Valor mínimo e máximo", + "min-value": "Mín", + "max-value": "Máx", + "progress-bar": "Barra de progresso", + "progress-arrow": "Seta de progresso", + "warning-scale-color": "Cor da escala de aviso", + "critical-scale-color": "Cor da escala crítica", + "scale-color": "Cor da escala", + "target": "Alvo", + "high-warning-state": "Estado de aviso alto", + "show-high-warning-scale": "Mostrar escala de aviso alto", + "high-warning-scale": "Escala de aviso alto", + "high-warning-state-hint": "Valor decimal que indica um intervalo de aviso alto até um valor crítico alto ou máximo.", + "low-warning-state": "Estado de aviso baixo", + "show-low-warning-scale": "Mostrar escala de aviso baixo", + "low-warning-scale": "Escala de aviso baixo", + "low-warning-state-hint": "Valor decimal que indica um intervalo de aviso baixo até um valor crítico baixo ou mínimo.", + "high-critical-state": "Estado crítico alto", + "show-high-critical-scale": "Mostrar escala crítica alta", + "high-critical-scale": "Escala crítica alta", + "high-critical-state-hint": "Valor decimal que indica um intervalo crítico alto até a escala de valor máximo.", + "low-critical-state": "Estado crítico baixo", + "show-low-critical-scale": "Mostrar estado crítico baixo", + "low-critical-scale": "Estado crítico baixo", + "low-critical-state-hint": "Valor decimal que indica um intervalo crítico baixo até a escala de valor mínimo.", + "filter-color": "Cor do filtro", + "colors": "Cores", + "indicator-colors": "Cores do indicador", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "on": "LIGADO", + "off": "DESLIGADO", + "on-off-state": "Estado ligado/desligado", + "on-off-state-hint": "Indica se o componente está no estado Ligado ou Desligado.", + "on-update-state": "Estado de atualização para ligado", + "on-update-state-hint": "Ação invocada quando o usuário clica para atualizar o estado para Ligado.", + "off-update-state": "Estado de atualização para desligado", + "off-update-state-hint": "Ação invocada quando o usuário clica para atualizar o estado para Desligado.", + "voltage": "Tensão", + "input-voltage": "Tensão de entrada", + "input-voltage-hint": "Valor decimal que indica o valor da tensão de entrada.", + "output-voltage": "Tensão de saída", + "output-voltage-hint": "Valor decimal que indica o valor da tensão de saída.", + "first-phase-voltage": "Tensão da primeira fase", + "second-phase-voltage": "Tensão da segunda fase", + "third-phase-voltage": "Tensão da terceira fase", + "phase-voltage-hint": "Valor decimal que indica o valor de tensão para a fase atual", + "voltage-hint": "Valor decimal que indica a tensão atual", + "current-voltage-color": "Cor da tensão atual", + "phase-indicator-color": "Cor do indicador de fase", + "measured": "Medido", + "measured-hint": "Valor decimal que indica o consumo de energia em quilowatt-hora", + "day-rate": "Tarifa diurna", + "night-rate": "Tarifa noturna", + "off-peak-rate": "Tarifa fora de pico", + "peak-rate": "Tarifa de pico", + "export-rate": "Tarifa de exportação", + "operating-mode": "Modo de operação", + "bypass-mode": "Bypass", + "operating-mode-hint": "Valor inteiro que indica o modo de operação atual (0 - DESLIGADO, 1 - LIGADO, 2 - BYPASS)", + "connected": "Conectado", + "connected-hint": "Indica se o componente está em estado conectado.", + "disconnected": "Desconectado", + "indicator": "Indicador", + "operation-mode": "Modo de operação", + "operation-mode-hint": "Indica se o inversor está no modo Rede ou Inversor.", + "operation-mode-indicators-color": "Cor dos indicadores de modo de operação", + "mains-on-mode": "Rede ligada", + "inverter-on-mode": "Inversor ligado", + "charging-mode": "Modo de carregamento", + "charging-mode-hint": "Valor inteiro que indica o modo de carregamento atual (1 - Bulk, 2 - Absorção, 3 - Float)", + "charging-mode-indicators-color": "Cor dos indicadores de modo de carregamento", + "inverter-faults": "Falhas", + "inverter-fault-indicators-color": "Cor dos indicadores de falha", + "overload-fault": "Sobrecarga", + "overload-fault-hint": "Indica se o inversor está em condição de sobrecarga.", + "low-battery-fault": "Bateria fraca", + "low-battery-fault-hint": "Indica se a bateria está excessivamente descarregada.", + "temperature-fault": "Temperatura", + "temperature-fault-hint": "Indica se há alta temperatura no inversor.", + "triangle": "Triângulo", + "socket": "Tomada", + "left-button": "Botão esquerdo", + "right-button": "Botão direito", + "alarm-colors": "Cores de alarme", + "hook-color": "Cor do gancho" + } + }, "item": { "selected": "Selecionado" }, "js-func": { "no-return-error": "A função deve retornar um valor!", "return-type-mismatch": "A função deve retornar um valor do tipo '{{type}}'!", - "tidy": "Tidy", - "mini": "Mini" + "tidy": "Organizar", + "mini": "Mini", + "modules": "Módulos", + "remove-module": "Remover módulo", + "no-modules": "Nenhum módulo configurado", + "add-module": "Adicionar módulo", + "module-alias": "Alias", + "invalid-module-alias-name": "Nome de alias inválido", + "module-resource": "Recurso do módulo JS", + "not-unique-module-aliases-error": "Os aliases de módulo devem ser únicos!", + "show-module-info": "Mostrar informações do módulo", + "show-module-source-code": "Mostrar código-fonte do módulo", + "module-members": "Membros do módulo", + "module-no-members": "O módulo não possui membros exportados", + "module-load-error": "Erro ao carregar módulo", + "source-code": "Código-fonte", + "source-code-load-error": "Erro ao carregar código-fonte", + "no-js-module-text": "Nenhum módulo JS encontrado", + "no-js-module-matching": "Nenhum módulo JS correspondente a '{{module}}' foi encontrado." }, "key-val": { "key": "Chave", "value": "Valor", "remove-entry": "Remover entrada", "add-entry": "Adicionar entrada", - "no-data": "Sem entradas" + "no-data": "Nenhuma entrada" }, "layout": { "layout": "Layout", + "layouts": "Layouts", "manage": "Gerenciar layouts", - "settings": "Configuração de layout", + "settings": "Configurações de layout", "color": "Cor", "main": "Principal", "right": "Direita", - "select": "Selecionar layout alvo" + "left": "Esquerda", + "select": "Selecionar layout de destino", + "percentage-width": "Largura percentual (%)", + "fixed-width": "Largura fixa (px)", + "left-width": "Coluna esquerda (%)", + "right-width": "Coluna direita (%)", + "pick-fixed-side": "Lado fixo: ", + "layout-fixed-width": "Largura fixa (px)", + "value-min-error": "O valor deve ser maior que {{min}}{{unit}}", + "value-max-error": "O valor deve ser menor que {{max}}{{unit}}", + "layout-fixed-width-required": "A largura fixa é obrigatória", + "right-width-percentage-required": "O percentual direito é obrigatório", + "left-width-percentage-required": "O percentual esquerdo é obrigatório", + "divider": "Divisor", + "right-side": "Layout do lado direito", + "left-side": "Layout do lado esquerdo", + "add-new-breakpoint": "Adicionar novo breakpoint", + "breakpoint": "Breakpoint", + "breakpoints": "Breakpoints", + "copy-from": "Copiar de", + "size": "Tamanho", + "delete-breakpoint-title": "Tem certeza de que deseja excluir o breakpoint '{{name}}'?", + "delete-breakpoint-text": "Atenção: após a confirmação, o breakpoint não poderá ser recuperado e as configurações serão revertidas para o breakpoint padrão." }, "legend": { - "direction": "Direção da legenda", - "position": "Posição da legenda", + "direction": "Direção", + "position": "Posição", + "show-values": "Mostrar valores", + "min-option": "Mín", + "max-option": "Máx", + "average-option": "Média", + "total-option": "Total", + "latest-option": "Mais recente", + "sort-legend": "Ordenar chaves de dados na legenda", "show-max": "Mostrar valor máximo", "show-min": "Mostrar valor mínimo", "show-avg": "Mostrar valor médio", "show-total": "Mostrar valor total", - "settings": "Configuração de legenda", - "min": "mín.", - "max": "máx.", - "avg": "méd.", + "show-latest": "Mostrar valor mais recente", + "settings": "Configurações de legenda", + "min": "mín", + "max": "máx", + "avg": "méd", "total": "total", + "latest": "mais recente", + "Min": "Mín", + "Max": "Máx", + "Avg": "Méd", + "Total": "Total", + "Latest": "Mais recente", "comparison-time-ago": { + "previousInterval": "(intervalo anterior)", + "customInterval": "(intervalo personalizado)", "days": "(dia atrás)", "weeks": "(semana atrás)", "months": "(mês atrás)", "years": "(ano atrás)" - } + }, + "column-title": "Título da coluna", + "label": "Rótulo", + "value": "Valor" }, "login": { "login": "Login", "request-password-reset": "Solicitar redefinição de senha", "reset-password": "Redefinir senha", "create-password": "Criar senha", - "passwords-mismatch-error": "As senhas inseridas devem ser idênticas!", - "password-again": "Repetir senha", - "username": "Nome de usuário (e-mail)", - "remember-me": "Lembrar-me", - "forgot-password": "Esqueceu a senha?", - "password-reset": "Redefinir senha", - "expired-password-reset-message": "As credenciais de expiraram! Crie uma nova senha.", + "two-factor-authentication": "Autenticação de dois fatores", + "passwords-mismatch-error": "As senhas inseridas devem ser iguais!", + "password-again": "Confirmar senha", + "sign-in": "Entrar", + "username": "Nome de usuário (email)", + "remember-me": "Lembrar de mim", + "forgot-password": "Esqueceu sua senha?", + "password-reset": "Redefinição de senha", + "expired-password-reset-message": "Sua senha expirou! \nInsira uma nova senha.", "new-password": "Nova senha", - "new-password-again": "Repetir nova senha", - "password-link-sent-message": "O link de redefinição de senha foi enviado corretamente!", - "email": "E-mail", - "login-with": "Login com {{name}}", + "new-password-again": "Confirmar nova senha", + "password-link-sent-message": "Link de redefinição foi enviado", + "email": "Email", + "invalid-email-format": "Formato de email inválido.", + "sign-in-with": "Entrar com {{name}}", + "sign-in-to-your-account": "Entrar na sua conta", "or": "ou", - "error": "Erro de login" + "error": "Erro de login", + "verify-your-identity": "Verificar sua identidade", + "select-way-to-verify": "Selecionar uma forma de verificação", + "resend-code": "Reenviar código", + "resend-code-wait": "Reenviar código em { time, plural, =1 {1 segundo} other {# segundos} }", + "try-another-way": "Tentar outra forma", + "totp-auth-description": "Insira o código de segurança do seu aplicativo autenticador.", + "totp-auth-placeholder": "Código", + "sms-auth-description": "Um código de segurança foi enviado para o seu telefone em {{contact}}.", + "sms-auth-placeholder": "Código SMS", + "email-auth-description": "Um código de segurança foi enviado para o seu endereço de email em {{contact}}.", + "email-auth-placeholder": "Código de email", + "backup-code-auth-description": "Insira um dos seus códigos de backup.", + "backup-code-auth-placeholder": "Código de backup", + "activation-link-expired": "O link de ativação expirou", + "activation-link-expired-message": "O link para ativar seu perfil expirou. Você pode retornar à página de login para receber um novo email.", + "reset-password-link-expired": "O link de redefinição de senha expirou", + "reset-password-link-expired-message": "O link para redefinir sua senha expirou. Você pode retornar à página de login para receber um novo email.", + "two-fa": "Autenticação de dois fatores", + "two-fa-required": "A autenticação de dois fatores é obrigatória", + "set-up-verification-method": "Configurar um método de verificação para continuar", + "set-up-verification-method-login": "Configurar um método de verificação ou fazer login", + "enable-authenticator-app": "Habilitar aplicativo autenticador", + "enable-authenticator-app-description": "Insira o código de segurança do seu aplicativo autenticador", + "enable-authenticator-sms": "Habilitar autenticador SMS", + "enable-authenticator-sms-description": "Insira o código de 6 dígitos que acabamos de enviar para ", + "enable-authenticator-email": "Habilitar autenticador de email", + "enable-authenticator-email-description": "Um código de segurança foi enviado para o seu endereço de email em ", + "enter-key-manually": "ou insira esta chave de 32 dígitos manualmente:", + "continue": "Continuar", + "confirm": "Confirmar", + "authenticator-app-success": "Aplicativo autenticador habilitado com sucesso", + "authenticator-app-success-description": "Na próxima vez que fizer login, será necessário fornecer um código de autenticação de dois fatores", + "authenticator-sms-success": "Autenticador SMS habilitado com sucesso", + "authenticator-sms-success-description": "Na próxima vez que fizer login, será solicitado que insira o código de segurança que será enviado para o número de telefone", + "authenticator-email-success": "Autenticador de email habilitado com sucesso", + "authenticator-email-success-description": "Na próxima vez que fizer login, será solicitado que insira o código de segurança que será enviado para o seu endereço de email", + "authenticator-backup-code-success": "Código de backup habilitado com sucesso", + "authenticator-backup-code-success-description": "Na próxima vez que fizer login, será solicitado que insira o código de segurança ou use um dos códigos de backup.", + "add-verification-method": "Adicionar método de verificação", + "get-backup-code": "Obter código de backup", + "copy-key": "Copiar chave", + "send-code": "Enviar código", + "email-label": "Email", + "email-description": "Insira um email para usar como autenticador.", + "sms-description": "Insira um número de telefone para usar como autenticador.", + "backup-code-description": "Imprima os códigos para tê-los à mão quando precisar usá-los para entrar na sua conta. Cada código de backup pode ser usado uma vez.", + "backup-code-warn": "Após sair desta página, esses códigos não poderão ser exibidos novamente. Armazene-os com segurança usando as opções abaixo.", + "download-txt": "Baixar (txt)", + "print": "Imprimir", + "verification-code": "Código de 6 dígitos", + "verification-code-invalid": "Formato de código de verificação inválido", + "verification-code-incorrect": "O código de verificação está incorreto", + "verification-code-many-request": "Muitas solicitações para verificar o código de verificação", + "scan-qr-code": "Escaneie este código QR com seu aplicativo de verificação", + "phone-input": { + "phone-input-label": "Número de telefone", + "phone-input-required": "O número de telefone é obrigatório", + "phone-input-validation": "O número de telefone é inválido ou não é possível", + "phone-input-pattern": "Número de telefone inválido. Deve estar no formato E.164, ex. {{phoneNumber}}", + "phone-input-hint": "Número de telefone no formato E.164, ex. {{phoneNumber}}" + } + }, + "mobile": { + "add-application": "Adicionar aplicação", + "app-id": "ID de associação do site do aplicativo", + "app-id-required": "O ID de associação do site do aplicativo é obrigatório", + "app-id-pattern": "Formato inválido do ID de associação do site do aplicativo", + "app-store-link": "Link da App Store", + "app-store-link-required": "O link da App Store é obrigatório", + "application-details": "Detalhes da aplicação", + "application-package": "Pacote da aplicação", + "application-secret": "Application Secret", + "application-secret-required": "O Application Secret é obrigatório", + "application": "Aplicação", + "applications": "Aplicações", + "copy-app-id": "Copiar ID do aplicativo", + "copy-app-store-link": "Copiar link da App Store", + "copy-application-package": "Copiar pacote da aplicação", + "copy-application-secret": "Copiar Application Secret", + "copy-google-play-link": "Copiar link do Google Play", + "copy-sha256-certificate-fingerprints": "Copiar impressões digitais do certificado SHA256", + "delete-application": "Excluir aplicação", + "delete-application-button-text": "Entendo as consequências, excluir aplicação", + "delete-application-text": "Esta ação não pode ser desfeita. Isso excluirá permanentemente sua aplicação.
Se não quiser excluí-la permanentemente, você pode suspender a aplicação temporariamente.
Para excluir a aplicação mesmo assim, digite \"{{phrase}}\" para confirmar.", + "delete-application-title-short": "Tem certeza de que deseja excluir a aplicação '{{name}}'?", + "delete-application-text-short": "Tenha cuidado, após a confirmação, as aplicações e todos os dados relacionados serão irrecuperáveis.", + "delete-application-phrase": "excluir aplicação", + "delete-applications-bundle-text": "Tenha cuidado, após a confirmação, o conjunto mobile e todos os dados relacionados serão irrecuperáveis.", + "delete-applications-bundle-title": "Tem certeza de que deseja excluir o conjunto mobile '{{bundleName}}'?", + "generate-application-secret": "Gerar Application Secret", + "google-play-link": "Link do Google Play", + "google-play-link-required": "O link do Google Play é obrigatório", + "latest-version": "Versão mais recente", + "min-version": "Versão mínima", + "invalid-version-pattern": "Formato de versão inválido. Use o formato: major.minor.patch (ex.: 1.0.0).", + "mobile-center": "Central móvel", + "mobile-package": "Pacote da aplicação", + "mobile-package-max-length": "O pacote da aplicação deve ter menos de 256 caracteres", + "mobile-package-required": "O pacote da aplicação é obrigatório.", + "mobile-package-pattern": "Formato inválido do pacote da aplicação", + "mobile-package-title": "Título da aplicação", + "mobile-package-title-max-length": "O título da aplicação deve ter menos de 256 caracteres", + "no-application": "Nenhuma aplicação encontrada", + "no-bundles": "Nenhum conjunto encontrado", + "platform-type": "Tipo de plataforma", + "search-application": "Pesquisar aplicações", + "search-bundles": "Pesquisar conjuntos", + "set": "Definir", + "sha256-certificate-fingerprints": "Impressões digitais do certificado SHA256", + "sha256-certificate-fingerprints-required": "As impressões digitais do certificado SHA256 são obrigatórias", + "sha256-certificate-fingerprints-pattern": "Formato inválido da impressão digital do certificado SHA256", + "show-hidden-pages": "Mostrar páginas ocultas", + "status": "Status", + "status-type": { + "deprecated": "Descontinuado", + "draft": "Rascunho", + "published": "Publicado", + "suspended": "Suspenso" + }, + "store-information": "Informações da loja", + "version-information": "Informações de versão", + "min-version-release-notes": "Notas de versão da versão mínima", + "latest-version-release-notes": "Notas de versão da versão mais recente", + "bundle": "Conjunto", + "bundles": "Conjuntos", + "add-bundle": "Adicionar conjunto", + "title": "Título", + "title-required": "O título é obrigatório", + "title-cannot-contain-only-spaces": "O título não pode conter apenas espaços", + "title-max-length": "O título deve ter menos de 256 caracteres", + "oauth-clients": "Clientes OAuth 2.0", + "android-app": "Aplicativo Android", + "android-application": "Aplicação Android", + "ios-app": "Aplicativo iOS", + "ios-application": "Aplicação iOS", + "invalid-store-link": "Link da loja inválido", + "enable-oauth": "Habilitar OAuth 2.0", + "enable-self-registration": "Habilitar autorregistro", + "edit-bundle": "Editar conjunto", + "description": "Descrição", + "basic-settings": "Configurações básicas", + "no-application-matching": "Nenhuma aplicação correspondente a '{{entity}}' foi encontrada.", + "no-bundle-matching": "Nenhum conjunto correspondente a '{{entity}}' foi encontrado.", + "application-required": "A aplicação é obrigatória.", + "bundle-required": "O conjunto é obrigatório.", + "no-application-text": "Nenhuma aplicação encontrada", + "no-bundle-text": "Nenhum conjunto encontrado", + "layout": "Layout", + "pages": "Páginas", + "hide-all-pages": "Ocultar todas as páginas", + "reset-to-default-pages": "Redefinir para páginas padrão", + "add-specific-page": "Adicionar página específica", + "visible": "Visível", + "hidden": "Oculto", + "reset-to-page-default": "Redefinir página para o padrão", + "mobile-599": "Mobile (máx. 599px)", + "tablet-959": "Tablet (máx. 959px)", + "max-element-number": "Número máximo de elementos", + "page-name": "Nome da página", + "page-name-required": "O nome da página é obrigatório.", + "page-name-cannot-contain-only-spaces": "O nome da página não pode conter apenas espaços.", + "page-name-max-length": "O nome da página deve ter menos de 256 caracteres", + "page-type": "Tipo de página", + "pages-types": { + "dashboard": "Dashboard", + "web-view": "Visualização web", + "custom": "Personalizado" + }, + "url": "URL", + "invalid-url-format": "Formato de URL inválido", + "path": "Caminho", + "invalid-path-format": "Formato de caminho inválido", + "custom-page": "Página personalizada", + "edit-page": "Editar página", + "edit-custom-page": "Editar página personalizada", + "delete-page": "Excluir página", + "qr-code-widget": "Widget de código QR", + "type-here": "Digite aqui", + "configuration-dialog": "Diálogo de configuração", + "configuration-app": "Aplicação de configuração", + "configuration-step": { + "prepare-environment-title": "Preparar ambiente de desenvolvimento", + "prepare-environment-text": "O Flutter ThingsBoard Mobile Application requer o Flutter SDK. Siga as instruções para configurar o Flutter SDK.", + "get-source-code-title": "Obter código-fonte do aplicativo", + "get-source-code-text": "Você pode obter o código-fonte do Flutter ThingsBoard Mobile Application clonando-o do repositório GitHub:", + "configure-app-settings-title": "Configurar as definições do aplicativo", + "configure-app-settings-text": "Baixe o arquivo de configuração e coloque-o no diretório raiz do projeto clonado na etapa anterior.", + "download-file": "Baixar arquivo", + "run-app-title": "Executar o aplicativo", + "run-app-text": "Execute o aplicativo conforme descrito na sua IDE.\nSe estiver usando o terminal, execute o aplicativo com o seguinte comando:", + "more-information": "Informações detalhadas podem ser encontradas na nossa documentação de Primeiros Passos.", + "getting-started": "Primeiros Passos" + } + }, + "notification": { + "action-button": "Botão de ação", + "action-type": "Tipo de ação", + "active": "Ativo", + "add-notification-recipients-group": "Adicionar grupo de destinatários de notificação", + "add-notification-template": "Adicionar modelo de notificação", + "add-recipient": "Adicionar destinatário", + "add-recipients": "Adicionar destinatários", + "add-rule": "Adicionar regra", + "add-stage": "Adicionar etapa", + "add-template": "Adicionar modelo", + "after": "Após", + "alarm-assignment-trigger-settings": "Configurações do gatilho de atribuição de alarme", + "alarm-comment-trigger-settings": "Configurações do gatilho de comentário de alarme", + "alarm-trigger-settings": "Configurações do gatilho de alarme", + "all": "Todos", + "api-feature-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os recursos de API", + "api-usage-trigger-settings": "Configurações do gatilho de uso de API", + "new-platform-version-trigger-settings": "Configurações do gatilho de nova versão da plataforma", + "rate-limits-trigger-settings": "Configurações do gatilho de limites de taxa excedidos", + "task-processing-failure-trigger-settings": "Configurações do gatilho de falha no processamento de tarefas", + "resources-shortage-trigger-settings": "Configurações do gatilho de escassez de recursos", + "at-least-one-should-be-selected": "Pelo menos um deve ser selecionado", + "basic-settings": "Configurações básicas", + "button-text": "Texto do botão", + "button-text-required": "O texto do botão é obrigatório", + "button-text-max-length": "O texto do botão deve ter no máximo {{ length }} caracteres", + "compose": "Redigir", + "conversation": "Conversa", + "conversation-required": "A conversa é obrigatória", + "copy-notification-template": "Copiar modelo de notificação", + "copy-rule": "Copiar regra", + "copy-template": "Copiar modelo", + "create-new": "Criar novo", + "created": "Criado", + "customize-messages": "Personalizar mensagens", + "cpu-threshold": "Limite de CPU", + "delete-notification-text": "Tenha cuidado, após a confirmação a notificação será irrecuperável.", + "delete-notification-title": "Tem certeza de que deseja excluir a notificação?", + "delete-notifications-text": "Tenha cuidado, após a confirmação as notificações serão irrecuperáveis.", + "delete-notifications-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 notificação} other {# notificações} }?", + "delete-recipient-text": "Tenha cuidado, após a confirmação o destinatário será irrecuperável.", + "delete-recipient-title": "Tem certeza de que deseja excluir o destinatário '{{recipientName}}'?", + "delete-recipients-text": "Tenha cuidado, após a confirmação os destinatários serão irrecuperáveis.", + "delete-recipients-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 destinatário} other {# destinatários} }?", + "delete-request-text": "Tenha cuidado, após a confirmação a solicitação será irrecuperável.", + "delete-request-title": "Tem certeza de que deseja excluir a solicitação?", + "delete-requests-text": "Tenha cuidado, após a confirmação as solicitações serão irrecuperáveis.", + "delete-requests-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 solicitação} other {# solicitações} }?", + "delete-rule-text": "Tenha cuidado, após a confirmação a regra será irrecuperável.", + "delete-rule-title": "Tem certeza de que deseja excluir a regra '{{ruleName}}'?", + "delete-rules-text": "Tenha cuidado, após a confirmação as regras serão irrecuperáveis.", + "delete-rules-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 regra} other {# regras} }?", + "delete-template-text": "Tenha cuidado, após a confirmação o modelo será irrecuperável.", + "delete-template-title": "Tem certeza de que deseja excluir o modelo '{{templateName}}'?", + "delete-templates-text": "Tenha cuidado, após a confirmação os modelos serão irrecuperáveis.", + "delete-templates-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 modelo} other {# modelos} }?", + "deleted": "Excluído", + "delivery-method": { + "delivery-method": "Método de entrega", + "email": "Email", + "email-preview": "Visualização de notificação por Email", + "slack": "Slack", + "slack-preview": "Visualização de notificação do Slack", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Visualização de notificação do Microsoft Teams", + "sms": "SMS", + "sms-preview": "Visualização de notificação por SMS", + "web": "Web", + "web-preview": "Visualização de notificação web", + "mobile-app": "Aplicativo mobile", + "mobile-app-preview": "Visualização de notificação do aplicativo mobile" + }, + "delivery-method-not-configure-click": "O método de entrega não está configurado. Clique para configurar.", + "delivery-method-not-configure-contact": "O método de entrega não está configurado. Entre em contato com o administrador do sistema.", + "delivery-methods": "Métodos de entrega", + "description": "Descrição", + "device-activity-trigger-settings": "Configurações do gatilho de atividade do dispositivo", + "device-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os dispositivos", + "device-profiles-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os perfis de dispositivo", + "disabled": "Desabilitado", + "edge-trigger-settings": "Configurações do gatilho do Edge", + "edge-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todas as instâncias do Edge", + "edit-notification-recipients-group": "Editar grupo de destinatários de notificação", + "edit-notification-template": "Editar modelo de notificação", + "edit-rule": "Editar regra", + "edit-template": "Editar modelo", + "enabled": "Habilitado", + "entities-limit-trigger-settings": "Configurações do gatilho de limite de entidades", + "entity-action-trigger-settings": "Configurações do gatilho de ação de entidade", + "entity-type": "Tipo de entidade", + "escalation-chain": "Cadeia de escalonamento", + "failed-send": "Falhas de envio", + "fails": "{ count, plural, =1 {1 falha} other {# falhas} }", + "filter": "Filtro", + "first-recipient": "Primeiro destinatário", + "inactive": "Inativo", + "inbox": "Caixa de entrada", + "notification-inbox": "Notificações / Caixa de entrada", + "input-field-support-templatization": "O campo de entrada suporta templatização.", + "input-fields-support-templatization": "Os campos de entrada suportam templatização.", + "link": "Link", + "link-required": "O link é obrigatório", + "link-max-length": "O link deve ter no máximo {{ length }} caracteres", + "link-type": { + "dashboard": "Abrir Dashboard", + "link": "Abrir link URL" + }, + "loading-notifications": "Carregando notificações...", + "management": "Gerenciamento de notificações", + "mark-all-as-read": "Marcar todas como lidas", + "mark-as-read": "Marcar como lida", + "message": "Mensagem", + "message-required": "A mensagem é obrigatória", + "message-max-length": "A mensagem deve ter no máximo {{ length }} caracteres", + "name": "Nome", + "name-required": "O nome é obrigatório", + "new-notification": "Nova notificação", + "no-inbox-notification": "Nenhuma notificação encontrada", + "no-notification-request": "Nenhuma solicitação de notificação", + "no-notification-templates": "Nenhum modelo de notificação encontrado", + "no-notifications-yet": "Nenhuma notificação ainda", + "no-recipients-notification": "Nenhuma notificação de destinatários", + "no-recipients-matching": "Nenhum destinatário correspondente a '{{entity}}' foi encontrado.", + "no-recipients-text": "Nenhum destinatário encontrado", + "no-rule": "Nenhuma regra configurada", + "no-rules-notification": "Nenhuma notificação de regras", + "no-severity-found": "Nenhuma severidade encontrada", + "no-severity-matching": "'{{severity}}' não encontrado.", + "no-template-matching": "Nenhum recurso correspondente a '{{template}}' foi encontrado.", + "create-new-template": "Criar um novo!", + "not-found-slack-recipient": "Destinatário Slack não encontrado", + "notification": "Notificação", + "notification-center": "Central de notificações", + "notification-tap-action": "Ação ao tocar na notificação", + "notification-tap-action-hint": "Se não estiver habilitado, o Dashboard de alarme padrão será usado", + "notify": "notificar", + "notify-again": "Notificar novamente", + "notify-alarm-action": { + "acknowledged": "Alarme reconhecido", + "assigned": "Alarme atribuído", + "cleared": "Alarme resolvido", + "created": "Alarme criado", + "severity-changed": "Severidade do alarme alterada", + "unassigned": "Alarme não atribuído" + }, + "notify-on": "Notificar em", + "notify-on-comment-update": "Notificar na atualização de comentário", + "notify-on-required": "Notificar em é obrigatório", + "notify-on-unassign": "Notificar ao cancelar atribuição", + "notify-only-user-comments": "Notificar apenas comentários de usuários", + "only-rule-chain-lifecycle-failures": "Apenas falhas no ciclo de vida da cadeia de regras", + "only-rule-node-lifecycle-failures": "Apenas falhas no ciclo de vida do nó de regra", + "platform-users": "Usuários da plataforma", + "ram-threshold": "Limite de RAM", + "rate-limits": "Limites de taxa", + "rate-limits-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os limites de taxa", + "recipient": "Destinatário", + "recipient-group": "Grupo de destinatários", + "recipient-type": { + "affected-tenant-administrators": "Administradores do tenant afetado", + "affected-user": "Usuário afetado", + "all-users": "Todos os usuários", + "customer-users": "Usuários do cliente", + "system-administrators": "Administradores do sistema", + "tenant-administrators": "Administradores do tenant", + "user-filters": "Filtro de usuário", + "user-list": "Lista de usuários", + "users-entity-owner": "Usuários do proprietário da entidade" + }, + "recipients": "Destinatários", + "notification-recipient": "Destinatário da notificação", + "notification-recipient-required": "O destinatário da notificação é obrigatório.", + "notification-recipients": "Notificações / Destinatários", + "recipients-count": "{ count, plural, =1 {1 destinatário} other {# destinatários} }", + "recipients-required": "Os destinatários são obrigatórios", + "refresh-allow-delivery-method": "Atualizar método de entrega permitido", + "request-search": "Pesquisar solicitações", + "request-status": { + "processing": "Processando", + "scheduled": "Agendado", + "sent": "Enviado" + }, + "review": "Revisar", + "rule": "Regra", + "rule-chain-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todas as cadeias de regras", + "rule-engine-events-trigger-settings": "Configurações do gatilho de eventos do motor de regras", + "rule-engine-filter": "Filtro do motor de regras", + "rule-name": "Nome da regra", + "rule-name-required": "O nome é obrigatório", + "rule-disable": "Desabilitar regra de notificação", + "rule-enable": "Habilitar regra de notificação", + "rule-node-filter": "Filtro de nó de regra", + "rules": "Regras", + "notification-rules": "Notificações / Regras", + "scheduler-later": "Agendar para mais tarde", + "search-notification": "Pesquisar notificações", + "search-recipients": "Pesquisar destinatários", + "search-rules": "Pesquisar regras", + "search-templates": "Pesquisar modelos", + "see-documentation": "Ver documentação", + "selected-notifications": "{ count, plural, =1 {1 notificação} other {# notificações} } selecionada(s)", + "selected-recipients": "{ count, plural, =1 {1 destinatário} other {# destinatários} } selecionado(s)", + "selected-requests": "{ count, plural, =1 {1 solicitação} other {# solicitações} } selecionada(s)", + "selected-rules": "{ count, plural, =1 {1 regra} other {# regras} } selecionada(s)", + "selected-template": "{ count, plural, =1 {1 modelo} other {# modelos} } selecionado(s)", + "send-notification": "Enviar notificação", + "sent": "Enviado", + "setup": "Configurar", + "notification-sent": "Notificações / Enviadas", + "set-entity-from-notification": "Definir entidade da notificação para o estado do Dashboard", + "slack-chanel-type": "Tipo de canal do Slack", + "slack-chanel-types": { + "direct": "Mensagem direta", + "private-channel": "Canal privado", + "public-channel": "Canal público" + }, + "start-from-scratch": "Começar do zero", + "status": "Status", + "stop-escalation-alarm-status-become": "Parar o escalonamento quando o status do alarme se tornar:", + "storage-threshold": "Limite de armazenamento", + "subject": "Assunto", + "subject-required": "O assunto é obrigatório", + "subject-max-length": "O assunto deve ter no máximo {{ length }} caracteres", + "template": "Modelo", + "template-name": "Nome do modelo", + "template-required": "O modelo é obrigatório", + "template-type": { + "alarm": "Alarme", + "alarm-assignment": "Atribuição de alarme", + "alarm-comment": "Comentário de alarme", + "api-usage-limit": "Limite de uso de API", + "device-activity": "Atividade do dispositivo", + "entities-limit": "Limite de entidades", + "entities-limit-increase-request": "Solicitação de aumento do limite de entidades", + "entity-action": "Ação de entidade", + "general": "Geral", + "rule-engine-lifecycle-event": "Evento de ciclo de vida do motor de regras", + "rule-node": "Nó de regra", + "new-platform-version": "Nova versão da plataforma", + "rate-limits": "Limites de taxa excedidos", + "edge-communication-failure": "Falha de comunicação do Edge", + "edge-connection": "Conexão do Edge", + "task-processing-failure": "Falha no processamento de tarefas", + "resources-shortage": "Escassez de recursos" + }, + "templates": "Modelos", + "notification-templates": "Notificações / Modelos", + "tenant-profiles-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os perfis de tenant", + "tenants-list-rule-hint": "Se o campo estiver vazio, o gatilho será aplicado a todos os tenants", + "threshold": "Limite", + "theme-color": "Cor do tema", + "time": "Hora", + "track-rule-node-events": "Rastrear eventos de nó de regra", + "trigger": { + "alarm": "Alarme", + "alarm-assignment": "Atribuição de alarme", + "alarm-comment": "Comentário de alarme", + "api-usage-limit": "Limite de uso de API", + "device-activity": "Atividade do dispositivo", + "entities-limit": "Limite de entidades", + "entity-action": "Ação de entidade", + "rule-engine-lifecycle-event": "Evento de ciclo de vida do motor de regras", + "new-platform-version": "Nova versão da plataforma", + "rate-limits": "Limites de taxa excedidos", + "edge-connection": "Conexão do Edge", + "edge-communication-failure": "Falha de comunicação do Edge", + "task-processing-failure": "Falha no processamento de tarefas", + "resources-shortage": "Escassez de recursos", + "trigger": "Gatilho", + "trigger-required": "O gatilho é obrigatório" + }, + "type": "Tipo", + "unread": "Não lida", + "updated": "Atualizado", + "use-deprecated-webhook-connectors": "Usar conectores Webhook obsoletos", + "use-old-api": "Usar API antiga", + "use-template": "Usar modelo", + "view-all": "Ver todos", + "warning": "Aviso", + "webhook-url": "Webhook URL", + "webhook-url-required": "O Webhook URL é obrigatório", + "workflow-url": "Workflow URL", + "workflow-url-required": "O Workflow URL é obrigatório", + "channel-name": "Nome do canal", + "channel-name-required": "O nome do canal é obrigatório", + "settings": { + "notification-settings": "Configurações de notificação", + "reset-all": "Redefinir todas as configurações", + "reset-all-title": "Tem certeza de que deseja redefinir o formulário?", + "reset-all-text": "Após a confirmação, o formulário de configurações será redefinido para o valor padrão e salvo.", + "type": "Tipo", + "enable-all": "Habilitar todos", + "disable-all": "Desabilitar todos", + "delivery-not-configured": "O método de entrega não está configurado" + } + }, + "ota-update": { + "add": "Adicionar pacote", + "assign-firmware": "Firmware atribuído", + "assign-firmware-required": "O firmware atribuído é obrigatório", + "assign-software": "Software atribuído", + "assign-software-required": "O software atribuído é obrigatório", + "auto-generate-checksum": "Gerar checksum automaticamente", + "checksum": "Checksum", + "checksum-hint": "Se o checksum estiver vazio, será gerado automaticamente", + "checksum-algorithm": "Algoritmo de checksum", + "checksum-copied-message": "O checksum do pacote foi copiado para a área de transferência", + "change-firmware": "A alteração do firmware pode causar a atualização de { count, plural, =1 {1 dispositivo} other {# dispositivos} }.", + "change-software": "A alteração do software pode causar a atualização de { count, plural, =1 {1 dispositivo} other {# dispositivos} }.", + "change-ota-setting-title": "Tem certeza de que deseja alterar as configurações de OTA?", + "chose-compatible-device-profile": "O pacote carregado estará disponível apenas para dispositivos com o perfil escolhido.", + "chose-firmware-distributed-device": "Escolher o firmware que será distribuído aos dispositivos", + "chose-software-distributed-device": "Escolher o software que será distribuído aos dispositivos", + "content-type": "Tipo de conteúdo", + "copy-checksum": "Copiar checksum", + "copy-direct-url": "Copiar URL direta", + "copyId": "Copiar ID do pacote", + "copied": "Copiado!", + "delete": "Excluir pacote", + "delete-ota-update-text": "Tenha cuidado, após a confirmação a atualização OTA será irrecuperável.", + "delete-ota-update-title": "Tem certeza de que deseja excluir a atualização OTA '{{title}}'?", + "delete-ota-updates-text": "Tenha cuidado, após a confirmação todas as atualizações OTA selecionadas serão removidas.", + "delete-ota-updates-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 atualização OTA} other {# atualizações OTA} }?", + "description": "Descrição", + "direct-url": "URL direta", + "direct-url-copied-message": "A URL direta do pacote foi copiada para a área de transferência", + "direct-url-required": "A URL direta é obrigatória", + "download": "Baixar pacote", + "drop-file": "Arraste um arquivo de pacote ou clique para selecionar um arquivo para upload.", + "drop-package-file-or": "Arraste e solte um arquivo de pacote ou", + "file-name": "Nome do arquivo", + "file-size": "Tamanho do arquivo", + "file-size-bytes": "Tamanho do arquivo em bytes", + "idCopiedMessage": "O ID do pacote foi copiado para a área de transferência", + "no-firmware-matching": "Nenhum pacote de atualização OTA de Firmware compatível correspondente a '{{entity}}' foi encontrado.", + "no-firmware-text": "Nenhum pacote de atualização OTA de Firmware compatível provisionado.", + "no-packages-text": "Nenhum pacote encontrado", + "no-software-matching": "Nenhum pacote de atualização OTA de Software compatível correspondente a '{{entity}}' foi encontrado.", + "no-software-text": "Nenhum pacote de atualização OTA de Software compatível provisionado.", + "ota-update": "Atualização OTA", + "ota-update-details": "Detalhes da atualização OTA", + "ota-updates": "Atualizações OTA", + "package-file": "Arquivo de pacote", + "package-type": "Tipo de pacote", + "packages-repository": "Repositório de pacotes", + "search": "Pesquisar pacotes", + "selected-package": "{ count, plural, =1 {1 pacote} other {# pacotes} } selecionado(s)", + "title": "Título", + "title-required": "O título é obrigatório.", + "title-max-length": "O título deve ter menos de 256 caracteres", + "types": { + "firmware": "Firmware", + "software": "Software" + }, + "upload-binary-file": "Fazer upload de arquivo binário", + "use-external-url": "Usar URL externa", + "version": "Versão", + "version-required": "A versão é obrigatória.", + "version-tag": "Tag de versão", + "version-tag-hint": "A tag personalizada deve corresponder à versão do pacote informada pelo seu dispositivo.", + "version-max-length": "A versão deve ter menos de 256 caracteres", + "warning-after-save-no-edit": "Após o upload do pacote, não será possível modificar o título, a versão, o perfil do dispositivo e o tipo de pacote." }, "position": { - "top": "Acima", - "bottom": "Abaixo", + "top": "Superior", + "bottom": "Inferior", "left": "Esquerda", "right": "Direita" }, "profile": { "profile": "Perfil", "last-login-time": "Último login", - "change-password": "Modificar senha", - "current-password": "Senha atual" + "change-password": "Alterar senha", + "current-password": "Senha atual", + "copy-jwt-token": "Copiar token JWT", + "jwt-token": "Token JWT", + "token-valid-till": "Token válido até", + "tokenCopiedSuccessMessage": "Token JWT foi copiado para a área de transferência", + "tokenCopiedWarnMessage": "Token JWT expirou! Por favor, atualize a página." + }, + "profiles": { + "profiles": "Perfis" + }, + "security": { + "security": "Segurança", + "general-settings": "Configurações gerais de segurança", + "access-token": "Token de acesso", + "access-token-required": "Token de acesso é obrigatório", + "clientId": "Client ID", + "clientId-required": "Client ID é obrigatório", + "username": "Nome de usuário", + "username-required": "Nome de usuário é obrigatório", + "ca-cert": "Certificado CA", + "2fa": { + "2fa": "Autenticação de dois fatores", + "2fa-description": "A autenticação de dois fatores protege sua conta contra acesso não autorizado. Basta inserir um código de segurança ao fazer login.", + "authenticate-with": "Você pode autenticar com:", + "disable-2fa-provider-text": "Desativar {{name}} tornará sua conta menos segura", + "disable-2fa-provider-title": "Tem certeza de que deseja desativar {{name}}?", + "get-new-code": "Obter novo código", + "main-2fa-method": "Usar como método principal de autenticação de dois fatores", + "dialog": { + "activation-step-description-email": "Na próxima vez que fizer login, será solicitado que insira o código de segurança que será enviado para seu endereço de email.", + "activation-step-description-sms": "Na próxima vez que fizer login, será solicitado que insira o código de segurança que será enviado para o número de telefone.", + "activation-step-description-totp": "Na próxima vez que fizer login, será necessário fornecer um código de autenticação de dois fatores.", + "activation-step-label": "Ativação", + "backup-code-description": "Imprima os códigos para tê-los à mão quando precisar usá-los para fazer login em sua conta. Você pode usar cada código de backup uma vez.", + "backup-code-warn": "Ao sair desta página, esses códigos não poderão ser exibidos novamente. Armazene-os com segurança usando as opções abaixo.", + "download-txt": "Baixar (txt)", + "email-step-description": "Insira um email para usar como autenticador.", + "email-step-label": "Email", + "enable-email-title": "Ativar autenticador por email", + "enable-sms-title": "Ativar autenticador por SMS", + "enable-totp-title": "Ativar aplicativo autenticador", + "enter-verification-code": "Insira o código de 6 dígitos aqui", + "get-backup-code-title": "Obter código de backup", + "next": "Próximo", + "scan-qr-code": "Escaneie este código QR com seu aplicativo de verificação", + "send-code": "Enviar código", + "sms-step-description": "Insira um número de telefone para usar como autenticador.", + "sms-step-label": "Número de telefone", + "success": "Sucesso!", + "totp-step-description-install": "Você pode instalar aplicativos como Google Authenticator, Authy ou Duo.", + "totp-step-description-open": "Abra o aplicativo autenticador no seu celular.", + "totp-step-label": "Obter aplicativo", + "verification-code": "Código de 6 dígitos", + "verification-code-invalid": "Formato de código de verificação inválido", + "verification-code-incorrect": "Código de verificação incorreto", + "verification-code-many-request": "Muitas solicitações para verificar o código de verificação", + "verification-step-description": "Insira o código de 6 dígitos que acabamos de enviar para {{address}}", + "verification-step-label": "Verificação" + }, + "provider": { + "email": "Email", + "email-description": "Use um código de segurança enviado para seu endereço de email para autenticar.", + "email-hint": "Códigos de autenticação são enviados por email para {{ info }}", + "sms": "SMS", + "sms-description": "Use seu telefone para autenticar. Enviaremos um código de segurança via SMS quando você fizer login.", + "sms-hint": "Códigos de autenticação são enviados por mensagem de texto para {{ info }}", + "totp": "Aplicativo autenticador", + "totp-description": "Use aplicativos como Google Authenticator, Authy ou Duo no seu telefone para autenticar. Será gerado um código de segurança para login.", + "totp-hint": "O aplicativo autenticador está configurado para sua conta", + "backup_code": "Código de backup", + "backup-code-description": "Esses códigos de acesso único imprimíveis permitem que você faça login quando estiver longe do seu telefone, como quando está viajando.", + "backup-code-hint": "{{ info }} códigos de uso único estão ativos no momento" + } + }, + "password-requirement": { + "at-least": "Pelo menos:", + "character": "{ count, plural, =1 {1 caractere} other {# caracteres} }", + "digit": "{ count, plural, =1 {1 dígito} other {# dígitos} }", + "password-tooltip-min-length": "Pelo menos {{minimumLength}} caracteres", + "password-tooltip-max-length": "No máximo {{maximumLength}} caracteres", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} letra maiúscula", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} letra minúscula", + "password-tooltip-digit": "{{minimumDigits}} número", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} caractere especial", + "incorrect-password-try-again": "Senha incorreta. Tente novamente", + "lowercase-letter": "{ count, plural, =1 {1 letra minúscula} other {# letras minúsculas} }", + "new-passwords-not-match": "A nova senha não coincide", + "password-should-not-contain-spaces": "Sua senha não deve conter espaços", + "password-not-meet-requirements": "A senha não atende aos requisitos", + "password-requirements": "Requisitos de senha", + "password-should-difference": "A nova senha deve ser diferente da atual", + "special-character": "{ count, plural, =1 {1 caractere especial} other {# caracteres especiais} }", + "uppercase-letter": "{ count, plural, =1 {1 letra maiúscula} other {# letras maiúsculas} }", + "at-most": "No máximo:" + } }, "relation": { "relations": "Relações", "direction": "Direção", + "clear-relation-type": "Limpar tipo de relação", "search-direction": { "FROM": "De", "TO": "Para" @@ -1421,214 +4967,2182 @@ }, "from-relations": "Relações de saída", "to-relations": "Relações de entrada", - "selected-relations": "{ count, plural, =1 {1 relation} other {# relations} } selecionado(s)", + "selected-relations": "{ count, plural, =1 {1 relação} other {# relações} } selecionada(s)", "type": "Tipo", - "to-entity-type": "Para tipo de entidade", - "to-entity-name": "Para nome de entidade", - "from-entity-type": "De tipo de entidade", - "from-entity-name": "De nome de entidade", - "to-entity": "Para entidade", - "from-entity": "De entidade", + "to-entity-type": "Tipo de entidade destino", + "to-entity-name": "Nome da entidade destino", + "from-entity-type": "Tipo de entidade origem", + "from-entity-name": "Nome da entidade origem", + "to-entity": "Entidade destino", + "from-entity": "Entidade origem", "delete": "Excluir relação", "relation-type": "Tipo de relação", - "relation-type-required": "O tipo de relação é obrigatório.", + "relation-type-required": "Tipo de relação é obrigatório.", + "relation-type-max-length": "Tipo de relação deve ter menos de 256 caracteres", "any-relation-type": "Qualquer tipo", "add": "Adicionar relação", "edit": "Editar relação", "delete-to-relation-title": "Tem certeza de que deseja excluir a relação com a entidade '{{entityName}}'?", - "delete-to-relation-text": "Cuidado, após confirmar, a relação da entidade '{{entityName}}' com a entidade atual será anulada.", - "delete-to-relations-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 relation} other {# relations} }?", - "delete-to-relations-text": "Cuidado, após confirmar, todas as relações serão removidas e a relação das entidades correspondentes com a entidade atual será anulada.", + "delete-to-relation-text": "Atenção: após a confirmação, a entidade '{{entityName}}' ficará sem relação com a entidade atual.", + "delete-to-relations-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 relação} other {# relações} }?", + "delete-to-relations-text": "Atenção: após a confirmação, todas as relações selecionadas serão removidas e as entidades correspondentes ficarão sem relação com a entidade atual.", "delete-from-relation-title": "Tem certeza de que deseja excluir a relação da entidade '{{entityName}}'?", - "delete-from-relation-text": "Cuidado, após confirmar, a relação da entidade atual com a entidade '{{entityName}}' será anulada.", - "delete-from-relations-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 relation} other {# relations} }?", - "delete-from-relations-text": "Cuidado, após confirmar, todas as relações serão removidas e a relação da entidade atual com as entidades correspondentes será anulada.", + "delete-from-relation-text": "Atenção: após a confirmação, a entidade atual ficará sem relação com a entidade '{{entityName}}'.", + "delete-from-relations-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 relação} other {# relações} }?", + "delete-from-relations-text": "Atenção: após a confirmação, todas as relações selecionadas serão removidas e a entidade atual ficará sem relação com as entidades correspondentes.", "remove-relation-filter": "Remover filtro de relação", + "remove-filter": "Remover filtro", "add-relation-filter": "Adicionar filtro de relação", "any-relation": "Qualquer relação", "relation-filters": "Filtros de relação", + "relation-filter": "Filtro de relação", "additional-info": "Informações adicionais (JSON)", - "invalid-additional-info": "Impossível analisar informações adicionais de json", - "no-relations-text": "Nenhuma relação encontrada" + "invalid-additional-info": "Não foi possível analisar o JSON de informações adicionais.", + "no-relations-text": "Nenhuma relação encontrada", + "not": "Não", + "copy-type": "Copiar tipo" + }, + "resource": { + "add": "Adicionar recurso", + "all-types": "Todos", + "copyId": "Copiar ID do recurso", + "delete": "Excluir recurso", + "delete-resource-text": "Atenção: após a confirmação, o recurso se tornará irrecuperável.", + "delete-resource-title": "Tem certeza de que deseja excluir o recurso '{{resourceTitle}}'?", + "delete-resources-action-title": "Excluir { count, plural, =1 {1 recurso} other {# recursos} }", + "delete-resources-text": "Observe que os recursos selecionados, mesmo que estejam sendo usados em perfis de dispositivos, serão excluídos.", + "delete-resources-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 recurso} other {# recursos} }?", + "download": "Baixar recurso", + "drop-file": "Solte um arquivo de recurso ou clique para selecionar um arquivo para enviar.", + "drop-resource-file-or": "Arraste e solte um arquivo de recurso ou", + "empty": "Recurso está vazio", + "file-name": "Nome do arquivo", + "idCopiedMessage": "ID do recurso foi copiado para a área de transferência", + "no-resource-matching": "Nenhum recurso correspondente a '{{widgetsBundle}}' foi encontrado.", + "no-resource-text": "Nenhum recurso encontrado", + "open-widgets-bundle": "Abrir conjunto de widgets", + "resource": "Recurso", + "resource-file": "Arquivo de recurso", + "resource-files": "Arquivos de recurso", + "resource-library-details": "Detalhes do recurso", + "resource-type": "Tipo de recurso", + "resources-library": "Biblioteca de recursos", + "search": "Pesquisar recursos", + "selected-resources": "{ count, plural, =1 {1 recurso} other {# recursos} } selecionado(s)", + "system": "Sistema", + "title": "Título", + "title-required": "Título é obrigatório.", + "title-max-length": "Título deve ter menos de 256 caracteres", + "type": { + "jks": "JKS", + "js-module": "Módulo JS", + "lwm2m-model": "Modelo LWM2M", + "pkcs-12": "PKCS #12", + "general": "Geral" + }, + "resource-sub-type": "Subtipo", + "sub-type": { + "image": "imagem", + "scada-symbol": "Símbolo Scada", + "extension": "Extensão", + "module": "Módulo" + }, + "resource-is-in-use": "Recurso está sendo usado por outras entidades", + "resources-are-in-use": "Recursos estão sendo usados por outras entidades", + "resource-is-in-use-text": "O recurso '{{title}}' não foi excluído porque está sendo usado pelas seguintes entidades:", + "resources-are-in-use-text": "Nem todos os recursos foram excluídos porque estão sendo usados por outras entidades.
Você pode visualizar as entidades referenciadas clicando no botão Referências na linha do recurso correspondente.
Se ainda quiser excluir esses recursos, selecione-os na tabela abaixo e clique no botão Excluir selecionados.", + "delete-resource-in-use-text": "Se ainda quiser excluir o recurso, clique no botão Excluir assim mesmo." + }, + "javascript": { + "add": "Adicionar recurso JavaScript", + "delete": "Excluir recurso JavaScript", + "delete-javascript-resource-text": "Atenção: após a confirmação, o recurso JavaScript se tornará irrecuperável.", + "delete-javascript-resource-title": "Tem certeza de que deseja excluir o recurso JavaScript '{{resourceTitle}}'?", + "delete-javascript-resources-action-title": "Excluir JavaScript { count, plural, =1 {1 recurso} other {# recursos} }", + "delete-javascript-resources-text": "Observe que os recursos JavaScript selecionados, mesmo que estejam sendo usados em funções JavaScript, serão excluídos.", + "delete-javascript-resources-title": "Tem certeza de que deseja excluir JavaScript { count, plural, =1 {1 recurso} other {# recursos} }?", + "delete-javascript-resource-in-use-text": "Se ainda quiser excluir o recurso JavaScript, clique no botão Excluir assim mesmo.", + "download": "Baixar recurso JavaScript", + "upload-from-file": "Enviar JavaScript de arquivo", + "resource-file": "Arquivo de recurso JavaScript", + "drop-file": "Solte um arquivo JavaScript ou clique para selecionar um arquivo para enviar.", + "drop-resource-file-or": "Arraste e solte um arquivo JavaScript ou", + "javascript-library": "Biblioteca JavaScript", + "javascript-type": "Tipo JavaScript", + "javascript-resource-details": "Detalhes do recurso JavaScript", + "javascript-resource-is-in-use": "Recurso JavaScript está sendo usado por outras entidades", + "javascript-resources-are-in-use": "Recursos JavaScript estão sendo usados por outras entidades", + "javascript-resource-is-in-use-text": "O recurso JavaScript '{{title}}' não foi excluído porque está sendo usado pelas seguintes entidades:", + "javascript-resources-are-in-use-text": "Nem todos os recursos JavaScript foram excluídos porque estão sendo usados por outras entidades.
Você pode visualizar as entidades referenciadas clicando no botão Referências na linha do recurso correspondente.
Se ainda quiser excluir esses recursos JavaScript, selecione-os na tabela abaixo e clique no botão Excluir selecionados.", + "search": "Pesquisar recursos JavaScript", + "selected-javascript-resources": "{ count, plural, =1 {1 recurso JavaScript} other {# recursos JavaScript} } selecionado(s)", + "no-javascript-resource-text": "Nenhum recurso JavaScript encontrado", + "all-types": "Todos", + "module-script": "Script de módulo" + }, + "rpc": { + "error": { + "target-device-is-not-set": "Dispositivo de destino não está definido!", + "invalid-target-entity": "Comandos RPC não são suportados pela entidade {{entityType}}.", + "failed-to-resolve-target-device": "Falha ao resolver o dispositivo de destino!", + "request-timeout": "Tempo limite da solicitação", + "rpc-http-error": "Erro: {{status}} - {{statusText}}" + } }, "rulechain": { "rulechain": "Cadeia de regras", + "rulechain-events": "Eventos da cadeia de regras", "rulechains": "Cadeias de regras", "root": "Raiz", "delete": "Excluir cadeia de regras", "name": "Nome", - "name-required": "O nome é obrigatório.", + "name-required": "Nome é obrigatório.", + "name-max-length": "Nome deve ter menos de 256 caracteres", "description": "Descrição", "add": "Adicionar cadeia de regras", - "set-root": "Tornar cadeia de regras em raiz", - "set-root-rulechain-title": "Tem certeza de que deseja tornar a cadeia de regras '{{ruleChainName}}' em raiz?", - "set-root-rulechain-text": "Após confirmar, a cadeia de regras se tornará em raiz e irá tratar todas as mensagens de transporte recebidas.", + "set-root": "Definir cadeia de regras como raiz", + "set-root-rulechain-title": "Tem certeza de que deseja definir a cadeia de regras '{{ruleChainName}}' como raiz?", + "set-root-rulechain-text": "Após a confirmação, a cadeia de regras se tornará raiz e irá processar todas as mensagens de transporte recebidas.", "delete-rulechain-title": "Tem certeza de que deseja excluir a cadeia de regras '{{ruleChainName}}'?", - "delete-rulechain-text": "Cuidado, após confirmar, não será possível recuperar a cadeia de regras e todos os dados associados.", - "delete-rulechains-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 rule chain} other {# rule chains} }?", - "delete-rulechains-action-title": "Excluir { count, plural, =1 {1 rule chain} other {# rule chains} }", - "delete-rulechains-text": "Cuidado, após confirmar, todas as cadeias de regras serão removidas e não será possível recuperar nenhum dado associado.", + "delete-rulechain-text": "Atenção: após a confirmação, a cadeia de regras e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-rulechains-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 cadeia de regras} other {# cadeias de regras} }?", + "delete-rulechains-action-title": "Excluir { count, plural, =1 {1 cadeia de regras} other {# cadeias de regras} }", + "delete-rulechains-text": "Atenção: após a confirmação, todas as cadeias de regras selecionadas serão removidas e todos os dados relacionados se tornarão irrecuperáveis.", "add-rulechain-text": "Adicionar nova cadeia de regras", "no-rulechains-text": "Nenhuma cadeia de regras encontrada", - "rulechain-details": "Detalhes de cadeia de regras", + "rulechain-details": "Detalhes da cadeia de regras", "details": "Detalhes", "events": "Eventos", "system": "Sistema", "import": "Importar cadeia de regras", "export": "Exportar cadeia de regras", - "export-failed-error": "Impossível exportar cadeia de regras: {{error}}", + "export-failed-error": "Não foi possível exportar a cadeia de regras: {{error}}", "create-new-rulechain": "Criar nova cadeia de regras", "rulechain-file": "Arquivo de cadeia de regras", - "invalid-rulechain-file-error": "Impossível importar cadeia de regras: Estrutura de dados de cadeia de regras inválida.", - "copyId": "Copiar ID de cadeia de regras", - "idCopiedMessage": "O ID da cadeia de regras foi copiado para a área de transferência", + "invalid-rulechain-file-error": "Não foi possível importar a cadeia de regras: estrutura de dados da cadeia de regras inválida.", + "copyId": "Copiar ID da cadeia de regras", + "idCopiedMessage": "ID da cadeia de regras foi copiado para a área de transferência", "select-rulechain": "Selecionar cadeia de regras", - "no-rulechains-matching": "Nenhuma cadeia de regras encontrada que coincida com '{{entity}}'.", - "rulechain-required": "A cadeia de regras é obrigatória", + "no-rulechains-matching": "Nenhuma cadeia de regras correspondente a '{{entity}}' foi encontrada.", + "rulechain-required": "Cadeia de regras é obrigatória", "management": "Gerenciamento de regras", "debug-mode": "Modo de depuração", "search": "Pesquisar cadeias de regras", - "selected-rulechains": "{ count, plural, =1 {1 rule chain} other {# rule chains} } selecionada(s)", - "open-rulechain": "Abrir cadeia de regras" + "selected-rulechains": "{ count, plural, =1 {1 cadeia de regras} other {# cadeias de regras} } selecionada(s)", + "open-rulechain": "Abrir cadeia de regras", + "edge-template-root": "Raiz do template", + "assign-to-edge": "Atribuir ao edge", + "edge-rulechain": "Cadeia de regras do edge", + "unassign-rulechain-from-edge-text": "Após a confirmação, a cadeia de regras será desatribuída e não estará acessível pelo edge.", + "unassign-rulechains-from-edge-title": "Tem certeza de que deseja desatribuir { count, plural, =1 {1 cadeia de regras} other {# cadeias de regras} }?", + "unassign-rulechains-from-edge-text": "Após a confirmação, todas as cadeias de regras selecionadas serão desatribuídas e não estarão acessíveis pelo edge.", + "assign-rulechain-to-edge-title": "Atribuir cadeia(s) de regras ao edge", + "assign-rulechain-to-edge-text": "Selecione as cadeias de regras para atribuir ao edge", + "set-edge-template-root-rulechain": "Definir cadeia de regras como raiz do template do edge", + "set-edge-template-root-rulechain-title": "Tem certeza de que deseja definir a cadeia de regras '{{ruleChainName}}' como raiz do template do edge?", + "set-edge-template-root-rulechain-text": "Após a confirmação, a cadeia de regras se tornará a raiz do template do edge e será a cadeia de regras raiz para os edges criados.", + "invalid-rulechain-type-error": "Não foi possível importar a cadeia de regras: tipo de cadeia de regras inválido. O tipo esperado é {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Atribuir cadeia de regras ao(s) edge(s) na criação", + "set-auto-assign-to-edge-title": "Tem certeza de que deseja atribuir a cadeia de regras do edge '{{ruleChainName}}' ao(s) edge(s) na criação?", + "set-auto-assign-to-edge-text": "Após a confirmação, a cadeia de regras do edge será automaticamente atribuída ao(s) edge(s) na criação.", + "unset-auto-assign-to-edge": "Não atribuir cadeia de regras ao(s) edge(s) na criação", + "unset-auto-assign-to-edge-title": "Tem certeza de que não deseja atribuir a cadeia de regras do edge '{{ruleChainName}}' ao(s) edge(s) na criação?", + "unset-auto-assign-to-edge-text": "Após a confirmação, a cadeia de regras do edge não será mais automaticamente atribuída ao(s) edge(s) na criação.", + "unassign-rulechain-title": "Tem certeza de que deseja desatribuir a cadeia de regras '{{ruleChainName}}'?", + "unassign-rulechains": "Desatribuir cadeias de regras" }, "rulenode": { + "rule-node-events": "Eventos do nó de regra", "details": "Detalhes", "events": "Eventos", "search": "Pesquisar nós", "open-node-library": "Abrir biblioteca de nós", + "close-node-library": "Fechar biblioteca de nós", "add": "Adicionar nó de regra", "name": "Nome", - "name-required": "O nome é obrigatório.", + "name-required": "Nome é obrigatório.", + "name-max-length": "Nome deve ter menos de 256 caracteres", "type": "Tipo", - "description": "Descrição", - "delete": "Excluir nó de regras", + "rule-node-description": "Descrição do nó de regra", + "delete": "Excluir nó de regra", "select-all-objects": "Selecionar todos os nós e conexões", "deselect-all-objects": "Desmarcar todos os nós e conexões", "delete-selected-objects": "Excluir nós e conexões selecionados", - "delete-selected": "Excluir selecionado", + "delete-selected": "Excluir selecionados", + "create-nested-rulechain": "Criar cadeia de regras aninhada", "select-all": "Selecionar tudo", - "copy-selected": "Copiar selecionado(s)", + "copy-selected": "Copiar selecionados", "deselect-all": "Desmarcar tudo", - "rulenode-details": "Detalhes do nó de regras", + "rulenode-details": "Detalhes do nó de regra", "debug-mode": "Modo de depuração", + "singleton": "Singleton", "configuration": "Configuração", "link": "Link", - "link-details": "Detalhes do link do nó de regras", + "link-details": "Detalhes do link do nó de regra", "add-link": "Adicionar link", - "link-label": "Etiqueta de link", - "link-label-required": "A etiqueta de link é obrigatória.", - "custom-link-label": "Etiqueta de link personalizada", - "custom-link-label-required": "A etiqueta de link personalizada é obrigatória.", - "link-labels": "Etiquetas de links", - "link-labels-required": "As etiquetas de links são obrigatórias.", - "no-link-labels-found": "Nenhuma etiqueta de link encontrada", + "link-label": "Rótulo do link", + "link-label-required": "Rótulo do link é obrigatório.", + "custom-link-label": "Rótulo de link personalizado", + "custom-link-label-required": "Rótulo de link personalizado é obrigatório.", + "link-labels": "Rótulos do link", + "link-labels-required": "Rótulos do link são obrigatórios.", + "no-link-labels-found": "Nenhum rótulo de link encontrado", "no-link-label-matching": "'{{label}}' não encontrado.", "create-new-link-label": "Criar um novo!", - "type-filter": "Filtrar", + "type-filter": "Filtro", "type-filter-details": "Filtrar mensagens recebidas com condições configuradas", "type-enrichment": "Enriquecimento", - "type-enrichment-details": "Adicionar informações adicionais aos Metadados de mensagens", + "type-enrichment-details": "Adicionar informações adicionais nos Metadados da mensagem", "type-transformation": "Transformação", - "type-transformation-details": "Alterar payload e metadados de mensagens", + "type-transformation-details": "Alterar payload e Metadados da mensagem", "type-action": "Ação", "type-action-details": "Executar ação especial", "type-external": "Externo", "type-external-details": "Interage com sistema externo", - "type-rule-chain": "Cadeia de Regras", - "type-rule-chain-details": "Encaminha mensagens recebidas para cadeia de regras especificada", + "type-rule-chain": "Cadeia de regras", + "type-rule-chain-details": "Encaminha mensagens recebidas para a cadeia de regras especificada", + "type-flow": "Fluxo", + "type-flow-details": "Organiza o fluxo de mensagens", "type-input": "Entrada", - "type-input-details": "Entrada lógica de cadeia de regras, encaminha mensagens recebidas para nó de regras associado", + "type-input-details": "Entrada lógica da cadeia de regras, encaminha mensagens recebidas para o próximo nó de regra relacionado", "type-unknown": "Desconhecido", - "type-unknown-details": "Nó de regras não resolvido", - "directive-is-not-loaded": "Diretriz de configuração '{{directiveName}}' definida não está disponível.", - "ui-resources-load-error": "Erro ao carregar configuração de recursos de interface de usuário.", - "invalid-target-rulechain": "Impossível resolver cadeia de regras alvo!", - "test-script-function": "Testar funcionamento de script", + "type-unknown-details": "Nó de regra não resolvido", + "directive-is-not-loaded": "A diretiva de configuração definida '{{directiveName}}' não está disponível.", + "ui-resources-load-error": "Falha ao carregar recursos de interface de configuração.", + "invalid-target-rulechain": "Não foi possível resolver a cadeia de regras de destino!", + "test-script-function": "Testar função de script", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", "message": "Mensagem", "message-type": "Tipo de mensagem", "select-message-type": "Selecionar tipo de mensagem", - "message-type-required": "O tipo de mensagem é obrigatório", + "message-type-required": "Tipo de mensagem é obrigatório", "metadata": "Metadados", - "metadata-required": "As entradas de metadados não podem estar em branco.", + "metadata-required": "As entradas de metadados não podem estar vazias.", "output": "Saída", - "test": "Teste", - "help": "Ajuda" + "test": "Testar", + "help": "Ajuda", + "reset-debug-settings": "Redefinir configurações de depuração em todos os nós", + "test-with-this-message": "{{test}} com esta mensagem", + "queue-hint": "Selecione uma fila para encaminhamento de mensagens para outra fila. A fila 'Main' é usada por padrão.", + "queue-singleton-hint": "Selecione uma fila para encaminhamento de mensagens em ambientes com múltiplas instâncias. A fila 'Main' é usada por padrão." + }, + "rule-node-config": { + "id": "Id", + "additional-info": "Informações adicionais", + "advanced-settings": "Configurações avançadas", + "create-entity-if-not-exists": "Criar nova entidade se não existir", + "create-entity-if-not-exists-hint": "Se habilitado, uma nova entidade com os parâmetros especificados será criada caso ainda não exista. As entidades existentes serão usadas como estão para a relação.", + "select-device-connectivity-event": "Selecionar evento de conectividade do dispositivo", + "entity-name-pattern": "Padrão de nome", + "device-name-pattern": "Nome do dispositivo", + "asset-name-pattern": "Nome do ativo", + "entity-view-name-pattern": "Nome da visualização de entidade", + "customer-title-pattern": "Título do cliente", + "dashboard-name-pattern": "Título do dashboard", + "user-name-pattern": "Email do usuário", + "edge-name-pattern": "Nome do edge", + "entity-name-pattern-required": "O padrão de nome é obrigatório", + "entity-name-pattern-hint": "O campo de padrão de nome suporta templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "copy-message-type": "Copiar tipo de mensagem", + "entity-type-pattern": "Padrão de tipo", + "entity-type-pattern-required": "O padrão de tipo é obrigatório", + "message-type-value": "Valor do tipo de mensagem", + "message-type-value-required": "O valor do tipo de mensagem é obrigatório", + "message-type-value-max-length": "O valor do tipo de mensagem deve ter menos de 256 caracteres", + "output-message-type": "Tipo de mensagem de saída", + "entity-cache-expiration": "Tempo de expiração do cache de entidades (seg)", + "entity-cache-expiration-hint": "Especifica o intervalo de tempo máximo permitido para armazenar os registros de entidades encontrados. O valor 0 significa que os registros nunca expirarão.", + "entity-cache-expiration-required": "O tempo de expiração do cache de entidades é obrigatório.", + "entity-cache-expiration-range": "O tempo de expiração do cache de entidades deve ser maior ou igual a 0.", + "customer-name-pattern": "Título do cliente", + "customer-name-pattern-required": "O título do cliente é obrigatório", + "customer-name-pattern-hint": "Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "create-customer-if-not-exists": "Criar novo cliente se não existir", + "unassign-from-customer": "Desatribuir de cliente específico se o originador for um dashboard", + "unassign-from-customer-tooltip": "Apenas dashboards podem ser atribuídos a múltiplos clientes ao mesmo tempo. \nSe o originador da mensagem for um dashboard, é necessário especificar explicitamente o título do cliente para desatribuir.", + "customer-cache-expiration": "Tempo de expiração do cache de clientes (seg)", + "customer-cache-expiration-hint": "Especifica o intervalo de tempo máximo permitido para armazenar os registros de clientes encontrados. O valor 0 significa que os registros nunca expirarão.", + "customer-cache-expiration-required": "O tempo de expiração do cache de clientes é obrigatório.", + "customer-cache-expiration-range": "O tempo de expiração do cache de clientes deve ser maior ou igual a 0.", + "interval-start": "Início do intervalo", + "interval-end": "Fim do intervalo", + "time-unit": "Unidade de tempo", + "fetch-mode": "Modo de busca", + "order-by-timestamp": "Ordenar por timestamp", + "limit": "Limite", + "limit-hint": "O valor mínimo do limite é 2 e o máximo é 1000. Se desejar buscar uma única entrada, selecione o modo de busca 'Primeiro' ou 'Último'.", + "limit-required": "O limite é obrigatório.", + "limit-range": "O limite deve estar no intervalo de 2 a 1000.", + "time-unit-milliseconds": "Milissegundos", + "time-unit-seconds": "Segundos", + "time-unit-minutes": "Minutos", + "time-unit-hours": "Horas", + "time-unit-days": "Dias", + "time-value-range": "Intervalo permitido de 1 a 2147483647.", + "start-interval-value-required": "O início do intervalo é obrigatório.", + "end-interval-value-required": "O fim do intervalo é obrigatório.", + "filter": "Filtro", + "switch": "Switch", + "math-templatization-tooltip": "Este campo suporta templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "add-message-type": "Adicionar tipo de mensagem", + "select-message-types-required": "Pelo menos um tipo de mensagem deve ser selecionado.", + "select-message-types": "Selecionar tipos de mensagem", + "no-message-types-found": "Nenhum tipo de mensagem encontrado", + "no-message-type-matching": "'{{messageType}}' não encontrado.", + "create-new-message-type": "Criar um novo.", + "message-types-required": "Os tipos de mensagem são obrigatórios.", + "client-attributes": "Atributos do cliente", + "shared-attributes": "Atributos compartilhados", + "server-attributes": "Atributos do servidor", + "attributes-keys": "Chaves de atributos", + "attributes-keys-required": "As chaves de atributos são obrigatórias", + "attributes-scope": "Escopo dos atributos", + "attributes-scope-value": "Valor do escopo dos atributos", + "attributes-scope-value-copy": "Copiar valor do escopo dos atributos", + "attributes-scope-hint": "Use a chave de metadados 'scope' para definir dinamicamente o escopo do atributo por mensagem. Se fornecida, isso substitui o escopo definido na configuração.", + "notify-device": "Forçar notificação ao dispositivo", + "send-attributes-updated-notification": "Enviar notificação de atributos atualizados", + "send-attributes-updated-notification-hint": "Envia notificação sobre atributos atualizados como uma mensagem separada para a fila do motor de regras.", + "send-attributes-deleted-notification": "Enviar notificação de atributos excluídos", + "send-attributes-deleted-notification-hint": "Envia notificação sobre atributos excluídos como uma mensagem separada para a fila do motor de regras.", + "update-attributes-only-on-value-change": "Salvar atributos somente se o valor mudar", + "update-attributes-only-on-value-change-hint": "Atualiza os atributos a cada mensagem recebida independentemente de o valor ter mudado. Aumenta o uso da API e reduz o desempenho.", + "update-attributes-only-on-value-change-hint-enabled": "Atualiza os atributos somente se o valor tiver mudado. Se o valor não for alterado, nenhuma atualização no timestamp do atributo nem notificação de alteração de atributo será enviada.", + "fetch-credentials-to-metadata": "Buscar credenciais para os metadados", + "notify-device-on-update-hint": "Se habilitado, força a notificação ao dispositivo sobre a atualização de atributos compartilhados. Se desabilitado, o comportamento da notificação é controlado pelo parâmetro 'notifyDevice' dos metadados da mensagem recebida. Para desativar a notificação, os metadados da mensagem devem conter o parâmetro 'notifyDevice' definido como 'false'. Em qualquer outro caso, a notificação será enviada ao dispositivo.", + "notify-device-on-delete-hint": "Se habilitado, força a notificação ao dispositivo sobre a remoção de atributos compartilhados. Se desabilitado, o comportamento da notificação é controlado pelo parâmetro 'notifyDevice' dos metadados da mensagem recebida. Para ativar a notificação, os metadados da mensagem devem conter o parâmetro 'notifyDevice' definido como 'true'. Em qualquer outro caso, a notificação não será disparada para o dispositivo.", + "latest-timeseries": "Chaves de dados de séries temporais mais recentes", + "timeseries-keys": "Chaves de séries temporais", + "timeseries-keys-required": "Pelo menos uma chave de série temporal deve ser selecionada.", + "add-timeseries-key": "Adicionar chave de série temporal", + "add-message-field": "Adicionar campo de mensagem", + "relation-search-parameters": "Parâmetros de busca de relação", + "relation-parameters": "Parâmetros de relação", + "add-metadata-field": "Adicionar campo de metadados", + "data-keys": "Nomes dos campos da mensagem", + "copy-from": "Copiar de", + "data-to-metadata": "Dados para metadados", + "metadata-to-data": "Metadados para dados", + "use-regular-expression-hint": "Use expressão regular para copiar chaves por padrão.\n\nDicas e truques:\nPressione 'Enter' para concluir a entrada do nome do campo.\nPressione 'Backspace' para excluir o nome do campo. Múltiplos nomes de campos são suportados.", + "interval": "Intervalo", + "interval-required": "O intervalo é obrigatório", + "interval-hint": "Intervalo de deduplicação em segundos.", + "interval-min-error": "O valor mínimo permitido é 1", + "max-pending-msgs": "Máximo de mensagens pendentes", + "max-pending-msgs-hint": "Número máximo de mensagens armazenadas em memória para cada id de deduplicação único.", + "max-pending-msgs-required": "O máximo de mensagens pendentes é obrigatório", + "max-pending-msgs-max-error": "O valor máximo permitido é 1000", + "max-pending-msgs-min-error": "O valor mínimo permitido é 1", + "max-retries": "Máximo de tentativas", + "max-retries-required": "O máximo de tentativas é obrigatório", + "max-retries-hint": "Número máximo de tentativas para enviar as mensagens deduplicadas para a fila. Um atraso de 10 segundos é utilizado entre as tentativas", + "max-retries-max-error": "O valor máximo permitido é 100", + "max-retries-min-error": "O valor mínimo permitido é 0", + "strategy": "Estratégia", + "strategy-required": "A estratégia é obrigatória", + "strategy-all-hint": "Retorna todas as mensagens recebidas durante o período de deduplicação como uma única mensagem de array JSON. Onde cada elemento representa um objeto com as propriedades internas msg e metadata.", + "strategy-first-hint": "Retorna a primeira mensagem recebida durante o período de deduplicação.", + "strategy-last-hint": "Retorna a última mensagem recebida durante o período de deduplicação.", + "first": "Primeiro", + "last": "Último", + "all": "Todos", + "output-msg-type-hint": "O tipo de mensagem do resultado de deduplicação.", + "queue-name-hint": "O nome da fila onde o resultado de deduplicação será publicado.", + "keys": "Chaves", + "keys-required": "As chaves são obrigatórias", + "rename-keys-in": "Renomear chaves em", + "data": "Dados", + "message": "Mensagem", + "metadata": "Metadados", + "current-key-name": "Nome da chave atual", + "key-name-required": "O nome da chave é obrigatório", + "new-key-name": "Novo nome de chave", + "new-key-name-required": "O novo nome de chave é obrigatório", + "metadata-keys": "Nomes dos campos de metadados", + "json-path-expression": "Expressão de caminho JSON", + "json-path-expression-required": "A expressão de caminho JSON é obrigatória", + "json-path-expression-hint": "O JSONPath especifica um caminho para um elemento ou conjunto de elementos em uma estrutura JSON. '$' representa o objeto ou array raiz.", + "relations-query": "Consulta de relações", + "device-relations-query": "Consulta de relações de dispositivos", + "max-relation-level": "Nível máximo de relação", + "max-relation-level-error": "O valor deve ser maior que 0 ou não especificado.", + "max-relation-level-invalid": "O valor deve ser um número inteiro.", + "relation-type": "Tipo de relação", + "relation-type-pattern": "Padrão de tipo de relação", + "relation-type-pattern-required": "O padrão de tipo de relação é obrigatório", + "relation-types-list": "Tipos de relação a propagar", + "relation-types-list-hint": "Se os tipos de relação a propagar não forem selecionados, os alarmes serão propagados sem filtragem por tipo de relação.", + "unlimited-level": "Nível ilimitado", + "latest-telemetry": "Telemetria mais recente", + "add-telemetry-key": "Adicionar chave de telemetria", + "delete-from": "Excluir de", + "use-regular-expression-delete-hint": "Use expressão regular para excluir chaves por padrão.\n\nDicas e truques:\nPressione 'Enter' para concluir a entrada do nome do campo.\nPressione 'Backspace' para excluir o nome do campo.\nMúltiplos nomes de campos são suportados.", + "fetch-into": "Buscar em", + "attr-mapping": "Mapeamento de atributos:", + "source-attribute": "Chave do atributo de origem", + "source-attribute-required": "A chave do atributo de origem é obrigatória.", + "source-telemetry": "Chave de telemetria de origem", + "source-telemetry-required": "A chave de telemetria de origem é obrigatória.", + "target-key": "Chave de destino", + "target-key-required": "A chave de destino é obrigatória.", + "attr-mapping-required": "Pelo menos uma entrada de mapeamento deve ser especificada.", + "fields-mapping": "Mapeamento de campos", + "fields-mapping-hint": "Se o campo da mensagem for definido como $entityId, o id do originador da mensagem será salvo na coluna da tabela especificada.", + "relations-query-config-direction-suffix": "originador", + "profile-name": "Nome do perfil", + "fetch-circle-parameter-info-from-metadata-hint": "O campo de metadados '{{perimeterKeyName}}' deve ser definido no seguinte formato: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "O campo de metadados '{{perimeterKeyName}}' deve ser definido no seguinte formato: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "fields-mapping-required": "Pelo menos um mapeamento de campo deve ser especificado.", + "at-least-one-field-required": "Pelo menos um campo de entrada deve ter valor(es) fornecido(s).", + "originator-fields-sv-map-hint": "Os campos de chave de destino suportam templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "sv-map-hint": "Apenas os campos de chave de destino suportam templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "source-field": "Campo de origem", + "source-field-required": "O campo de origem é obrigatório.", + "originator-source": "Fonte do originador", + "new-originator": "Novo originador", + "originator-customer": "Cliente", + "originator-tenant": "Tenant", + "originator-related": "Entidade relacionada", + "originator-alarm-originator": "Originador do alarme", + "originator-entity": "Entidade por padrão de nome", + "clone-message": "Clonar mensagem", + "transform": "Transformar", + "default-ttl": "TTL padrão", + "default-ttl-required": "O TTL padrão é obrigatório.", + "default-ttl-hint": "O nó de regra buscará o valor de Time-to-Live (TTL) nos metadados da mensagem. Se nenhum valor estiver presente, o padrão será o TTL especificado na configuração. Se o valor for definido como 0, o TTL da configuração do perfil do tenant será aplicado.", + "default-ttl-zero-hint": "O TTL não será aplicado se seu valor for definido como 0.", + "min-default-ttl-message": "Apenas o TTL mínimo de 0 é permitido.", + "generation-parameters": "Parâmetros de geração", + "message-count": "Limite de mensagens geradas (0 - ilimitado)", + "message-count-required": "O limite de mensagens geradas é obrigatório.", + "min-message-count-message": "Apenas a contagem mínima de mensagens igual a 0 é permitida.", + "period-seconds": "Período em segundos", + "period-seconds-required": "O período é obrigatório.", + "generation-frequency-seconds": "Frequência de geração em segundos", + "generation-frequency-required": "A frequência de geração é obrigatória.", + "min-generation-frequency-message": "O mínimo permitido é 60 segundos.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Usar padrão de período em segundos", + "use-metadata-period-in-seconds-patterns-hint": "Se selecionado, o nó de regra usa o padrão de intervalo de período em segundos dos metadados ou dados da mensagem, assumindo que os intervalos estão em segundos.", + "period-in-seconds-pattern": "Padrão de período em segundos", + "period-in-seconds-pattern-required": "O padrão de período em segundos é obrigatório", + "min-period-seconds-message": "O período mínimo permitido é 60 segundos.", + "originator": "Originador", + "message-body": "Corpo da mensagem", + "message-metadata": "Metadados da mensagem", + "generate": "Gerar", + "current-rule-node": "Nó de regra atual", + "current-tenant": "Tenant atual", + "generator-function": "Função geradora", + "test-generator-function": "Testar função geradora", + "generator": "Gerador", + "test-filter-function": "Testar função de filtro", + "test-switch-function": "Testar função switch", + "test-transformer-function": "Testar função de transformação", + "transformer": "Transformador", + "alarm-create-condition": "Condição de criação de alarme", + "test-condition-function": "Testar função de condição", + "alarm-clear-condition": "Condição de limpeza de alarme", + "alarm-details-builder": "Construtor de detalhes do alarme", + "test-details-function": "Testar função de detalhes", + "alarm-type": "Tipo de alarme", + "select-entity-types": "Selecionar tipos de entidade", + "alarm-type-required": "O tipo de alarme é obrigatório.", + "alarm-severity": "Severidade do alarme", + "alarm-severity-required": "A severidade do alarme é obrigatória", + "alarm-severity-pattern": "Padrão de severidade do alarme", + "alarm-status-filter": "Filtro de status do alarme", + "alarm-status-list-empty": "A lista de status de alarme está vazia", + "no-alarm-status-matching": "Nenhum status de alarme correspondente foi encontrado.", + "propagate": "Propagar alarme para entidades relacionadas", + "propagate-to-owner": "Propagar alarme para o proprietário da entidade (Cliente ou Tenant)", + "propagate-to-tenant": "Propagar alarme para o Tenant", + "condition": "Condição", + "details": "Detalhes", + "to-string": "Para string", + "test-to-string-function": "Testar função para string", + "from-template": "De", + "from-template-required": "O campo De é obrigatório", + "message-to-metadata": "Mensagem para metadados", + "metadata-to-message": "Metadados para mensagem", + "from-message": "Da mensagem", + "from-metadata": "Dos metadados", + "to-template": "Para", + "to-template-required": "O template Para é obrigatório", + "mail-address-list-template-hint": "Lista de endereços separada por vírgula, use ${metadataKey} para valor dos metadados, $[messageKey] para valor do corpo da mensagem", + "cc-template": "Cc", + "bcc-template": "Bcc", + "subject-template": "Assunto", + "subject-template-required": "O template de Assunto é obrigatório", + "body-template": "Corpo", + "body-template-required": "O template de Corpo é obrigatório", + "dynamic-mail-body-type": "Tipo de corpo de email dinâmico", + "mail-body-type": "Tipo de corpo do email", + "body-type-template": "Template de tipo de corpo", + "reply-routing-configuration": "Configuração de roteamento de resposta", + "rpc-reply-routing-configuration-hint": "Estes parâmetros de configuração especificam os nomes das chaves de metadados usados para identificar o serviço, a sessão e a solicitação para o envio de uma resposta.", + "reply-routing-configuration-hint": "Estes parâmetros de configuração especificam os nomes das chaves de metadados usados para identificar o serviço e a solicitação para o envio de uma resposta.", + "request-id-metadata-attribute": "Id da solicitação", + "service-id-metadata-attribute": "Id do serviço", + "session-id-metadata-attribute": "Id da sessão", + "timeout-sec": "Tempo Limite em segundos", + "timeout-required": "O tempo limite é obrigatório", + "min-timeout-message": "Apenas o valor mínimo de tempo limite igual a 0 é permitido.", + "endpoint-url-pattern": "Padrão de URL do Endpoint", + "endpoint-url-pattern-required": "O padrão de URL do Endpoint é obrigatório", + "request-method": "Método da solicitação", + "use-simple-client-http-factory": "Usar factory HTTP de cliente simples", + "ignore-request-body": "Sem corpo de solicitação", + "parse-to-plain-text": "Converter para texto simples", + "parse-to-plain-text-hint": "Se selecionado, o payload da mensagem do corpo da solicitação será transformado de string JSON para texto simples, por exemplo, msg = \"Hello,\\t\"world\"\" será convertido para Hello, \"world\"", + "read-timeout": "Tempo Limite de leitura em milissegundos", + "read-timeout-hint": "O valor 0 significa tempo limite infinito", + "max-parallel-requests-count": "Número máximo de solicitações paralelas", + "max-parallel-requests-count-hint": "O valor 0 especifica sem limite no processamento paralelo", + "max-response-size": "Tamanho máximo de resposta (em KB)", + "max-response-size-hint": "A quantidade máxima de memória alocada para armazenamento em buffer de dados ao decodificar ou codificar mensagens HTTP, como payloads JSON ou XML", + "headers": "Cabeçalhos", + "headers-hint": "Use ${metadataKey} para valor dos metadados, $[messageKey] para valor do corpo da mensagem nos campos de cabeçalho/valor", + "header": "Cabeçalho", + "header-required": "O cabeçalho é obrigatório", + "value": "Valor", + "value-required": "O valor é obrigatório", + "topic-pattern": "Padrão de tópico", + "key-pattern": "Padrão de chave", + "key-pattern-hint": "Opcional. Se um número de partição válido for especificado, ele será usado ao enviar o registro. Se nenhuma partição for especificada, a chave será usada. Se nenhum dos dois for especificado, uma partição será atribuída de forma round-robin.", + "topic-pattern-required": "O padrão de tópico é obrigatório", + "topic": "Tópico", + "topic-required": "O tópico é obrigatório", + "bootstrap-servers": "Servidores de bootstrap", + "bootstrap-servers-required": "O valor dos servidores bootstrap é obrigatório", + "other-properties": "Outras propriedades", + "key": "Chave", + "key-required": "A chave é obrigatória", + "retries": "Número de tentativas automáticas em caso de falha", + "min-retries-message": "Apenas 0 tentativas mínimas é permitido.", + "batch-size-bytes": "Tamanho do lote produzido em bytes", + "min-batch-size-bytes-message": "Apenas 0 como tamanho mínimo de lote é permitido.", + "linger-ms": "Tempo de buffer local (ms)", + "min-linger-ms-message": "Apenas 0 ms como valor mínimo é permitido.", + "buffer-memory-bytes": "Tamanho máximo do buffer do cliente em bytes", + "min-buffer-memory-message": "Apenas 0 como tamanho mínimo de buffer é permitido.", + "memory-buffer-size-range": "O tamanho do buffer de memória deve estar entre 0 e {{max}} KB", + "acks": "Número de confirmações", + "topic-arn-pattern": "Padrão de ARN do tópico", + "topic-arn-pattern-required": "O padrão de ARN do tópico é obrigatório", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID é obrigatório", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key é obrigatório", + "aws-region": "Região AWS", + "aws-region-required": "Região AWS é obrigatória", + "exchange-name-pattern": "Padrão de nome de exchange", + "routing-key-pattern": "Padrão de chave de roteamento", + "message-properties": "Propriedades da mensagem", + "host": "Host", + "host-required": "O host é obrigatório", + "port": "Porta", + "port-required": "A porta é obrigatória", + "port-range": "A porta deve estar no intervalo de 1 a 65535.", + "virtual-host": "Host virtual", + "username": "Nome de usuário", + "password": "Senha", + "automatic-recovery": "Recuperação automática", + "connection-timeout-ms": "Tempo Limite de conexão (ms)", + "min-connection-timeout-ms-message": "Apenas 0 ms como valor mínimo é permitido.", + "handshake-timeout-ms": "Tempo Limite de handshake (ms)", + "min-handshake-timeout-ms-message": "Apenas 0 ms como valor mínimo é permitido.", + "client-properties": "Propriedades do cliente", + "queue-url-pattern": "Padrão de URL da fila", + "queue-url-pattern-required": "O padrão de URL da fila é obrigatório", + "delay-seconds": "Atraso (segundos)", + "min-delay-seconds-message": "Apenas 0 segundos como valor mínimo é permitido.", + "max-delay-seconds-message": "Apenas 900 segundos como valor máximo é permitido.", + "name": "Nome", + "name-required": "O nome é obrigatório", + "queue-type": "Tipo de fila", + "sqs-queue-standard": "Padrão", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "ID do projeto GCP", + "gcp-project-id-required": "O ID do projeto GCP é obrigatório", + "gcp-service-account-key": "Arquivo de chave de conta de serviço GCP", + "gcp-service-account-key-required": "O arquivo de chave de conta de serviço GCP é obrigatório", + "pubsub-topic-name": "Nome do tópico", + "pubsub-topic-name-required": "O nome do tópico é obrigatório", + "message-attributes": "Atributos da mensagem", + "message-attributes-hint": "Use ${metadataKey} para valor dos metadados, $[messageKey] para valor do corpo da mensagem nos campos de nome/valor", + "connect-timeout": "Tempo limite de conexão (seg)", + "connect-timeout-required": "O tempo limite de conexão é obrigatório.", + "connect-timeout-range": "O tempo limite de conexão deve estar no intervalo de 1 a 200.", + "client-id": "Client ID", + "client-id-hint": "Opcional. Deixe em branco para Client ID gerado automaticamente. Tenha cuidado ao especificar o Client ID. A maioria dos brokers MQTT não permite múltiplas conexões com o mesmo Client ID. Para conectar a esses brokers, seu Client ID MQTT deve ser único. Quando a plataforma está sendo executada no modo de microsserviços, a cópia do nó de regra é iniciada em cada microsserviço. Isso automaticamente levará a múltiplos clientes mqtt com o mesmo ID e pode causar falhas no nó de regra. Para evitar tais falhas, habilite a opção \"Adicionar ID do serviço como sufixo ao Client ID\" abaixo.", + "append-client-id-suffix": "Adicionar ID do serviço como sufixo ao Client ID", + "client-id-suffix-hint": "Opcional. Aplicado quando o \"Client ID\" é especificado explicitamente. Se selecionado, o ID do serviço será adicionado ao Client ID como sufixo. Ajuda a evitar falhas quando a plataforma está sendo executada no modo de microsserviços.", + "device-id": "ID do dispositivo", + "device-id-required": "O ID do dispositivo é obrigatório.", + "clean-session": "Sessão limpa", + "enable-ssl": "Habilitar SSL", + "credentials": "Credenciais", + "credentials-type": "Tipo de credenciais", + "credentials-type-required": "O tipo de credenciais é obrigatório.", + "credentials-anonymous": "Anônimo", + "credentials-basic": "Básico", + "credentials-pem": "PEM", + "credentials-pem-hint": "Pelo menos o arquivo de certificado CA do servidor ou um par de arquivos de certificado do cliente e chave privada do cliente são obrigatórios", + "credentials-sas": "Shared Access Signature", + "sas-key": "Chave SAS", + "sas-key-required": "A chave SAS é obrigatória.", + "hostname": "Nome do host", + "hostname-required": "O nome do host é obrigatório.", + "azure-ca-cert": "Arquivo de certificado CA", + "username-required": "O nome de usuário é obrigatório.", + "password-required": "A senha é obrigatória.", + "ca-cert": "Arquivo de certificado CA do servidor", + "private-key": "Arquivo de chave privada do cliente", + "cert": "Arquivo de certificado do cliente", + "no-file": "Nenhum arquivo selecionado.", + "drop-file": "Arraste um arquivo ou clique para selecionar um arquivo para upload.", + "private-key-password": "Senha da chave privada", + "use-system-smtp-settings": "Usar configurações SMTP do sistema", + "use-metadata-dynamic-interval": "Usar intervalo dinâmico", + "metadata-dynamic-interval-hint": "Os campos de entrada de início e fim do intervalo suportam templatização. O valor do template substituído deve ser definido em milissegundos. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "use-metadata-interval-patterns-hint": "Se selecionado, o nó de regra usa os padrões de intervalo de início e fim dos metadados ou dados da mensagem, assumindo que os intervalos estão em milissegundos.", + "use-message-alarm-data": "Usar dados de alarme da mensagem", + "overwrite-alarm-details": "Sobrescrever detalhes do alarme", + "use-alarm-severity-pattern": "Usar padrão de severidade de alarme", + "check-all-keys": "Verificar se todos os campos especificados estão presentes", + "check-all-keys-hint": "Se selecionado, verifica se todas as chaves especificadas estão presentes nos dados e metadados da mensagem.", + "check-relation-to-specific-entity": "Verificar relação com entidade específica", + "check-relation-to-specific-entity-tooltip": "Se habilitado, verifica a presença de relação com uma entidade específica; caso contrário, verifica a presença de relação com qualquer entidade. Em ambos os casos, a busca de relação é baseada na direção e no tipo configurados.", + "check-relation-hint": "Verifica a existência de relação com uma entidade específica ou com qualquer entidade com base na direção e no tipo de relação.", + "delete-relation-with-specific-entity": "Excluir relação com entidade específica", + "delete-relation-with-specific-entity-hint": "Se habilitado, excluirá a relação com apenas uma entidade específica. Caso contrário, a relação será removida de todas as entidades correspondentes.", + "delete-relation-hint": "Exclui a relação do originador da mensagem recebida para a entidade especificada ou lista de entidades com base na direção e no tipo.", + "remove-current-relations": "Remover relações atuais", + "remove-current-relations-hint": "Remove as relações atuais do originador da mensagem recebida com base na direção e no tipo.", + "change-originator-to-related-entity": "Alterar originador para entidade relacionada", + "change-originator-to-related-entity-hint": "Usado para processar a mensagem enviada como uma mensagem de outra entidade.", + "start-interval": "Início do intervalo", + "end-interval": "Fim do intervalo", + "start-interval-required": "O início do intervalo é obrigatório.", + "end-interval-required": "O fim do intervalo é obrigatório.", + "smtp-protocol": "Protocolo", + "smtp-host": "Host SMTP", + "smtp-host-required": "O host SMTP é obrigatório.", + "smtp-port": "Porta SMTP", + "smtp-port-required": "É necessário fornecer uma porta SMTP.", + "smtp-port-range": "A porta SMTP deve estar no intervalo de 1 a 65535.", + "timeout-msec": "Tempo Limite ms", + "min-timeout-msec-message": "Apenas 0 ms como valor mínimo é permitido.", + "enter-username": "Inserir nome de usuário", + "enter-password": "Inserir senha", + "enable-tls": "Habilitar TLS", + "tls-version": "Versão TLS", + "enable-proxy": "Ativar proxy", + "use-system-proxy-properties": "Usar propriedades de proxy do sistema", + "proxy-host": "Host do proxy", + "proxy-host-required": "O host do proxy é obrigatório.", + "proxy-port": "Porta do proxy", + "proxy-port-required": "A porta do proxy é obrigatória.", + "proxy-port-range": "A porta do proxy deve estar no intervalo de 1 a 65535.", + "proxy-user": "Usuário do proxy", + "proxy-password": "Senha do proxy", + "proxy-scheme": "Esquema do proxy", + "numbers-to-template": "Números de telefone para o template", + "numbers-to-template-required": "Números de telefone para o template são obrigatórios", + "numbers-to-template-hint": "Números de telefone separados por vírgula. Use ${metadataKey} para valor de metadados, $[messageKey] para valor do corpo da mensagem", + "sms-message-template": "Template da mensagem SMS", + "sms-message-template-required": "O template da mensagem SMS é obrigatório", + "use-system-sms-settings": "Usar configurações do provedor SMS do sistema", + "min-period-0-seconds-message": "Somente período mínimo de 0 segundo é permitido.", + "max-pending-messages": "Máximo de mensagens pendentes", + "max-pending-messages-required": "O máximo de mensagens pendentes é obrigatório.", + "max-pending-messages-range": "O máximo de mensagens pendentes deve estar no intervalo de 1 a 100000.", + "originator-types-filter": "Filtro de tipos de originador", + "interval-seconds": "Intervalo em segundos", + "interval-seconds-required": "O intervalo é obrigatório.", + "int-range": "O valor não deve exceder o limite máximo de inteiro (2147483648)", + "min-interval-seconds-message": "Somente intervalo mínimo de 1 segundo é permitido.", + "output-timeseries-key-prefix": "Prefixo da chave de série temporal de saída", + "output-timeseries-key-prefix-required": "O prefixo da chave de série temporal de saída é obrigatório.", + "separator-hint": "Pressione \"Enter\" para concluir a entrada do campo.", + "select-details": "Selecionar detalhes", + "entity-details-id": "Id", + "entity-details-title": "Título", + "entity-details-country": "País", + "entity-details-state": "Estado", + "entity-details-city": "Cidade", + "entity-details-zip": "CEP", + "entity-details-address": "Endereço", + "entity-details-address2": "Endereço2", + "entity-details-additional_info": "Informações adicionais", + "entity-details-phone": "Telefone", + "entity-details-email": "Email", + "email-sender": "Remetente do email", + "fields-to-check": "Campos a verificar", + "add-detail": "Adicionar detalhe", + "check-all-keys-tooltip": "Se habilitado, verifica a presença de todos os campos listados nos nomes de campos da mensagem e dos metadados dentro da mensagem recebida e seus metadados.", + "fields-to-check-hint": "Pressione \"Enter\" para concluir a entrada do nome do campo. Múltiplos nomes de campos são suportados.", + "entity-details-list-empty": "Pelo menos um detalhe deve ser selecionado.", + "alarm-status": "Status do alarme", + "alarm-required": "Pelo menos um status de alarme deve ser selecionado.", + "no-entity-details-matching": "Nenhum detalhe de entidade correspondente foi encontrado.", + "custom-table-name": "Nome da tabela personalizada", + "custom-table-name-required": "O nome da tabela é obrigatório", + "custom-table-hint": "A tabela deve ser criada no seu cluster Cassandra e seu nome deve começar com o prefixo 'cs_tb_' para evitar a inserção de dados nas tabelas comuns do TB. Insira o nome da tabela aqui sem o prefixo 'cs_tb_'.", + "message-field": "Campo da mensagem", + "message-field-required": "O campo da mensagem é obrigatório.", + "table-col": "Coluna da tabela", + "table-col-required": "A coluna da tabela é obrigatória.", + "latitude-field-name": "Nome do campo de latitude", + "longitude-field-name": "Nome do campo de longitude", + "latitude-field-name-required": "O nome do campo de latitude é obrigatório.", + "longitude-field-name-required": "O nome do campo de longitude é obrigatório.", + "fetch-perimeter-info-from-metadata": "Buscar informações de perímetro dos metadados", + "fetch-perimeter-info-from-metadata-tooltip": "Se o tipo de perímetro for definido como 'Polygon', o valor do campo de metadados '{{perimeterKeyName}}' será definido como a definição do perímetro sem análise adicional do valor. Caso contrário, se o tipo de perímetro for definido como 'Circle', o valor do campo de metadados '{{perimeterKeyName}}' será analisado para extrair os campos 'latitude', 'longitude', 'radius', 'radiusUnit' usados para a definição do perímetro circular.", + "perimeter-key-name": "Nome da chave do perímetro", + "perimeter-key-name-hint": "Nome do campo de metadados que inclui as informações do perímetro.", + "perimeter-key-name-required": "O nome da chave do perímetro é obrigatório.", + "perimeter-circle": "Círculo", + "perimeter-polygon": "Polígono", + "perimeter-type": "Tipo de perímetro", + "circle-center-latitude": "Latitude do centro", + "circle-center-latitude-required": "A latitude do centro é obrigatória.", + "circle-center-longitude": "Longitude do centro", + "circle-center-longitude-required": "A longitude do centro é obrigatória.", + "range-unit-meter": "Metro", + "range-unit-kilometer": "Quilômetro", + "range-unit-foot": "Pé", + "range-unit-mile": "Milha", + "range-unit-nautical-mile": "Milha náutica", + "range-units": "Unidades de alcance", + "range-units-required": "As unidades de alcance são obrigatórias.", + "range": "Alcance", + "range-required": "O alcance é obrigatório.", + "polygon-definition": "Definição do polígono", + "polygon-definition-required": "A definição do polígono é obrigatória.", + "polygon-definition-hint": "Use o seguinte formato para a definição manual do polígono: [[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]].", + "min-inside-duration": "Duração mínima interna", + "min-inside-duration-value-required": "A duração mínima interna é obrigatória", + "min-inside-duration-time-unit": "Unidade de tempo da duração mínima interna", + "min-outside-duration": "Duração mínima externa", + "min-outside-duration-value-required": "A duração mínima externa é obrigatória", + "min-outside-duration-time-unit": "Unidade de tempo da duração mínima externa", + "tell-failure-if-absent": "Indicar falha", + "tell-failure-if-absent-hint": "Se pelo menos uma chave selecionada não existir, a mensagem de saída reportará \"Falha\".", + "get-latest-value-with-ts": "Buscar timestamp para os valores de telemetria mais recentes", + "get-latest-value-with-ts-hint": "Se selecionado, os valores de telemetria mais recentes também incluirão o timestamp, ex: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "Ignorar strings nulas", + "ignore-null-strings-hint": "Se selecionado, o nó de regra ignorará campos de entidade com valor vazio.", + "add-metadata-key-values-as-kafka-headers": "Adicionar pares chave-valor dos metadados da mensagem aos cabeçalhos do registro Kafka", + "add-metadata-key-values-as-kafka-headers-hint": "Se selecionado, os pares chave-valor dos metadados da mensagem serão adicionados aos cabeçalhos dos registros de saída como arrays de bytes com codificação de charset predefinida.", + "charset-encoding": "Codificação de charset", + "charset-encoding-required": "A codificação de charset é obrigatória.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "O nome da fila pode ser selecionado em uma lista suspensa ou adicionar um nome personalizado.", + "device-profile-node-hint": "Útil se você tiver condições de duração ou de repetição para garantir a continuidade da avaliação do estado do alarme.", + "persist-alarm-rules": "Persistir estado das regras de alarme", + "persist-alarm-rules-hint": "Se habilitado, o nó de regra armazenará o estado do processamento no banco de dados.", + "fetch-alarm-rules": "Buscar estado das regras de alarme", + "fetch-alarm-rules-hint": "Se habilitado, o nó de regra restaurará o estado do processamento na inicialização e garantirá que os alarmes sejam acionados mesmo após reinicializações do servidor. Caso contrário, o estado será restaurado quando a primeira mensagem do dispositivo chegar.", + "input-value-key": "Chave do valor de entrada", + "input-value-key-required": "A chave do valor de entrada é obrigatória.", + "output-value-key": "Chave do valor de saída", + "output-value-key-required": "A chave do valor de saída é obrigatória.", + "number-of-digits-after-floating-point": "Número de dígitos após o ponto flutuante", + "number-of-digits-after-floating-point-range": "O número de dígitos após o ponto flutuante deve estar no intervalo de 0 a 15.", + "failure-if-delta-negative": "Indicar falha se o delta for negativo", + "failure-if-delta-negative-tooltip": "O nó de regra força a falha no processamento da mensagem se o valor delta for negativo.", + "use-caching": "Usar cache", + "use-caching-tooltip": "O nó de regra armazenará em cache o valor de \"{{inputValueKey}}\" que chega da mensagem recebida para melhorar o desempenho. Observe que o cache não será atualizado se você modificar o valor de \"{{inputValueKey}}\" em outro lugar.", + "add-time-difference-between-readings": "Adicionar a diferença de tempo entre leituras de \"{{inputValueKey}}\"", + "add-time-difference-between-readings-tooltip": "Se habilitado, o nó de regra adicionará o \"{{periodValueKey}}\" à mensagem de saída.", + "period-value-key": "Chave do valor de período", + "period-value-key-required": "A chave do valor de período é obrigatória.", + "general-pattern-hint": "Use ${metadataKey} para valor de metadados, $[messageKey] para valor do corpo da mensagem.", + "alarm-severity-pattern-hint": "Use ${metadataKey} para valor de metadados, $[messageKey] para valor do corpo da mensagem. A severidade do alarme deve ser do sistema (CRITICAL, MAJOR etc.)", + "output-node-name-hint": "O nome do nó de regra corresponde ao tipo de relação da mensagem de saída e é usado para encaminhar mensagens para outros nós de regra na cadeia de regras chamadora.", + "use-server-ts": "Usar timestamp do servidor", + "use-server-ts-hint": "Use o timestamp atual do servidor para dados de série temporal que não possuem um timestamp explícito. Isso ajuda a manter a ordenação correta ao processar mensagens de múltiplas fontes ou quando as mensagens chegam fora de sequência.", + "kv-map-pattern-hint": "Todos os campos de entrada suportam templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "kv-map-single-pattern-hint": "O campo de entrada suporta templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "shared-scope": "Escopo compartilhado", + "server-scope": "Escopo do servidor", + "client-scope": "Escopo do cliente", + "attribute-type": "Atributo", + "attribute-type-description": "Buscar valor do atributo do banco de dados", + "attribute-type-result-description": "Armazenar resultado como atributo de entidade no banco de dados", + "constant-type": "Constante", + "constant-type-description": "Definir valor constante", + "time-series-type": "Série temporal", + "time-series-type-description": "Buscar o valor de série temporal mais recente do banco de dados", + "time-series-type-result-description": "Armazenar resultado como série temporal de entidade no banco de dados", + "message-body-type": "Mensagem", + "message-body-type-description": "Buscar valor do argumento da mensagem recebida", + "message-body-type-result-description": "Adicionar resultado à mensagem de saída", + "message-metadata-type": "Metadados", + "message-metadata-type-description": "Buscar valor do argumento dos metadados da mensagem recebida", + "message-metadata-result-description": "Adicionar resultado aos metadados da mensagem de saída", + "argument-tile": "Argumentos", + "no-arguments-prompt": "Nenhum argumento configurado", + "result-title": "Resultado", + "functions-field-input": "Funções", + "no-option-found": "Nenhuma opção encontrada", + "argument-source-field-input": "Fonte", + "argument-source-field-input-required": "A fonte do argumento é obrigatória.", + "argument-key-field-input": "Chave", + "argument-key-field-input-required": "A chave do argumento é obrigatória.", + "constant-value-field-input": "Valor constante", + "constant-value-field-input-required": "O valor constante é obrigatório.", + "attribute-scope-field-input": "Escopo do atributo", + "attribute-scope-field-input-required": "O escopo do atributo é obrigatório.", + "default-value-field-input": "Valor padrão", + "type-field-input": "Tipo", + "type-field-input-required": "O tipo é obrigatório.", + "key-field-input": "Chave", + "add-entity-type": "Adicionar tipo de entidade", + "add-device-profile": "Adicionar perfil do dispositivo", + "key-field-input-required": "A chave é obrigatória.", + "number-floating-point-field-input": "Número de dígitos após o ponto flutuante", + "number-floating-point-field-input-hint": "Use 0 para converter o resultado em inteiro", + "add-to-message-field-input": "Adicionar à mensagem", + "add-to-metadata-field-input": "Adicionar aos metadados", + "custom-expression-field-input": "Expressão matemática", + "custom-expression-field-input-required": "A expressão matemática é obrigatória", + "custom-expression-field-input-hint": "Especifique uma expressão matemática para avaliar. A expressão padrão demonstra como transformar Fahrenheit em Celsius", + "retained-message": "Retida", + "attributes-mapping": "Mapeamento de atributos", + "latest-telemetry-mapping": "Mapeamento de telemetria mais recente", + "add-mapped-attribute-to": "Adicionar atributos mapeados a", + "add-mapped-latest-telemetry-to": "Adicionar telemetria mais recente mapeada a", + "add-mapped-fields-to": "Adicionar campos mapeados a", + "add-selected-details-to": "Adicionar detalhes selecionados a", + "clear-selected-types": "Limpar tipos selecionados", + "clear-selected-details": "Limpar detalhes selecionados", + "clear-selected-fields": "Limpar campos selecionados", + "clear-selected-keys": "Limpar chaves selecionadas", + "geofence-configuration": "Configuração de geofence", + "coordinate-field-names": "Nomes dos campos de coordenadas", + "coordinate-field-hint": "O nó de regra tenta recuperar os campos especificados da mensagem. Se não estiverem presentes, buscará nos metadados.", + "presence-monitoring-strategy": "Estratégia de monitoramento de presença", + "presence-monitoring-strategy-on-first-message": "Na primeira mensagem", + "presence-monitoring-strategy-on-each-message": "A cada mensagem", + "presence-monitoring-strategy-on-first-message-hint": "Reporta o status de presença 'Dentro' ou 'Fora' na primeira mensagem após a duração mínima configurada ter decorrido desde a última atualização do status de presença 'Entrou' ou 'Saiu'.", + "presence-monitoring-strategy-on-each-message-hint": "Reporta o status de presença 'Dentro' ou 'Fora' a cada mensagem após a atualização do status de presença 'Entrou' ou 'Saiu'.", + "fetch-credentials-to": "Buscar credenciais para", + "add-originator-attributes-to": "Adicionar atributos do originador a", + "originator-attributes": "Atributos do originador", + "fetch-latest-telemetry-with-timestamp": "Buscar telemetria mais recente com timestamp", + "fetch-latest-telemetry-with-timestamp-tooltip": "Se selecionado, os valores de telemetria mais recentes serão adicionados aos metadados de saída com o timestamp, ex: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "Indicar falha se algum dos atributos estiver ausente", + "tell-failure-tooltip": "Se pelo menos uma chave selecionada não existir, a mensagem de saída reportará \"Falha\".", + "created-time": "Hora de criação", + "chip-help": "Pressione 'Enter' para concluir a entrada de {{inputName}}. \nPressione 'Backspace' para excluir {{inputName}}. \nMúltiplos valores são suportados.", + "detail": "detalhe", + "field-name": "nome do campo", + "device-profile": "perfil do dispositivo", + "entity-type": "tipo de entidade", + "message-type": "tipo de mensagem", + "timeseries-key": "chave de série temporal", + "type": "Tipo", + "first-name": "Nome", + "last-name": "Sobrenome", + "label": "Rótulo", + "originator-fields-mapping": "Mapeamento de campos do originador", + "add-mapped-originator-fields-to": "Adicionar campos do originador mapeados a", + "fields": "Campos", + "skip-empty-fields": "Ignorar campos vazios", + "skip-empty-fields-tooltip": "Campos com valores vazios não serão adicionados à mensagem de saída/metadados de saída.", + "fetch-interval": "Intervalo de busca", + "fetch-strategy": "Estratégia de busca", + "fetch-timeseries-from-to": "Buscar série temporal de {{startInterval}} {{startIntervalTimeUnit}} atrás até {{endInterval}} {{endIntervalTimeUnit}} atrás.", + "fetch-timeseries-from-to-invalid": "Busca de série temporal inválida (\"Início do intervalo\" deve ser menor que \"Fim do intervalo\").", + "use-metadata-dynamic-interval-tooltip": "Se selecionado, o nó de regra usará intervalo dinâmico de início e fim baseado nos padrões da mensagem e dos metadados.", + "all-mode-hint": "Se o modo de busca \"All\" for selecionado, o nó de regra recuperará a telemetria do intervalo de busca com parâmetros de consulta configuráveis.", + "first-mode-hint": "Se o modo de busca \"First\" for selecionado, o nó de regra recuperará a telemetria mais próxima do início do intervalo de busca.", + "last-mode-hint": "Se o modo de busca \"Last\" for selecionado, o nó de regra recuperará a telemetria mais próxima do fim do intervalo de busca.", + "ascending": "Crescente", + "descending": "Decrescente", + "min": "Mín", + "max": "Máx", + "average": "Média", + "sum": "Soma", + "count": "Contagem", + "none": "Nenhum", + "last-level-relation-tooltip": "Se selecionado, o nó de regra pesquisará entidades relacionadas somente no nível definido no nível máximo de relação.", + "last-level-device-relation-tooltip": "Se selecionado, o nó de regra pesquisará dispositivos relacionados somente no nível definido no nível máximo de relação.", + "data-to-fetch": "Dados a buscar", + "mapping-of-customers": "Mapeamento de clientes", + "map-fields-required": "Todos os campos de mapeamento são obrigatórios.", + "attributes": "Atributos", + "related-device-attributes": "Atributos de dispositivos relacionados", + "add-selected-attributes-to": "Adicionar atributos selecionados a", + "device-profiles": "Perfis do dispositivo", + "mapping-of-tenant": "Mapeamento do tenant", + "add-attribute-key": "Adicionar chave de atributo", + "message-template": "Template da mensagem", + "message-template-required": "O template da mensagem é obrigatório", + "use-system-slack-settings": "Usar configurações do Slack do sistema", + "slack-api-token": "Token da API do Slack", + "slack-api-token-required": "O token da API do Slack é obrigatório", + "keys-mapping": "mapeamento de chaves", + "add-key": "Adicionar chave", + "recipients": "Destinatários", + "message-subject-and-content": "Assunto e conteúdo da mensagem", + "template-rules-hint": "Ambos os campos de entrada suportam templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados da mensagem.", + "originator-customer-desc": "Usar o cliente do originador da mensagem recebida como novo originador.", + "originator-tenant-desc": "Usar o tenant atual como novo originador.", + "originator-related-entity-desc": "Usar entidade relacionada como novo originador. Busca baseada no tipo de relação e direção configurados.", + "originator-alarm-originator-desc": "Usar o originador do alarme como novo originador. Somente se o originador da mensagem recebida for uma entidade de alarme.", + "originator-entity-by-name-pattern-desc": "Usar entidade buscada do banco de dados como novo originador. Busca baseada no tipo de entidade e no padrão de nome especificado.", + "email-from-template-hint": "Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "recipients-block-main-hint": "Lista de endereços separados por vírgula. Todos os campos de entrada suportam templatização. Use $[messageKey] para extrair o valor da mensagem e ${metadataKey} para extrair o valor dos metadados.", + "forward-msg-default-rule-chain": "Encaminhar mensagem para a cadeia de regras padrão do originador", + "forward-msg-default-rule-chain-tooltip": "Se habilitado, a mensagem será encaminhada para a cadeia de regras padrão do originador, ou para a cadeia de regras da configuração, se o originador não tiver cadeia de regras padrão definida no perfil da entidade.", + "exclude-zero-deltas": "Excluir deltas zero da mensagem de saída", + "exclude-zero-deltas-hint": "Se habilitado, a chave de saída \"{{outputValueKey}}\" será adicionada à mensagem de saída somente se seu valor não for zero.", + "exclude-zero-deltas-time-difference-hint": "Se habilitado, as chaves de saída \"{{outputValueKey}}\" e \"{{periodValueKey}}\" serão adicionadas à mensagem de saída somente se o valor de \"{{outputValueKey}}\" não for zero.", + "search-direction-from": "Do originador para a entidade de destino", + "search-direction-to": "Da entidade de destino para o originador", + "del-relation-direction-from": "Do originador", + "del-relation-direction-to": "Para o originador", + "target-entity": "Entidade de destino", + "function-configuration": "Configuração da função", + "function-name": "Nome da função", + "function-name-required": "O nome da função é obrigatório.", + "qualifier": "Qualificador", + "qualifier-hint": "Se o qualificador não for especificado, o qualificador padrão \"$LATEST\" será usado.", + "aws-credentials": "Credenciais AWS", + "connection-timeout": "Tempo Limite de conexão", + "connection-timeout-required": "O tempo limite de conexão é obrigatório.", + "connection-timeout-min": "O tempo limite mínimo de conexão é 0.", + "connection-timeout-hint": "O tempo de espera em segundos ao estabelecer inicialmente uma conexão antes de desistir e expirar. Um valor de 0 significa infinito e não é recomendado.", + "request-timeout": "Tempo Limite de requisição", + "request-timeout-required": "O tempo limite de requisição é obrigatório", + "request-timeout-min": "O tempo limite mínimo de requisição é 0", + "request-timeout-hint": "O tempo de espera em segundos para a requisição ser concluída antes de desistir e expirar. Um valor de 0 significa infinito e não é recomendado.", + "units": "Unidades", + "tell-failure-aws-lambda": "Indicar falha se a execução da função AWS Lambda lançar exceção", + "tell-failure-aws-lambda-hint": "O nó de regra força a falha no processamento da mensagem se a execução da função AWS Lambda lançar exceção.", + "basic-mode": "Básico", + "advanced-mode": "Avançado", + "save-time-series": { + "processing-settings": "Configurações de processamento", + "processing-settings-hint": "Defina como as mensagens recebidas são processadas. As configurações básicas de processamento permitem selecionar estratégias pré-configuradas, enquanto as configurações avançadas permitem selecionar estratégias de processamento individuais para cada ação.", + "advanced-settings-hint": "Tenha cuidado ao configurar estratégias de processamento. Certas combinações podem levar a comportamentos inesperados.", + "strategy": "Estratégia", + "deduplication-interval": "Intervalo de deduplicação", + "deduplication-interval-required": "O intervalo de deduplicação é obrigatório", + "deduplication-interval-min-max-range": "O intervalo de deduplicação deve ser de pelo menos 1 segundo e no máximo 1 dia", + "strategy-type": { + "every-message": "A cada mensagem", + "skip": "Ignorar", + "deduplicate": "Deduplicar", + "web-sockets-only": "Somente WebSockets" + }, + "time-series": "Série temporal", + "latest": "Valores mais recentes", + "web-sockets": "WebSockets", + "calculated-fields-and-alarm-rules": "Campos calculados e regras de alarme" + }, + "save-attribute": { + "processing-settings": "Configurações de processamento", + "processing-settings-hint": "Defina como as mensagens recebidas são processadas. As configurações básicas de processamento permitem selecionar estratégias pré-configuradas, enquanto as configurações avançadas permitem selecionar estratégias de processamento individuais para cada ação.", + "advanced-settings-hint": "Tenha cuidado ao configurar estratégias de processamento. Certas combinações podem levar a comportamentos inesperados.", + "strategy": "Estratégia", + "deduplication-interval": "Intervalo de deduplicação", + "deduplication-interval-required": "O intervalo de deduplicação é obrigatório", + "deduplication-interval-min-max-range": "O intervalo de deduplicação deve ser de pelo menos 1 segundo e no máximo 1 dia", + "scope": "Escopo", + "strategy-type": { + "every-message": "A cada mensagem", + "skip": "Ignorar", + "deduplicate": "Deduplicar", + "web-sockets-only": "Somente WebSockets" + }, + "attributes": "Atributos" + }, + "key-val": { + "key": "Chave", + "value": "Valor", + "see-examples": "Ver exemplos.", + "remove-entry": "Remover entrada", + "remove-mapping-entry": "Remover entrada de mapeamento", + "add-mapping-entry": "Adicionar mapeamento", + "add-entry": "Adicionar entrada", + "copy-key-values-from": "Copiar pares chave-valor de", + "delete-key-values": "Excluir pares chave-valor", + "delete-key-values-from": "Excluir pares chave-valor de", + "at-least-one-key-error": "Pelo menos uma chave deve ser selecionada.", + "unique-key-value-pair-error": "'{{keyText}}' deve ser diferente de '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Texto simples", + "html": "HTML", + "dynamic": "Dinâmico", + "use-body-type-template": "Usar template de tipo de corpo", + "plain-text-description": "Texto simples sem formatação especial ou estilização.", + "html-text-description": "Permite usar tags HTML para formatação, links e imagens no corpo do email.", + "dynamic-text-description": "Permite usar o tipo de corpo Texto Simples ou HTML dinamicamente com base no recurso de templatização.", + "after-template-evaluation-hint": "Após a avaliação do template, o valor deve ser true para HTML e false para texto simples." + }, + "ai": { + "ai-model": "Modelo de IA", + "model": "Modelo", + "ai-model-hint": "Selecione o modelo de IA pré-configurado para processar as requisições enviadas por este nó de regra, ou use \"Criar novo\" para configurar um novo.", + "prompt-settings": "Configurações de prompt", + "prompt-settings-hint": "O prompt de sistema opcional define o papel geral e as restrições da IA, enquanto o prompt do usuário define a tarefa específica a ser executada. Ambos os campos também suportam templatização.", + "system-prompt": "Prompt de sistema", + "system-prompt-max-length": "O prompt de sistema deve ter 500000 caracteres ou menos.", + "system-prompt-blank": "O prompt de sistema não deve estar em branco.", + "user-prompt": "Prompt do usuário", + "user-prompt-required": "O prompt do usuário é obrigatório.", + "user-prompt-max-length": "O prompt do usuário deve ter 500000 caracteres ou menos.", + "user-prompt-blank": "O prompt do usuário não deve estar em branco.", + "response-format": "Formato de resposta", + "response-text": "Texto", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Permite que o modelo gere texto arbitrário, que pode ou não ser um objeto JSON válido. Se a saída não for um objeto JSON válido, será automaticamente encapsulada em um objeto JSON sob a chave \"response\".", + "response-format-hint-JSON": "O modelo é obrigado a gerar uma resposta que seja um JSON válido. Se a saída não for um objeto JSON válido, será automaticamente encapsulada em um objeto JSON sob a chave \"response\".", + "response-format-hint-JSON_SCHEMA": "O modelo é obrigado a gerar um JSON que corresponda à estrutura específica e aos tipos de dados definidos no esquema fornecido. Se a saída não for um objeto JSON válido, será automaticamente encapsulada em um objeto JSON sob a chave \"response\".", + "response-json-schema-hint": "Embora qualquer JSON Schema válido possa ser inserido, este nó de regra suporta apenas um subconjunto limitado de seus recursos. Consulte a documentação do nó para obter detalhes.", + "response-json-schema-required": "O JSON Schema é obrigatório", + "advanced-settings": "Configurações avançadas", + "timeout": "Tempo Limite", + "timeout-hint": "Tempo máximo de espera por uma resposta \ndo modelo de IA antes de a requisição ser encerrada.", + "timeout-required": "O tempo limite é obrigatório", + "timeout-validation": "Deve ser de 1 segundo a 10 minutos.", + "force-acknowledgement": "Forçar confirmação", + "force-acknowledgement-hint": "Se habilitado, a mensagem recebida é confirmada imediatamente. A resposta do modelo é então enfileirada como uma nova mensagem separada.", + "ai-resources": "Recursos de IA" + } }, "timezone": { "timezone": "Fuso horário", "select-timezone": "Selecionar fuso horário", - "no-timezones-matching": "Nenhum fuso horário encontrado que coincida com '{{timezone}}'.", - "timezone-required": "O fuso horário é obrigatório." + "no-timezones-matching": "Nenhum fuso horário correspondente a '{{timezone}}' foi encontrado.", + "timezone-required": "Fuso horário é obrigatório.", + "browser-time": "Horário do navegador" }, "queue": { - "select_name": "Selecionar nome de fila", - "name": "Nome de Fila", - "name_required": "O nome de fila é obrigatório!" + "queue-name": "Fila", + "no-queues-found": "Nenhuma fila encontrada.", + "no-queues-matching": "Nenhuma fila correspondente a '{{queue}}' foi encontrada.", + "select-name": "Selecionar nome da fila", + "name": "Nome", + "name-required": "O nome da fila é obrigatório!", + "name-unique": "O nome da fila não é único!", + "name-pattern": "O nome da fila contém um caractere diferente de alfanuméricos ASCII, '.', '_' e '-'!", + "queue-required": "A fila é obrigatória!", + "topic-required": "O tópico da fila é obrigatório!", + "poll-interval-required": "O intervalo de polling é obrigatório!", + "poll-interval-min-value": "O valor do intervalo de polling não pode ser menor que 1", + "partitions-required": "As partições são obrigatórias!", + "partitions-min-value": "O valor das partições não pode ser menor que 1", + "pack-processing-timeout-required": "O tempo limite de processamento é obrigatório", + "pack-processing-timeout-min-value": "O valor do tempo limite de processamento não pode ser menor que 1", + "batch-size-required": "O tamanho do lote é obrigatório!", + "batch-size-min-value": "O valor do tamanho do lote não pode ser menor que 1", + "retries-required": "As tentativas são obrigatórias!", + "retries-min-value": "O valor das tentativas não pode ser negativo", + "failure-percentage-required": "O percentual de falhas é obrigatório!", + "failure-percentage-min-value": "O valor do percentual de falhas não pode ser menor que 0", + "failure-percentage-max-value": "O valor do percentual de falhas não pode ser maior que 100", + "pause-between-retries-required": "A pausa entre tentativas é obrigatória!", + "pause-between-retries-min-value": "O valor da pausa entre tentativas não pode ser menor que 1", + "max-pause-between-retries-required": "A pausa máxima entre tentativas é obrigatória!", + "max-pause-between-retries-min-value": "O valor da pausa máxima entre tentativas não pode ser menor que 1", + "submit-strategy-type-required": "O tipo de estratégia de envio é obrigatório!", + "processing-strategy-type-required": "O tipo de estratégia de processamento é obrigatório!", + "queues": "Filas", + "selected-queues": "{ count, plural, =1 {1 fila} other {# filas} } selecionada(s)", + "delete-queue-title": "Tem certeza de que deseja excluir a fila '{{queueName}}'?", + "delete-queues-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 fila} other {# filas} }?", + "delete-queue-text": "Atenção: após a confirmação, a fila e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-queues-text": "Após a confirmação, todas as filas selecionadas serão excluídas e não estarão mais acessíveis.", + "search": "Pesquisar fila", + "add": "Adicionar fila", + "details": "Detalhes da fila", + "topic": "Tópico", + "submit-settings": "Configurações de envio", + "submit-strategy": "Tipo de estratégia *", + "grouping-parameter": "Parâmetro de agrupamento", + "processing-settings": "Configurações de processamento de tentativas", + "processing-strategy": "Tipo de processamento *", + "retries-settings": "Configurações de tentativas", + "polling-settings": "Configurações de polling", + "batch-processing": "Processamento em lote", + "poll-interval": "Intervalo de polling", + "partitions": "Partições", + "immediate-processing": "Processamento imediato", + "consumer-per-partition": "Enviar polling de mensagens para cada consumidor", + "consumer-per-partition-hint": "Ativar consumidor(es) separado(s) por partição", + "duplicate-msg-to-all-partitions": "Duplicar mensagem para todas as partições", + "processing-timeout": "Processamento em, ms", + "batch-size": "Tamanho do lote", + "retries": "Número de tentativas (0 – ilimitado)", + "failure-percentage": "Mensagens de falha para ignorar tentativas, %", + "pause-between-retries": "Tentar novamente em, seg", + "max-pause-between-retries": "Tentativa adicional em, seg", + "delete": "Excluir fila", + "copyId": "Copiar ID da fila", + "idCopiedMessage": "O ID da fila foi copiado para a área de transferência", + "description": "Descrição", + "description-hint": "Este texto será exibido na descrição da fila em vez da estratégia selecionada", + "alt-description": "Estratégia de envio: {{submitStrategy}}, Estratégia de processamento: {{processingStrategy}}", + "custom-properties": "Propriedades personalizadas", + "custom-properties-hint": "Propriedades personalizadas de criação de fila (tópico), ex.: 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Sequencial por originador", + "sequential-by-originator-hint": "A nova mensagem para, por ex., o dispositivo A não é enviada até que a mensagem anterior para o dispositivo A seja confirmada", + "sequential-by-tenant-label": "Sequencial por tenant", + "sequential-by-tenant-hint": "A nova mensagem para, por ex., o tenant A não é enviada até que a mensagem anterior para o tenant A seja confirmada", + "sequential-label": "Sequencial", + "sequential-hint": "A nova mensagem não é enviada até que a mensagem anterior seja confirmada", + "burst-label": "Burst", + "burst-hint": "Todas as mensagens são enviadas para as cadeias de regras na ordem em que chegam", + "batch-label": "Lote", + "batch-hint": "O novo lote não é enviado até que o lote anterior seja confirmado", + "skip-all-failures-label": "Ignorar todas as falhas", + "skip-all-failures-hint": "Ignorar todas as falhas", + "skip-all-failures-and-timeouts-label": "Ignorar todas as falhas e tempos limite", + "skip-all-failures-and-timeouts-hint": "Ignorar todas as falhas e tempos limite", + "retry-all-label": "Tentar novamente todos", + "retry-all-hint": "Tentar novamente todas as mensagens do pacote de processamento", + "retry-failed-label": "Tentar novamente as com falha", + "retry-failed-hint": "Tentar novamente todas as mensagens com falha do pacote de processamento", + "retry-timeout-label": "Tentar novamente as com tempo limite", + "retry-timeout-hint": "Tentar novamente todas as mensagens com tempo limite do pacote de processamento", + "retry-failed-and-timeout-label": "Tentar novamente as com falha e tempo limite", + "retry-failed-and-timeout-hint": "Tentar novamente todas as mensagens com falha e tempo limite do pacote de processamento" + } + }, + "queue-statistics": { + "queue-statistics": "Estatísticas de fila", + "no-queue-statistics-matching": "Nenhuma estatística de fila correspondente a '{{entity}}' foi encontrada.", + "queue-statistics-required": "Estatísticas de fila são obrigatórias.", + "list-of-queue-statistics": "{ count, plural, =1 {Uma estatística de fila} other {Lista de # estatísticas de fila} }", + "selected-queue-statistics": "{ count, plural, =1 {1 estatística de fila} other {# estatísticas de fila} } selecionada(s)", + "no-queue-statistics-text": "Nenhuma estatística de fila encontrada", + "queue-statistics-starts-with": "Estatísticas de fila cujos nomes começam com '{{prefix}}'" + }, + "server-error": { + "general": "Erro geral do servidor", + "authentication": "Erro de autenticação", + "jwt-token-expired": "Token JWT expirado", + "tenant-trial-expired": "Período de avaliação do tenant expirado", + "credentials-expired": "Credenciais expiradas", + "permission-denied": "Permissão negada", + "invalid-arguments": "Argumentos inválidos", + "bad-request-params": "Parâmetros de requisição inválidos", + "item-not-found": "Item não encontrado", + "too-many-requests": "Muitas requisições", + "too-many-updates": "Muitas atualizações", + "entities-limit-exceeded": "Limite de entidades excedido" }, "tenant": { - "tenant": "Locatário", - "tenants": "Locatários", - "management": "Gerenciamento de locatários", - "add": "Adicionar Locatário", + "tenant": "Tenant", + "tenants": "Tenants", + "management": "Gerenciamento de tenants", + "add": "Adicionar tenant", "admins": "Administradores", - "manage-tenant-admins": "Gerenciar administradores de locatários", - "delete": "Excluir locatário", - "add-tenant-text": "Adicionar novo locatário", - "no-tenants-text": "Nenhum locatário encontrado", - "tenant-details": "Detalhes do locatário", - "delete-tenant-title": "Tem certeza de que deseja excluir o locatário '{{tenantTitle}}'?", - "delete-tenant-text": "Cuidado, após confirmar, não será possível recuperar o locatário e nenhum dado associado.", + "manage-tenant-admins": "Gerenciar administradores do tenant", + "delete": "Excluir tenant", + "add-tenant-text": "Adicionar novo tenant", + "no-tenants-text": "Nenhum tenant encontrado", + "tenant-details": "Detalhes do tenant", + "title-max-length": "O título deve ter menos de 256 caracteres", + "delete-tenant-title": "Tem certeza de que deseja excluir o tenant '{{tenantTitle}}'?", + "delete-tenant-text": "Atenção: após a confirmação, o tenant e todos os dados relacionados se tornarão irrecuperáveis.", "delete-tenants-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 tenant} other {# tenants} }?", "delete-tenants-action-title": "Excluir { count, plural, =1 {1 tenant} other {# tenants} }", - "delete-tenants-text": "Cuidado, após confirmar, todos os locatários selecionados serão removidos e não será possível recuperar nenhum dado associado.", + "delete-tenants-text": "Atenção: após a confirmação, todos os tenants selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", "title": "Título", "title-required": "O título é obrigatório.", "description": "Descrição", "details": "Detalhes", "events": "Eventos", - "copyId": "Copiar ID do locatário", - "idCopiedMessage": "O ID do locatário foi copiado para a área de transferência", - "select-tenant": "Selecionar locatário", - "no-tenants-matching": "Nenhum locatário encontrado que coincida com '{{entity}}'.", - "tenant-required": "O locatário é obrigatório", - "search": "Pesquisar locatários", + "copyId": "Copiar ID do tenant", + "idCopiedMessage": "O ID do tenant foi copiado para a área de transferência", + "select-tenant": "Selecionar tenant", + "no-tenants-matching": "Nenhum tenant correspondente a '{{entity}}' foi encontrado.", + "tenant-required": "Tenant é obrigatório", + "search": "Pesquisar tenants", "selected-tenants": "{ count, plural, =1 {1 tenant} other {# tenants} } selecionado(s)", - "isolated-tb-rule-engine": "Processamento em contêiner isolado do ThingsBoard Rule Engine", - "isolated-tb-rule-engine-details": "Exige microsserviço(s) separado(s) para cada locatário isolado" + "isolated-tb-rule-engine": "Usar filas isoladas do ThingsBoard Motor de regras", + "isolated-tb-rule-engine-details": "Cada tenant terá filas dedicadas do Motor de regras" + }, + "tenant-profile": { + "tenant-profile": "Perfil do tenant", + "tenant-profiles": "Perfis do tenant", + "add": "Adicionar perfil do tenant", + "add-profile": "Adicionar perfil", + "debug": "Depuração", + "edit": "Editar perfil do tenant", + "tenant-profile-details": "Detalhes do perfil do tenant", + "no-tenant-profiles-text": "Nenhum perfil do tenant encontrado", + "name-max-length": "O nome deve ter menos de 256 caracteres", + "search": "Pesquisar perfis do tenant", + "selected-tenant-profiles": "{ count, plural, =1 {1 perfil do tenant} other {# perfis do tenant} } selecionado(s)", + "no-tenant-profiles-matching": "Nenhum perfil do tenant correspondente a '{{entity}}' foi encontrado.", + "tenant-profile-required": "Perfil do tenant é obrigatório", + "idCopiedMessage": "O ID do perfil do tenant foi copiado para a área de transferência", + "set-default": "Definir perfil do tenant como padrão", + "delete": "Excluir perfil do tenant", + "copyId": "Copiar ID do perfil do tenant", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "data": "Dados do perfil", + "profile-configuration": "Configuração do perfil", + "description": "Descrição", + "default": "Padrão", + "delete-tenant-profile-title": "Tem certeza de que deseja excluir o perfil do tenant '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Atenção: após a confirmação, o perfil do tenant e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-tenant-profiles-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 perfil do tenant} other {# perfis do tenant} }?", + "delete-tenant-profiles-text": "Atenção: após a confirmação, todos os perfis do tenant selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "set-default-tenant-profile-title": "Tem certeza de que deseja tornar o perfil do tenant '{{tenantProfileName}}' o padrão?", + "set-default-tenant-profile-text": "Após a confirmação, o perfil do tenant será marcado como padrão e será utilizado para novos tenants sem perfil especificado.", + "no-tenant-profiles-found": "Nenhum perfil do tenant encontrado.", + "create-new-tenant-profile": "Criar um novo!", + "create-tenant-profile": "Criar novo perfil do tenant", + "import": "Importar perfil do tenant", + "export": "Exportar perfil do tenant", + "export-failed-error": "Não foi possível exportar o perfil do tenant: {{error}}", + "tenant-profile-file": "Arquivo de perfil do tenant", + "invalid-tenant-profile-file-error": "Não foi possível importar o perfil do tenant: estrutura de dados do perfil do tenant inválida.", + "advanced-settings": "Configurações avançadas", + "entities": "Entidades", + "rule-engine": "Motor de regras", + "time-to-live": "Time-to-live", + "calculated-fields": "Campos calculados", + "alarms-and-notifications": "Alarmes e notificações", + "ota-files-in-bytes": "Arquivos", + "ws-title": "WS", + "unlimited": "(0 - ilimitado)", + "maximum-devices": "Número máximo de dispositivos", + "maximum-devices-required": "O número máximo de dispositivos é obrigatório.", + "maximum-devices-range": "O número máximo de dispositivos não pode ser negativo", + "maximum-assets": "Número máximo de ativos", + "maximum-assets-required": "O número máximo de ativos é obrigatório.", + "maximum-assets-range": "O número máximo de ativos não pode ser negativo", + "maximum-customers": "Número máximo de clientes", + "maximum-customers-required": "O número máximo de clientes é obrigatório.", + "maximum-customers-range": "O número máximo de clientes não pode ser negativo", + "maximum-users": "Número máximo de usuários", + "maximum-users-required": "O número máximo de usuários é obrigatório.", + "maximum-users-range": "O número máximo de usuários não pode ser negativo", + "maximum-dashboards": "Número máximo de dashboards", + "maximum-dashboards-required": "O número máximo de dashboards é obrigatório.", + "maximum-dashboards-range": "O número máximo de dashboards não pode ser negativo", + "maximum-edges": "Número máximo de edges", + "maximum-edges-required": "O número máximo de edges é obrigatório.", + "maximum-edges-range": "O número máximo de edges não pode ser negativo", + "maximum-rule-chains": "Número máximo de cadeias de regras", + "maximum-rule-chains-required": "O número máximo de cadeias de regras é obrigatório.", + "maximum-rule-chains-range": "O número máximo de cadeias de regras não pode ser negativo", + "maximum-resources-sum-data-size": "Tamanho total máximo dos arquivos de recursos (bytes)", + "maximum-resources-sum-data-size-required": "O tamanho total máximo dos arquivos de recursos é obrigatório.", + "maximum-resources-sum-data-size-range": "O tamanho total máximo dos arquivos de recursos não pode ser negativo", + "maximum-resource-size": "Tamanho máximo do arquivo de recurso (bytes)", + "maximum-resource-size-required": "O tamanho máximo do arquivo de recurso é obrigatório", + "maximum-resource-size-range": "O tamanho máximo do arquivo de recurso não pode ser negativo", + "maximum-ota-packages-sum-data-size": "Tamanho total máximo dos arquivos de pacotes OTA (bytes)", + "maximum-ota-package-sum-data-size-required": "O tamanho total máximo dos arquivos de pacotes OTA é obrigatório.", + "maximum-ota-package-sum-data-size-range": "O tamanho total máximo dos arquivos de pacotes OTA não pode ser negativo", + "maximum-debug-duration-min": "Duração máxima de depuração (min)", + "maximum-debug-duration-min-range": "A duração máxima de depuração não pode ser negativa", + "rest-requests-for-tenant": "Requisições REST para tenant", + "transport-tenant-telemetry-msg-rate-limit": "Mensagens de telemetria do tenant de transporte", + "transport-tenant-telemetry-data-points-rate-limit": "Pontos de dados de telemetria do tenant de transporte", + "transport-device-msg-rate-limit": "Mensagens de dispositivo de transporte", + "transport-device-telemetry-msg-rate-limit": "Mensagens de telemetria do dispositivo de transporte", + "transport-device-telemetry-data-points-rate-limit": "Pontos de dados de telemetria do dispositivo de transporte", + "transport-gateway-msg-rate-limit": "Mensagens de gateway de transporte", + "transport-gateway-telemetry-msg-rate-limit": "Mensagens de telemetria do gateway de transporte", + "transport-gateway-telemetry-data-points-rate-limit": "Pontos de dados de telemetria do gateway de transporte", + "transport-gateway-device-msg-rate-limit": "Mensagens de dispositivo do gateway de transporte", + "transport-gateway-device-telemetry-msg-rate-limit": "Mensagens de telemetria do dispositivo do gateway de transporte", + "transport-gateway-device-telemetry-data-points-rate-limit": "Pontos de dados de telemetria do dispositivo do gateway de transporte", + "tenant-entity-export-rate-limit": "Criação de versão de entidade", + "tenant-entity-import-rate-limit": "Carregamento de versão de entidade", + "tenant-notification-request-rate-limit": "Requisições de notificação", + "tenant-notification-requests-per-rule-rate-limit": "Requisições de notificação por regra de notificação", + "max-calculated-fields": "Número máximo de campos calculados por entidade", + "max-calculated-fields-range": "O número máximo de campos calculados por entidade não pode ser negativo", + "max-calculated-fields-required": "O número máximo de campos calculados por entidade é obrigatório", + "max-data-points-per-rolling-arg": "Número máximo de pontos de dados em argumentos de janela deslizante", + "max-data-points-per-rolling-arg-range": "O número máximo de pontos de dados em argumentos de janela deslizante não pode ser negativo", + "max-data-points-per-rolling-arg-required": "O número máximo de pontos de dados em argumentos de janela deslizante é obrigatório", + "max-arguments-per-cf": "Número máximo de argumentos por campo calculado", + "max-arguments-per-cf-range": "O número máximo de argumentos por campo calculado não pode ser negativo", + "max-arguments-per-cf-required": "O número máximo de argumentos por campo calculado é obrigatório", + "max-related-level-per-argument": "Nível máximo de relação por argumento de 'Entidades relacionadas'", + "max-related-level-per-argument-range": "O número máximo de nível de relação por argumento de 'Entidades relacionadas' não pode ser menor que '1'", + "max-related-level-per-argument-required": "O número máximo de nível de relação por argumento de 'Entidades relacionadas' é obrigatório", + "min-allowed-scheduled-update-interval": "Intervalo mínimo permitido de atualização para argumentos de 'Entidades relacionadas' (segundos)", + "min-allowed-scheduled-update-interval-range": "O número mínimo do intervalo mínimo permitido de atualização não pode ser negativo", + "min-allowed-deduplication-interval": "Intervalo mínimo permitido de deduplicação (segundos)", + "min-allowed-deduplication-interval-range": "O valor do intervalo mínimo permitido de deduplicação não pode ser negativo", + "min-allowed-deduplication-interval-required": "O intervalo mínimo permitido de deduplicação é obrigatório", + "intermediate-aggregation-interval": "Intervalo de agregação intermediária (segundos)", + "intermediate-aggregation-interval-range": "O valor do intervalo de agregação intermediária não pode ser menor que '1'", + "intermediate-aggregation-interval-required": "O intervalo de agregação intermediária é obrigatório", + "reevaluation-check-interval": "Intervalo de verificação de reavaliação (segundos)", + "reevaluation-check-interval-range": "O valor do intervalo de verificação de reavaliação não pode ser menor que '1'", + "reevaluation-check-interval-required": "O intervalo de verificação de reavaliação é obrigatório", + "alarms-reevaluation-interval": "Intervalo de reavaliação de alarmes (segundos)", + "alarms-reevaluation-interval-range": "O valor do intervalo de reavaliação de alarmes não pode ser menor que '1'", + "alarms-reevaluation-interval-required": "O intervalo de reavaliação de alarmes é obrigatório", + "min-allowed-aggregation-interval": "Intervalo mínimo permitido de agregação (segundos)", + "min-allowed-aggregation-interval-range": "O valor do intervalo mínimo permitido de agregação não pode ser negativo", + "min-allowed-aggregation-interval-required": "O intervalo mínimo permitido de agregação é obrigatório", + "min-allowed-scheduled-update-interval-required": "O número mínimo do intervalo mínimo permitido de atualização é obrigatório", + "max-state-size": "Tamanho máximo do estado em KB", + "max-state-size-range": "O tamanho máximo do estado em KB não pode ser negativo", + "max-state-size-required": "O tamanho máximo do estado em KB é obrigatório", + "max-value-argument-size": "Tamanho máximo do argumento de valor único em KB", + "max-value-argument-size-range": "O tamanho máximo do argumento de valor único em KB não pode ser negativo", + "max-value-argument-size-required": "O tamanho máximo do argumento de valor único em KB é obrigatório", + "max-transport-messages": "Número máximo de mensagens de transporte", + "max-transport-messages-required": "O número máximo de mensagens de transporte é obrigatório.", + "max-transport-messages-range": "O número máximo de mensagens de transporte não pode ser negativo", + "max-transport-data-points": "Número máximo de pontos de dados de transporte ", + "max-transport-data-points-required": "O número máximo de pontos de dados de transporte é obrigatório.", + "max-transport-data-points-range": "O número máximo de pontos de dados de transporte não pode ser negativo", + "max-r-e-executions": "Número máximo de execuções do Motor de regras", + "max-r-e-executions-required": "O número máximo de execuções do Motor de regras é obrigatório.", + "max-r-e-executions-range": "O número máximo de execuções do Motor de regras não pode ser negativo", + "max-j-s-executions": "Número máximo de execuções JavaScript ", + "max-j-s-executions-required": "O número máximo de execuções JavaScript é obrigatório.", + "max-j-s-executions-range": "O número máximo de execuções JavaScript não pode ser negativo", + "max-tbel-executions": "Número máximo de execuções TBEL ", + "max-tbel-executions-required": "O número máximo de execuções TBEL é obrigatório.", + "max-tbel-executions-range": "O número máximo de execuções TBEL não pode ser negativo", + "max-d-p-storage-days": "Número máximo de dias de armazenamento de pontos de dados", + "max-d-p-storage-days-required": "O número máximo de dias de armazenamento de pontos de dados é obrigatório.", + "max-d-p-storage-days-range": "O número máximo de dias de armazenamento de pontos de dados não pode ser negativo", + "default-storage-ttl-days": "Dias de TTL de armazenamento por padrão", + "default-storage-ttl-days-required": "Os dias de TTL de armazenamento por padrão são obrigatórios.", + "default-storage-ttl-days-range": "Os dias de TTL de armazenamento por padrão não podem ser negativos", + "alarms-ttl-days": "Dias de TTL de alarmes", + "alarms-ttl-days-required": "Os dias de TTL de alarmes são obrigatórios", + "alarms-ttl-days-days-range": "Os dias de TTL de alarmes não podem ser negativos", + "rpc-ttl-days": "Dias de TTL de RPC", + "rpc-ttl-days-required": "Os dias de TTL de RPC são obrigatórios", + "rpc-ttl-days-days-range": "Os dias de TTL de RPC não podem ser negativos", + "queue-stats-ttl-days": "Dias de TTL de estatísticas de fila", + "queue-stats-ttl-days-required": "Os dias de TTL de estatísticas de fila são obrigatórios", + "queue-stats-ttl-days-range": "Os dias de TTL de estatísticas de fila não podem ser negativos", + "rule-engine-exceptions-ttl-days": "Dias de TTL de exceções do Motor de regras", + "rule-engine-exceptions-ttl-days-required": "Os dias de TTL de exceções do Motor de regras são obrigatórios", + "rule-engine-exceptions-ttl-days-range": "Os dias de TTL de exceções do Motor de regras não podem ser negativos", + "max-rule-node-executions-per-message": "Número máximo de execuções de nó de regra por mensagem", + "max-rule-node-executions-per-message-required": "O número máximo de execuções de nó de regra por mensagem é obrigatório.", + "max-rule-node-executions-per-message-range": "O número máximo de execuções de nó de regra por mensagem não pode ser negativo", + "max-emails": "Número máximo de emails enviados", + "max-emails-required": "O número máximo de emails enviados é obrigatório.", + "max-emails-range": "O número máximo de emails enviados não pode ser negativo", + "sms-enabled": "SMS ativado", + "max-sms": "Número máximo de SMS enviados", + "max-sms-required": "O número máximo de SMS enviados é obrigatório.", + "max-sms-range": "O número máximo de SMS enviados não pode ser negativo", + "max-created-alarms": "Número máximo de alarmes criados", + "max-created-alarms-required": "O número máximo de alarmes criados é obrigatório.", + "max-created-alarms-range": "O número máximo de alarmes criados não pode ser negativo", + "no-queue": "Nenhuma fila configurada", + "add-queue": "Adicionar fila", + "queues-with-count": "Filas ({{count}})", + "tenant-rest-limits": "Requisições REST para tenant", + "customer-rest-limits": "Requisições REST para cliente", + "incorrect-pattern-for-rate-limits": "O formato são pares separados por vírgula de capacidade e período (em segundos) com dois-pontos entre eles, ex.: 100:1,2000:60", + "too-small-value-zero": "O valor deve ser maior que 0", + "too-small-value-one": "O valor deve ser maior que 1", + "queue-size-is-limited-by-system-configuration": "O tamanho da fila também é limitado pela configuração do sistema.", + "cassandra-write-tenant-core-limits-configuration": "Consultas de gravação Cassandra da API REST", + "cassandra-read-tenant-core-limits-configuration": "Consultas de leitura Cassandra da API REST e telemetria WS", + "cassandra-write-tenant-rule-engine-limits-configuration": "Consultas de gravação Cassandra de telemetria do Motor de regras", + "cassandra-read-tenant-rule-engine-limits-configuration": "Consultas de leitura Cassandra de telemetria do Motor de regras", + "ws-limit-max-sessions-per-tenant": "Número máximo de sessões por tenant", + "ws-limit-max-sessions-per-customer": "Número máximo de sessões por cliente", + "ws-limit-max-sessions-per-regular-user": "Número máximo de sessões por usuário regular", + "ws-limit-max-sessions-per-public-user": "Número máximo de sessões por usuário público", + "ws-limit-queue-per-session": "Tamanho máximo da fila de mensagens por sessão", + "ws-limit-max-subscriptions-per-tenant": "Número máximo de assinaturas por tenant", + "ws-limit-max-subscriptions-per-customer": "Número máximo de assinaturas por cliente", + "ws-limit-max-subscriptions-per-regular-user": "Número máximo de assinaturas por usuário regular", + "ws-limit-max-subscriptions-per-public-user": "Número máximo de assinaturas por usuário público", + "ws-limit-updates-per-session": "Atualizações WS por sessão", + "relation-search-entity-limit": "Limite de entidades na pesquisa de relações", + "relation-search-entity-limit-hint": "Limita o número de entidades resolvidas no último nível do caminho de relação. Aplica-se aos argumentos de 'Entidades relacionadas' e campos de Propagação.", + "relation-search-entity-limit-required": "Limite de entidades na pesquisa de relações", + "relation-search-entity-limit-range": "O limite de entidades na pesquisa de relações não pode ser menor que '1'", + "rate-limits": { + "add-limit": "Adicionar limite", + "and-also-less-than": "e também menor que", + "advanced-settings": "Configurações avançadas", + "edit-limit": "Editar limite", + "calculated-field-debug-event-rate-limit": "Eventos de depuração do campo calculado", + "edit-calculated-field-debug-event-rate-limit": "Editar limites de taxa de eventos de depuração do campo calculado", + "edit-transport-tenant-msg-title": "Editar limites de taxa de mensagens do tenant de transporte", + "edit-transport-tenant-telemetry-msg-title": "Editar limites de taxa de mensagens de telemetria do tenant de transporte", + "edit-transport-tenant-telemetry-data-points-title": "Editar limites de taxa de pontos de dados de telemetria do tenant de transporte", + "edit-transport-device-msg-title": "Editar limites de taxa de mensagens de dispositivo de transporte", + "edit-transport-device-telemetry-msg-title": "Editar limites de taxa de mensagens de telemetria do dispositivo de transporte", + "edit-transport-device-telemetry-data-points-title": "Editar limites de taxa de pontos de dados de telemetria do dispositivo de transporte", + "edit-transport-gateway-msg-title": "Editar limites de taxa de mensagens de gateway de transporte", + "edit-transport-gateway-telemetry-msg-title": "Editar limites de taxa de mensagens de telemetria do gateway de transporte", + "edit-transport-gateway-telemetry-data-points-title": "Editar limites de taxa de pontos de dados de telemetria do gateway de transporte", + "edit-transport-gateway-device-msg-title": "Editar limites de taxa de mensagens de dispositivo do gateway de transporte", + "edit-transport-gateway-device-telemetry-msg-title": "Editar limites de taxa de mensagens de telemetria do dispositivo do gateway de transporte", + "edit-transport-gateway-device-telemetry-data-points-title": "Editar limites de taxa de pontos de dados de telemetria do dispositivo do gateway de transporte", + "edit-tenant-rest-limits-title": "Editar limites de taxa de requisições REST para tenant", + "edit-customer-rest-limits-title": "Editar limites de taxa de requisições REST para cliente", + "edit-ws-limit-updates-per-session-title": "Editar limites de taxa de atualizações WS por sessão", + "edit-cassandra-write-tenant-core-limits-configuration": "Editar consultas de gravação Cassandra da API REST", + "edit-cassandra-read-tenant-core-limits-configuration": "Editar consultas de leitura Cassandra da API REST e telemetria WS", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "Editar consultas de gravação Cassandra de telemetria do Motor de regras", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "Editar consultas de leitura Cassandra de telemetria do Motor de regras", + "edit-tenant-entity-export-rate-limit-title": "Editar limites de taxa de criação de versão de entidade", + "edit-tenant-entity-import-rate-limit-title": "Editar limites de taxa de carregamento de versão de entidade", + "edit-tenant-notification-request-rate-limit-title": "Editar limites de taxa de requisições de notificação", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Editar limites de taxa de requisições de notificação por regra de notificação", + "edit-edge-events-rate-limit": "Editar limites de taxa de eventos de edge", + "edit-edge-events-per-edge-rate-limit": "Editar limites de taxa de eventos de edge por edge", + "edge-events-rate-limit": "Eventos de edge", + "edge-events-per-edge-rate-limit": "Eventos de edge por edge", + "edit-edge-uplink-messages-rate-limit": "Editar limites de taxa de mensagens uplink de edge", + "edit-edge-uplink-messages-per-edge-rate-limit": "Editar limites de taxa de mensagens uplink de edge por edge", + "edge-uplink-messages-rate-limit": "Mensagens uplink de edge", + "edge-uplink-messages-per-edge-rate-limit": "Mensagens uplink de edge por edge", + "messages-per": "mensagens por", + "not-set": "Não definido", + "number-of-messages": "Número de mensagens", + "number-of-messages-required": "O número de mensagens é obrigatório.", + "number-of-messages-min": "O valor mínimo é 1.", + "preview": "Visualizar", + "per-seconds": "Por segundos", + "per-seconds-required": "A taxa de tempo é obrigatória.", + "per-seconds-min": "O valor mínimo é 1.", + "per-seconds-duplicate": "Taxa de tempo duplicada. Cada intervalo de tempo deve ser único.", + "rate-limits": "Limites de taxa", + "remove-limit": "Remover limite", + "transport-tenant-msg": "Mensagens do tenant de transporte", + "transport-tenant-telemetry-msg": "Mensagens de telemetria do tenant de transporte", + "transport-tenant-telemetry-data-points": "Pontos de dados de telemetria do tenant de transporte", + "transport-device-msg": "Mensagens de dispositivo de transporte", + "transport-device-telemetry-msg": "Mensagens de telemetria do dispositivo de transporte", + "transport-device-telemetry-data-points": "Pontos de dados de telemetria do dispositivo de transporte", + "transport-gateway-msg": "Mensagens de gateway de transporte", + "transport-gateway-telemetry-msg": "Mensagens de telemetria do gateway de transporte", + "transport-gateway-telemetry-data-points": "Pontos de dados de telemetria do gateway de transporte", + "transport-gateway-device-msg": "Mensagens de dispositivo do gateway de transporte", + "transport-gateway-device-telemetry-msg": "Mensagens de telemetria do dispositivo do gateway de transporte", + "transport-gateway-device-telemetry-data-points": "Pontos de dados de telemetria do dispositivo do gateway de transporte", + "sec": "seg" + } }, "timeinterval": { - "seconds-interval": "{ seconds, plural, =1 {1 second} other {# seconds} }", - "minutes-interval": "{ minutes, plural, =1 {1 minute} other {# minutes} }", - "hours-interval": "{ hours, plural, =1 {1 hour} other {# hours} }", - "days-interval": "{ days, plural, =1 {1 day} other {# days} }", + "seconds-interval": "{ seconds, plural, =1 {1 segundo} other {# segundos} }", + "minutes-interval": "{ minutes, plural, =1 {1 minuto} other {# minutos} }", + "hours-interval": "{ hours, plural, =1 {1 hora} other {# horas} }", + "days-interval": "{ days, plural, =1 {1 dia} other {# dias} }", "days": "Dias", "hours": "Horas", "minutes": "Minutos", "seconds": "Segundos", - "advanced": "Avançado" + "advanced": "Avançado", + "custom": "Personalizado", + "predefined": { + "yesterday": "Ontem", + "day-before-yesterday": "Anteontem", + "this-day-last-week": "Este dia na semana passada", + "previous-week": "Semana anterior (Dom - Sáb)", + "previous-week-iso": "Semana anterior (Seg - Dom)", + "previous-month": "Mês anterior", + "previous-quarter": "Trimestre anterior", + "previous-half-year": "Semestre anterior", + "previous-year": "Ano anterior", + "current-hour": "Hora atual", + "current-day": "Dia atual", + "current-day-so-far": "Dia atual até agora", + "current-week": "Semana atual (Dom - Sáb)", + "current-week-iso": "Semana atual (Seg - Dom)", + "current-week-so-far": "Semana atual até agora (Dom - Sáb)", + "current-week-iso-so-far": "Semana atual até agora (Seg - Dom)", + "current-month": "Mês atual", + "current-month-so-far": "Mês atual até agora", + "current-quarter": "Trimestre atual", + "current-quarter-so-far": "Trimestre atual até agora", + "current-half-year": "Semestre atual", + "current-half-year-so-far": "Semestre atual até agora", + "current-year": "Ano atual", + "current-year-so-far": "Ano atual até agora" + }, + "type": { + "week": "Semana (Dom - Sáb)", + "week-iso": "Semana (Seg - Dom)", + "month": "Mês", + "quarter": "Trimestre" + } + }, + "timeunit": { + "milliseconds": "Milissegundos", + "seconds": "Segundos", + "minutes": "Minutos", + "hours": "Horas", + "days": "Dias" }, "timewindow": { - "days": "{ days, plural, =1 { day } other {# days } }", - "hours": "{ hours, plural, =0 { hour } =1 {1 hour } other {# hours } }", - "minutes": "{ minutes, plural, =0 { minute } =1 {1 minute } other {# minutes } }", - "seconds": "{ seconds, plural, =0 { second } =1 {1 second } other {# seconds } }", + "timewindow": "Janela de tempo", + "timewindow-settings": "Configurações da janela de tempo", + "years": "{ years, plural, =1 {1 ano } other {# anos } }", + "years-short": "{{ years }}y", + "months": "{ months, plural, =1 {1 mês } other {# meses } }", + "months-short": "{{ months }}M", + "weeks": "{ weeks, plural, =1 {1 semana } other {# semanas } }", + "weeks-short": "{{ weeks }}w", + "days": "{ days, plural, =1 {1 dia } other {# dias } }", + "days-short": "{{ days }}d", + "hours": "{ hours, plural, =0 { hora } =1 {1 hora } other {# horas } }", + "hr": "{{ hr }} hr", + "hr-short": "{{ hr }}h", + "minutes": "{ minutes, plural, =0 { minuto } =1 {1 minuto } other {# minutos } }", + "min": "{{ min }} min", + "min-short": "{{ min }}m", + "seconds": "{ seconds, plural, =0 { segundo } =1 {1 segundo } other {# segundos } }", + "sec": "{{ sec }} sec", + "sec-short": "{{ sec }}s", + "short": { + "years": "{ years, plural, =1 {1 ano } other {# anos } }", + "days": "{ days, plural, =1 {1 dia } other {# dias } }", + "hours": "{ hours, plural, =1 {1 hora } other {# horas } }", + "minutes": "{{minutes}} min ", + "seconds": "{{seconds}} sec " + }, "realtime": "Tempo real", "history": "Histórico", - "last-prefix": "último", + "last-prefix": "últimos", "period": "de {{ startTime }} até {{ endTime }}", "edit": "Editar janela de tempo", "date-range": "Intervalo de datas", - "last": "Última", + "for-all-time": "Para todo o período", + "last": "Último", "time-period": "Período de tempo", - "hide": "Ocultar" + "hide": "Ocultar", + "interval": "Intervalo", + "just-now": "Agora mesmo", + "just-now-lower": "agora mesmo", + "ago": "atrás", + "style": "Estilo da janela de tempo", + "icon": "Ícone", + "icon-position": "Posição do ícone", + "icon-position-left": "Esquerda", + "icon-position-right": "Direita", + "font": "Fonte", + "color": "Cor", + "displayTypePrefix": "Exibir prefixo Tempo real/Histórico", + "preview": "Pré-visualização", + "relative": "Relativo", + "range": "Intervalo", + "hide-timewindow-section": "Ocultar seção de janela de tempo dos usuários finais", + "hide-last-interval": "Ocultar último intervalo dos usuários finais", + "hide-relative-interval": "Ocultar intervalo relativo dos usuários finais", + "hide-fixed-interval": "Ocultar intervalo fixo dos usuários finais", + "hide-aggregation": "Ocultar agregação dos usuários finais", + "hide-group-interval": "Ocultar intervalo de agrupamento dos usuários finais", + "hide-max-values": "Ocultar valores máximos dos usuários finais", + "hide-timezone": "Ocultar fuso horário dos usuários finais", + "disable-custom-interval": "Desativar seleção de intervalo personalizado", + "edit-aggregation-functions-list": "Editar lista de funções de agregação", + "edit-aggregation-functions-list-hint": "É possível especificar a lista de opções disponíveis.", + "allowed-aggregation-functions": "Funções de agregação permitidas", + "edit-intervals-list": "Editar lista de intervalos", + "allowed-agg-intervals": "Intervalos de agrupamento", + "default-agg-interval": "Intervalo de agrupamento padrão", + "edit-intervals-list-hint": "É possível especificar a lista de opções de intervalo disponíveis.", + "edit-grouping-intervals-list-hint": "É possível configurar a lista de intervalos de agrupamento e o intervalo de agrupamento padrão.", + "all": "Todos", + "save-current-settings-as-default": "Salvar configurações atuais como janela de tempo padrão", + "hide-option-from-end-users": "Ocultar opção dos usuários finais" + }, + "tooltip": { + "trigger": "Acionador", + "trigger-point": "Ponto", + "trigger-axis": "Eixo", + "label": "Rótulo", + "value": "Valor", + "date": "Data", + "show-date-time-interval": "Exibir intervalo de data e hora", + "show-date-time-interval-hint": "Exibir intervalo de data e hora conforme a agregação de dados.", + "hide-zero-tooltip-values": "Ocultar valores zero", + "show-stack-total": "Exibir valor total no modo empilhado", + "background-color": "Cor de fundo", + "background-blur": "Desfoque de fundo" + }, + "unit": { + "set-unit-conversion": "Definir conversão de unidade", + "unit-settings": { + "unit-settings": "Configurações de unidade", + "source-unit": "Unidade de origem", + "source-unit-hint": "Esta é a unidade do valor armazenado. A unidade a partir da qual você está convertendo. Insira o símbolo usado pelos dados de origem (ex.: m, km, ft, in).", + "target-metric-unit": "Unidade métrica de destino", + "target-metric-unit-hint": "Escolha para qual unidade métrica (SI) deseja converter o valor de origem (ex.: cm, mm, km).", + "target-imperial-unit": "Unidade imperial de destino", + "target-imperial-unit-hint": "Escolha para qual unidade imperial deseja converter o valor de origem (ex.: in, ft, yd).", + "target-hybrid-unit": "Unidade híbrida de destino", + "target-hybrid-unit-hint": "Escolha para qual unidade híbrida deseja converter o valor de origem (ex.: cm, in, km). Unidades híbridas combinam unidades métricas ou imperiais.", + "enable-unit-conversion": "Ativar conversão de unidade", + "enable-unit-conversion-hint": "Ative para habilitar a conversão. Quando desativado, o valor de origem será transmitido sem alterações. Desativado se houver apenas uma unidade no grupo de medição correspondente (ex.: fluxo luminoso, AQI)." + }, + "unit-system": "Sistema de unidades", + "unit-system-type": { + "AUTO": "Automático", + "METRIC": "Métrico", + "IMPERIAL": "Imperial", + "HYBRID": "Híbrido" + }, + "measures": { + "absorbed-dose-rate": "Taxa de dose absorvida", + "acceleration": "Aceleração", + "acidity": "Acidez", + "air-quality-index": "Índice de qualidade do ar", + "amount-of-substance": "Quantidade de substância", + "angle": "Ângulo", + "angular-acceleration": "Aceleração angular", + "area": "Área", + "area-density": "Densidade de área", + "capacitance": "Capacitância", + "catalytic-activity": "Atividade catalítica", + "catalytic-concentration": "Concentração catalítica", + "charge": "Carga", + "current-density": "Densidade de corrente", + "data-transfer-rate": "Taxa de transferência de dados", + "density": "Densidade", + "digital": "Digital", + "dimension-ratio": "Razão dimensional", + "dynamic-viscosity": "Viscosidade dinâmica", + "earthquake-magnitude": "Magnitude de terremoto", + "electric-charge-density": "Densidade de carga elétrica", + "electric-current": "Corrente elétrica", + "electric-dipole-moment": "Momento de dipolo elétrico", + "electric-field-strength": "Intensidade do campo elétrico", + "electric-flux": "Fluxo elétrico", + "electric-permittivity": "Permissividade elétrica", + "electric-polarizability": "Polarizabilidade elétrica", + "electrical-conductance": "Condutância elétrica", + "electrical-conductivity": "Condutividade elétrica", + "energy": "Energia", + "energy-density": "Densidade de energia", + "force": "Força", + "frequency": "Frequência", + "fuel-efficiency": "Eficiência de combustível", + "heat-capacity": "Capacidade térmica", + "illuminance": "Iluminância", + "inductance": "Indutância", + "kinematic-viscosity": "Viscosidade cinemática", + "length": "Comprimento", + "light-exposure": "Exposição à luz", + "linear-charge-density": "Densidade linear de carga", + "logarithmic-ratio": "Razão logarítmica", + "luminous-efficacy": "Eficácia luminosa", + "luminous-flux": "Fluxo luminoso", + "luminous-intensity": "Intensidade luminosa", + "magnetic-field-gradient": "Gradiente do campo magnético", + "magnetic-flux": "Fluxo magnético", + "magnetic-flux-density": "Densidade de fluxo magnético", + "magnetic-moment": "Momento magnético", + "magnetic-permeability": "Permeabilidade magnética", + "mass": "Massa", + "mass-fraction": "Fração mássica", + "molar-concentration": "Concentração molar", + "molar-energy": "Energia molar", + "molar-heat-capacity": "Capacidade térmica molar", + "molar-mass": "Massa molar", + "number-concentration": "Concentração numérica", + "parts-per-million": "Partes por milhão", + "power": "Potência", + "power-density": "Densidade de potência", + "pressure": "Pressão", + "radiance": "Radiância", + "radiant-intensity": "Intensidade radiante", + "radiation-dose": "Dose de radiação", + "radioactive-decay": "Decaimento radioativo", + "radioactivity": "Radioatividade", + "radioactivity-concentration": "Concentração de radioatividade", + "reciprocal-length": "Comprimento recíproco", + "resistance": "Resistência", + "reynolds-number": "Número de Reynolds", + "signal-level": "Nível de sinal", + "solid-angle": "Ângulo sólido", + "specific-energy": "Energia específica", + "specific-heat-capacity": "Capacidade térmica específica", + "specific-humidity": "Umidade específica", + "specific-volume": "Volume específico", + "speed": "Velocidade", + "surface-charge-density": "Densidade de carga superficial", + "surface-tension": "Tensão superficial", + "temperature": "Temperatura", + "thermal-conductivity": "Condutividade térmica", + "time": "Tempo", + "torque": "Torque", + "turbidity": "Turbidez", + "voltage": "Tensão", + "volume": "Volume", + "volume-flow": "Vazão volumétrica" + }, + "millimeter": "Milímetro", + "centimeter": "Centímetro", + "decimeter": "Decímetro", + "angstrom": "Angstrom", + "nanometer": "Nanômetro", + "micrometer": "Micrômetro", + "meter": "Metro", + "kilometer": "Quilômetro", + "inch": "Polegada", + "foot": "Pé", + "foot-us": "Pé (levantamento EUA)", + "yard": "Jarda", + "mile": "Milha", + "nautical-mile": "Milha náutica", + "astronomical-unit": "Unidade astronômica", + "reciprocal-metre": "Metro recíproco", + "meter-per-meter": "Metro por metro", + "steradian": "Esterradiano", + "thou": "Thou", + "barleycorn": "Barleycorn", + "hand": "Hand", + "chain": "Chain", + "furlong": "Furlong", + "league": "League", + "fathom": "Fathom", + "cable": "Cable", + "link": "Link", + "rod": "Rod", + "nanogram": "Nanograma", + "microgram": "Micrograma", + "milligram": "Miligrama", + "gram": "Grama", + "kilogram": "Quilograma", + "tonne": "Tonelada", + "ounce": "Onça", + "pound": "Libra", + "stone": "Stone", + "hundredweight-count": "Hundredweight", + "short-tons": "Toneladas curtas", + "dalton": "Dalton", + "grain": "Grão", + "drachm": "Dracma", + "quarter": "Quarter", + "slug": "Slug", + "carat": "Quilate", + "cubic-millimeter": "Milímetro cúbico", + "cubic-centimeter": "Centímetro cúbico", + "cubic-meter": "Metro cúbico", + "cubic-kilometer": "Quilômetro cúbico", + "microliter": "Microlitro", + "milliliter": "Mililitro", + "liter": "Litro", + "hectoliter": "Hectolitro", + "cubic-inch": "Polegada cúbica", + "cubic-foot": "Pé cúbico", + "cubic-yard": "Jarda cúbica", + "fluid-ounce": "Onça fluida", + "fluid-ounce-per-second": "Onça fluida por segundo", + "pint": "Pinta", + "quart": "Quart", + "gallon": "Galão", + "oil-barrels": "Barril de petróleo", + "cubic-meter-per-kilogram": "Metro cúbico por quilograma", + "gill": "Gill", + "hogshead": "Hogshead", + "teaspoon": "Colher de chá", + "tablespoon": "Colher de sopa", + "cup": "Xícara", + "celsius": "Celsius", + "kelvin": "Kelvin", + "rankine": "Rankine", + "fahrenheit": "Fahrenheit", + "percent": "Porcentagem", + "meter-per-second": "Metro por segundo", + "kilometer-per-hour": "Quilômetro por hora", + "foot-per-second": "Pé por segundo", + "foot-per-minute": "Pé por minuto", + "mile-per-hour": "Milha por hora", + "knot": "Nó", + "inch-per-second": "Polegada por segundo", + "inch-per-hour": "Polegada por hora", + "millimeters-per-minute": "Milímetros por minuto", + "meter-per-minute": "Metro por minuto", + "kilometer-per-hour-squared": "Quilômetro por hora ao quadrado", + "foot-per-second-squared": "Pé por segundo ao quadrado", + "pascal": "Pascal", + "kilopascal": "Quilopascal", + "megapascal": "Megapascal", + "gigapascal": "Gigapascal", + "millibar": "Milibar", + "bar": "Bar", + "kilobar": "Quilobar", + "newton": "Newton", + "newton-meter": "Newton-metro", + "foot-pounds": "Pé-libra", + "inch-pounds": "Polegada-libra", + "newton-per-meter": "Newton por metro", + "atmospheres": "Atmosferas", + "pounds-per-square-inch": "Libras por polegada quadrada", + "kilopound-per-square-inch": "Quilolibra por polegada quadrada", + "torr": "Torr", + "inches-of-mercury": "Polegadas de mercúrio", + "pascal-per-square-meter": "Pascal por metro quadrado", + "pound-per-square-inch": "Libra por polegada quadrada", + "newton-per-square-meter": "Newton por metro quadrado", + "kilogram-force-per-square-meter": "Quilograma-força por metro quadrado", + "pascal-per-square-centimeter": "Pascal por centímetro quadrado", + "ton-force-per-square-inch": "Tonelada-força por polegada quadrada", + "kilonewton-per-square-meter": "Quilonewton por metro quadrado", + "newton-per-square-millimeter": "Newton por milímetro quadrado", + "microjoule": "Microjoule", + "millijoule": "Milijoule", + "joule": "Joule", + "kilojoule": "Quilojoule", + "megajoule": "Megajoule", + "gigajoule": "Gigajoule", + "watt-hour": "Watt-hora", + "watt-minute": "Watt-minuto", + "kilowatt-hour": "Quilowatt-hora", + "milliwatt-hour": "Miliwatt-hora", + "megawatt-hour": "Megawatt-hora", + "gigawatt-hour": "Gigawatt-hora", + "electron-volts": "Elétron-volts", + "joules-per-coulomb": "Joules por coulomb", + "british-thermal-unit": "Unidades térmicas britânicas", + "thousand-british-thermal-unit": "Mil unidades térmicas britânicas", + "million-british-thermal-unit": "Milhão de unidades térmicas britânicas", + "foot-pound": "Pé-libra", + "calorie": "Caloria", + "small-calorie": "Caloria pequena", + "kilocalorie": "Quilocaloria", + "joule-per-kelvin": "Joule por kelvin", + "joule-per-kilogram-kelvin": "Joule por quilograma-kelvin", + "joule-per-kilogram": "Joule por quilograma", + "watt-per-meter-kelvin": "Watt por metro-kelvin", + "joule-per-cubic-meter": "Joule por metro cúbico", + "therm": "Therm", + "electric-dipole-moment": "Momento de dipolo elétrico", + "magnetic-dipole-moment": "Momento de dipolo magnético", + "debye": "Debye", + "coulomb-per-square-meter-per-volt": "Coulomb por metro quadrado por volt", + "milliwatt": "Miliwatt", + "microwatt": "Microwatt", + "watt": "Watt", + "kilowatt": "Quilowatt", + "megawatt": "Megawatt", + "gigawatt": "Gigawatt", + "metric-horsepower": "Cavalo-vapor métrico", + "milliwatt-per-square-centimeter": "Miliwatts por centímetro quadrado", + "watt-per-square-centimeter": "Watts por centímetro quadrado", + "kilowatt-per-square-centimeter": "Quilowatts por centímetro quadrado", + "milliwatt-per-square-meter": "Miliwatts por metro quadrado", + "watt-per-square-meter": "Watts por metro quadrado", + "kilowatt-per-square-meter": "Quilowatts por metro quadrado", + "watt-per-square-inch": "Watts por polegada quadrada", + "kilowatt-per-square-inch": "Quilowatts por polegada quadrada", + "horsepower": "Cavalo-vapor", + "btu-per-hour": "Unidades térmicas britânicas por hora", + "btu-per-second": "Unidades térmicas britânicas por segundo", + "btu-per-day": "Unidades térmicas britânicas por dia", + "mbtu-per-hour": "Mil unidades térmicas britânicas por hora", + "mbtu-per-second": "Mil unidades térmicas britânicas por segundo", + "mbtu-per-day": "Mil unidades térmicas britânicas por dia", + "mmbtu-per-hour": "Milhão de unidades térmicas britânicas por hora", + "mmbtu-per-second": "Milhão de unidades térmicas britânicas por segundo", + "mmbtu-per-day": "Milhão de unidades térmicas britânicas por dia", + "foot-pound-per-second": "Pé-libra por segundo", + "coulomb": "Coulomb", + "millicoulomb": "Millicoulombs", + "microcoulomb": "Microcoulomb", + "nanocoulomb": "Nanocoulomb", + "picocoulomb": "Picocoulomb", + "coulomb-per-meter": "Coulomb por metro", + "coulomb-per-cubic-meter": "Coulomb por metro cúbico", + "coulomb-per-square-meter": "Coulomb por metro quadrado", + "square-millimeter": "Milímetro quadrado", + "square-centimeter": "Centímetro quadrado", + "square-meter": "Metro quadrado", + "hectare": "Hectare", + "square-kilometer": "Quilômetro quadrado", + "square-inch": "Polegada quadrada", + "square-foot": "Pé quadrado", + "square-yard": "Jarda quadrada", + "acre": "Acre", + "square-mile": "Milha quadrada", + "are": "Are", + "barn": "Barn", + "circular-inch": "Polegada circular", + "milliampere-hour": "Miliampere-hora", + "ampere-hours": "Ampere-horas", + "kiloampere-hours": "Quiloampere-horas", + "nanoampere": "Nanoampere", + "picoampere": "Picoampere", + "microampere": "Microampere", + "milliampere": "Miliampere", + "ampere": "Ampere", + "kiloampere": "Quiloampere", + "megaampere": "Megaampere", + "gigaampere": "Gigaampere", + "microampere-per-square-centimeter": "Microampere por centímetro quadrado", + "ampere-per-square-meter": "Ampere por metro quadrado", + "ampere-per-meter": "Ampere por metro", + "oersted": "Oersted", + "bohr-magneton": "Magneton de Bohr", + "ampere-meter-squared": "Ampere-metro quadrado", + "nanovolt": "Nanovolt", + "picovolt": "Picovolt", + "millivolt": "Milivolts", + "microvolt": "Microvolts", + "volt": "Volt", + "kilovolt": "Quilovolt", + "megavolt": "Megavolt", + "dbmV": "Decibel-volt", + "dbm": "Decibel-miliwatts", + "volt-meter": "Volt-metro", + "kilovolt-meter": "Quilovolt-metro", + "megavolt-meter": "Megavolt-metro", + "microvolt-meter": "Microvolt-metro", + "millivolt-meter": "Milivolt-metro", + "nanovolt-meter": "Nanovolt-metro", + "ohm": "Ohm", + "microohm": "Micro-ohm", + "milliohm": "Mili-ohm", + "kilohm": "Quilohm", + "megohm": "Megohm", + "gigohm": "Gigohm", + "millihertz": "Milihertz", + "hertz": "Hertz", + "kilohertz": "Quilohertz", + "megahertz": "Megahertz", + "gigahertz": "Gigahertz", + "terahertz": "Terahertz", + "rpm": "Rotações por minuto", + "candela-per-square-meter": "Candela por metro quadrado", + "candela": "Candela", + "lumen": "Lúmen", + "lux": "Lux", + "foot-candle": "Pé-candela", + "lumen-per-square-meter": "Lúmen por metro quadrado", + "lux-second": "Lux-segundo", + "lumen-second": "Lúmen-segundo", + "lumens-per-watt": "Lúmens por watt", + "mole": "Mol", + "nanomole": "Nanomol", + "micromole": "Micromol", + "millimole": "Milimol", + "kilomole": "Quilomol", + "mole-per-cubic-meter": "Mol por metro cúbico", + "rssi": "Indicador de intensidade do sinal recebido", + "ppm": "Partes por milhão", + "ppb": "Partes por bilhão", + "micrograms-per-cubic-meter": "Microgramas por metro cúbico", + "aqi": "Aqi", + "gram-per-cubic-meter": "Grama por metro cúbico", + "gram-per-kilogram": "Umidade específica", + "millimeters-per-second": "Milímetros por segundo", + "neper": "Neper", + "bel": "Bel", + "decibel": "Decibel", + "meters-per-second-squared": "Metros por segundo ao quadrado", + "becquerel": "Becquerel", + "curie": "Curie", + "gray": "Gray", + "sievert": "Sievert", + "roentgen": "Roentgen", + "cps": "Contagens por segundo", + "rad": "Rad", + "rem": "Rem", + "dps": "Desintegrações por segundo", + "rutherford": "Rutherford", + "coulombs-per-kilogram": "Coulombs por quilograma", + "becquerels-per-cubic-meter": "Becquerels por metro cúbico", + "curies-per-liter": "Curies por litro", + "becquerels-per-second": "Becquerels por segundo", + "curies-per-second": "Curies por segundo", + "gy-per-second": "Gray por segundo", + "watt-per-steradian": "Watt por esterradiano", + "watt-per-square-metre-steradian": "Watt por metro quadrado-esterradiano", + "ph-level": "Nível de pH", + "turbidity": "Turbidez", + "mg-per-liter": "Miligramas por litro", + "microsiemens-per-centimeter": "Microsiemens por centímetro", + "millisiemens-per-meter": "Milisiemens por metro", + "siemens-per-meter": "Siemens por metro", + "kilogram-per-cubic-meter": "Quilograma por metro cúbico", + "gram-per-cubic-centimeter": "Grama por centímetro cúbico", + "kilogram-per-square-meter": "Quilograma por metro quadrado", + "milligram-per-milliliter": "Miligrama por mililitro", + "milligram-per-cubic-meter": "Miligrama por metro cúbico", + "pound-per-cubic-foot": "Libra por pé cúbico", + "ounces-per-cubic-inch": "Onças por polegada cúbica", + "tons-per-cubic-yard": "Toneladas por jarda cúbica", + "particle-density": "Densidade de partículas", + "kilometers-per-liter": "Quilômetros por litro", + "miles-per-gallon": "Milhas por galão", + "liters-per-100-km": "Litros por 100 km", + "gallons-per-mile": "Galões por milha", + "liters-per-hour": "Litros por hora", + "gallons-per-hour": "Galões por hora", + "beats-per-minute": "Batimentos por minuto", + "millimeters-of-mercury": "Milímetros de mercúrio", + "milligrams-per-deciliter": "Miligramas por decilitro", + "g-force": "Força g", + "kilonewton": "Quilonewton", + "kilogram-force": "Quilograma-força", + "pound-force": "Libra-força", + "kilopound-force": "Quilolibra-força", + "dyne": "Dina", + "poundal": "Poundal", + "kip": "Kip", + "gal": "Gal", + "gravity": "Gravidade", + "hectopascal": "Hectopascal", + "atmosphere": "Atmosfera", + "millibars": "Milibares", + "inch-of-mercury": "Uma polegada de mercúrio", + "richter-scale": "Escala Richter", + "nanosecond": "Nanossegundo", + "microsecond": "Microssegundo", + "millisecond": "Milissegundo", + "second": "Segundo", + "minute": "Minuto", + "hour": "Hora", + "day": "Dia", + "week": "Semana", + "month": "Mês", + "year": "Ano", + "cubic-foot-per-minute": "Pé cúbico por minuto", + "cubic-meters-per-hour": "Metros cúbicos por hora", + "cubic-meters-per-second": "Metros cúbicos por segundo", + "liter-per-second": "Litro por segundo", + "liter-per-minute": "Litro por minuto", + "gallons-per-minute": "Galões por minuto", + "cubic-foot-per-second": "Pé cúbico por segundo", + "milliliters-per-minute": "Mililitros por minuto", + "cubic-decimeter-per-second": "Decímetro cúbico por segundo", + "bit": "Bit", + "byte": "Byte", + "kilobyte": "Kilobyte", + "megabyte": "Megabyte", + "gigabyte": "Gigabyte", + "terabyte": "Terabyte", + "petabyte": "Petabyte", + "exabyte": "Exabyte", + "zettabyte": "Zettabyte", + "yottabyte": "Yottabyte", + "bit-per-second": "Bit por segundo", + "kilobit-per-second": "Kilobit por segundo", + "megabit-per-second": "Megabit por segundo", + "gigabit-per-second": "Gigabit por segundo", + "terabit-per-second": "Terabit por segundo", + "byte-per-second": "Byte por segundo", + "kilobyte-per-second": "Kilobyte por segundo", + "megabyte-per-second": "Megabyte por segundo", + "gigabyte-per-second": "Gigabyte por segundo", + "degree": "Grau", + "radian": "Radiano", + "gradian": "Gradiano", + "arcminute": "Arcminuto", + "arcsecond": "Arcssegundo", + "milliradian": "Miliradiano", + "revolution": "Revolução", + "siemens": "Siemens", + "millisiemens": "Milisiemens", + "microsiemens": "Microsiemens", + "kilosiemens": "Quilosiemens", + "megasiemens": "Megasiemens", + "gigasiemens": "Gigasiemens", + "farad": "Farad", + "millifarad": "Milifarad", + "microfarad": "Microfarad", + "nanofarad": "Nanofarad", + "picofarad": "Picofarad", + "kilofarad": "Quilofarad", + "megafarad": "Megafarad", + "gigafarad": "Gigafarad", + "terfarad": "Terafarad", + "farad-per-meter": "Farad por metro", + "tesla": "Tesla", + "gauss": "Gauss", + "kilogauss": "Quilogauss", + "millitesla": "Militesla", + "microtesla": "Microtesla", + "nanotesla": "Nanotesla", + "kilotesla": "Quilotesla", + "megatesla": "Megatesla", + "millitesla-square-meters": "Militesla metros quadrados", + "gamma": "Gamma", + "lambda": "Lambda", + "square-meter-per-second": "Metro quadrado por segundo", + "square-centimeter-per-second": "Centímetro quadrado por segundo", + "stoke": "Stoke", + "centistokes": "Centistokes", + "square-foot-per-second": "Pé quadrado por segundo", + "square-inch-per-second": "Polegada quadrada por segundo", + "pascal-second": "Pascal-segundo", + "centipoise": "Centipoise", + "poise": "Poise", + "reynolds": "Reynolds", + "pound-per-foot-hour": "Libra por pé-hora", + "newton-second-per-square-meter": "Newton-segundo por metro quadrado", + "dyne-second-per-square-centimeter": "Dina-segundo por centímetro quadrado", + "kilogram-per-meter-second": "Quilograma por metro-segundo", + "tesla-square-meters": "Tesla metros quadrados", + "maxwell": "Maxwell", + "tesla-per-meter": "Tesla por metro", + "gauss-per-centimeter": "Gauss por centímetro", + "weber": "Weber", + "microweber": "Microweber", + "milliweber": "Miliweber", + "gauss-square-centimeter": "Gauss-centímetro quadrado", + "kilogauss-square-centimeter": "Quilogauss-centímetro quadrado", + "henry": "Henry", + "millihenry": "Milihenry", + "microhenry": "Microhenry", + "nanohenry": "Nanohenry", + "henry-per-meter": "Henry por metro", + "tesla-meter-per-ampere": "Tesla-metro por ampere", + "gauss-per-oersted": "Gauss por oersted", + "kilogram-per-mole": "Quilograma por mol", + "gram-per-mole": "Grama por mol", + "milligram-per-mole": "Miligrama por mol", + "joule-per-mole": "Joule por mol", + "joule-per-mole-kelvin": "Joule por mol-kelvin", + "millivolts-per-meter": "Milivolts por metro", + "volts-per-meter": "Volts por metro", + "kilovolts-per-meter": "Quilovolts por metro", + "radian-per-second": "Radiano por segundo", + "radian-per-second-squared": "Radiano por segundo ao quadrado", + "revolutions-per-minute-per-second": "Aceleração angular", + "deg-per-second": "Graus por segundo", + "rotation-per-minute": "Rotação por minuto", + "degrees-brix": "Graus Brix", + "katal": "Katal", + "katal-per-cubic-metre": "Katal por metro cúbico", + "paris-inch": "Polegada de Paris" }, "user": { "user": "Usuário", "users": "Usuários", "customer-users": "Usuários do cliente", - "tenant-admins": "Administradores de locatários", + "tenant-admins": "Administradores do tenant", "sys-admin": "Administrador do sistema", - "tenant-admin": "Administrador de locatários", + "tenant-admin": "Administrador do tenant", "customer": "Cliente", "anonymous": "Anônimo", "add": "Adicionar usuário", @@ -1637,221 +7151,998 @@ "no-users-text": "Nenhum usuário encontrado", "user-details": "Detalhes do usuário", "delete-user-title": "Tem certeza de que deseja excluir o usuário '{{userEmail}}'?", - "delete-user-text": "Cuidado, após confirmar, não será possível recuperar o usuário e nenhum dado associado.", - "delete-users-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 user} other {# users} }?", - "delete-users-action-title": "Excluir { count, plural, =1 {1 user} other {# users} }", - "delete-users-text": "Cuidado, após confirmar, todos os usuários selecionados serão removidos e não será possível recuperar nenhum dado associado.", - "activation-email-sent-message": "O e-mail de ativação foi enviado corretamente!", + "delete-user-text": "Atenção: após a confirmação, o usuário e todos os dados relacionados serão irrecuperáveis.", + "delete-users-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 usuário} other {# usuários} }?", + "delete-users-action-title": "Excluir { count, plural, =1 {1 usuário} other {# usuários} }", + "delete-users-text": "Atenção: após a confirmação, todos os usuários selecionados serão removidos e todos os dados relacionados serão irrecuperáveis.", + "activation-email-sent-message": "Email de ativação enviado com sucesso!", "resend-activation": "Reenviar ativação", - "email": "E-mail", - "email-required": "O e-mail é obrigatório.", - "invalid-email-format": "Formato de e-mail inválido.", + "email": "Email", + "email-required": "Email é obrigatório.", + "invalid-email-format": "Formato de email inválido.", "first-name": "Nome", "last-name": "Sobrenome", "description": "Descrição", "default-dashboard": "Dashboard padrão", - "always-fullscreen": "Sempre tela inteira", + "always-fullscreen": "Sempre em tela cheia", "select-user": "Selecionar usuário", - "no-users-matching": "Nenhum usuário encontrado que coincida com '{{entity}}'.", - "user-required": "O usuário é obrigatório", + "no-users-matching": "Nenhum usuário correspondente a '{{entity}}' foi encontrado.", + "user-required": "Usuário é obrigatório", "activation-method": "Método de ativação", "display-activation-link": "Exibir link de ativação", - "send-activation-mail": "Enviar e-mail de ativação", - "activation-link": "Link de ativação de usuário", - "activation-link-text": "Para ativar o usuário, utilize o seguinte link de ativação:", + "send-activation-mail": "Enviar email de ativação", + "activation-link": "Link de ativação do usuário", + "activation-link-text": "Para ativar o usuário, utilize o seguinte link de ativação (expira em {{activationLinkTtl}}) :", "copy-activation-link": "Copiar link de ativação", - "activation-link-copied-message": "O link de ativação foi copiado para a área de transferência", + "activation-link-copied-message": "Link de ativação do usuário copiado para a área de transferência", "details": "Detalhes", - "login-as-tenant-admin": "Login como administrador de locatários", - "login-as-customer-user": "Login como usuário do cliente", + "login-as-tenant-admin": "Entrar como administrador do tenant", + "login-as-customer-user": "Entrar como usuário do cliente", "search": "Pesquisar usuários", - "selected-users": "{ count, plural, =1 {1 user} other {# users} } selecionado(s)", - "disable-account": "Desativar conta de usuário", - "enable-account": "Ativar conta de usuário", - "enable-account-message": "A conta de usuário foi ativada corretamente!", - "disable-account-message": "A conta de usuário foi desativada corretamente!" + "selected-users": "{ count, plural, =1 {1 usuário} other {# usuários} } selecionado(s)", + "disable-account": "Desativar conta do usuário", + "enable-account": "Ativar conta do usuário", + "enable-account-message": "Conta do usuário ativada com sucesso!", + "disable-account-message": "Conta do usuário desativada com sucesso!", + "copyId": "Copiar ID do usuário", + "idCopiedMessage": "ID do usuário copiado para a área de transferência", + "user-list": "Lista de usuários", + "user-list-required": "Lista de usuários é obrigatória" }, "value": { "type": "Tipo de valor", - "string": "Cadeia de caracteres", - "string-value": "Valor da cadeia de caracteres", - "string-value-required": "O valor da cadeia de caracteres é obrigatório", - "integer": "Número inteiro", - "integer-value": "Valor do número inteiro", - "integer-value-required": "O valor do número inteiro é obrigatório", - "invalid-integer-value": "Valor de número inteiro inválido", - "double": "Duplo", - "double-value": "Valor duplo", - "double-value-required": "O valor duplo de caracteres é obrigatório", - "boolean": "Booliano", - "boolean-value": "Valor booliano", + "string": "String", + "string-value": "Valor string", + "string-value-required": "Valor string é obrigatório", + "integer": "Inteiro", + "integer-value": "Valor inteiro", + "integer-value-required": "Valor inteiro é obrigatório", + "invalid-integer-value": "Valor inteiro inválido", + "double": "Double", + "double-value": "Valor double", + "double-value-required": "Valor double é obrigatório", + "boolean": "Booleano", + "boolean-value": "Valor booleano", "false": "Falso", "true": "Verdadeiro", - "long": "Longo", + "long": "Long", "json": "JSON", - "json-value": "Valor de JSON", - "json-value-invalid": "O formato do valor de JSON é inválido", - "json-value-required": "O valor de JSON é obrigatório." + "json-value": "Valor JSON", + "json-value-invalid": "O valor JSON possui um formato inválido", + "json-value-required": "Valor JSON é obrigatório." + }, + "version-control": { + "version-control": "Controle de versão", + "management": "Gerenciamento de controle de versão", + "search": "Pesquisar versões", + "branch": "Branch", + "default": "Padrão", + "select-branch": "Selecionar branch", + "branch-required": "Branch é obrigatório", + "create-entity-version": "Criar versão da entidade", + "version-name": "Nome da versão", + "version-name-required": "Nome da versão é obrigatório", + "author": "Autor", + "export-relations": "Exportar relações", + "export-attributes": "Exportar atributos", + "export-credentials": "Exportar credenciais", + "export-calculated-fields": "Exportar campos calculados \ne regras de alarme", + "export-alarm-rules": "Exportar regras de alarme", + "entity-versions": "Versões da entidade", + "versions": "Versões", + "created-time": "Hora de criação", + "version-id": "ID da versão", + "no-entity-versions-text": "Nenhuma versão de entidade encontrada", + "no-versions-text": "Nenhuma versão encontrada", + "copy-full-version-id": "Copiar ID completo da versão", + "create-version": "Criar versão", + "creating-version": "Criando versão... Aguarde", + "nothing-to-commit": "Nenhuma alteração para confirmar", + "restore-version": "Restaurar versão", + "restore-entity-from-version": "Restaurar entidade da versão '{{versionName}}'", + "restoring-entity-version": "Restaurando versão da entidade... Aguarde", + "load-relations": "Carregar relações", + "load-attributes": "Carregar atributos", + "load-credentials": "Carregar credenciais", + "load-calculated-fields": "Carregar campos calculados e regras de alarme", + "load-alarm-rules": "Carregar regras de alarme", + "compare-with-current": "Comparar com o atual", + "diff-entity-with-version": "Diferença com a versão da entidade '{{versionName}}'", + "previous-difference": "Diferença anterior", + "next-difference": "Próxima diferença", + "current": "Atual", + "differences": "{ count, plural, =1 {1 diferença} other {# diferenças} }", + "create-entities-version": "Criar versão das entidades", + "default-sync-strategy": "Estratégia de sincronização padrão", + "sync-strategy-merge": "Mesclar", + "sync-strategy-overwrite": "Sobrescrever", + "entities-to-export": "Entidades para exportar", + "entities-to-restore": "Entidades para restaurar", + "sync-strategy": "Estratégia de sincronização", + "all-entities": "Todas as entidades", + "no-entities-to-export-prompt": "Especifique as entidades a exportar", + "no-entities-to-restore-prompt": "Especifique as entidades a restaurar", + "add-entity-type": "Adicionar tipo de entidade", + "remove-all": "Remover todos", + "version-create-result": "{ added, plural, =0 {Nenhuma entidade} =1 {1 entidade} other {# entidades} } adicionada(s).
{ modified, plural, =0 {Nenhuma entidade} =1 {1 entidade} other {# entidades} } modificada(s).
{ removed, plural, =0 {Nenhuma entidade} =1 {1 entidade} other {# entidades} } removida(s).", + "remove-other-entities": "Remover outras entidades", + "find-existing-entity-by-name": "Localizar entidade existente por nome", + "restore-entities-from-version": "Restaurar entidades da versão '{{versionName}}'", + "restoring-entities-from-version": "Restaurando entidades... Aguarde", + "no-entities-restored": "Nenhuma entidade restaurada", + "created": "{{created}} criado(s)", + "updated": "{{updated}} atualizado(s)", + "deleted": "{{deleted}} excluído(s)", + "remove-other-entities-confirm-text": "Atenção! Isso irá excluir permanentemente todas as entidades atuais
não presentes na versão que deseja restaurar.

Digite \"remove other entities\" para confirmar.", + "auto-commit-to-branch": "auto-commit para o branch {{ branch }}", + "default-create-entity-version-name": "Atualização de {{entityName}}", + "sync-strategy-merge-hint": "Cria ou atualiza as entidades selecionadas no repositório. Todas as demais entidades do repositório não são modificadas.", + "sync-strategy-overwrite-hint": "Cria ou atualiza as entidades selecionadas no repositório. Todas as demais entidades do repositório são excluídas.", + "device-credentials-conflict": "Não foi possível carregar o dispositivo com ID externo {{entityId}}
pois as mesmas credenciais já estão presentes no banco de dados para outro dispositivo.
Considere desativar a configuração carregar credenciais no formulário de restauração.", + "missing-referenced-entity": "Não foi possível carregar o {{sourceEntityTypeName}} com ID externo {{sourceEntityId}}
porque ele faz referência a um {{targetEntityTypeName}} ausente com ID {{targetEntityId}}.", + "runtime-failed": "Falha: {{message}}", + "auto-commit-settings-read-only-hint": "O recurso de auto-commit não funciona com a opção somente leitura habilitada nas configurações do repositório.", + "rollback-on-error": "Reverter em caso de erro", + "rollback-on-error-hint": "Se houver um grande número de entidades para restaurar, considere desativar esta opção para aumentar o desempenho.\n Observe que, se ocorrer um erro durante o carregamento da versão, as entidades já persistidas (com relações, atributos, etc.) permanecerão como estão" }, "widget": { "widget-library": "Biblioteca de widgets", - "widget-bundle": "Pacote de widgets", - "select-widgets-bundle": "Selecionar pacote de widgets", + "widget-bundle": "Conjunto de widgets", + "all-bundles": "Todos os conjuntos", + "select-widgets-bundle": "Selecionar conjunto de widgets", + "widgets": "Widgets", + "all-widgets": "Todos os widgets", + "widget": "Widget", + "select-widget": "Selecionar widget", + "no-widgets-matching": "Nenhum widget correspondente a '{{entity}}' foi encontrado.", + "no-widgets": "Nenhum widget ainda", + "no-widgets-text": "Nenhum widget encontrado", "management": "Gerenciamento de widgets", - "editor": "Editor de widgets", - "widget-type-not-found": "Problema ao carregar configuração de widget.
Provavelmente\n o tipo de widget foi removido.", + "editor": "Editor de widget", + "confirm-to-exit-editor-html": "Você tem configurações de widget não salvas.
Tem certeza de que deseja sair desta página?", + "widget-type-not-found": "Problema ao carregar a configuração do widget.
Provavelmente o tipo de widget\n associado foi removido.", "widget-type-load-error": "O widget não foi carregado devido aos seguintes erros:", "remove": "Remover widget", + "delete": "Excluir widget", "edit": "Editar widget", "remove-widget-title": "Tem certeza de que deseja remover o widget '{{widgetTitle}}'?", - "remove-widget-text": "Após confirmar, não será possível recuperar o widget e nenhum dado associado.", - "timeseries": "Intervalos de tempo", + "remove-widget-text": "Após a confirmação, o widget e todos os dados relacionados se tornarão irrecuperáveis.", + "replace-reference-with-widget-copy": "Substituir referência por cópia do widget", + "timeseries": "Série temporal", "search-data": "Pesquisar dados", "no-data-found": "Nenhum dado encontrado", "latest": "Últimos valores", "rpc": "Widget de controle", "alarm": "Widget de alarme", "static": "Widget estático", + "timeseries-short": "séries", + "latest-short": "últimos", + "rpc-short": "controle", + "alarm-short": "alarme", + "static-short": "estático", "select-widget-type": "Selecionar tipo de widget", - "missing-widget-title-error": "É necessário especificar o título de widget!", + "missing-widget-title-error": "O título do widget deve ser especificado!", "widget-saved": "Widget salvo", - "unable-to-save-widget-error": "Impossível salvar widget! O widget tem erros!", + "unable-to-save-widget-error": "Não foi possível salvar o widget! O widget contém erros!", "save": "Salvar widget", "saveAs": "Salvar widget como", - "save-widget-type-as": "Salvar tipo de widget como", - "save-widget-type-as-text": "Inserir novo título de widget e/ou selecionar pacote de widgets alvo", - "toggle-fullscreen": "Alternar tela inteira", + "move": "Mover widget", + "save-widget-as": "Salvar widget como", + "save-widget-as-text": "Insira o novo título do widget", + "toggle-fullscreen": "Alternar tela cheia", "run": "Executar widget", - "title": "Título de widget", + "widget-title": "Título do widget", + "title": "Título", "title-required": "O título do widget é obrigatório.", + "title-max-length": "O título deve ter menos de 256 caracteres", + "system": "Sistema", "type": "Tipo de widget", "resources": "Recursos", - "resource-url": "URL de JavaScript/CSS", + "resource-url": "URL JavaScript/CSS", + "resource-is-extension": "É extensão", "remove-resource": "Remover recurso", "add-resource": "Adicionar recurso", "html": "HTML", - "tidy": "Tidy", + "tidy": "Organizar", "css": "CSS", - "settings-schema": "Esquema de configuração", - "datakey-settings-schema": "Esquema de configuração de chave de dados", + "settings-form": "Formulário de configurações", + "data-key-settings-form": "Formulário de configurações de chave de dados", + "latest-data-key-settings-form": "Formulário de configurações de chave de dados mais recentes", + "widget-settings": "Configurações do widget", + "description": "Descrição", + "tags": "Tags", + "image-preview": "Pré-visualização de imagem", + "settings-form-selector": "Seletor de formulário de configurações", + "data-key-settings-form-selector": "Seletor de formulário de configurações de chave de dados", + "latest-data-key-settings-form-selector": "Seletor de formulário de configurações de chave de dados mais recentes", + "all": "Todos", + "actual": "Atual", + "scada": "Símbolo SCADA", + "deprecated": "Obsoleto", + "has-basic-mode": "Possui modo básico", + "basic-mode-form-selector": "Seletor de formulário do modo básico", + "basic-mode": "Básico", + "advanced-mode": "Avançado", "javascript": "Javascript", "js": "JS", - "add-widget-type": "Adicionar novo tipo de widget", - "widget-template-load-failed-error": "Erro ao carregar modelo de widget!", + "delete-widget-title": "Tem certeza de que deseja excluir o widget '{{widgetName}}'?", + "delete-widget-text": "Após a confirmação, o widget e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-widgets-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 widget} other {# widgets} }?", + "delete-widgets-text": "Atenção: após a confirmação, todos os widgets selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-widget": "Excluir widget", + "widget-template-load-failed-error": "Falha ao carregar o template do widget!", + "details": "Detalhes", + "widget-details": "Detalhes do widget", "add": "Adicionar widget", - "undo": "Desfazer alterações de widget", + "add-existing-widget": "Adicionar widget existente", + "add-new-widget": "Adicionar novo widget", + "search-widgets": "Pesquisar widgets", + "selected-widgets": "{ count, plural, =1 {1 widget} other {# widgets} } selecionado(s)", + "undo": "Desfazer alterações do widget", "export": "Exportar widget", - "no-data": "Nenhum dado para exibição em widget", - "data-overflow": "O widget exibe {{count}} de um total de {{total}} entidades", - "alarm-data-overflow": "O widget exibe alarmes para (no máximo) {{allowedEntities}} entidades de um total de {{totalEntities}} entidades" + "export-prompt": "Incorporar imagens e recursos do widget", + "export-widgets": "Exportar widgets", + "export-widgets-prompt": "Incorporar imagens e recursos dos widgets", + "import": "Importar widget", + "no-data": "Sem dados para exibir no widget", + "data-overflow": "O widget exibe {{count}} de {{total}} entidades", + "alarm-data-overflow": "O widget exibe alarmes para {{allowedEntities}} (máximo permitido) entidades de {{totalEntities}} entidades", + "search": "Pesquisar widget", + "filter": "Tipo de filtro do widget", + "loading-widgets": "Carregando widgets...", + "widget-template-error": "Template HTML do widget inválido.", + "reference": "Referência" }, "widget-action": { - "header-button": "Botão de cabeçalho de widget", - "open-dashboard-state": "Navegar para novo estado de dashboard", - "update-dashboard-state": "Atualizar estado de dashboard atual", + "header-button": "Botão do cabeçalho do widget", + "do-nothing": "Não fazer nada", + "open-dashboard-state": "Navegar para novo estado do dashboard", + "update-dashboard-state": "Atualizar estado atual do dashboard", "open-dashboard": "Navegar para outro dashboard", - "custom": "Personalizar ação", - "custom-pretty": "Personalizar ação (com modelo de HTML)", - "target-dashboard-state": "Estado de dashboard alvo", - "target-dashboard-state-required": "O estado do dashboard alvo é obrigatório", - "set-entity-from-widget": "Definir entidade em widget", - "target-dashboard": "Dashboard alvo", - "open-right-layout": "Abrir layout do dashboard à direita (exibição móvel)" + "custom": "Ação personalizada", + "custom-pretty": "Ação personalizada (com template HTML)", + "custom-pretty-error-title": "Erro no diálogo personalizado", + "custom-pretty-template-error": "Template do diálogo personalizado inválido.", + "custom-pretty-controller-error": "Ocorreu um erro ao avaliar a função do diálogo personalizado.", + "mobile-action": "Ação mobile", + "target-dashboard-state": "Estado de destino do dashboard", + "target-dashboard-state-required": "O estado de destino do dashboard é obrigatório", + "set-entity-from-widget": "Definir entidade a partir do widget", + "target-dashboard": "Dashboard de destino", + "select-target-dashboard": "Selecionar dashboard de destino", + "target-dashboard-required": "O dashboard de destino é obrigatório.", + "open-right-layout": "Abrir layout direito do dashboard (visualização mobile)", + "state-display-type": "Opção de exibição do estado do dashboard", + "open-normal": "Normal", + "open-in-separate-dialog": "Abrir em diálogo separado", + "open-in-popover": "Abrir em popover", + "dialog-title": "Título do diálogo", + "dialog-hide-dashboard-toolbar": "Ocultar barra de ferramentas do dashboard no diálogo", + "dialog-width": "Largura do diálogo em percentual relativo à largura da viewport", + "dialog-height": "Altura do diálogo em percentual relativo à altura da viewport", + "dialog-size-range-error": "O valor percentual do tamanho do diálogo deve estar entre 1 e 100.", + "popover-preferred-placement": "Posicionamento preferencial do popover", + "popover-placement-top": "Superior", + "popover-placement-topLeft": "Superior esquerdo", + "popover-placement-topRight": "Superior direito", + "popover-placement-right": "Direito", + "popover-placement-rightTop": "Direito superior", + "popover-placement-rightBottom": "Direito inferior", + "popover-placement-bottom": "Inferior", + "popover-placement-bottomLeft": "Inferior esquerdo", + "popover-placement-bottomRight": "Inferior direito", + "popover-placement-left": "Esquerdo", + "popover-placement-leftTop": "Esquerdo superior", + "popover-placement-leftBottom": "Esquerdo inferior", + "popover-hide-on-click-outside": "Ocultar popover ao clicar fora", + "popover-hide-dashboard-toolbar": "Ocultar barra de ferramentas do dashboard no popover", + "popover-width": "Largura do popover", + "popover-height": "Altura do popover", + "popover-style": "Estilo do popover", + "open-new-browser-tab": "Abrir em nova aba do navegador", + "open-URL": "Abrir URL", + "URL": "URL", + "url-required": "URL é obrigatória.", + "mobile": { + "device-provision": "Provisionamento de dispositivo", + "action-type": "Tipo de ação mobile", + "select-action-type": "Selecionar tipo de ação mobile", + "action-type-required": "O tipo de ação mobile é obrigatório", + "take-picture-from-gallery": "Tirar foto da galeria", + "take-photo": "Tirar foto", + "map-direction": "Abrir direções no mapa", + "map-location": "Abrir localização no mapa", + "scan-qr-code": "Escanear QR Code", + "make-phone-call": "Fazer ligação", + "get-location": "Obter localização do telefone", + "take-screenshot": "Tirar screenshot", + "handle-provision-success-function": "Função para tratar sucesso do provisionamento", + "get-location-function": "Função para obter localização", + "process-launch-result-function": "Função para processar resultado de inicialização", + "get-phone-number-function": "Função para obter número de telefone", + "process-image-function": "Função para processar imagem", + "process-qr-code-function": "Função para processar QR code", + "process-location-function": "Função para processar localização", + "handle-empty-result-function": "Função para tratar resultado vazio", + "handle-error-function": "Função para tratar erro", + "handle-non-mobile-fallback-function": "Função de fallback para ambiente não mobile", + "save-to-gallery": "Salvar na 'Galeria de Imagens'", + "provision-type": "Tipo de provisionamento", + "auto": "Automático", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" + }, + "custom-action-function": "Função de ação personalizada", + "custom-pretty-function": "Função de ação personalizada (com template HTML)", + "map-item-type": "Tipo de item do mapa", + "map-item": { + "marker": "Marcador", + "polygon": "Polígono", + "rectangle": "Retângulo", + "circle": "Círculo", + "polyline": "Polilinha" + }, + "place-map-item": "Posicionar item no mapa", + "map-item-tooltip": { + "customize-map-item-tooltips": "Personalizar tooltips de itens do mapa", + "place-marker": "Posicionar marcador", + "start-draw-rectangle": "Iniciar desenho de retângulo", + "finish-draw-rectangle": "Finalizar desenho de retângulo", + "start-draw-polygon": "Iniciar desenho de polígono", + "continue-draw-polygon": "Continuar desenho de polígono", + "finish-draw-polygon": "Finalizar desenho de polígono", + "start-draw-circle": "Iniciar desenho de círculo", + "finish-draw-circle": "Finalizar desenho de círculo", + "start-draw-polyline": "Iniciar desenho de polilinha", + "finish-draw-polyline": "Finalizar desenho de polilinha" + } }, "widgets-bundle": { - "current": "Pacote atual", - "widgets-bundles": "Pacotes de widgets", - "add": "Adicionar pacote de widgets", - "delete": "Excluir pacote de widgets", + "current": "Conjunto atual", + "widgets-bundles": "Conjuntos de widgets", + "widgets-bundle-widgets": "Widgets do conjunto de widgets", + "add": "Adicionar conjunto de widgets", + "delete": "Excluir conjunto de widgets", "title": "Título", "title-required": "O título é obrigatório.", - "add-widgets-bundle-text": "Adicionar novo pacote de widgets", - "no-widgets-bundles-text": "Nenhum pacote de widgets encontrado", - "empty": "O pacote de widgets está vazio", + "title-max-length": "O título deve ter menos de 256 caracteres", + "description": "Descrição", + "image-preview": "Pré-visualização de imagem", + "scada": "Conjunto de widgets SCADA", + "order": "Ordem", + "add-widgets-bundle-text": "Adicionar novo conjunto de widgets", + "no-widgets-bundles-text": "Nenhum conjunto de widgets encontrado", + "empty": "O conjunto de widgets está vazio", "details": "Detalhes", - "widgets-bundle-details": "Detalhes do pacote de widgets", - "delete-widgets-bundle-title": "Tem certeza de que deseja excluir o pacote de widgets '{{widgetsBundleTitle}}'?", - "delete-widgets-bundle-text": "Cuidado, após confirmar, não será possível recuperar o pacote de widgets e nenhum dado associado.", - "delete-widgets-bundles-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 widgets bundle} other {# widgets bundles} }?", - "delete-widgets-bundles-action-title": "Excluir { count, plural, =1 {1 widgets bundle} other {# widgets bundles} }", - "delete-widgets-bundles-text": "Cuidado, após confirmar, todos os pacotes de widgets selecionados serão removidos e não será possível recuperar nenhum dado associado.", - "no-widgets-bundles-matching": "Nenhum conversor de pacotes de widgets encontrado que coincida com '{{widgetsBundle}}'.", - "widgets-bundle-required": "O pacote de widgets é obrigatório.", + "widgets-bundle-details": "Detalhes do conjunto de widgets", + "delete-widgets-bundle-title": "Tem certeza de que deseja excluir o conjunto de widgets '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Atenção: após a confirmação, o conjunto de widgets e todos os dados relacionados se tornarão irrecuperáveis.", + "delete-widgets-bundles-title": "Tem certeza de que deseja excluir { count, plural, =1 {1 conjunto de widgets} other {# conjuntos de widgets} }?", + "delete-widgets-bundles-action-title": "Excluir { count, plural, =1 {1 conjunto de widgets} other {# conjuntos de widgets} }", + "delete-widgets-bundles-text": "Atenção: após a confirmação, todos os conjuntos de widgets selecionados serão removidos e todos os dados relacionados se tornarão irrecuperáveis.", + "no-widgets-bundles-matching": "Nenhum conjunto de widgets correspondente a '{{widgetsBundle}}' foi encontrado.", + "widgets-bundle-required": "O conjunto de widgets é obrigatório.", "system": "Sistema", - "import": "Importar pacote de widgets", - "export": "Exportar pacote de widgets", - "export-failed-error": "Impossível exportar pacote de widgets: {{error}}", - "create-new-widgets-bundle": "Criar novo pacote de widgets", - "widgets-bundle-file": "Arquivo de pacote de widgets", - "invalid-widgets-bundle-file-error": "Impossível importar pacote de widgets: Estrutura de dados de pacote de widgets inválida.", - "search": "Pesquisar pacotes de widgets", - "selected-widgets-bundles": "{ count, plural, =1 {1 widgets bundle} other {# widgets bundles} } selecionado(s)", - "open-widgets-bundle": "Abrir pacote de widgets" + "import": "Importar conjunto de widgets", + "export": "Exportar conjunto de widgets", + "export-widgets-bundle-widgets-prompt": "Incluir widgets do conjunto nos dados exportados (caso contrário, apenas os FQNs dos widgets referenciados serão exportados)", + "export-failed-error": "Não foi possível exportar o conjunto de widgets: {{error}}", + "create-new-widgets-bundle": "Criar novo conjunto de widgets", + "widgets-bundle-file": "Arquivo do conjunto de widgets", + "invalid-widgets-bundle-file-error": "Não foi possível importar o conjunto de widgets: estrutura de dados do conjunto de widgets inválida.", + "search": "Pesquisar conjuntos de widgets", + "selected-widgets-bundles": "{ count, plural, =1 {1 conjunto de widgets} other {# conjuntos de widgets} } selecionado(s)", + "open-widgets-bundle": "Abrir conjunto de widgets", + "loading-widgets-bundles": "Carregando conjuntos de widgets...", + "create-new": "Criar novo conjunto de widgets" }, "widget-config": { "data": "Dados", "settings": "Configurações", "advanced": "Avançado", + "appearance": "Aparência", + "widget-card": "Card do widget", + "mobile": "Mobile", "title": "Título", - "title-tooltip": "Título da dica de ferramenta", - "general-settings": "Configuração geral", - "display-title": "Exibir título", - "drop-shadow": "Sombra projetada", - "enable-fullscreen": "Habilitar tela inteira", + "title-tooltip": "Tooltip do título", + "general-settings": "Configurações gerais", + "display-title": "Exibir título do widget", + "card-title": "Título do card", + "drop-shadow": "Sombra", + "enable-fullscreen": "Habilitar tela cheia", "background-color": "Cor de fundo", - "text-color": "Cor de texto", - "padding": "Preenchimento", + "text-color": "Cor do texto", + "border-radius": "Raio da borda", + "padding": "Espaçamento interno", "margin": "Margem", - "widget-style": "Estilo de widget", + "widget-style": "Estilo do widget", + "widget-css": "CSS do widget", "title-style": "Estilo do título", - "mobile-mode-settings": "Configuração do modo móvel", + "mobile-mode-settings": "Modo mobile", "order": "Ordem", "height": "Altura", + "mobile-hide": "Ocultar widget no modo mobile", + "desktop-hide": "Ocultar widget no modo desktop", "units": "Símbolo especial a ser exibido ao lado do valor", - "decimals": "Número de dígitos após ponto de flutuação", + "units-by-default": "Unidades padrão", + "decimals": "Número de dígitos após o ponto flutuante", + "decimals-by-default": "Decimais padrão", + "default-data-key-parameter-hint": "Este parâmetro se aplica a todos os valores do widget, a menos que seja substituído pela configuração da chave de dados", + "units-short": "Unidades", + "decimals-short": "Decimais", + "decimals-suffix": "decimais", + "digits-suffix": "dígitos", "timewindow": "Janela de tempo", "use-dashboard-timewindow": "Usar janela de tempo do dashboard", - "display-timewindow": "Exibir timewindow", + "use-widget-timewindow": "Usar janela de tempo do widget", + "display-timewindow": "Exibir janela de tempo", + "legend": "Legenda", "display-legend": "Exibir legenda", "datasources": "Fontes de dados", - "maximum-datasources": "No máximo { count, plural, =1 {1 datasource is allowed.} other {# datasources are allowed} }", + "datasource": "Fonte de dados", + "maximum-datasources": "Máximo de { count, plural, =1 {1 fonte de dados permitida.} other {# fontes de dados permitidas} }", + "timeseries-key-error": "Pelo menos uma chave de dados de série temporal deve ser especificada", "datasource-type": "Tipo", "datasource-parameters": "Parâmetros", "remove-datasource": "Remover fonte de dados", "add-datasource": "Adicionar fonte de dados", - "target-device": "Dispositivo alvo", - "alarm-source": "Fonte do alarme", + "target-device": "Dispositivo de destino", + "alarm-source": "Fonte de alarme", "actions": "Ações", "action": "Ação", "add-action": "Adicionar ação", "search-actions": "Pesquisar ações", "no-actions-text": "Nenhuma ação encontrada", "action-source": "Fonte da ação", + "select-action-source": "Selecionar fonte da ação", "action-source-required": "A fonte da ação é obrigatória.", + "column-index": "Índice da coluna", + "select-column-index": "Selecionar índice da coluna", + "column-index-required": "O índice da coluna é obrigatório.", + "not-set": "Não definido", "action-name": "Nome", - "action-name-required": "O nome da ação é obrigatório!", - "action-name-not-unique": "Já existe outra ação com o mesmo nome.\nO nome da ação na mesma fonte de ação deve ser exclusivo.", + "action-name-required": "O nome da ação é obrigatório.", + "action-name-not-unique": "Já existe outra ação com o mesmo nome.\nO nome da ação deve ser único dentro da mesma fonte de ação.", "action-icon": "Ícone", + "header-button": { + "button-settings": "Configurações do botão", + "button-type": "Tipo de botão", + "button-type-basic": "Básico", + "button-type-raised": "Elevado", + "button-type-stroked": "Contornado", + "button-type-flat": "Plano", + "button-type-icon": "Ícone", + "button-type-mini-fab": "FAB", + "colors": "Cores", + "color": "Cor", + "background": "Plano de fundo", + "border": "Borda", + "advanced-button-style": "Estilo avançado do botão", + "button-style": "Estilo do botão" + }, + "show-hide-action-using-function": "Mostrar/ocultar ação usando função", + "show-action-function": "Função para exibir ação", "action-type": "Tipo", "action-type-required": "O tipo de ação é obrigatório.", "edit-action": "Editar ação", "delete-action": "Excluir ação", - "delete-action-title": "Excluir ação de widget", - "delete-action-text": "Tem certeza de que deseja excluir a ação de widget com o nome '{{actionName}}'?", - "display-icon": "Exibir ícone de título", + "delete-action-title": "Excluir ação do widget", + "delete-action-text": "Tem certeza de que deseja excluir a ação do widget com o nome '{{actionName}}'?", + "title-icon": "Ícone do título", + "display-icon": "Exibir ícone do título", + "card-icon": "Ícone do card", + "icon": "Ícone", "icon-color": "Cor do ícone", - "icon-size": "Tamanho do ícone" + "icon-size": "Tamanho do ícone", + "advanced-settings": "Configurações avançadas", + "data-settings": "Configurações de dados", + "limits": "Limites", + "no-data-display-message": "Mensagem alternativa para \"Sem dados para exibir\"", + "data-page-size": "Máximo de entidades por fonte de dados", + "settings-component-not-found": "Componente de formulário de configurações não encontrado para o seletor '{{selector}}'", + "preview": "Pré-visualização", + "set": "Definir", + "set-message": "Definir mensagem", + "advanced-title-style": "Estilo avançado do título", + "card-style": "Estilo do card", + "text": "Texto", + "background": "Plano de fundo", + "advanced-widget-style": "Estilo avançado do widget", + "card-buttons": "Botões do card", + "show-card-buttons": "Exibir botões do card", + "card-border-radius": "Raio da borda do card", + "card-padding": "Espaçamento interno do card", + "card-appearance": "Aparência do card", + "color": "Cor", + "tooltip": "Tooltip", + "units-required": "A unidade é obrigatória.", + "list-layout": "Layout de lista", + "layout": "Layout", + "resize-options": "Opções de redimensionamento", + "resizable": "Redimensionável", + "preserve-aspect-ratio": "Preservar proporção de aspecto" }, "widget-type": { "import": "Importar tipo de widget", "export": "Exportar tipo de widget", - "export-failed-error": "Impossível exportar tipo de widget: {{error}}", - "create-new-widget-type": "Criar novo tipo de widget", - "widget-type-file": "Arquivo de tipo de widget", - "invalid-widget-type-file-error": "Impossível importar tipo de widget: Estrutura de dados de tipo de widget inválida." + "export-failed-error": "Não foi possível exportar o widget: {{error}}", + "widget-file": "Arquivo do widget", + "invalid-widget-file-error": "Não foi possível importar o widget: estrutura de dados do widget inválida." + }, + "markdown": { + "edit": "Editar", + "preview": "Visualizar", + "copy-code": "Clique para copiar", + "copied": "Copiado!" }, "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "A configuração depende do widget de QR code do aplicativo móvel nas configurações principais da plataforma", + "get-it-on-google-play": "Disponível no Google Play", + "download-on-the-app-store": "Baixar na App Store" + }, + "action-button": { + "behavior": "Comportamento", + "on-click": "Ao clicar", + "on-click-hint": "Ação acionada quando o botão é clicado", + "first-button-click": "Clique no primeiro botão", + "first-button-click-hint": "Ação ao pressionar o primeiro botão.", + "second-button-click": "Clique no segundo botão", + "second-button-click-hint": "Ação ao pressionar o segundo botão.", + "button-click-hint": "Ação ao pressionar o widget." + }, + "command-button": { + "behavior": "Comportamento", + "on-click": "Ao clicar", + "on-click-hint": "Ação executada quando o botão é clicado." + }, + "power-button": { + "behavior": "Comportamento", + "power-on": "Ligar 'On'", + "power-on-hint": "Ação executada para ligar o componente.", + "power-off": "Desligar 'Off'", + "power-off-hint": "Ação executada para desligar o componente.", + "on-label": "On", + "off-label": "Off", + "layout": "Layout", + "layout-default": "Padrão", + "layout-simplified": "Simplificado", + "layout-outlined": "Contornado", + "layout-default-volume": "Padrão.Volume", + "layout-simplified-volume": "Simplificado.Volume", + "layout-outlined-volume": "Contornado.Volume", + "layout-default-icon": "Padrão.Ícone", + "layout-simplified-icon": "Simplificado.Ícone", + "layout-outlined-icon": "Contornado.Ícone", + "main": "Principal", + "background": "Plano de fundo", + "button-icon-on": "Ícone do botão 'On'", + "button-icon-off": "Ícone do botão 'Off'", + "power-on-colors": "Cores 'On'", + "power-off-colors": "Cores 'Off'", + "disabled-colors": "Cores desabilitadas", + "button": "Botão" + }, + "toggle-button": { + "behavior": "Comportamento", + "checked": "Marcado", + "unchecked": "Desmarcado", + "check": "Marcar", + "check-hint": "Ação executada para marcar o componente.", + "uncheck": "Desmarcar", + "uncheck-hint": "Ação executada para desmarcar o componente.", + "auto-scale": "Escala automática", + "horizontal-fill": "Preenchimento horizontal", + "vertical-fill": "Preenchimento vertical", + "button-appearance": "Aparência do botão" + }, + "segmented-button": { + "layout": "Layout", + "layout-squared": "Quadrado", + "layout-rounded": "Arredondado", + "card-border": "Borda do cartão", + "button-appearance": "Aparência do botão", + "first": "Primeiro", + "second": "Segundo", + "color-styles": "Estilos de cor", + "selected": "Selecionado", + "unselected": "Não selecionado" + }, + "button": { + "layout": "Layout", + "outlined": "Contornado", + "filled": "Preenchido", + "underlined": "Sublinhado", + "basic": "Básico", + "auto-scale": "Escala automática", + "label": "Rótulo", + "icon": "Ícone", + "border-radius": "Raio da borda", + "color-palette": "Paleta de cores", + "main": "Principal", + "background": "Plano de fundo", + "border": "Borda", + "custom-styles": "Estilos personalizados", + "clear-style": "Limpar estilo", + "shadow": "Sombra", + "enabled": "Habilitado", + "disabled": "Desabilitado", + "preview": "Visualização", + "copy-style-from": "Copiar estilo de" + }, + "value-stepper": { + "behavior": "Comportamento", + "simplified": "Simplificado", + "filled": "Preenchido", + "outlined": "Contornado", + "volume": "Volume", + "initial-state": "Estado inicial", + "initial-state-hint": "Ação para obter o valor inicial.", + "disabled-state": "Estado desabilitado", + "disabled-state-hint": "Configure a condição sob a qual o componente é desabilitado.", + "right-button-click": "Clique no botão direito", + "right-button-click-hint": "Ação ao pressionar o botão direito.", + "left-button-click": "Clique no botão esquerdo", + "left-button-click-hint": "Ação ao pressionar o botão esquerdo.", + "auto-scale": "Escala automática", + "value-range": "Intervalo", + "min-range": "Mín", + "max-range": "Máx", + "value-increment-decrement-step": "Passo de incremento/decremento do valor", + "value": "Valor", + "value-box-background": "Plano de fundo da caixa de valor", + "border": "Borda", + "button-appearance": "Aparência do botão", + "left": "Esquerdo", + "right": "Direito", + "left-button": "Botão esquerdo", + "right-button": "Botão direito", + "icon": "Ícone", + "color-palette": "Paleta de cores", + "main": "Principal", + "background": "Plano de fundo", + "button-icon-on": "Ícone do botão 'On'", + "button-on-colors": "Cores 'On'", + "disabled-colors": "Cores desabilitadas" + }, + "button-state": { + "activated-state": "Estado ativado", + "activated-state-hint": "Configure a condição sob a qual o botão está ativo.", + "disabled-state": "Estado desabilitado", + "disabled-state-hint": "Configure a condição sob a qual o botão está desabilitado.", + "selected-state": "Selecionar botão", + "selected-state-hint": "Configure a condição sob a qual o botão está selecionado.", + "enabled": "Habilitado", + "hovered": "Com cursor", + "pressed": "Pressionado", + "activated": "Ativado", + "disabled": "Desabilitado", + "initial": "Primeiro botão", + "first": "Primeiro", + "second": "Segundo" + }, + "background": { + "background": "Plano de fundo", + "background-settings": "Configurações de plano de fundo", + "background-type-image": "Imagem", + "background-type-color": "Cor", + "image-url": "URL da imagem", + "overlay": "Sobreposição", + "enable-overlay": "Habilitar sobreposição", + "blur": "Desfoque", + "preview": "Pré-visualização" + }, + "bar-chart": { + "bar-appearance": "Aparência da barra", + "label-on-bar": "Rótulo na barra", + "value-on-bar": "Valor na barra", + "bar-chart-style": "Estilo do gráfico de barras", + "bar-axis": "Eixo da barra" + }, + "polar-area-chart": { + "polar-axis": "Eixo polar", + "start-angle": "Ângulo inicial", + "polar-area-chart-style": "Estilo do gráfico de área polar" + }, + "battery-level": { + "layout": "Layout", + "layout-vertical-solid": "Vertical. Sólido", + "layout-horizontal-solid": "Horizontal. Sólido", + "layout-vertical-divided": "Vertical. Dividido", + "layout-horizontal-divided": "Horizontal. Dividido", + "icon": "Ícone", + "value": "Valor", + "auto-scale": "Escala automática", + "battery-level-color": "Cor do nível da bateria", + "battery-shape-color": "Cor do formato da bateria", + "battery-level-card-style": "Estilo do cartão de nível da bateria", + "sections-count": "Quantidade de seções" + }, + "signal-strength": { + "value": "Valor", + "last-update": "Última atualização", + "no-signal": "Sem sinal", + "layout": "Layout", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Barra celular", + "icon": "Ícone", + "date": "Data", + "active-bars-color": "Cor das barras de sinal ativas", + "inactive-bars-color": "Cor das barras de sinal inativas", + "signal-strength-card-style": "Estilo do cartão de intensidade de sinal", + "no-signal-rssi-value": "Valor rssi \"Sem sinal\"" + }, + "status-widget": { + "behavior": "Comportamento", + "layout": "Layout", + "layout-default": "Padrão", + "layout-center": "Centro", + "layout-icon": "Ícone", + "on": "On", + "off": "Off", + "label": "Rótulo", + "status": "Status", + "icon": "Ícone", + "color-palette": "Paleta de cores", + "disabled-color-palette": "Paleta de cores desabilitada", + "primary": "Primário", + "primary-color-hint": "Cor do ícone e rótulo", + "secondary": "Secundário", + "secondary-color-hint": "Cor do status", + "background": "Plano de fundo" + }, + "chart": { + "common-settings": "Configurações comuns", + "enable-stacking-mode": "Habilitar modo de empilhamento", + "selection": "Seleção de intervalo de tempo", + "enable-selection-mode": "Habilitar modo de seleção", + "line-shadow-size": "Tamanho da sombra da linha", + "display-smooth-lines": "Exibir linhas suaves (curvas)", + "default-bar-width": "Largura padrão da barra para dados não agregados (milissegundos)", + "bar-alignment": "Alinhamento da barra", + "bar-alignment-left": "Esquerda", + "bar-alignment-right": "Direita", + "bar-alignment-center": "Centro", + "default-font": "Fonte padrão", + "default-font-size": "Tamanho da fonte padrão", + "default-font-color": "Cor da fonte padrão", + "thresholds-line-width": "Largura de linha padrão para todos os limites", + "tooltip-settings": "Configurações de tooltip", + "tooltip": "Tooltip", + "show-tooltip": "Exibir tooltip", + "hover-individual-points": "Passar o cursor sobre pontos individuais", + "show-cumulative-values": "Exibir valores cumulativos no modo de empilhamento", + "hide-zero-false-values": "Ocultar valores zero/falso do tooltip", + "tooltip-value-format-function": "Função de formatação do valor do tooltip", + "grid-settings": "Configurações de grade", + "show-vertical-lines": "Exibir linhas verticais", + "show-horizontal-lines": "Exibir linhas horizontais", + "grid-outline-border-width": "Largura do contorno/borda da grade (px)", + "primary-color": "Cor primária", + "background-color": "Cor de fundo", + "ticks-color": "Cor dos marcadores", + "xaxis-settings": "Configurações do eixo X", + "axis-title": "Título do eixo", + "xaxis-tick-labels-settings": "Configurações dos rótulos de marcadores do eixo X", + "show-tick-labels": "Exibir rótulos de marcadores do eixo", + "yaxis-settings": "Configurações do eixo Y", + "min-scale-value": "Valor mínimo na escala", + "max-scale-value": "Valor máximo na escala", + "yaxis-tick-labels-settings": "Configurações dos rótulos de marcadores do eixo Y", + "tick-step-size": "Tamanho do passo entre marcadores", + "number-of-decimals": "Quantidade de casas decimais a exibir", + "ticks-formatter-function": "Função de formatação dos marcadores", + "comparison-settings": "Configurações de comparação", + "enable-comparison": "Habilitar comparação", + "time-for-comparison": "Período de comparação", + "time-for-comparison-previous-interval": "Intervalo anterior (padrão)", + "time-for-comparison-days": "Dia atrás", + "time-for-comparison-weeks": "Semana atrás", + "time-for-comparison-months": "Mês atrás", + "time-for-comparison-years": "Ano atrás", + "time-for-comparison-custom-interval": "Intervalo personalizado", + "custom-interval-value": "Valor do intervalo personalizado (ms)", + "comparison-x-axis-settings": "Configurações do eixo X de comparação", + "axis-position": "Posição do eixo", + "axis-position-top": "Superior (padrão)", + "axis-position-bottom": "Inferior", + "custom-legend-settings": "Configurações de legenda personalizada", + "enable-custom-legend": "Habilitar legenda personalizada (permite usar valores de atributo/série temporal nos rótulos de chave)", + "key-name": "Nome da chave", + "key-name-required": "Nome da chave é obrigatório", + "key-type": "Tipo de chave", + "key-type-attribute": "Atributo", + "key-type-timeseries": "Série temporal", + "label-keys-list": "Lista de chaves para usar nos rótulos", + "no-label-keys": "Nenhuma chave configurada", + "add-label-key": "Adicionar nova chave", + "line-width": "Largura da linha", + "color": "Cor", + "data-is-hidden-by-default": "Dados ocultos por padrão", + "disable-data-hiding": "Desabilitar ocultação de dados", + "remove-from-legend": "Remover chave de dados da legenda", + "exclude-from-stacking": "Excluir do empilhamento (disponível no modo \"Empilhamento\")", + "line-settings": "Configurações de linha", + "show-line": "Exibir linha", + "fill-line": "Preencher linha", + "fill-line-opacity": "Opacidade do preenchimento", + "points-settings": "Configurações de pontos", + "show-points": "Exibir pontos", + "points-line-width": "Largura da linha dos pontos", + "points-radius": "Raio dos pontos", + "point-shape": "Forma do ponto", + "point-shape-circle": "Círculo", + "point-shape-cross": "Cruz", + "point-shape-diamond": "Diamante", + "point-shape-square": "Quadrado", + "point-shape-triangle": "Triângulo", + "point-shape-custom": "Função personalizada", + "point-shape-draw-function": "Função de desenho da forma do ponto", + "show-separate-axis": "Exibir eixo separado", + "axis-position-left": "Esquerda", + "axis-position-right": "Direita", + "thresholds": "Limites", + "no-thresholds": "Nenhum limite configurado", + "add-threshold": "Adicionar limite", + "show-values-for-comparison": "Exibir valores históricos para comparação", + "comparison-values-label": "Rótulo dos valores históricos", + "comparison-line-color": "Cor da linha de comparação", + "threshold-settings": "Configurações de limite", + "use-as-threshold": "Usar valor da chave como limite", + "threshold-line-width": "Largura da linha do limite", + "threshold-color": "Cor do limite", + "common-pie-settings": "Configurações comuns do gráfico de pizza", + "radius": "Raio", + "inner-radius": "Raio interno", + "tilt": "Inclinação", + "common-pie-settings-range-error": "O valor deve estar no intervalo de 0 a 1", + "stroke-settings": "Configurações de traço", + "width-pixels": "Largura (pixels)", + "show-labels": "Exibir rótulos", + "animation-settings": "Configurações de animação", + "animated-pie": "Habilitar animação de pizza (experimental)", + "border-settings": "Configurações de borda", + "border-width": "Largura da borda", + "border-color": "Cor da borda", + "legend-settings": "Configurações de legenda", + "display-legend": "Exibir legenda", + "labels-font-color": "Cor da fonte dos rótulos", + "series": "Séries", + "add-series": "Adicionar série", + "series-settings": "Configurações de séries", + "remove-series": "Remover série", + "no-series": "Nenhuma série configurada", + "no-series-error": "Pelo menos uma série deve ser especificada", + "chart-appearance": "Aparência do gráfico", + "vertical-grid-lines": "Linhas de grade verticais", + "horizontal-grid-lines": "Linhas de grade horizontais", + "chart-background": "Plano de fundo do gráfico", + "grid-lines-color": "Cor das linhas de grade", + "border": "Borda", + "axis": "Eixo", + "vertical-axis": "Eixo vertical", + "ticks": "Marcadores", + "horizontal-axis": "Eixo horizontal", + "shape-empty-circle": "Círculo vazio", + "shape-circle": "Círculo", + "shape-rect": "Retângulo", + "shape-round-rect": "Retângulo arredondado", + "shape-triangle": "Triângulo", + "shape-diamond": "Diamante", + "shape-pin": "Pino", + "shape-arrow": "Seta", + "shape-none": "Nenhum", + "line-type-solid": "Sólido", + "line-type-dashed": "Tracejado", + "line-type-dotted": "Pontilhado", + "label-position-top": "Superior", + "label-position-bottom": "Inferior", + "label-position-outside": "Fora", + "label-position-inside": "Dentro", + "fill": "Preenchimento", + "fill-type-none": "Nenhum", + "fill-type-solid": "Sólido", + "fill-type-opacity": "Opacidade", + "fill-type-gradient": "Gradiente", + "background": "Plano de fundo", + "opacity": "Opacidade", + "gradient-stops": "Paradas de gradiente", + "gradient-start": "início", + "gradient-end": "fim", + "animation": { + "animation": "Animação", + "animation-threshold": "Limite de animação", + "animation-duration": "Duração da animação", + "animation-easing": "Suavização da animação", + "animation-delay": "Atraso da animação", + "update-animation-duration": "Duração da animação de atualização", + "update-animation-easing": "Suavização da animação de atualização", + "update-animation-delay": "Atraso da animação de atualização" + }, + "chart-axis": { + "limit": "Limite", + "source": "Fonte", + "key-value": "Chave / Valor", + "value-required": "Valor é obrigatório.", + "entity-key-required": "Chave de entidade é obrigatória.", + "key-required": "Chave é obrigatória.", + "scale-limits": "Limites de escala", + "scale-appearance": "Aparência da escala", + "scale": "Escala", + "scale-min": "mín", + "scale-max": "máx", + "scale-auto": "Automático" + }, + "bar": { + "show-border": "Exibir borda", + "border-width": "Largura da borda", + "border-radius": "Raio da borda", + "bar-width": "Largura da barra", + "label": "Rótulo", + "label-hint": "Exibir rótulo acima da barra.", + "series-label-hint": "Exibir rótulo com valor acima da barra.", + "label-background": "Plano de fundo do rótulo" + } + }, + "color": { + "color-settings": "Configurações de cor", + "color-type-constant": "Constante", + "color-type-gradient": "Gradiente", + "color-type-range": "Intervalo", + "color-type-function": "Função", + "color": "Cor", + "value-range": "Intervalo de valores", + "from": "De", + "to": "Até", + "color-function": "Função de cor", + "copy-color-settings-from": "Copiar configurações de cor de", + "copy-from": "Copiar de", + "settings-type": "Tipo de configurações", + "basic-mode": "Básico", + "advanced-mode": "Avançado", + "entity-alias": "Alias de entidade", + "entity-attribute": "Atributo de entidade", + "gradient-color": "Cor do gradiente", + "gradient-color-min": "Cor", + "gradient-start": "Cor inicial do gradiente", + "gradient-start-min": "Início", + "gradient-end": "Cor final do gradiente", + "gradient-end-min": "Fim", + "start-value": "Valor inicial", + "end-value": "Valor final", + "gradient-type": "Tipo de gradiente" + }, + "dashboard-state": { + "dashboard-state-settings": "Configurações de estado do Dashboard", + "dashboard-state": "ID do estado do Dashboard", + "autofill-state-layout": "Preenchimento automático da altura do layout do estado por padrão", + "default-margin": "Margem padrão dos widgets", + "default-background-color": "Cor de fundo padrão", + "sync-parent-state-params": "Sincronizar parâmetros de estado com o Dashboard pai" + }, "date-range-navigator": { + "date-range-picker-settings": "Configurações do seletor de intervalo de datas", + "hide-date-range-picker": "Ocultar seletor de intervalo de datas", + "picker-one-panel": "Seletor de intervalo de datas em painel único", + "picker-auto-confirm": "Confirmação automática do seletor de intervalo de datas", + "picker-show-template": "Exibir modelo no seletor de intervalo de datas", + "first-day-of-week": "Primeiro dia da semana", + "interval-settings": "Configurações de intervalo", + "hide-interval": "Ocultar intervalo", + "initial-interval": "Intervalo inicial", + "interval-hour": "Hora", + "interval-day": "Dia", + "interval-week": "Semana", + "interval-two-weeks": "2 semanas", + "interval-month": "Mês", + "interval-three-months": "3 meses", + "interval-six-months": "6 meses", + "step-settings": "Configurações de passo", + "hide-step-size": "Ocultar tamanho do passo", + "initial-step-size": "Tamanho do passo inicial", + "hide-labels": "Ocultar rótulos", + "use-session-storage": "Usar armazenamento de sessão", "localizationMap": { "Sun": "Dom", "Mon": "Seg", @@ -1864,7 +8155,7 @@ "Feb": "Fev", "Mar": "Mar", "Apr": "Abr", - "May": "Maio", + "May": "Mai", "Jun": "Jun", "Jul": "Jul", "Aug": "Ago", @@ -1883,7 +8174,7 @@ "October": "Outubro", "November": "Novembro", "December": "Dezembro", - "Custom Date Range": "Personalizar intervalo de datas", + "Custom Date Range": "Intervalo de datas personalizado", "Date Range Template": "Modelo de intervalo de datas", "Today": "Hoje", "Yesterday": "Ontem", @@ -1898,77 +8189,2005 @@ "Hour": "Hora", "Day": "Dia", "Week": "Semana", - "2 weeks": "2 semanas", + "2 weeks": "2 Semanas", "Month": "Mês", - "3 months": "3 meses", - "6 months": "6 meses", + "3 months": "3 Meses", + "6 months": "6 Meses", "Custom interval": "Intervalo personalizado", "Interval": "Intervalo", - "Step size": "Tamanho da etapa", - "Ok": "OK" + "Step size": "Tamanho do passo", + "Ok": "Ok" } }, + "doughnut": { + "doughnut-appearance": "Aparência do gráfico de rosca", + "layout": "Layout", + "layout-default": "Padrão", + "layout-with-total": "Com total", + "central-total-value": "Valor total central", + "doughnut-card-style": "Estilo do cartão de rosca" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Configurações de dados da hierarquia", + "relations-query-function": "Função de consulta de relações do nó", + "has-children-function": "Função de verificação de filhos do nó", + "node-state-settings": "Configurações de estado do nó", + "node-opened-function": "Função padrão de nó aberto", + "node-disabled-function": "Função de nó desabilitado", + "display-settings": "Configurações de exibição", + "node-icon-function": "Função de ícone do nó", + "node-text-function": "Função de texto do nó", + "sort-settings": "Configurações de ordenação", + "nodes-sort-function": "Função de ordenação dos nós" + }, + "edge": { + "display-default-title": "Exibir título padrão" + }, + "gateway": { + "general-settings": "Configurações gerais", + "widget-title": "Título do widget", + "default-archive-file-name": "Nome padrão do arquivo de arquivamento", + "device-type-for-new-gateway": "Tipo de dispositivo para novo gateway", + "messages-settings": "Configurações de mensagens", + "save-config-success-message": "Mensagem de texto sobre configuração do gateway salva com sucesso", + "device-name-exists-message": "Mensagem de texto quando dispositivo com o nome informado já existe", + "gateway-title": "Formulário do gateway", + "read-only": "Somente leitura", + "events-title": "Título do formulário de eventos do gateway", + "events-filter": "Filtro de eventos", + "event-key-contains": "Chave do evento contém...", + "show-connector": "Exibir para o conector", + "connector-state-param-key": "Chave do parâmetro de estado do conector", + "message": "Mensagem", + "level": "Nível", + "created-time": "Hora de criação" + }, + "gauge": { + "default-color": "Cor padrão", + "radial-gauge-settings": "Configurações do medidor radial", + "ticks-settings": "Configurações de marcadores", + "min-value": "Valor mínimo", + "max-value": "Valor máximo", + "min-value-short": "mín", + "max-value-short": "máx", + "start-ticks-angle": "Ângulo inicial dos marcadores", + "ticks-angle": "Ângulo dos marcadores", + "major-ticks": "Marcadores principais", + "major-ticks-count": "Quantidade de marcadores principais", + "major-ticks-color": "Cor dos marcadores principais", + "minor-ticks": "Marcadores secundários", + "minor-ticks-count": "Quantidade de marcadores secundários", + "minor-ticks-color": "Cor dos marcadores secundários", + "tick-numbers-font": "Fonte dos números dos marcadores", + "unit-title-settings": "Configurações do título da unidade", + "show-unit-title": "Título das unidades", + "unit-title": "Título da unidade", + "title-font": "Fonte do texto do título", + "units-settings": "Configurações de unidades", + "units-font": "Fonte do texto das unidades", + "value-box-settings": "Configurações da caixa de valor", + "show-value-box": "Exibir caixa de valor", + "value-box": "Caixa de valor", + "value-int": "Quantidade de dígitos para a parte inteira do valor", + "value-text": "Texto do valor", + "value-text-shadow": "Sombra do texto do valor", + "value-font": "Fonte do texto do valor", + "rect-stroke-color-start": "Cor do traço do retângulo - gradiente inicial", + "rect-stroke-color-end": "Cor do traço do retângulo - gradiente final", + "background-color": "Cor de fundo", + "shadow-color": "Cor da sombra", + "value-box-rect-stroke-color": "Cor do traço do retângulo da caixa de valor", + "value-box-rect-stroke-color-end": "Cor do traço do retângulo da caixa de valor - gradiente final", + "value-box-background-color": "Cor de fundo da caixa de valor", + "value-box-shadow-color": "Cor da sombra da caixa de valor", + "plate-settings": "Configurações do mostrador", + "show-plate-border": "Borda do mostrador", + "plate-color": "Cor do mostrador", + "needle-settings": "Configurações do ponteiro", + "needle-circle-size": "Tamanho do círculo do ponteiro", + "needle-color": "Cor do ponteiro", + "needle-color-start": "Cor do ponteiro - gradiente inicial", + "needle-color-end": "Cor do ponteiro - gradiente final", + "needle-color-shadow-up": "Cor da sombra da metade superior do ponteiro", + "needle-color-shadow-down": "Sombra projetada", + "highlights-settings": "Configurações de destaques", + "highlights-width": "Largura dos destaques", + "highlights": "Destaques", + "highlight-from": "De", + "highlight-to": "Até", + "highlight-color": "Cor", + "no-highlights": "Nenhum destaque configurado", + "add-highlight": "Adicionar destaque", + "animation-settings": "Configurações de animação", + "enable-animation": "Animação", + "animation-duration-rule": "Duração e regra de animação", + "animation-duration": "Duração da animação", + "animation-rule": "Regra de animação", + "animation-linear": "Linear", + "animation-quad": "Quad", + "animation-quint": "Quint", + "animation-cycle": "Cycle", + "animation-bounce": "Bounce", + "animation-elastic": "Elastic", + "animation-dequad": "Dequad", + "animation-dequint": "Dequint", + "animation-decycle": "Decycle", + "animation-debounce": "Debounce", + "animation-delastic": "Delastic", + "linear-gauge-settings": "Configurações do medidor linear", + "bar-stroke": "Traço da barra", + "bar-stroke-width": "Largura do traço da barra", + "bar-stroke-color": "Cor do traço da barra", + "bar-background-color": "Cor de fundo da barra - gradiente inicial", + "bar-background-color-end": "Cor de fundo da barra - gradiente final", + "progress-bar-color": "Cor da barra de progresso", + "progress-bar": "Barra de progresso", + "progress-bar-color-start": "Cor da barra de progresso - gradiente inicial", + "progress-bar-color-end": "Cor da barra de progresso - gradiente final", + "major-ticks-names": "Nomes dos marcadores principais", + "show-stroke-ticks": "Exibir traço dos marcadores", + "major-ticks-font": "Fonte dos marcadores principais", + "border-color": "Cor da borda", + "border-width": "Largura da borda", + "needle-circle": "Círculo do ponteiro", + "needle-circle-color": "Cor do círculo do ponteiro", + "animation-target": "Alvo da animação", + "animation-target-needle": "Ponteiro", + "animation-target-plate": "Mostrador", + "common-settings": "Configurações comuns do medidor", + "gauge-type": "Tipo de medidor", + "gauge-type-arc": "Arco", + "gauge-type-donut": "Rosca", + "gauge-type-horizontal-bar": "Barra horizontal", + "gauge-type-vertical-bar": "Barra vertical", + "donut-start-angle": "Ângulo inicial (graus)", + "bar-settings": "Configurações da barra do medidor", + "relative-bar-width": "Largura relativa da barra", + "neon-glow-brightness": "Brilho do efeito de brilho neon (0-100)", + "neon-glow-brightness-hint": "0 - desabilitar efeito ", + "stripes-thickness": "Espessura das listras", + "stripes-thickness-hint": "0 - sem listras", + "rounded-line-cap": "Tampa de linha arredondada", + "bar-color-settings": "Configurações de cor da barra", + "use-precise-level-color-values": "Usar níveis de cor precisos", + "bar-colors": "Cores da barra, da inferior para a superior", + "color": "Cor", + "no-bar-colors": "Nenhuma cor de barra configurada", + "add-bar-color": "Adicionar cor de barra", + "from": "De", + "to": "Até", + "fixed-level-colors": "Cores da barra usando valores de limite", + "gauge-title-settings": "Configurações do título do medidor", + "show-gauge-title": "Exibir título do medidor", + "gauge-title": "Título do medidor", + "gauge-title-font": "Fonte do título do medidor", + "unit-title-and-timestamp-settings": "Configurações do título de unidades e timestamp", + "show-timestamp": "Timestamp", + "timestamp-format": "Formato do timestamp", + "label-font": "Fonte do rótulo exibido abaixo do valor", + "value-settings": "Configurações de valor", + "show-value": "Exibir texto do valor", + "min-max-settings": "Configurações dos rótulos mínimo/máximo", + "show-min-max": "Exibir valores mínimo e máximo", + "min-max-font": "Fonte dos rótulos mínimo e máximo", + "show-ticks": "Exibir marcadores", + "tick-width": "Largura do marcador", + "tick-color": "Cor do marcador", + "tick-values": "Valores dos marcadores", + "no-tick-values": "Nenhum valor de marcador configurado", + "add-tick-value": "Adicionar valor de marcador", + "gauge-appearance": "Aparência do medidor", + "units-title": "Título das unidades", + "value": "Valor", + "ticks": "Marcadores", + "arrow-and-scale-color": "Cor padrão da seta e da escala", + "scale-settings": "Configurações de escala", + "scale": "Escala", + "scale-color": "Cores da escala", + "compass-appearance": "Aparência da bússola", + "label": "Rótulo", + "labels": "Rótulos", + "label-style": "Estilo do rótulo", + "simple-gauge-type": "Tipo", + "gauge-bar-background": "Plano de fundo da barra do medidor", + "bar-color": "Cor da barra", + "min-and-max-value": "Valor mínimo e máximo", + "min-and-max-label": "Rótulo mínimo e máximo", + "font": "Fonte", + "tick-width-and-color": "Largura e cor do marcador", + "min-max-validation-text": "O valor máximo deve ser maior que o valor mínimo" + }, + "gpio": { + "pin": "Pino", + "label": "Rótulo", + "row": "Linha", + "column": "Coluna", + "color": "Cor", + "panel-settings": "Configurações do painel", + "background-color": "Cor de fundo", + "gpio-switches": "Chaves GPIO", + "no-gpio-switches": "Nenhuma chave GPIO configurada", + "add-gpio-switch": "Adicionar chave GPIO", + "gpio-status-request": "Solicitação de status GPIO", + "method-name": "Nome do método", + "method-body": "Corpo do método", + "gpio-status-change-request": "Solicitação de mudança de status GPIO", + "parse-gpio-status-function": "Função de análise do status gpio", + "gpio-leds": "LEDs GPIO", + "no-gpio-leds": "Nenhum LED GPIO configurado", + "add-gpio-led": "Adicionar LED GPIO" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, "input-widgets": { "attribute-not-allowed": "O parâmetro de atributo não pode ser usado neste widget", "blocked-location": "A geolocalização está bloqueada no seu navegador", - "claim-device": "Obter dispositivo", - "claim-failed": "Erro ao obter dispositivo!", + "claim-device": "Reivindicar dispositivo", + "claim-failed": "Falha ao reivindicar o dispositivo!", "claim-not-found": "Dispositivo não encontrado!", - "claim-successful": "O dispositivo foi obtido corretamente!", + "claim-successful": "Dispositivo reivindicado com sucesso!", "date": "Data", "device-name": "Nome do dispositivo", - "device-name-required": "O nome do dispositivo é obrigatório", + "device-name-required": "Nome do dispositivo é obrigatório", "discard-changes": "Descartar alterações", - "entity-attribute-required": "O atributo da entidade é obrigatório", - "entity-coordinate-required": "Os dois campos, latitude e longitude, são obrigatórios", - "entity-timeseries-required": "Os intervalos de tempo de entidade são obrigatórios", + "entity-attribute-required": "Atributo de entidade é obrigatório", + "entity-coordinate-required": "Ambos os campos, latitude e longitude, são obrigatórios", + "entity-timeseries-required": "Série temporal de entidade é obrigatória", "get-location": "Obter localização atual", + "invalid-date": "Data inválida", "latitude": "Latitude", "longitude": "Longitude", + "min-value-error": "Valor mínimo é {{value}}", + "max-value-error": "Valor máximo é {{value}}", "not-allowed-entity": "A entidade selecionada não pode ter atributos compartilhados", "no-attribute-selected": "Nenhum atributo selecionado", "no-datakey-selected": "Nenhuma chave de dados selecionada", - "no-coordinate-specified": "Chave de dados de latitude/longitude não especificada", + "no-coordinate-specified": "Chave de dados para latitude/longitude não especificada", "no-entity-selected": "Nenhuma entidade selecionada", "no-image": "Sem imagem", - "no-support-geolocation": "Seu navegador não é compatível com geolocalização", - "no-support-web-camera": "Nenhuma câmera Web compatível", - "no-timeseries-selected": "Nenhum intervalo de tempo selecionado", - "secret-key": "Chave de segredo", - "secret-key-required": "A chave de segredo é obrigatória", - "switch-attribute-value": "Alternar valor de atributo da entidade", + "no-support-geolocation": "Seu navegador não suporta geolocalização", + "no-support-web-camera": "Seu navegador não suporta câmeras", + "enable-https-use-widget": "Habilite HTTPS para usar este widget", + "no-found-your-camera": "Não foi possível encontrar sua câmera", + "no-permission-camera": "Permissão negada pelo usuário / Este site não tem permissão para usar a câmera", + "no-timeseries-selected": "Nenhuma série temporal selecionada", + "secret-key": "Chave secreta", + "secret-key-required": "Chave secreta é obrigatória", + "switch-attribute-value": "Alternar valor do atributo da entidade", "switch-camera": "Alternar câmera", - "switch-timeseries-value": "Alternar valor de intervalo de tempo da entidade", + "switch-timeseries-value": "Alternar valor da série temporal da entidade", "take-photo": "Tirar foto", "time": "Hora", - "timeseries-not-allowed": "O parâmetro de intervalo de série não pode ser usado neste widget", + "timeseries-not-allowed": "O parâmetro de série temporal não pode ser usado neste widget", "update-failed": "Atualização falhou", "update-successful": "Atualização bem-sucedida", "update-attribute": "Atualizar atributo", - "update-timeseries": "Atualizar intervalos de tempo", - "value": "Valor" + "update-timeseries": "Atualizar série temporal", + "value": "Valor", + "general-settings": "Configurações gerais", + "widget-title": "Título do widget", + "claim-button-label": "Rótulo do botão de reivindicação", + "show-secret-key-field": "Exibir campo de entrada 'Chave secreta'", + "labels-settings": "Configurações de rótulos", + "show-labels": "Exibir rótulos", + "device-name-label": "Rótulo para o campo de entrada do nome do dispositivo", + "secret-key-label": "Rótulo para o campo de entrada da chave secreta", + "messages-settings": "Configurações de mensagens", + "claim-device-success-message": "Mensagem de texto de reivindicação de dispositivo bem-sucedida", + "claim-device-not-found-message": "Mensagem de texto quando o dispositivo não é encontrado", + "claim-device-failed-message": "Mensagem de texto de falha na reivindicação do dispositivo", + "claim-device-name-required-message": "Mensagem de erro 'Nome do dispositivo obrigatório'", + "claim-device-secret-key-required-message": "Mensagem de erro 'Chave secreta obrigatória'", + "show-label": "Exibir rótulo", + "label": "Rótulo", + "required": "Obrigatório", + "required-error-message": "Mensagem de erro 'Obrigatório'", + "show-result-message": "Exibir mensagem de resultado", + "integer-field-settings": "Configurações de campo inteiro", + "min-value": "Valor mínimo", + "max-value": "Valor máximo", + "double-field-settings": "Configurações de campo decimal", + "text-field-settings": "Configurações de campo de texto", + "min-length": "Comprimento mínimo", + "max-length": "Comprimento máximo", + "checkbox-settings": "Configurações de caixa de seleção", + "true-label": "Rótulo marcado", + "false-label": "Rótulo desmarcado", + "image-input-settings": "Configurações de entrada de imagem", + "display-preview": "Exibir visualização", + "display-clear-button": "Exibir botão limpar", + "display-apply-button": "Exibir botão aplicar", + "display-discard-button": "Exibir botão descartar", + "datetime-field-settings": "Configurações de campo de data/hora", + "display-time-input": "Exibir entrada de hora", + "latitude-key-name": "Nome da chave de latitude", + "longitude-key-name": "Nome da chave de longitude", + "show-get-location-button": "Exibir botão 'Obter localização atual'", + "use-high-accuracy": "Usar alta precisão", + "location-fields-settings": "Configurações de campos de localização", + "latitude-label": "Rótulo para latitude", + "longitude-label": "Rótulo para longitude", + "input-fields-alignment": "Alinhamento dos campos de entrada", + "input-fields-alignment-column": "Coluna (padrão)", + "input-fields-alignment-row": "Linha", + "layout": "Layout", + "row-gap": "Espaçamento entre linhas em pixels", + "column-gap": "Espaçamento entre colunas em pixels", + "latitude-field-required": "Campo de latitude obrigatório", + "longitude-field-required": "Campo de longitude obrigatório", + "attribute-settings": "Configurações de atributo", + "widget-mode": "Modo do widget", + "widget-mode-update-attribute": "Atualizar atributo", + "widget-mode-update-timeseries": "Atualizar série temporal", + "attribute-scope": "Escopo do atributo", + "attribute-scope-server": "Atributo do servidor", + "attribute-scope-shared": "Atributo compartilhado", + "value-required": "Valor obrigatório", + "image-settings": "Configurações de saída de imagem", + "image-format": "Formato de imagem", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Qualidade da imagem", + "max-image-width": "Largura máxima", + "max-image-height": "Altura máxima", + "action-buttons": "Botões de ação", + "show-action-buttons": "Exibir botões de ação", + "update-all-values": "Atualizar todos os valores, não apenas os modificados", + "save-button-label": "Rótulo do botão 'SALVAR'", + "reset-button-label": "Rótulo do botão 'DESFAZER'", + "group-settings": "Configurações de grupo", + "show-group-title": "Exibir título para grupo de campos relacionados a diferentes entidades", + "group-title": "Título do grupo", + "fields-alignment": "Alinhamento dos campos", + "fields-alignment-row": "Linha (padrão)", + "fields-alignment-column": "Coluna", + "fields-in-row": "Número de campos na linha", + "option-value": "Valor (escreva 'null' para criar opção vazia)", + "option-label": "Rótulo", + "hide-input-field": "Ocultar campo de entrada", + "datakey-type": "Tipo de chave de dados", + "datakey-type-server": "Atributo do servidor (padrão)", + "datakey-type-shared": "Atributo compartilhado", + "datakey-type-timeseries": "Série temporal", + "datakey-value-type": "Tipo de valor da chave de dados", + "datakey-value-type-string": "String", + "datakey-value-type-double": "Double", + "datakey-value-type-integer": "Inteiro", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Booleano (Caixa de seleção)", + "datakey-value-type-boolean-switch": "Booleano (Alternância)", + "datakey-value-type-date-time": "Data e Hora", + "datakey-value-type-date": "Data", + "datakey-value-type-time": "Hora", + "datakey-value-type-select": "Seleção", + "datakey-value-type-radio": "Rádio", + "datakey-value-type-color": "Cor", + "value-is-required": "Valor é obrigatório", + "ability-to-edit-attribute": "Capacidade de editar atributo", + "ability-to-edit-attribute-editable": "Editável (padrão)", + "ability-to-edit-attribute-disabled": "Desabilitado", + "ability-to-edit-attribute-readonly": "Somente leitura", + "disable-on-datakey-name": "Desabilitar quando o valor falso de outra chave de dados (especifique o nome da chave de dados)", + "field-appearance": "Aparência do campo", + "appearance-fill": "Preenchido", + "appearance-outline": "Contornado", + "subscript-sizing": "Dimensionamento de subscrito", + "subscript-sizing-fixed": "Fixo", + "subscript-sizing-dynamic": "Dinâmico", + "slide-toggle-settings": "Configurações de alternância deslizante", + "slide-toggle-label-position": "Posição do rótulo da alternância deslizante", + "slide-toggle-label-position-after": "Depois", + "slide-toggle-label-position-before": "Antes", + "select-options": "Opções de seleção", + "no-select-options": "Nenhuma opção de seleção configurada", + "add-select-option": "Adicionar opção de seleção", + "numeric-field-settings": "Configurações de campo numérico", + "step-interval": "Intervalo de passo entre valores", + "error-messages": "Mensagens de erro", + "min-value-error-message": "Mensagem de erro 'Valor mínimo'", + "max-value-error-message": "Mensagem de erro 'Valor máximo'", + "invalid-date-error-message": "Mensagem de erro 'Data inválida'", + "invalid-JSON-error-message": "Mensagem de erro 'JSON inválido'", + "icon-settings": "Configurações de ícone", + "dialog-editor-settings": "Configurações do editor de diálogo", + "use-custom-icon": "Usar ícone personalizado", + "input-cell-icon": "Ícone a exibir antes da célula de entrada", + "value-conversion-settings": "Configurações de conversão de valor", + "get-value-settings": "Configurações de obtenção de valor", + "use-get-value-function": "Usar função getValue", + "get-value-function": "Função getValue", + "set-value-settings": "Configurações de definição de valor", + "use-set-value-function": "Usar função setValue", + "set-value-function": "Função setValue", + "json-invalid": "O valor JSON tem formato inválido", + "title": "Título", + "cancel-button-label": "Rótulo do botão 'Cancelar'", + "radio-button-settings": "Configurações de botão de rádio", + "color": "Cor", + "columns": "Colunas", + "radio-options": "Opções de rádio", + "no-radio-options": "Nenhuma opção de rádio configurada", + "add-radio-option": "Adicionar opção de rádio", + "radio-label-position": "Posição do rótulo", + "radio-label-position-before": "Antes", + "radio-label-position-after": "Depois", + "save-image": "Salvar imagem", + "save-to-gallery": "Armazenar automaticamente imagens capturadas na Galeria de Imagens", + "public-image": "Torna a imagem disponível para qualquer usuário não autenticado" + }, + "invalid-qr-code-text": "Texto de entrada inválido para o código QR. A entrada deve ser do tipo string", + "qr-code": { + "use-qr-code-text-function": "Usar função de texto do código QR", + "qr-code-text-pattern": "Padrão de texto do código QR (ex.: '${entityName} | ${keyName} - algum texto.')", + "qr-code-text-pattern-hint": "O padrão de texto do código QR usa o valor da primeira chave encontrada nas entidades no alias de entidade.", + "qr-code-text-pattern-required": "O padrão de texto do código QR é obrigatório.", + "qr-code-text-function": "Função de texto do código QR" + }, + "label-widget": { + "label-pattern": "Padrão", + "label-pattern-hint": "Dica: ex.: 'Texto ${keyName} unidades.' ou ${#<key index>} unidades'", + "label-pattern-required": "O padrão é obrigatório", + "label-position": "Posição (Porcentagem relativa ao fundo)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Cor de fundo", + "font-settings": "Configurações de fonte", + "background-image": "Imagem do plano de fundo", + "labels": "Rótulos", + "no-labels": "Nenhum rótulo configurado", + "add-label": "Adicionar rótulo" + }, + "navigation": { + "title": "Título", + "navigation-path": "Caminho de navegação", + "filter-type": "Tipo de filtro", + "filter-type-all": "Todos os itens", + "filter-type-include": "Incluir itens", + "filter-type-exclude": "Excluir itens", + "items": "Itens", + "enter-urls-to-filter": "Insira URLs para filtrar..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Tipo de mensagem", + "method": "Método", + "params": "Parâmetros", + "created-time": "Hora de criação", + "expiration-time": "Hora de expiração", + "retries": "Tentativas", + "status": "Status", + "filter": "Filtro", + "refresh": "Atualizar", + "add": "Adicionar solicitação RPC", + "details": "Detalhes", + "delete": "Excluir", + "delete-request-title": "Excluir solicitação RPC persistente", + "delete-request-text": "Tem certeza de que deseja excluir a solicitação?", + "details-title": "Detalhes RPC ID: ", + "additional-info": "Informações adicionais", + "response": "Resposta", + "any-status": "Qualquer status", + "rpc-status-list": "Lista de status RPC", + "no-request-prompt": "Nenhuma solicitação para exibir", + "send-request": "Enviar solicitação", + "add-title": "Criar solicitação RPC persistente", + "method-error": "O método é obrigatório.", + "timeout-error": "O valor mínimo de tempo limite é 5000 (5 segundos).", + "white-space-error": "Espaço em branco não é permitido.", + "rpc-status": { + "QUEUED": "QUEUED", + "SENT": "SENT", + "DELIVERED": "DELIVERED", + "SUCCESSFUL": "SUCCESSFUL", + "TIMEOUT": "TEMPO LIMITE", + "EXPIRED": "EXPIRED", + "FAILED": "FAILED" + }, + "rpc-search-status-all": "ALL", + "message-types": { + "false": "Bidirecional", + "true": "Unidirecional" + }, + "general-settings": "Configurações gerais", + "enable-filter": "Ativar filtro", + "enable-sticky-header": "Exibir cabeçalho durante a rolagem", + "enable-sticky-action": "Exibir coluna de ações durante a rolagem", + "display-request-details": "Exibir detalhes da solicitação", + "allow-send-request": "Permitir enviar solicitação RPC", + "allow-delete-request": "Permitir excluir solicitação", + "columns-settings": "Configurações de colunas", + "display-columns": "Colunas para exibir", + "column": "Coluna", + "no-columns-found": "Nenhuma coluna encontrada", + "no-columns-matching": "'{{column}}' não encontrado." + }, + "range-chart": { + "chart": "Gráfico", + "data-zoom": "Zoom de dados", + "range-chart-appearance": "Aparência do gráfico de intervalo", + "range-colors": "Cores do intervalo", + "out-of-range-color": "Cor fora do intervalo", + "show-range-thresholds": "Exibir limites do intervalo", + "range-thresholds-settings": "Configurações de limites do intervalo", + "fill-area": "Preencher área", + "fill-area-opacity": "Opacidade do preenchimento da área", + "range-chart-style": "Estilo do gráfico de intervalo" + }, + "knob": { + "behavior": "Comportamento", + "initial-value": "Valor inicial", + "initial-value-hint": "Ação para obter o valor inicial do knob.", + "on-value-change": "Ao alterar o valor", + "on-value-change-hint": "Ação acionada quando o valor do knob é alterado.", + "range": "Intervalo", + "min": "mín", + "max": "máx", + "value": "Valor", + "fallback-initial-value": "Valor inicial de fallback" + }, + "rpc": { + "value-settings": "Configurações de valor", + "initial-value": "Valor inicial", + "retrieve-value-settings": "Configurações de recuperação do valor ligado/desligado", + "retrieve-value-method": "Recuperar valor usando método", + "retrieve-value-method-none": "Não recuperar", + "retrieve-value-method-rpc": "Chamar método RPC de obtenção de valor", + "retrieve-value-method-attribute": "Assinar atributo", + "retrieve-value-method-timeseries": "Assinar série temporal", + "attribute-value-key": "Chave do atributo", + "timeseries-value-key": "Chave da série temporal", + "get-value-method": "Método RPC de obtenção de valor", + "parse-value-function": "Função de análise de valor", + "update-value-settings": "Configurações de atualização de valor", + "set-value-method": "Método RPC de definição de valor", + "convert-value-function": "Função de conversão de valor", + "rpc-settings": "Configurações de RPC", + "request-timeout": "Tempo Limite da requisição RPC (ms)", + "persistent-rpc-settings": "Configurações RPC persistente", + "request-persistent": "Requisição RPC persistente", + "persistent-polling-interval": "Intervalo de polling (ms) para obter resposta do comando RPC persistente", + "common-settings": "Configurações comuns", + "switch-title": "Título do switch", + "show-on-off-labels": "Exibir rótulos ligado/desligado", + "slide-toggle-label": "Rótulo do botão deslizante", + "label-position": "Posição do rótulo", + "label-position-before": "Antes", + "label-position-after": "Depois", + "slider-color": "Cor do slider", + "slider-color-primary": "Primária", + "slider-color-accent": "Destaque", + "slider-color-warn": "Aviso", + "button-style": "Estilo do botão", + "button-raised": "Botão elevado", + "button-primary": "Cor primária", + "button-background-color": "Cor de fundo do botão", + "button-text-color": "Cor do texto do botão", + "widget-title": "Título do widget", + "button-label": "Rótulo do botão", + "device-attribute-scope": "Escopo do atributo do dispositivo", + "server-attribute": "Atributo do servidor", + "shared-attribute": "Atributo compartilhado", + "device-attribute-parameters": "Parâmetros do atributo do dispositivo", + "is-one-way-command": "É comando unidirecional", + "rpc-method": "Método RPC", + "rpc-method-params": "Parâmetros do método RPC", + "show-rpc-error": "Exibir erro de execução do comando RPC", + "led-title": "Título do LED", + "led-color": "Cor do LED", + "check-status-settings": "Configurações de verificação de status", + "perform-rpc-status-check": "Realizar verificação de status do dispositivo via RPC", + "retrieve-led-status-value-method": "Recuperar valor de status do LED usando método", + "led-status-value-attribute": "Atributo do dispositivo contendo o valor de status do LED", + "led-status-value-timeseries": "Série temporal do dispositivo contendo o valor de status do LED", + "check-status-method": "Método RPC de verificação de status do dispositivo", + "parse-led-status-value-function": "Função de análise do valor de status do LED", + "knob-title": "Título do knob" + }, + "maps": { + "map-type": { + "type": "Tipo de mapa", + "map": "Mapa", + "image": "Imagem" + }, + "image": { + "image-source": "Fonte da imagem", + "image-source-image": "Imagem", + "image-source-entity-key": "Chave de entidade", + "source-entity-alias": "Alias de entidade de origem", + "image-url-key": "Chave de URL da imagem", + "image-url-key-required": "A chave de URL da imagem é obrigatória" + }, + "control": { + "map-controls": "Controles do mapa", + "position": "Posição", + "position-topleft": "Superior esquerdo", + "position-topright": "Superior direito", + "position-bottomleft": "Inferior esquerdo", + "position-bottomright": "Inferior direito", + "zoom-actions": "Ações de zoom", + "zoom-scroll": "Rolagem", + "zoom-double-click": "Duplo clique", + "zoom-control-buttons": "Botões de controle", + "scale": "Escala", + "scale-metric": "Métrico", + "scale-imperial": "Imperial", + "switch-to-drag-mode-using-button": "Alternar para modo arrastar usando botão" + }, + "timeline": { + "control-panel": "Painel de controle da linha do tempo", + "time-step": "Passo de tempo", + "speed-options": "Opções de velocidade", + "timestamp": "Timestamp", + "snap-to-real-location": "Ajustar à localização real", + "location-snap-filter-function": "Função de filtro de ajuste de localização", + "no-trips-data-available": "Nenhum dado de viagem disponível" + }, + "map-action": { + "map-action-buttons": "Botões de ação do mapa", + "label": "Rótulo", + "icon": "Ícone", + "color": "Cor", + "action": "Ação", + "add-button": "Adicionar botão", + "no-action-buttons-configured": "Nenhum botão de ação configurado", + "remove-action-button": "Remover botão de ação", + "map-action-button": "Botão de ação do mapa", + "button-requires": "O botão requer rótulo ou ícone" + }, + "common": { + "common-map-settings": "Configurações comuns do mapa", + "fit-map-bounds": "Ajustar limites do mapa para cobrir todos os marcadores", + "default-map-center-position": "Posição central padrão do mapa", + "default-map-zoom-level": "Nível de zoom padrão do mapa", + "entities-limit": "Limite de entidades a carregar" + }, + "layer": { + "label": "Rótulo", + "layer": "Camada", + "layers": "Camadas", + "map-layers": "Camadas do mapa", + "add-layer": "Adicionar camada", + "layer-settings": "Configurações da camada", + "remove-layer": "Remover camada", + "no-layers": "Nenhuma camada configurada", + "roadmap": "Mapa de ruas", + "satellite": "Satélite", + "hybrid": "Híbrido", + "reference": { + "reference-layer": "Camada de referência", + "no-layer": "Sem camada", + "openstreetmap-hybrid": "OpenStreetMap Hybrid", + "world-edition-hybrid": "World Edition Hybrid", + "enhanced-contrast-hybrid": "Enhanced Contrast Hybrid" + }, + "provider": { + "provider": "Provedor", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Mapa de ruas", + "satellite": "Satélite", + "hybrid": "Híbrido", + "terrain": "Terreno" + }, + "here": { + "title": "HERE", + "normal-day": "Dia normal", + "normal-night": "Noite normal", + "hybrid-day": "Dia híbrido", + "terrain-day": "Dia de terreno" + }, + "tencent": { + "title": "Tencent", + "normal": "Normal", + "satellite": "Satélite", + "terrain": "Terreno" + }, + "custom": { + "title": "Personalizado", + "tile-url": "URL de tile" + } + }, + "credentials": { + "credentials": "Credenciais", + "api-key": "Chave de API" + } + }, + "overlays": { + "overlays": "Sobreposições", + "overlays-hint": "Configure fontes de dados, aparência, comportamento, opções de edição e agrupamento para entidades do mapa", + "trips": "Viagens", + "markers": "Marcadores", + "polygons": "Polígonos", + "circles": "Círculos", + "polylines": "Polilinhas" + }, + "data-layer": { + "source": "Fonte", + "filter": "Filtro", + "additional-data-keys": "Chaves de dados adicionais", + "additional-datasources": "Fontes de dados adicionais", + "additional-datasources-hint": "Fonte de dados para acessar atributos ou telemetria de entidades não exibidas no mapa, utilizável em funções de sobreposição do mapa.", + "more-datasources": "Mais fontes de dados", + "data-keys": "Chaves de dados", + "add-datasource": "Adicionar fonte de dados", + "no-datasources": "Nenhuma fonte de dados configurada", + "remove-datasource": "Remover fonte de dados", + "behavior": "Comportamento", + "on-click": "Ao clicar", + "on-click-hint": "Ação invocada quando o usuário clica no item do mapa.", + "groups": "Grupos", + "groups-hint": "Lista de nomes de grupos atribuídos à sobreposição, usada para alternar sua visibilidade no mapa.", + "color": "Cor", + "color-settings": "Configurações de cor", + "color-type-constant": "Constante", + "color-type-range": "Intervalo", + "color-type-function": "Função", + "color-range-source-key": "Chave de origem do intervalo de cores", + "color-range-source-key-required": "A chave de origem do intervalo de cores é obrigatória", + "color-range": "Intervalo de cores", + "color-function": "Função de cor", + "label": "Rótulo", + "tooltip": "Tooltip", + "pattern-type-pattern": "Padrão", + "pattern-type-function": "Função", + "label-pattern": "Rótulo (exemplos de padrão: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "label-function": "Função de rótulo", + "tooltip-pattern": "Tooltip (ex.: 'Texto ${keyName} unidades.' ou Texto do link)", + "tooltip-function": "Função de tooltip", + "tooltip-trigger": "Acionador do tooltip", + "tooltip-trigger-click": "Exibir tooltip ao clicar", + "tooltip-trigger-hover": "Exibir tooltip ao passar o mouse", + "auto-close-tooltips": "Fechar tooltips automaticamente", + "tooltip-offset": "Deslocamento do tooltip", + "tooltip-offset-horizontal": "Horizontal", + "tooltip-offset-vertical": "Vertical", + "tooltip-tag-actions": "Ações de tag", + "add-tooltip-tag-action": "Adicionar ação de tag", + "edit-tooltip-tag-action": "Editar ação de tag", + "remove-tooltip-tag-action": "Remover ação de tag", + "action-add": "Adicionar", + "action-edit": "Editar", + "action-move": "Mover", + "action-remove": "Remover", + "edit-instruments": "Instrumentos", + "persist-location-attribute-scope": "Escopo do atributo para persistir localização", + "enable-snapping": "Ativar ajuste a outros vértices para desenho preciso", + "enable-snapping-hint": "Alinha automaticamente novos pontos com formas existentes para facilitar e tornar o desenho mais preciso.", + "drag-drop-mode": "Modo arrastar e soltar", + "trip": { + "no-trips": "Nenhuma viagem configurada", + "add-trip": "Adicionar viagem", + "trip-configuration": "Configuração de viagem", + "remove-trip": "Remover viagem" + }, + "marker": { + "marker": "Marcador", + "latitude-key": "Chave de latitude", + "longitude-key": "Chave de longitude", + "x-pos-key": "Chave de posição X", + "y-pos-key": "Chave de posição Y", + "latitude-key-required": "Chave de latitude obrigatória", + "longitude-key-required": "Chave de longitude obrigatória", + "x-pos-key-required": "Chave de posição X obrigatória", + "y-pos-key-required": "Chave de posição Y obrigatória", + "no-markers": "Nenhum marcador configurado", + "add-marker": "Adicionar marcador", + "marker-configuration": "Configuração de marcador", + "remove-marker": "Remover marcador", + "marker-type": "Tipo de marcador", + "marker-type-shape": "Forma", + "marker-type-icon": "Ícone", + "marker-type-image": "Imagem", + "shape": "Forma", + "icon": "Ícone", + "image": "Imagem", + "marker-shapes": "Formas de marcador", + "marker-icon": "Ícone do marcador", + "marker-appearance": "Aparência do marcador", + "marker-image": "Imagem do marcador", + "marker-image-type-image": "Imagem", + "marker-image-type-function": "Função", + "custom-marker-image-size": "Tamanho personalizado da imagem do marcador", + "marker-image-function": "Função de imagem do marcador", + "marker-images": "Imagens do marcador", + "marker-offset": "Deslocamento do marcador", + "offset-horizontal": "Horizontal", + "offset-vertical": "Vertical", + "rotate-marker": "Girar marcador", + "offset-angle": "Ângulo de deslocamento", + "position-conversion": "Conversão de posição", + "position-conversion-function": "Função de conversão de posição, deve retornar coordenadas x,y como double de 0 a 1 cada", + "clustering": { + "use-map-markers-clustering": "Usar agrupamento de marcadores do mapa", + "zoom-on-cluster-click": "Aplicar zoom ao clicar em um cluster", + "max-zoom": "O nível máximo de zoom em que um marcador pode fazer parte de um cluster (0 - 18)", + "max-radius": "Raio máximo que um cluster cobrirá", + "zoom-animation": "Animação nos marcadores ao aplicar zoom", + "bounds-on-cluster-mouse-over": "Limites dos marcadores ao passar o mouse sobre um cluster", + "spiderfy-max-zoom-level": "Spiderfy no nível máximo de zoom (para ver todos os marcadores do cluster)", + "load-optimization": "Otimização de carregamento", + "chunked-load": "Usar blocos para adicionar marcadores de forma que a página não congele", + "lazy-load": "Usar carregamento lazy para adicionar marcadores", + "use-cluster-marker-color-function": "Usar função de cor dos marcadores do cluster", + "marker-color-function": "Função de cor do marcador" + }, + "edit": "Editar marcador", + "remove-marker-for": "Remover marcador para '{{entityName}}'", + "place-marker": "Posicionar marcador", + "place-marker-hint": "Clique para posicionar o marcador", + "place-marker-hint-with-entity": "Clique para posicionar a entidade '{{entityName}}'" + }, + "path": { + "path": "Caminho", + "path-decorator": "Decorador de caminho", + "decorator-symbol": "Símbolo do decorador", + "decorator-symbol-arrow-head": "Seta", + "decorator-symbol-dash": "Traço", + "decorator-arrangement": "Disposição do decorador", + "decorator-offset": "Início", + "decorator-end-offset": "Fim", + "decorator-repeat": "Repetir" + }, + "points": { + "points": "Pontos", + "point-tooltip": "Tooltip do ponto" + }, + "shape": { + "fill": "Preenchimento", + "fill-type-color": "Cor", + "fill-type-stripe": "Listras", + "fill-type-image": "Imagem", + "color": "Cor", + "stripe": "Listras", + "image": "Imagem", + "stroke": "Contorno", + "fill-image": "Imagem de preenchimento", + "fill-image-type-image": "Imagem", + "fill-image-type-function": "Função", + "preserve-aspect-ratio": "Preservar proporção", + "opacity": "Opacidade", + "angle": "Ângulo de rotação", + "scale": "Escala", + "fill-image-function": "Função de imagem de preenchimento da forma", + "fill-images": "Imagens de preenchimento da forma", + "stripe-pattern": "Padrão de listras", + "first-stripe": "Primeira listra", + "second-stripe": "Segunda listra" + }, + "polygon": { + "polygon-key": "Chave do polígono", + "polygon-key-required": "Chave do polígono obrigatória", + "no-polygons": "Nenhum polígono configurado", + "add-polygon": "Adicionar polígono", + "polygon-configuration": "Configuração de polígono", + "remove-polygon": "Remover polígono", + "edit": "Editar polígono", + "remove-polygon-for": "Remover polígono para '{{entityName}}'", + "cut": "Recortar área do polígono", + "rotate": "Girar polígono", + "draw-rectangle": "Desenhar retângulo", + "draw-polygon": "Desenhar polígono", + "polygon-place-first-point-cut-hint": "Clique para posicionar o primeiro ponto", + "continue-polygon-cut-hint": "Clique para continuar desenhando", + "finish-polygon-cut-hint": "Clique no primeiro marcador para concluir e salvar", + "polygon-place-first-point-hint": "Polígono: clique para posicionar o primeiro ponto", + "polygon-place-first-point-hint-with-entity": "Polígono para '{{entityName}}': clique para posicionar o primeiro ponto", + "continue-polygon-hint": "Polígono: clique para continuar desenhando", + "continue-polygon-hint-with-entity": "Polígono para '{{entityName}}': clique para continuar desenhando", + "finish-polygon-hint": "Polígono: clique no primeiro marcador para concluir o desenho", + "finish-polygon-hint-with-entity": "Polígono para '{{entityName}}': clique no primeiro marcador para concluir e salvar", + "rectangle-place-first-point-hint": "Retângulo: clique para posicionar o primeiro ponto", + "rectangle-place-first-point-hint-with-entity": "Retângulo para '{{entityName}}': clique para posicionar o primeiro ponto", + "finish-rectangle-hint": "Retângulo: clique para concluir o desenho", + "finish-rectangle-hint-with-entity": "Retângulo para '{{entityName}}': clique para concluir e salvar" + }, + "circle": { + "circle-key": "Chave do círculo", + "circle-key-required": "Chave do círculo obrigatória", + "no-circles": "Nenhum círculo configurado", + "add-circle": "Adicionar círculo", + "circle-configuration": "Configuração de círculo", + "remove-circle": "Remover círculo", + "edit": "Editar círculo", + "remove-circle-for": "Remover círculo para '{{entityName}}'", + "draw-circle": "Desenhar círculo", + "place-circle-center-hint-with-entity": "Círculo para '{{entityName}}': clique para posicionar o centro do círculo", + "place-circle-center-hint": "Círculo: clique para posicionar o centro do círculo", + "finish-circle-hint-with-entity": "Círculo para '{{entityName}}': clique para concluir e salvar o círculo", + "finish-circle-hint": "Círculo: clique para concluir o desenho" + }, + "polyline": { + "polyline-key": "Chave da polilinha", + "polyline-key-required": "Chave da polilinha obrigatória", + "no-polylines": "Nenhuma polilinha configurada", + "add-polylines": "Adicionar polilinha", + "polyline-configuration": "Configuração de polilinha", + "remove-polyline": "Remover polilinha", + "edit": "Editar polilinha", + "cut": "Recortar área da polilinha", + "rotate": "Girar polilinha", + "remove-polyline-for": "Remover polilinha para '{{entityName}}'", + "draw-polyline": "Desenhar polilinha", + "polyline-place-first-point-hint-with-entity": "Polilinha para '{{entityName}}': clique para posicionar o primeiro ponto", + "polyline-place-first-point-hint": "Polilinha: clique para posicionar o primeiro ponto", + "finish-polyline-hint-with-entity": "Polilinha para '{{entityName}}': clique para concluir o desenho", + "finish-polyline-hint": "Polilinha: clique para concluir o desenho", + "polyline-place-first-point-cut-hint": "Clique para posicionar o primeiro ponto", + "finish-polyline-cut-hint": "Clique no primeiro marcador para concluir e salvar" + }, + "select-entity": "Selecionar entidade", + "select-entity-hint": "Dica: após a seleção, clique no mapa para definir a posição" + }, + "select-entity": "Selecionar entidade", + "select-entity-hint": "Dica: após a seleção, clique no mapa para definir a posição", + "tooltips": { + "placeMarker": "Clique para posicionar a entidade '{{entityName}}'", + "firstVertex": "Polígono para '{{entityName}}': clique para posicionar o primeiro ponto", + "firstVertex-cut": "Clique para posicionar o primeiro ponto", + "continueLine": "Polígono para '{{entityName}}': clique para continuar desenhando", + "continueLine-cut": "Clique para continuar desenhando", + "finishLine": "Clique em qualquer marcador existente para concluir", + "finishPoly": "Polígono para '{{entityName}}': clique no primeiro marcador para concluir e salvar", + "finishPoly-cut": "Clique no primeiro marcador para concluir e salvar", + "finishRect": "Polígono para '{{entityName}}': clique para concluir e salvar", + "startCircle": "Círculo para '{{entityName}}': clique para posicionar o centro do círculo", + "finishCircle": "Círculo para '{{entityName}}': clique para concluir o círculo", + "placeCircleMarker": "Clique para posicionar o marcador de círculo" + }, + "actions": { + "finish": "Concluir", + "cancel": "Cancelar", + "removeLastVertex": "Remover último ponto" + }, + "buttonTitles": { + "drawMarkerButton": "Posicionar entidade", + "drawPolyButton": "Criar polígono", + "drawLineButton": "Criar polilinha", + "drawCircleButton": "Criar círculo", + "drawRectButton": "Criar retângulo", + "editButton": "Modo de edição", + "dragButton": "Modo arrastar e soltar", + "cutButton": "Recortar área do polígono", + "deleteButton": "Remover", + "drawCircleMarkerButton": "Criar marcador de círculo", + "rotateButton": "Girar polígono" + }, + "map-provider-settings": "Configurações do provedor de mapa", + "map-provider": "Provedor de mapa", + "map-provider-google": "Google Maps", + "map-provider-openstreet": "OpenStreet Maps", + "map-provider-here": "HERE Maps", + "map-provider-image": "Mapa de imagem", + "map-provider-tencent": "Tencent Maps", + "openstreet-provider": "Provedor de mapa OpenStreet", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (Padrão)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Usar provedor personalizado", + "custom-provider-tile-url": "URL de tile do provedor personalizado", + "google-maps-api-key": "Chave de API do Google Maps", + "default-map-type": "Tipo de mapa padrão", + "google-map-type-roadmap": "Mapa de ruas", + "google-map-type-satelite": "Satélite", + "google-map-type-hybrid": "Híbrido", + "google-map-type-terrain": "Terreno", + "map-layer": "Camada do mapa", + "here-map-normal-day": "HERE.normalDay (Padrão)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Credenciais", + "here-app-id": "ID do app HERE", + "here-app-code": "Código do app HERE", + "here-api-key": "Chave de API HERE", + "here-use-new-version-api-3": "Usar versão 3 da API", + "tencent-maps-api-key": "Chave de API do Tencent Maps", + "tencent-map-type-roadmap": "Mapa de ruas", + "tencent-map-type-satelite": "Satélite", + "tencent-map-type-hybrid": "Híbrido", + "image-map-background": "Fundo do mapa de imagem", + "image-map-background-from-entity-attribute": "Obter fundo do mapa de imagem do atributo de entidade", + "image-url-source-entity-alias": "Alias de entidade de origem da URL da imagem", + "image-url-source-entity-attribute": "Atributo de entidade de origem da URL da imagem", + "common-map-settings": "Configurações comuns do mapa", + "x-pos-key-name": "Nome da chave de posição X", + "y-pos-key-name": "Nome da chave de posição Y", + "latitude-key-name": "Nome da chave de latitude", + "longitude-key-name": "Nome da chave de longitude", + "default-map-zoom-level": "Nível de zoom padrão do mapa (0 - 20)", + "default-map-center-position": "Posição central padrão do mapa (0,0)", + "disable-scroll-zooming": "Desativar zoom por rolagem", + "disable-double-click-zooming": "Desativar zoom por duplo clique", + "disable-zoom-control-buttons": "Desativar botões de controle de zoom", + "fit-map-bounds": "Ajustar limites do mapa para cobrir todos os marcadores", + "use-default-map-center-position": "Usar posição central padrão do mapa", + "entities-limit": "Limite de entidades a carregar", + "markers-settings": "Configurações de marcadores", + "marker-offset-x": "Deslocamento X do marcador relativo à posição multiplicado pela largura do marcador", + "marker-offset-y": "Deslocamento Y do marcador relativo à posição multiplicado pela altura do marcador", + "position-function": "Função de conversão de posição, deve retornar coordenadas x,y como double de 0 a 1 cada", + "draggable-marker": "Marcador arrastável", + "label": "Rótulo", + "show-label": "Exibir rótulo", + "use-label-function": "Usar função de rótulo", + "label-pattern": "Rótulo (exemplos de padrão: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "label-function": "Função de rótulo", + "tooltip": "Tooltip", + "show-tooltip": "Exibir tooltip", + "show-tooltip-action": "Ação para exibir o tooltip", + "show-tooltip-action-click": "Exibir tooltip ao clicar (Padrão)", + "show-tooltip-action-hover": "Exibir tooltip ao passar o mouse", + "auto-close-tooltips": "Fechar tooltips automaticamente", + "use-tooltip-function": "Usar função de tooltip", + "tooltip-pattern": "Tooltip (ex.: 'Texto ${keyName} unidades.' ou Texto do link')", + "tooltip-function": "Função de tooltip", + "tooltip-offset-x": "Deslocamento X do tooltip relativo à âncora do marcador multiplicado pela largura do marcador", + "tooltip-offset-y": "Deslocamento Y do tooltip relativo à âncora do marcador multiplicado pela altura do marcador", + "color": "Cor", + "use-color-function": "Usar função de cor", + "color-function": "Função de cor", + "marker-image": "Imagem do marcador", + "use-marker-image-function": "Usar função de imagem do marcador", + "custom-marker-image": "Imagem personalizada do marcador", + "custom-marker-image-size": "Tamanho da imagem personalizada do marcador (px)", + "marker-image-function": "Função de imagem do marcador", + "marker-images": "Imagens do marcador", + "polygon-settings": "Configurações de polígono", + "show-polygon": "Exibir polígono", + "polygon-key-name": "Nome da chave do polígono", + "enable-polygon-edit": "Ativar edição de polígono", + "polygon-label": "Rótulo do polígono", + "show-polygon-label": "Exibir rótulo do polígono", + "use-polygon-label-function": "Usar função de rótulo do polígono", + "polygon-label-pattern": "Rótulo do polígono (exemplos de padrão: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "polygon-label-function": "Função de rótulo do polígono", + "polygon-tooltip": "Tooltip do polígono", + "show-polygon-tooltip": "Exibir tooltip do polígono", + "auto-close-polygon-tooltips": "Fechar tooltips do polígono automaticamente", + "use-polygon-tooltip-function": "Usar função de tooltip do polígono", + "polygon-tooltip-pattern": "Tooltip (ex.: 'Texto ${keyName} unidades.' ou Texto do link')", + "polygon-tooltip-function": "Função de tooltip do polígono", + "polygon-color": "Cor do polígono", + "polygon-opacity": "Opacidade do polígono", + "use-polygon-color-function": "Usar função de cor do polígono", + "polygon-color-function": "Função de cor do polígono", + "polygon-stroke": "Contorno do polígono", + "stroke-color": "Cor do contorno", + "stroke-opacity": "Opacidade do contorno", + "stroke-weight": "Espessura do contorno", + "use-polygon-stroke-color-function": "Usar função de cor do contorno do polígono", + "polygon-stroke-color-function": "Função de cor do contorno do polígono", + "circle-settings": "Configurações de círculo", + "show-circle": "Exibir círculo", + "circle-key-name": "Nome da chave do círculo", + "enable-circle-edit": "Ativar edição de círculo", + "circle-label": "Rótulo do círculo", + "show-circle-label": "Exibir rótulo do círculo", + "use-circle-label-function": "Usar função de rótulo do círculo", + "circle-label-pattern": "Rótulo do círculo (exemplos de padrão: '${entityName}', '${entityName}: (Texto ${keyName} unidades.)' )", + "circle-label-function": "Função de rótulo do círculo", + "circle-tooltip": "Tooltip do círculo", + "show-circle-tooltip": "Exibir tooltip do círculo", + "auto-close-circle-tooltips": "Fechar tooltips do círculo automaticamente", + "use-circle-tooltip-function": "Usar função de tooltip do círculo", + "circle-tooltip-pattern": "Tooltip (ex.: 'Texto ${keyName} unidades.' ou Texto do link')", + "circle-tooltip-function": "Função de tooltip do círculo", + "circle-fill-color": "Cor de preenchimento do círculo", + "circle-fill-color-opacity": "Opacidade da cor de preenchimento do círculo", + "use-circle-fill-color-function": "Usar função de cor de preenchimento do círculo", + "circle-fill-color-function": "Função de cor de preenchimento do círculo", + "circle-stroke": "Contorno do círculo", + "use-circle-stroke-color-function": "Usar função de cor do contorno do círculo", + "circle-stroke-color-function": "Função de cor do contorno do círculo", + "markers-clustering-settings": "Configurações de agrupamento de marcadores", + "use-map-markers-clustering": "Usar agrupamento de marcadores do mapa", + "zoom-on-cluster-click": "Aplicar zoom ao clicar em um cluster", + "max-cluster-zoom": "O nível máximo de zoom em que um marcador pode fazer parte de um cluster (0 - 18)", + "max-cluster-radius-pixels": "Raio máximo que um cluster cobrirá em pixels", + "cluster-zoom-animation": "Exibir animação nos marcadores ao aplicar zoom", + "show-markers-bounds-on-cluster-mouse-over": "Exibir os limites dos marcadores ao passar o mouse sobre um cluster", + "spiderfy-max-zoom-level": "Spiderfy no nível máximo de zoom (para ver todos os marcadores do cluster)", + "load-optimization": "Otimização de carregamento", + "cluster-chunked-loading": "Usar blocos para adicionar marcadores de forma que a página não congele", + "cluster-markers-lazy-load": "Usar carregamento lazy para adicionar marcadores", + "editor-settings": "Configurações do editor", + "enable-snapping": "Ativar ajuste a outros vértices para desenho preciso", + "init-draggable-mode": "Inicializar o mapa em modo arrastável", + "hide-all-edit-buttons": "Ocultar todos os botões de controle de edição", + "hide-draw-buttons": "Ocultar botões de desenho", + "hide-edit-buttons": "Ocultar botões de edição", + "hide-remove-button": "Ocultar botão de remoção", + "route-map-settings": "Configurações do mapa de rotas", + "trip-animation-settings": "Configurações de animação de viagem", + "normalization-step": "Passo de normalização de dados (ms)", + "tooltip-background-color": "Cor de fundo do tooltip", + "tooltip-font-color": "Cor da fonte do tooltip", + "tooltip-opacity": "Opacidade do tooltip (0-1)", + "auto-close-tooltip": "Fechar tooltip automaticamente", + "rotation-angle": "Definir ângulo de rotação adicional para o marcador (graus)", + "path-settings": "Configurações de caminho", + "path-color": "Cor do caminho", + "use-path-color-function": "Usar função de cor do caminho", + "path-color-function": "Função de cor do caminho", + "path-decorator": "Decorador de caminho", + "use-path-decorator": "Usar decorador de caminho", + "decorator-symbol": "Símbolo do decorador", + "decorator-symbol-arrow-head": "Seta", + "decorator-symbol-dash": "Traço", + "decorator-symbol-size": "Tamanho do símbolo do decorador (px)", + "use-path-decorator-custom-color": "Usar cor personalizada do decorador de caminho", + "decorator-custom-color": "Cor personalizada do decorador", + "decorator-offset": "Deslocamento do decorador", + "end-decorator-offset": "Deslocamento final do decorador", + "decorator-repeat": "Repetição do decorador", + "points-settings": "Configurações de pontos", + "show-points": "Exibir pontos", + "point-color": "Cor do ponto", + "point-size": "Tamanho do ponto (px)", + "use-point-color-function": "Usar função de cor do ponto", + "point-color-function": "Função de cor do ponto", + "use-point-as-anchor": "Usar ponto como âncora", + "point-as-anchor-function": "Função de ponto como âncora", + "independent-point-tooltip": "Tooltip de ponto independente", + "clustering-markers": "Agrupamento de marcadores", + "use-icon-create-function": "Usar função de cor dos marcadores", + "marker-color-function": "Função de cor do marcador" + }, + "markdown": { + "use-markdown-text-function": "Usar função de valor Markdown/HTML", + "markdown-text-function": "Função de valor Markdown/HTML", + "markdown-text-pattern": "Padrão Markdown/HTML (markdown ou HTML com variáveis, ex.: '${entityName} ou ${keyName} - algum texto.')", + "apply-default-markdown-style": "Aplicar estilo Markdown padrão", + "markdown-css": "CSS Markdown/HTML" + }, + "simple-card": { + "label": "Rótulo", + "label-position": "Posição do rótulo", + "label-position-left": "Esquerda", + "label-position-top": "Superior" + }, + "single-switch": { + "behavior": "Comportamento", + "layout": "Layout", + "layout-right": "Direita", + "layout-left": "Esquerda", + "layout-centered": "Centralizado", + "auto-scale": "Escala automática", + "label": "Rótulo", + "icon": "Ícone", + "switch-color": "Cor do switch", + "on": "On", + "off": "Off", + "disabled": "Desabilitado", + "tumbler-color": "Cor do botão", + "on-label": "Rótulo ligado", + "off-label": "Rótulo desligado", + "switch": "Switch" + }, + "slider": { + "behavior": "Comportamento", + "initial-value": "Valor inicial", + "initial-value-hint": "Ação para obter o valor inicial do slider.", + "on-value-change": "Ao alterar o valor", + "on-value-change-hint": "Ação acionada quando o valor do slider é alterado.", + "layout": "Layout", + "layout-default": "Padrão", + "layout-extended": "Estendido", + "layout-simplified": "Simplificado", + "auto-scale": "Escala automática", + "icon": "Ícone", + "value": "Valor", + "range": "Intervalo", + "min": "mín", + "max": "máx", + "range-ticks": "Marcações do intervalo", + "tick-marks": "Marcas de graduação", + "colors": "Cores", + "main": "Principal", + "background": "Plano de fundo", + "left-icon": "Ícone esquerdo", + "right-icon": "Ícone direito", + "slider": "Slider" + }, + "value-card": { + "layout": "Layout", + "layout-square": "Quadrado", + "layout-vertical": "Vertical", + "layout-centered": "Centralizado", + "layout-simplified": "Simplificado", + "layout-horizontal": "Horizontal", + "layout-horizontal-reversed": "Horizontal invertido", + "label": "Rótulo", + "icon": "Ícone", + "value": "Valor", + "date": "Data", + "value-card-style": "Estilo do cartão de valor", + "auto-scale": "Escala automática" + }, + "label-card": { + "auto-scale": "Escala automática", + "label": "Rótulo", + "icon": "Ícone", + "label-card-style": "Estilo do cartão de rótulo" + }, + "label-value-card": { + "value": "Valor", + "label-value-card-style": "Estilo do cartão de rótulo e valor" + }, + "liquid-level-card": { + "layout-simple": "Simples", + "layout-percentage": "Porcentagem", + "layout-absolute": "Absoluto", + "layout": "Layout", + "background-overlay": "Sobreposição de fundo do valor", + "total-volume": "Volume total", + "total-volume-units": "Unidades de volume total", + "tank": "Tanque", + "shape": "Forma", + "datasource-units": "Unidades da fonte", + "widget-units": "Unidades do widget", + "decimals": "Casas decimais", + "liquid": "Líquido", + "liquid-color": "Cor do líquido", + "value": "Valor", + "value-font": "Fonte do valor", + "level": "Nível", + "last-update": "Última atualização", + "shape-by-attribute": "Definir forma do tanque por nome de atributo", + "tooltip-background": "Cor de fundo", + "background-blur": "Desfoque de fundo", + "tank-color": "Cor do tanque", + "static": "Estático", + "see-examples": "Ver exemplos", + "attribute": "Atributo", + "shape-type": "Tipo", + "v-oval": "Oval vertical", + "v-cylinder": "Cilindro vertical", + "v-capsule": "Cápsula vertical", + "rectangle": "Retângulo", + "h-oval": "Oval horizontal", + "h-ellipse": "Elipse horizontal", + "h-dish-ends": "Extremidades de prato horizontal", + "h-cylinder": "Cilindro horizontal", + "h-capsule": "Cápsula horizontal", + "h-elliptical_2_1": "Elíptico horizontal 2:1", + "icon": "Ícone do card", + "title": "Título do card", + "units": "Unidades", + "color-and-font": "Cor e fonte", + "shape-attribute-name": "Nome do atributo", + "total-volume-required": "O volume total é obrigatório.", + "attribute-name-required": "O nome do atributo é obrigatório.", + "attribute-key-not-set": "Chave do atributo '{{attributeName}}' não definida", + "attribute-key-invalid": "Chave do atributo '{{attributeName}}' é inválida" + }, + "aggregated-value-card": { + "subtitle": "Subtítulo", + "chart": "Gráfico", + "values": "Valores", + "value-appearance": "Aparência do valor", + "position": "Posição", + "position-center": "Centro", + "position-right-top": "Direito superior", + "position-right-bottom": "Direito inferior", + "position-left-top": "Esquerdo superior", + "position-left-bottom": "Esquerdo inferior", + "font": "Fonte", + "color": "Cor", + "arrow": "Seta", + "display-up-down-arrow": "Exibir seta para cima/baixo", + "add-value": "Adicionar valor", + "remove-value": "Remover valor", + "no-values": "Nenhum valor configurado", + "aggregation": "Agregação", + "aggregated-value-card-style": "Estilo do cartão de valor agregado", + "auto-scale": "Escala automática" + }, + "value-chart-card": { + "layout": "Layout", + "layout-left": "Esquerda", + "layout-right": "Direita", + "auto-scale": "Escala automática", + "icon": "Ícone", + "value": "Valor", + "chart": "Gráfico", + "value-chart-card-style": "Estilo do cartão de gráfico de valor" + }, + "progress-bar": { + "layout": "Layout", + "layout-default": "Padrão", + "layout-simplified": "Simplificado", + "auto-scale": "Escala automática", + "icon": "Ícone", + "value": "Valor", + "range": "Intervalo", + "min": "mín", + "max": "máx", + "range-ticks": "Marcações do intervalo", + "bar": "Barra", + "bar-color": "Cor da barra", + "bar-background": "Fundo da barra", + "progress-bar-card-style": "Estilo do cartão de barra de progresso" + }, + "notification": { + "max-notification-display": "Máximo de notificações a exibir", + "counter": "Contador", + "counter-hint": "O contador será exibido se \"Título do widget\" estiver ativado", + "icon": "Ícone", + "counter-value": "Valor", + "counter-color": "Cor", + "notification-button": "Botões de notificação", + "button-view-all": "Ver todos", + "button-filter": "Filtro", + "type-filter": "Filtro de tipo", + "button-mark-read": "Marcar todos como lidos", + "notification-types": "Tipos de notificação", + "notification-type": "Tipo de notificação", + "search-type": "Tipo de pesquisa", + "any-type": "Qualquer tipo" + }, + "alarm-count": { + "alarm-count-card-style": "Estilo do cartão de contagem de alarmes" + }, + "entity-count": { + "entity-count-card-style": "Estilo do cartão de contagem de entidades" + }, + "count": { + "layout": "Layout", + "layout-column": "Coluna", + "layout-row": "Linha", + "label": "Rótulo", + "icon": "Ícone", + "icon-background": "Fundo do ícone", + "value": "Valor", + "chevron": "Divisa", + "auto-scale": "Escala automática" + }, + "table": { + "common-table-settings": "Configurações comuns da tabela", + "enable-search": "Ativar pesquisa", + "enable-sticky-header": "Sempre exibir cabeçalho", + "enable-sticky-action": "Sempre exibir coluna de ações", + "hidden-cell-button-display-mode": "Modo de exibição das ações de botão de célula oculta", + "show-empty-space-hidden-action": "Exibir espaço vazio em vez da ação de botão de célula oculta", + "dont-reserve-space-hidden-action": "Não reservar espaço para botões de ação ocultos", + "display-timestamp": "Timestamp", + "timestamp-column-name": "Timestamp", + "display-pagination": "Exibir paginação", + "default-page-size": "Tamanho padrão da página", + "page-step-settings": "Configurações de passo de página", + "page-step-count": "Número de passos", + "page-step-increment": "Incremento do passo", + "page-step-count-format-message": "Deve ser um valor inteiro no intervalo de 1 a 100.", + "page-step-increment-format-message": "Deve ser um valor inteiro maior ou igual a 1.", + "use-entity-label-tab-name": "Usar rótulo de entidade no nome da aba", + "hide-empty-lines": "Ocultar linhas vazias", + "row-style": "Estilo da linha", + "use-row-style-function": "Usar função de estilo de linha", + "row-style-function": "Função de estilo de linha", + "cell-style": "Estilo da célula", + "use-cell-style-function": "Usar função de estilo de célula", + "cell-style-function": "Função de estilo de célula", + "cell-content": "Conteúdo da célula", + "use-cell-content-function": "Usar função de conteúdo de célula", + "cell-content-function": "Função de conteúdo de célula", + "show-latest-data-column": "Exibir coluna de dados mais recentes", + "latest-data-column-order": "Ordem da coluna de dados mais recentes", + "entities-table-title": "Título da tabela de entidades", + "enable-select-column-display": "Ativar seleção de colunas a exibir", + "display-entity-name": "Exibir coluna de nome da entidade", + "entity-name-column-title": "Título da coluna de nome da entidade", + "display-entity-label": "Exibir coluna de rótulo da entidade", + "entity-label-column-title": "Título da coluna de rótulo da entidade", + "display-entity-type": "Exibir coluna de tipo de entidade", + "default-sort-order": "Ordem de classificação padrão", + "custom-title": "Título personalizado do cabeçalho", + "column-width": "Largura da coluna (px ou %)", + "default-column-visibility": "Visibilidade padrão da coluna", + "column-visibility-visible": "Visível", + "column-visibility-hidden": "Oculto", + "column-visibility-hidden-mobile": "Oculto no modo móvel", + "column-selection-to-display": "Seleção de coluna em 'Colunas para exibir'", + "column-selection-to-display-enabled": "Ativado", + "column-selection-to-display-disabled": "Desativado", + "alarms-table-title": "Título da tabela de alarmes", + "enable-alarms-selection": "Ativar seleção de alarmes", + "enable-alarms-search": "Ativar pesquisa de alarmes", + "enable-alarm-filter": "Ativar filtro de alarme", + "display-alarm-details": "Exibir detalhes do alarme", + "allow-alarms-ack": "Permitir confirmação de alarmes", + "allow-alarms-clear": "Permitir limpar alarmes", + "display-alarm-activity": "Exibir atividade do alarme", + "allow-alarms-assign": "Permitir atribuição de alarmes", + "columns": "Colunas", + "column-settings": "Configurações da coluna", + "remove-column": "Remover coluna", + "add-column": "Adicionar coluna", + "no-columns": "Nenhuma coluna configurada", + "columns-to-display": "Colunas para exibir", + "table-header": "Cabeçalho da tabela", + "header-buttons": "Botões do cabeçalho", + "table-buttons": "Botões da tabela", + "pagination": "Paginação", + "rows": "Linhas", + "timeseries-column-error": "Pelo menos uma coluna de série temporal deve ser especificada", + "alarm-column-error": "Pelo menos uma coluna de alarme deve ser especificada", + "table-tabs": "Abas da tabela", + "show-cell-actions-menu-mobile": "Exibir menu suspenso de ações de célula no modo móvel", + "disable-sorting": "Desativar classificação", + "sort-by": "Classificar abas por", + "sort-timestamp-option": "Hora de criação" + }, + "latest-chart": { + "total": "Total", + "auto-scale": "Escala automática", + "clockwise-layout": "Layout no sentido horário", + "sort-series": "Ordenar séries por rótulo", + "tooltip-value-type-absolute": "Absoluto", + "tooltip-value-type-percentage": "Porcentagem" + }, + "pie-chart": { + "pie-chart-appearance": "Aparência do gráfico de pizza", + "label": "Rótulo", + "border": "Borda", + "radius": "Raio", + "pie-chart-card-style": "Estilo do cartão de gráfico de pizza" + }, + "radar-chart": { + "radar-appearance": "Aparência do radar", + "shape": "Forma", + "shape-polygon": "Polígono", + "shape-circle": "Círculo", + "color": "Cor", + "line": "Linha", + "points": "Pontos", + "points-label": "Rótulo dos pontos", + "radar-axis": "Eixo do radar", + "axis-label": "Rótulo do eixo", + "ticks-label": "Rótulo das marcações", + "radar-chart-style": "Estilo do gráfico de radar", + "max-axes-scaling": "Escala máxima dos eixos", + "max-axes-scaling-hint": "Escolha se cada eixo do radar tem seu próprio valor máximo (Separado) ou compartilha o maior valor entre todos os eixos com base no conjunto de dados do widget (Comum).", + "separate": "Separado", + "common": "Comum" + }, + "time-series-chart": { + "chart": "Gráfico", + "chart-style": "Estilo do gráfico", + "data-zoom": "Zoom de dados", + "stack-mode": "Modo de empilhamento", + "stack-mode-hint": "Empilha séries no gráfico. As séries com a mesma unidade serão sobrepostas.", + "axes": "Eixos", + "y-axes": "Eixos Y", + "line-type": "Tipo de linha", + "line-width": "Largura da linha", + "type-line": "Linha", + "type-bar": "Barra", + "type-point": "Ponto", + "no-aggregation-bar-width-strategy": "Estratégia de largura de barra para dados não agregados", + "no-aggregation-bar-width-strategy-group": "Grupo", + "no-aggregation-bar-width-strategy-separate": "Separado", + "bar-group-width": "Largura do grupo de barras", + "bar-width": "Largura da barra", + "bar-width-relative": "Percentual da janela de tempo", + "bar-width-absolute": "Absoluto (ms)", + "comparison": { + "comparison": "Comparação", + "comparison-hint": "A comparação funciona apenas com dados históricos!", + "show": "Exibir", + "settings": "Configurações de comparação", + "show-values-for-comparison": "Exibir dados históricos para comparação", + "comparison-values-label": "Rótulo da chave de comparação", + "comparison-values-label-auto": "Automático", + "comparison-data-color": "Cor dos dados de comparação" + }, + "threshold": { + "thresholds": "Limites", + "source": "Fonte", + "key-value": "Chave / Valor", + "no-thresholds": "Nenhum limite configurado", + "add-threshold": "Adicionar limite", + "type-constant": "Constante", + "type-latest-key": "Chave", + "type-entity": "Entidade", + "threshold-settings": "Configurações de limite", + "remove-threshold": "Remover limite", + "threshold-value-required": "O valor do limite é obrigatório.", + "key-required": "A chave é obrigatória.", + "entity-key-required": "A chave da entidade é obrigatória.", + "line-appearance": "Aparência da linha", + "line-color": "Cor da linha", + "start-symbol": "Símbolo inicial", + "end-symbol": "Símbolo final", + "symbol-size": "tamanho", + "label": "Rótulo", + "label-position-start": "Início", + "label-position-middle": "Meio", + "label-position-end": "Fim", + "label-position-inside-start": "Início interno", + "label-position-inside-start-top": "Início interno superior", + "label-position-inside-start-bottom": "Início interno inferior", + "label-position-inside-middle": "Meio interno", + "label-position-inside-middle-top": "Meio interno superior", + "label-position-inside-middle-bottom": "Meio interno inferior", + "label-position-inside-end": "Fim interno", + "label-position-inside-end-top": "Fim interno superior", + "label-position-inside-end-bottom": "Fim interno inferior", + "label-background": "Plano de fundo do rótulo" + }, + "state": { + "states": "Estados", + "label": "Rótulo", + "ticks-value": "Valor das marcações", + "source": "Fonte", + "value-range": "Valor / Intervalo", + "no-states": "Nenhum estado configurado", + "add-state": "Adicionar estado", + "type-constant": "Constante", + "type-range": "Intervalo", + "from": "De", + "to": "Até", + "remove-state": "Remover estado" + }, + "grid": { + "grid": "Grade", + "background-color": "Cor do plano de fundo", + "border": "Borda" + }, + "axis": { + "axes": "Eixos", + "x-axis": "Eixo X", + "y-axis": "Eixo Y", + "y-axis-settings": "Configurações do eixo Y", + "comparison-x-axis-settings": "Configurações do eixo X de comparação", + "remove-y-axis": "Remover eixo Y", + "id": "Id", + "label": "Rótulo", + "position": "Posição", + "position-left": "Esquerda", + "position-right": "Direita", + "position-top": "Superior", + "position-bottom": "Inferior", + "tick-labels": "Rótulos das marcações", + "ticks-formatter-function": "Função de formatação de marcações", + "ticks-generator-function": "Função geradora de marcações", + "show-ticks": "Exibir marcações", + "show-line": "Exibir linha", + "show-split-lines": "Exibir linhas de divisão", + "show-split-lines-x-axis-hint": "Se habilitado, as linhas verticais no gráfico serão exibidas.", + "show-split-lines-y-axis-hint": "Se habilitado, as linhas horizontais no gráfico serão exibidas.", + "ticks-interval": "Intervalo das marcações", + "ticks-interval-hint": "Define obrigatoriamente o intervalo de segmentação para o eixo.", + "split-number": "Número de divisões", + "split-number-hint": "Número de segmentos em que o eixo é dividido.", + "min": "Mín", + "max": "Máx", + "show": "Exibir", + "add-y-axis": "Adicionar eixo Y" + }, + "series": { + "legend-settings": "Configurações da legenda", + "show-in-legend": "Exibir na legenda", + "show-in-legend-hint": "Exibe o nome da série e os dados na legenda.", + "hidden-by-default": "Oculto por padrão", + "hidden-by-default-hint": "Oculta a série na legenda por padrão.", + "series-type": "Tipo de série", + "type": "Tipo", + "type-line": "Linha", + "type-bar": "Barra", + "line": { + "line": "Linha", + "show-line": "Exibir linha", + "step-line": "Linha em degrau", + "step-type-start": "Início", + "step-type-middle": "Meio", + "step-type-end": "Fim", + "smooth-line": "Linha suavizada" + }, + "point": { + "points": "Pontos", + "show-points": "Exibir pontos", + "point-label": "Rótulo do ponto", + "point-label-hint": "Exibe rótulo com valor sobre o ponto da série.", + "point-label-background": "Plano de fundo do rótulo do ponto", + "point-shape": "Forma do ponto", + "point-size": "Tamanho do ponto" + } + } + }, + "wind-speed-direction": { + "layout": "Layout", + "layout-default": "Padrão", + "layout-advanced": "Avançado", + "layout-simplified": "Simplificado", + "values": "Valores", + "wind-direction": "Direção do vento", + "center-value": "Valor central", + "icon": "Ícone", + "arrow": "Seta", + "ticks": "Marcações", + "labels-type": "Tipo de rótulos", + "directional-names": "Nomes direcionais", + "degrees": "Graus", + "major-ticks": "Marcações principais", + "minor-ticks": "Marcações secundárias", + "wind-speed-direction-card-style": "Estilo do cartão de velocidade e direção do vento", + "ticks-color": "Cor das marcações", + "ticks-labels-type": "Tipo de rótulos das marcações", + "arrow-color": "Cor da seta" + }, + "value-source": { + "value-source": "Fonte do valor", + "predefined-value": "Constante", + "entity-attribute": "Atributo da entidade", + "value": "Valor", + "value-required": "O valor é obrigatório.", + "key-required": "A chave é obrigatória.", + "entity-key-required": "A chave da entidade é obrigatória.", + "source-entity-alias": "Alias da entidade de origem", + "source-entity-attribute": "Atributo da entidade de origem", + "type-constant": "Constante", + "type-latest-key": "Chave", + "type-entity": "Entidade" + }, + "rpc-state": { + "initial-state": "Estado inicial", + "initial-state-hint": "Ação para obter o estado inicial (Ligado/Desligado) do componente.", + "disabled-state": "Estado desabilitado", + "disabled-state-hint": "Configura a condição sob a qual o componente é desabilitado.", + "turn-on": "Ligar 'On'", + "turn-on-hint": "Ação acionada quando o controle deslizante é alternado para 'On'", + "turn-off": "Desligar 'Off'", + "turn-off-hint": "Ação acionada quando o controle deslizante é alternado para 'Off'", + "on": "On", + "off": "Off", + "disabled": "Desabilitado" + }, + "value-action": { + "do-nothing": "Não fazer nada", + "execute-rpc": "Executar RPC", + "get-attribute": "Obter atributo", + "set-attribute": "Definir atributo", + "get-time-series": "Obter série temporal", + "get-alarm-status": "Obter status do alarme", + "get-dashboard-state": "Obter id do estado do Dashboard", + "get-dashboard-state-object": "Obter objeto de estado do Dashboard", + "add-time-series": "Adicionar série temporal", + "execute-rpc-text": "Executar método RPC '{{methodName}}'", + "get-time-series-text": "Usar série temporal '{{key}}'", + "get-attribute-text": "Usar atributo '{{key}}'", + "get-alarm-status-text": "Usar status do alarme", + "get-dashboard-state-text": "Usar estado do Dashboard", + "get-dashboard-state-object-text": "Usar objeto de estado do Dashboard", + "when-dashboard-state-is-text": "Quando o id do estado do Dashboard for '{{state}}'", + "when-dashboard-state-function-is-text": "Quando f(id do estado do Dashboard) for '{{state}}'", + "when-dashboard-state-object-function-is-text": "Quando f(objeto de estado do Dashboard) for '{{state}}'", + "set-attribute-to-value-text": "Definir atributo '{{key}}' como: {{value}}", + "add-time-series-value-text": "Adicionar valor da série temporal '{{key}}': {{value}}", + "set-attribute-text": "Definir atributo '{{key}}'", + "add-time-series-text": "Adicionar série temporal '{{key}}'", + "action": "Ação", + "value": "Valor", + "init-value-hint": "Valor que será definido até que o dispositivo envie dados.", + "method": "Método", + "method-name-required": "O nome do método é obrigatório.", + "request-timeout-ms": "Tempo Limite da requisição RPC (ms)", + "request-timeout-required": "O tempo limite da requisição é obrigatório.", + "min-request-timeout-error": "O valor do tempo limite da requisição deve ser maior ou igual a 5000 ms (5 segundos).", + "request-persistent": "Requisição RPC persistente", + "persistent-polling-interval": "Intervalo de polling persistente (ms)", + "persistent-polling-interval-hint": "Intervalo de polling (ms) para obter a resposta do comando RPC persistente", + "persistent-polling-interval-required": "O intervalo de polling persistente é obrigatório.", + "min-persistent-polling-interval-error": "O valor do intervalo de polling persistente deve ser maior ou igual a 1000 ms (1 segundo).", + "attribute-scope": "Escopo do atributo", + "attribute-key": "Chave do atributo", + "attribute-key-required": "A chave do atributo é obrigatória.", + "time-series-key": "Chave da série temporal", + "time-series-key-required": "A chave da série temporal é obrigatória.", + "action-result-converter": "Conversor de resultado de ação", + "converter-none": "Nenhum", + "converter-function": "Função", + "converter-constant": "Constante", + "converter-value": "Valor", + "parse-value-function": "Função de análise de valor", + "state-when-result-is": "'{{state}}' quando o resultado for", + "parameters": "Parâmetros", + "convert-value-function": "Função de conversão de valor", + "error": { + "target-entity-is-not-set": "A entidade de destino não está definida!", + "failed-to-perform-action": "Falha ao executar a ação {{ actionLabel }}.", + "invalid-attribute-scope": "O escopo de atributo {{scope}} não é suportado pela entidade {{entityType}}." + } + }, + "widget-font": { + "font-settings": "Configurações de fonte", + "font-family": "Família da fonte", + "size": "Tamanho", + "relative-font-size": "Tamanho de fonte relativo (porcentagem)", + "font-style": "Estilo", + "font-style-normal": "Normal", + "font-style-italic": "Itálico", + "font-style-oblique": "Oblíquo", + "font-weight": "Peso", + "font-weight-normal": "Normal", + "font-weight-bold": "Negrito", + "font-weight-bolder": "Mais negrito", + "font-weight-lighter": "Mais leve", + "color": "Cor", + "shadow-color": "Cor da sombra", + "preview": "Visualização", + "line-height": "Altura da linha", + "auto": "Automático" + }, + "home": { + "no-data-available": "Nenhum dado disponível" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Disco", + "cpu-warning-text": "Uso elevado de CPU. Para evitar falha no sistema, otimize o desempenho do sistema.", + "cpu-critical-text": "Uso de CPU criticamente alto. Para evitar falha no sistema, otimize o desempenho do sistema.", + "ram-warning-text": "Reserva de RAM baixa. Para evitar falha no sistema, otimize o desempenho do sistema ou aumente o tamanho da RAM.", + "ram-critical-text": "Reserva de RAM criticamente baixa. Para evitar falha no sistema, otimize o desempenho do sistema ou aumente o tamanho da RAM.", + "disk-warning-text": "Pouco espaço em disco. Para evitar perda de dados, libere ou expanda o espaço em disco.", + "disk-critical-text": "Espaço em disco criticamente baixo. Para evitar perda de dados, libere ou expanda o espaço em disco." + }, + "cluster-info": { + "service-id": "Id do serviço", + "service-type": "Tipo de serviço", + "no-data": "Sem dados" + }, + "transport-messages": { + "title": "Mensagens de transporte", + "info": "Todas as mensagens recebidas dos dispositivos" + }, + "activity": { + "title": "Atividade" + }, + "documentation": { + "title": "Documentação", + "add-link": "Adicionar link", + "add-link-title": "Adicionar link de documentação", + "name": "Nome", + "name-required": "O nome é obrigatório.", + "link": "Link", + "link-required": "O link é obrigatório.", + "columns": "Colunas" + }, + "quick-links": { + "title": "Links rápidos", + "add-link": "Adicionar link", + "add-link-title": "Adicionar link rápido", + "quick-link": "Link rápido", + "quick-link-required": "O link rápido é obrigatório.", + "no-links-matching": "Nenhum link correspondente a '{{name}}' foi encontrado.", + "columns": "Colunas" + }, + "recent-dashboards": { + "title": "Dashboards", + "last": "Últimos visualizados", + "starred": "Marcados com estrela", + "name": "Nome", + "last-viewed": "Último visualizado", + "no-last-viewed-dashboards": "Nenhum Dashboard visualizado recentemente" + }, + "configured-features": { + "title": "Recursos configurados", + "info": "Status dos recursos que requerem configuração", + "email-feature": "Email", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Recurso está configurado.\nClique para configurar", + "feature-not-configured": "Recurso não está configurado.\nClique para configurar" + }, + "version-info": { + "title": "Versão", + "contact-us": "Fale conosco", + "current-version": "Versão atual", + "current": "Atual", + "available-version": "Versão disponível", + "available": "Disponível", + "upgrade": "Atualizar", + "version-is-up-to-date": "Versão está atualizada" + }, + "usage-info": { + "title": "Uso", + "entities": "Entidades", + "api-calls": "Chamadas de API" + }, + "functions": { + "title": "Funções", + "pe-feature-tooltip": "Disponível apenas no ThingsBoard\nProfessional Edition", + "switch-to-pe": "Migrar para PE", + "alarms": "Alarmes", + "dashboards": "Dashboards", + "entities-and-relations": "Entidades & Relações", + "profiles": "Perfis", + "advanced-features": "Recursos avançados", + "notification-center": "Central de notificações", + "api-usage": "Uso de API", + "customers": "Clientes", + "customers-hierarchy": "Hierarquia de clientes", + "roles-and-permissions": "Funções & Permissões", + "groups": "Grupos", + "integrations": "Integrações", + "solution-templates": "Modelos de solução", + "scheduler": "Agendador", + "white-labeling": "Personalização de marca" + }, + "devices": { + "view-docs": "Ver documentação", + "inactive": "Inativo", + "active": "Ativo", + "total": "Total" + }, + "alarms": { + "critical": "Crítico", + "assigned-to-me": "Atribuído a mim", + "total": "Total" + }, + "getting-started": { + "get-started": "Começar", + "finish": "Concluir", + "done-welcome-title": "Bem-vindo a bordo", + "done-welcome-text": "Você se saiu muito bem!", + "sys-admin": { + "step1": { + "title": "Criar Tenant e Administrador do Tenant", + "content": "

Um tenant é um indivíduo ou organização que possui ou produz dispositivos e ativos. O tenant pode ter múltiplos usuários administradores de tenant, clientes, dispositivos e ativos.

O Administrador do Tenant pode criar e gerenciar dispositivos, ativos, clientes e dashboards dentro da conta do tenant.

Siga a documentação sobre como fazer isso:

", + "how-to-create-tenant": "Como criar Tenant e Administrador do Tenant" + }, + "step2": { + "title": "Configurar recurso: Servidor de email", + "content": "

A configuração do servidor de email é essencial para ativação de usuários, recuperação de senha e entrega de notificações de alarme.

Siga a documentação sobre como fazer isso:

", + "how-to-configure-mail-server": "Como configurar o servidor de email" + }, + "step3": { + "title": "Configurar recurso: Provedor de SMS", + "content": "

Configure provedores de SMS para notificar os clientes sobre alarmes via SMS.

Siga a documentação sobre como fazer isso:

", + "how-to-configure-sms-provider": "Como configurar o provedor de SMS" + }, + "step4": { + "title": "Configurar recurso: Personalização de marca", + "content": "

Personalize facilmente o logotipo e o esquema de cores da sua empresa ou produto sem programação e sem reiniciar o serviço.

Siga a documentação sobre como fazer isso:

" + }, + "step5": { + "title": "Configurar recurso: 2FA", + "content": "

Melhore a segurança das contas da plataforma com autenticação de dois fatores.

Siga a documentação sobre como fazer isso:

" + }, + "step6": { + "title": "Configurar recurso: OAuth 2", + "content": "

Simplifique o login para usuários de tenant e clientes com a funcionalidade de Single Sign-On via OAuth 2.0.

Siga a documentação sobre como fazer isso:

" + } + }, + "tenant-admin": { + "step1": { + "title": "Criar dispositivo", + "content": "

Vamos provisionar seu primeiro dispositivo na plataforma via UI. Siga a documentação sobre como fazer isso:

", + "how-to-create-device": "Como criar Dispositivo" + }, + "step2": { + "title": "Conectar dispositivo", + "content-before": "

Para conectar o dispositivo, é necessário obter as credenciais do dispositivo. Recomendamos usar as credenciais padrão geradas automaticamente, que é o token de acesso para este guia.

  • Acesse a tabela de dispositivos
  • Clique na linha do dispositivo para abrir os detalhes do dispositivo
  • Pressione o botão \"Copiar token de acesso\"

Use comandos simples para publicar dados via HTTP. Não se esqueça de substituir $ACCESS_TOKEN pelo token de acesso do seu dispositivo:

", + "ubuntu": { + "install-curl": "Instalar cURL para Ubuntu:" + }, + "macos": { + "install-curl": "Instalar cURL para MacOS:" + }, + "windows": { + "install-curl": "A partir do Windows 10 b17063, o cURL está disponível por padrão." + }, + "replace-access-token": "Substitua $ACCESS_TOKEN pelo token do seu dispositivo:", + "content-after": "

Você também pode usar outros protocolos como MQTT, CoAP, etc.

Siga a documentação sobre como fazer isso:

", + "how-to-connect-device": "Como conectar Dispositivo" + }, + "step3": { + "title": "Criar dashboard", + "content": "

Crie um dashboard para visualizar dados de entidades como ativos, dispositivos, etc.

Siga a documentação sobre como fazer isso:

", + "how-to-create-dashboard": "Como criar Dashboard" + }, + "step4": { + "title": "Configurar regras de alarme", + "alarm-rules": "Regras de alarme", + "content": "

Vamos disparar um alarme quando a temperatura atingir 25°C. Siga a documentação sobre como fazer isso:

", + "how-to-configure-alarm-rules": "Como configurar Regras de alarme" + }, + "step5": { + "title": "Criar alarme", + "content-before": "

Para acionar o alarme, envie um novo valor de telemetria de 26°C ou superior.

", + "replace-access-token": "Substitua $ACCESS_TOKEN pelo token do seu dispositivo:", + "content-after": "

Siga a documentação sobre como fazer isso:

", + "how-to-create-alarm": "Como criar Alarme" + }, + "step6": { + "title": "Criar cliente e compartilhar dashboard", + "content": "

Ao criar dashboards para usuários finais, um usuário cliente pode ver apenas seus próprios dispositivos, e os dados de outro cliente serão ocultados.

Siga a documentação sobre como fazer isso:

" + } + } + }, + "api-usage": { + "api-usage": "Uso de API", + "label": "Rótulo", + "state-name": "Nome do estado", + "status": "Status", + "status-required": "O status é obrigatório.", + "limit": "Limite máximo", + "limit-required": "O limite máximo é obrigatório.", + "current-number": "Número atual", + "current-number-required": "O número atual é obrigatório.", + "add-key": "Adicionar chave", + "no-key": "Sem chave", + "delete-key": "Excluir chave", + "target-dashboard-state": "Estado do Dashboard de destino", + "go-to-main-state": "Ir para a visualização padrão" } }, "icon": { "icon": "Ícone", + "icons": "Ícones", + "custom": "Personalizado", "select-icon": "Selecionar ícone", - "material-icons": "Ícones de materiais", - "show-all": "Mostrar todos os ícones" + "material-icons": "Material icons", + "show-all": "Mostrar todos os ícones", + "search-icon": "Pesquisar ícone", + "no-icons-found": "Nenhum ícone encontrado para '{{iconSearch}}'" + }, + "phone-input": { + "phone-input-label": "Número de telefone", + "phone-input-required": "O número de telefone é obrigatório", + "phone-input-validation": "O número de telefone é inválido ou não é possível", + "phone-input-pattern": "Número de telefone inválido. Deve estar no formato E.164, ex. {{phoneNumber}}", + "phone-input-hint": "Número de telefone no formato E.164, ex. {{phoneNumber}}" }, "custom": { "widget-action": { - "action-cell-button": "Botão da célula de ação", - "row-click": "Clicar em linha", - "polygon-click": "Clique no polígono", - "marker-click": "Clique no marcador", - "tooltip-tag-action": "Ação do rótulo de dica de ferramenta", - "node-selected": "Selecionado no nó", - "element-click": "Clique em elemento de HTML", - "pie-slice-click": "Clique na fatia", - "row-double-click": "Dois clique na fila" + "action-cell-button": "Botão de ação da célula", + "row-click": "Ao clicar na linha", + "cell-click": "Ao clicar na célula", + "polygon-click": "Ao clicar no polígono", + "marker-click": "Ao clicar no marcador", + "circle-click": "Ao clicar no círculo", + "tooltip-tag-action": "Ação da tag de tooltip", + "node-selected": "Ao selecionar o nó", + "element-click": "Ao clicar no elemento HTML", + "pie-slice-click": "Ao clicar na fatia", + "row-double-click": "Ao clicar duas vezes na linha", + "cell-double-click": "Ao clicar duas vezes na célula", + "card-click": "Ao clicar no card", + "click": "Ao clicar" } }, + "paginator": { + "items-per-page": "Itens por página:", + "first-page-label": "Primeira página", + "last-page-label": "Última página", + "next-page-label": "Próxima página", + "previous-page-label": "Página anterior", + "items-per-page-separator": "de" + }, "language": { + "auto": "Automático", "language": "Idioma" } -} +} \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json index b7f6e8d8f0..0027c7d8ed 100644 --- a/ui-ngx/src/assets/locale/locale.constant-tr_TR.json +++ b/ui-ngx/src/assets/locale/locale.constant-tr_TR.json @@ -803,7 +803,7 @@ "no-assets-matching": "'{{entity}}' ile eşleşen varlık bulunamadı.", "asset-required": "Varlık gereklidir", "name-starts-with": "Varlık adı ifadesi", - "help-text": "İhtiyaca göre '%' kullanın: '%varlık_adı_içerir%', '%varlık_adı_biter', 'varlık_adı_başlar'.", + "help-text": "'%' işaretini ihtiyaca göre kullanın: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", "search": "Varlıklarda ara", "import": "Varlıkları içe aktar", "asset-file": "Varlık dosyası", @@ -910,9 +910,9 @@ "processing-timeouts": "${entityName} İşleme Zaman Aşımları", "rule-chain": "Kural Zinciri", "rule-engine": "Kural Motoru", - "rule-engine-daily-activity": "Kural Motoru günlük etkinliği", "rule-engine-executions": "Kural Motoru çalıştırmaları", "rule-engine-hourly-activity": "Kural Motoru saatlik etkinliği", + "rule-engine-daily-activity": "Kural Motoru günlük etkinliği", "rule-engine-monthly-activity": "Kural Motoru aylık etkinliği", "rule-engine-statistics": "Kural Motoru İstatistikleri", "rule-node": "Kural Düğümü", @@ -1095,6 +1095,7 @@ "expression": "İfade", "no-found": "Hesaplanmış alan bulunamadı", "list": "{ count, plural, =1 {Bir hesaplanmış alan} other {# hesaplanmış alan listesi} }", + "add": "Hesaplanmış alan ekle", "selected-fields": "{ count, plural, =1 {1 hesaplanmış alan} other {# hesaplanmış alan} } seçildi", "type": { "simple": "Basit", @@ -2124,7 +2125,7 @@ "remove-alias": "Cihaz takma adını kaldır", "add-alias": "Cihaz takma adı ekle", "name-starts-with": "Cihaz adı ifadesi", - "help-text": "İhtiyaca göre '%' kullanın: '%device_name_contains%', '%device_name_ends', 'device_starts_with'.", + "help-text": "'%' işaretini ihtiyaca göre kullanın: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", "device-list": "Cihaz listesi", "use-device-name-filter": "Filtre kullan", "device-list-empty": "Hiçbir cihaz seçilmedi.", @@ -3039,7 +3040,7 @@ "no-entities-text": "Varlık bulunamadı", "no-entity-types-matching": "'{{entityType}}' ile eşleşen varlık türü bulunamadı.", "name-starts-with": "İsim ifadesi", - "help-text": "İhtiyaca göre '%' kullanın: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'.", + "help-text": "'%' işaretini ihtiyaca göre kullanın: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", "use-entity-name-filter": "Filtre kullan", "entity-list-empty": "Seçilen varlık yok.", "entity-type-list-required": "En az bir varlık türü seçilmelidir.", @@ -3242,7 +3243,7 @@ "remove-alias": "Varlık görünümü takma adını kaldır", "add-alias": "Varlık görünümü takma adı ekle", "name-starts-with": "Varlık Görünümü ad ifadesi", - "help-text": "İhtiyaca göre '%' kullanın: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'.", + "help-text": "'%' işaretini ihtiyaca göre kullanın: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", "entity-view-list": "Varlık Görünümü listesi", "use-entity-view-name-filter": "Filtre kullan", "entity-view-list-empty": "Seçili varlık görünümü yok.", @@ -7465,7 +7466,7 @@ "handle-empty-result-function": "Boş sonuç fonksiyonunu işle", "handle-error-function": "Hata fonksiyonunu işle", "handle-non-mobile-fallback-function": "Mobil olmayan geri dönüş fonksiyonunu işle", - "save-to-gallery": "Galeriye kaydet", + "save-to-gallery": "'Resim Galerisi'ne kaydet", "provision-type": "Sağlama türü", "auto": "Otomatik", "wi-fi": "Wi-Fi", diff --git a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json index b8c67f655c..5e46c556ad 100644 --- a/ui-ngx/src/assets/locale/locale.constant-uk_UA.json +++ b/ui-ngx/src/assets/locale/locale.constant-uk_UA.json @@ -1,2618 +1,10193 @@ { - "access": { - "unauthorized": "Неавторизований", - "unauthorized-access": "Неавторизований доступ", - "unauthorized-access-text": "Щоб отримати доступ до цього ресурсу, потрібно ввійти в систему!", - "access-forbidden": "Доступ заборонено", - "access-forbidden-text": "Недостатньо прав для доступу!
Спробуйте увійти як інший користувач, якщо ви все ще хочете отримати доступ до цього ресурсу.", - "refresh-token-expired": "Дані про сесію застарілі", - "refresh-token-failed": "Не вдається відновити сеанс", - "permission-denied": "У дозволі відмовлено", - "permission-denied-text": "Ви не маєте дозволу на виконання цієї операції!" - }, - "account": { - "account": "Обліковий запис", - "notification-settings": "Налаштування сповіщень" - }, - "action": { - "activate": "Активувати", - "suspend": "Призупинити", - "save": "Зберегти", - "saveAs": "Зберегти як", - "move": "Перемістити", - "cancel": "Скасувати", - "ok": "OK", - "delete": "Видалити", - "add": "Додати", - "yes": "Так", - "no": "Ні", - "update": "Оновити", - "remove": "Видалити", - "select": "Вибрати", - "search": "Пошук", - "clear-search": "Очистити пошук", - "assign": "Надати", - "unassign": "Позбавити", - "share": "Поділитися", - "make-private": "Зробити приватним", - "apply": "Застосувати", - "apply-changes": "Застосувати зміни", - "edit-mode": "Режим редагування", - "enter-edit-mode": "Ввійти в режим редагування", - "decline-changes": "Відхилити зміни", - "decline": "Відхилити", - "close": "Закрити", - "back": "Назад", - "run": "Запустити", - "sign-in": "Увійти!", - "edit": "Редагувати", - "view": "Переглянути", - "create": "Створити", - "drag": "Перетягнути", - "refresh": "Оновити", - "undo": "Скасувати", - "copy": "Скопіювати", - "paste": "Вставити", - "copy-reference": "Копіювати посилання", - "paste-reference": "Вставити посилання", - "import": "Імпортувати", - "export": "Експортувати", - "share-via": "Поділитися через {{provider}}", - "continue": "Продовжити", - "discard-changes": "Скасувати зміни", - "download": "Завантажити", - "next": "Наступний", - "next-with-label": "Наступний: {{label}}", - "read-more": "Прочитати більше", - "hide": "Приховати", - "done": "Завершено", - "print": "Друкувати", - "restore": "Відновити", - "confirm": "Підтвердити", - "more": "Більше", - "less": "Менше", - "skip": "Пропустити", - "send": "Надіслати", - "reset": "Скидання", - "show-more": "Показати більше", - "dont-show-again": "Більше не показувати", - "see-documentation": "Див. документацію", - "clear": "Очистити", - "upload": "Надіслати", - "delete-anyway": "Все одно видалити", - "delete-selected": "Видалити вибране" - }, - "aggregation": { - "aggregation": "Агрегація", - "function": "Функція агрегації даних", - "limit": "Максимальні значення", - "group-interval": "Інтервал групування", - "min": "Мінімальний", - "max": "Максимальний", - "avg": "Середній", + "access": { + "unauthorized": "Неавторизовано", + "unauthorized-access": "Неавторизований доступ", + "unauthorized-access-text": "Вам потрібно увійти, щоб отримати доступ до цього ресурсу!", + "access-forbidden": "Доступ заборонено", + "access-forbidden-text": "Ви не маєте прав доступу до цього розташування!
Спробуйте увійти під іншим користувачем, якщо Ви все ще бажаєте отримати доступ до цього розташування.", + "refresh-token-expired": "Термін дії сеансу минув", + "refresh-token-failed": "Не вдалося оновити сеанс", + "permission-denied": "Відмовлено в доступі", + "permission-denied-text": "Ви не маєте дозволу виконати цю операцію!" + }, + "account": { + "account": "Обліковий запис", + "notification-settings": "Налаштування сповіщень" + }, + "action": { + "activate": "Активувати", + "suspend": "Призупинити", + "save": "Зберегти", + "saveAs": "Зберегти як", + "move": "Перемістити", + "cancel": "Скасувати", + "ok": "OK", + "delete": "Видалити", + "add": "Додати", + "yes": "Так", + "no": "Ні", + "update": "Оновити", + "remove": "Видалити", + "search": "Пошук", + "clear-search": "Очистити пошук", + "assign": "Призначити", + "unassign": "Скасувати призначення", + "share": "Поділитися", + "make-private": "Зробити приватним", + "apply": "Застосувати", + "apply-changes": "Застосувати зміни", + "edit-mode": "Режим редагування", + "enter-edit-mode": "Увійти в режим редагування", + "decline-changes": "Відхилити зміни", + "decline": "Відхилити", + "close": "Закрити", + "back": "Назад", + "run": "Запустити", + "sign-in": "Увійти!", + "edit": "Редагувати", + "view": "Переглянути", + "create": "Створити", + "drag": "Перетягнути", + "refresh": "Оновити", + "undo": "Повернути", + "copy": "Копіювати", + "paste": "Вставити", + "copy-reference": "Копіювати посилання", + "paste-reference": "Вставити посилання", + "import": "Імпортувати", + "export": "Експортувати", + "share-via": "Поділитися через {{provider}}", + "select": "Вибрати", + "continue": "Продовжити", + "discard-changes": "Скасувати зміни", + "download": "Скачати", + "next": "Далі", + "next-with-label": "Далі: {{label}}", + "read-more": "Читати далі", + "hide": "Приховати", + "test": "Тестувати", + "done": "Готово", + "print": "Друкувати", + "restore": "Відновити", + "confirm": "Підтвердити", + "more": "Більше", + "less": "Менше", + "skip": "Пропустити", + "send": "Надіслати", + "reset": "Скинути", + "show-more": "Показати більше", + "dont-show-again": "Більше не показувати", + "see-documentation": "Переглянути документацію", + "see-debug-events": "Переглянути події налагодження", + "clear": "Очистити", + "upload": "Завантажити", + "delete-anyway": "Видалити все одно", + "delete-selected": "Видалити вибране", + "set": "Встановити" + }, + "aggregation": { + "aggregation": "Агрегація", + "function": "Функція агрегації даних", + "limit": "Максимум значень", + "group-interval": "Інтервал групування", + "min": "Мін.", + "max": "Макс.", + "avg": "Середнє", + "sum": "Сума", + "count": "Кількість", + "none": "Немає" + }, + "admin": { + "settings": "Налаштування", + "general": "Загальні", + "general-settings": "Загальні налаштування", + "home-settings": "Налаштування домашньої сторінки", + "home": "Домашня сторінка", + "outgoing-mail": "Поштовий сервер", + "outgoing-mail-settings": "Налаштування сервера вихідної пошти", + "system-settings": "Налаштування системи", + "test-mail-sent": "Тестовий лист успішно надіслано!", + "base-url": "Базова URL-адреса", + "base-url-required": "Потрібна базова URL-адреса.", + "prohibit-different-url": "Заборонити використання імені хоста з заголовків запиту клієнта", + "prohibit-different-url-hint": "Цей параметр слід увімкнути для виробничих середовищ. Якщо вимкнено, це може спричинити проблеми безпеки", + "device-connectivity": { + "device-connectivity": "Підключення пристроїв", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "Якщо поля хоста або порту порожні, буде використано значення протоколу за замовчуванням.", + "host": "Хост", + "port": "Порт", + "port-pattern": "Порт має бути додатним цілим числом.", + "port-range": "Порт має бути в діапазоні від 1 до 65535." + }, + "mail-from": "Відправник листа", + "mail-from-required": "Потрібно вказати відправника листа.", + "smtp-protocol": "Протокол SMTP", + "smtp-host": "Хост SMTP", + "smtp-host-required": "Потрібно вказати хост SMTP.", + "smtp-port": "Порт SMTP", + "smtp-port-required": "Потрібно вказати порт SMTP.", + "smtp-port-invalid": "Схоже, це некоректний порт SMTP.", + "timeout-msec": "Тайм-аут (мс)", + "timeout-required": "Потрібно вказати тайм-аут.", + "timeout-invalid": "Схоже, це некоректний тайм-аут.", + "enable-tls": "Увімкнути TLS", + "tls-version": "Версія TLS", + "enable-proxy": "Увімкнути проксі", + "proxy-host": "Хост проксі", + "proxy-host-required": "Потрібно вказати хост проксі.", + "proxy-port": "Порт проксі", + "proxy-port-required": "Потрібно вказати порт проксі.", + "proxy-port-range": "Порт проксі має бути в діапазоні від 1 до 65535.", + "proxy-user": "Користувач проксі", + "proxy-password": "Пароль проксі", + "change-password": "Змінити пароль", + "send-test-mail": "Надіслати тестовий лист", + "sms-provider": "Постачальник SMS", + "sms-provider-settings": "Налаштування постачальника SMS", + "sms-provider-type": "Тип постачальника SMS", + "sms-provider-type-required": "Потрібно вказати тип постачальника SMS.", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "Потрібно вказати AWS Access Key ID", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "Потрібно вказати AWS Secret Access Key", + "aws-region": "Регіон AWS", + "aws-region-required": "Потрібно вказати регіон AWS", + "number-from": "Номер телефону відправника", + "number-from-required": "Потрібно вказати номер телефону відправника.", + "number-to": "Номер телефону отримувача", + "number-to-required": "Потрібно вказати номер телефону отримувача.", + "phone-number-hint": "Номер телефону у форматі E.164, напр. +19995550123", + "phone-number-hint-twilio": "Номер телефону у форматі E.164/SID номера телефону/SID сервісу повідомлень, напр. +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "Некоректний номер телефону. Має бути у форматі E.164, напр. +19995550123.", + "phone-number-pattern-twilio": "Некоректний номер телефону. Має бути у форматі E.164/SID номера телефону/SID сервісу повідомлень, напр. +19995550123/PNXXX/MGXXX.", + "sms-message": "SMS-повідомлення", + "sms-message-required": "Потрібно вказати SMS-повідомлення.", + "sms-message-max-length": "SMS-повідомлення не може перевищувати 1600 символів", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Потрібно вказати Twilio Account SID", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Потрібно вказати Twilio Account Token", + "send-test-sms": "Надіслати тестове SMS", + "test-sms-sent": "Тестове SMS успішно надіслано!", + "security-settings": "Налаштування безпеки", + "password-policy": "Політика паролів", + "minimum-password-length": "Мінімальна довжина пароля", + "minimum-password-length-required": "Потрібно вказати мінімальну довжину пароля", + "minimum-password-length-range": "Мінімальна довжина пароля має бути в діапазоні від 6 до 50", + "maximum-password-length": "Максимальна довжина пароля", + "maximum-password-length-min": "Максимальна довжина пароля має бути щонайменше 6", + "maximum-password-length-less-min": "Максимальна довжина пароля має бути більшою за мінімальну довжину", + "minimum-uppercase-letters": "Мінімальна кількість великих літер", + "minimum-uppercase-letters-range": "Мінімальна кількість великих літер не може бути від'ємною", + "minimum-lowercase-letters": "Мінімальна кількість малих літер", + "minimum-lowercase-letters-range": "Мінімальна кількість малих літер не може бути від'ємною", + "minimum-digits": "Мінімальна кількість цифр", + "minimum-digits-range": "Мінімальна кількість цифр не може бути від'ємною", + "minimum-special-characters": "Мінімальна кількість спеціальних символів", + "minimum-special-characters-range": "Мінімальна кількість спеціальних символів не може бути від'ємною", + "password-expiration-period-days": "Період дії пароля у днях", + "password-expiration-period-days-range": "Період дії пароля у днях не може бути від'ємним", + "password-reuse-frequency-days": "Частота повторного використання пароля у днях", + "password-reuse-frequency-days-range": "Частота повторного використання пароля у днях не може бути від'ємною", + "allow-whitespace": "Дозволити пробіли", + "force-reset-password-if-no-valid": "Примусово скинути пароль, якщо він недійсний", + "force-reset-password-if-no-valid-hint": "Будь ласка, будьте обережні, увімкнувши цю функцію: вона вимагатиме від користувачів з недійсним паролем скинути пароль через email.", + "general-policy": "Загальна політика", + "max-failed-login-attempts": "Максимальна кількість невдалих спроб входу перед блокуванням облікового запису", + "minimum-max-failed-login-attempts-range": "Максимальна кількість невдалих спроб входу не може бути від'ємною", + "user-lockout-notification-email": "У разі блокування облікового запису користувача надсилати сповіщення на email", + "user-activation-token-ttl": "TTL посилання активації користувача у годинах", + "user-activation-token-ttl-range": "TTL посилання активації користувача має бути в діапазоні від 1 до 24 годин", + "password-reset-token-ttl": "TTL посилання скидання пароля у годинах", + "password-reset-token-ttl-range": "TTL посилання скидання пароля має бути в діапазоні від 1 до 24 годин", + "mobile-secret-key-length": "Довжина мобільного секретного ключа", + "mobile-secret-key-length-range": "Довжина мобільного секретного ключа має бути додатною", + "domain-name": "Доменне ім'я", + "domain-name-unique": "Доменне ім'я та протокол мають бути унікальними.", + "domain-name-max-length": "Доменне ім'я має бути меншим за 256", + "error-verification-url": "Доменне ім'я не повинно містити символи '/' і ':'. Приклад: thingsboard.io", + "connection-settings": "Налаштування з'єднання", + "oauth2": { + "access-token-uri": "URI токена доступу", + "access-token-uri-required": "Потрібно вказати URI токена доступу.", + "activate-user": "Активувати користувача", + "add-domain": "Додати домен", + "delete-domain": "Видалити домен", + "add-provider": "Додати постачальника", + "delete-provider": "Видалити постачальника", + "allow-user-creation": "Дозволити створення користувачів", + "always-fullscreen": "Завжди на весь екран", + "authorization-uri": "URI авторизації", + "authorization-uri-required": "Потрібно вказати URI авторизації.", + "add-client": "Додати клієнта OAuth 2.0", + "client-details": "Деталі клієнта OAuth 2.0", + "client": "Клієнт OAuth 2.0", + "clients": "Клієнти OAuth 2.0", + "no-oauth2-clients": "Клієнтів OAuth 2.0 не знайдено", + "search-oauth2-clients": "Пошук клієнтів OAuth 2.0", + "delete-client-title": "Ви впевнені, що хочете видалити клієнта OAuth 2.0 '{{clientName}}'?", + "delete-client-text": "Будьте обережні: після підтвердження клієнт і всі пов'язані дані буде неможливо відновити.", + "delete-mobile-app-title": "Ви впевнені, що хочете видалити мобільний застосунок '{{applicationName}}'?", + "delete-mobile-app-text": "Будьте обережні: після підтвердження мобільний застосунок і всі пов'язані дані буде неможливо відновити.", + "title": "Назва", + "client-title-required": "Потрібно вказати назву", + "client-title-max-length": "Назва має бути меншою за 100", + "advanced-settings": "Розширені налаштування", + "domain-details": "Деталі домену", + "no-domains": "Доменів не знайдено", + "search-domains": "Пошук доменів", + "mobile-app-details": "Деталі мобільного застосунку", + "add-mobile-app": "Додати мобільний застосунок", + "no-mobile-apps": "Мобільних застосунків не знайдено", + "search-mobile-apps": "Пошук мобільних застосунків", + "send-token": "Надіслати токен", + "create-new": "Створити новий", + "client-authentication-method": "Метод автентифікації клієнта", + "client-id": "Client ID", + "client-id-required": "Потрібно вказати Client ID.", + "client-id-max-length": "Client ID має бути меншим за 256", + "client-secret": "Client secret", + "client-secret-required": "Потрібно вказати Client secret.", + "client-secret-max-length": "Client secret має бути меншим за 2049", + "custom-setting": "Користувацькі налаштування", + "customer-name-pattern": "Шаблон імені клієнта", + "customer-name-pattern-max-length": "Шаблон імені клієнта має бути меншим за 256", + "default-dashboard-name": "Назва дашборду за замовчуванням", + "default-dashboard-name-max-length": "Назва дашборду за замовчуванням має бути меншою за 256", + "delete-domain-text": "Будьте обережні: після підтвердження домен і всі дані постачальника будуть недоступні.", + "delete-domain-title": "Ви впевнені, що хочете видалити домен '{{domainName}}'?", + "delete-registration-text": "Будьте обережні: після підтвердження дані постачальника будуть недоступні.", + "delete-registration-title": "Ви впевнені, що хочете видалити постачальника '{{name}}'?", + "email-attribute-key": "Ключ атрибуту Email", + "email-attribute-key-required": "Потрібно вказати ключ атрибуту Email.", + "email-attribute-key-max-length": "Ключ атрибуту Email має бути меншим за 32", + "first-name-attribute-key": "Ключ атрибуту імені", + "first-name-attribute-key-max-length": "Ключ атрибуту імені має бути меншим за 32", + "general": "Загальні", + "jwk-set-uri": "URI JSON Web Key", + "last-name-attribute-key": "Ключ атрибуту прізвища", + "last-name-attribute-key-max-length": "Ключ атрибуту прізвища має бути меншим за 32", + "login-button-icon": "Іконка кнопки входу", + "login-button-label": "Назва постачальника", + "login-button-label-placeholder": "Увійти через $(Provider label)", + "login-button-label-required": "Потрібно вказати маркування.", + "login-provider": "Постачальник входу", + "mapper": "Мапер", + "new-domain": "Новий домен", + "oauth2": "OAuth 2.0", + "password-max-length": "Пароль має бути меншим за 256", + "redirect-uri-template": "Шаблон Redirect URI", + "copy-redirect-uri": "Копіювати Redirect URI", + "registration-id": "Registration ID", + "registration-id-required": "Потрібно вказати Registration ID.", + "registration-id-unique": "Registration ID має бути унікальним у системі.", + "scope": "Scope", + "scope-required": "Потрібно вказати Scope.", + "tenant-name-pattern": "Шаблон імені тенанта", + "tenant-name-pattern-required": "Потрібно вказати шаблон імені тенанта.", + "tenant-name-pattern-max-length": "Шаблон імені тенанта має бути меншим за 256", + "tenant-name-strategy": "Стратегія імені тенанта", + "type": "Тип мапера", + "uri-pattern-error": "Некоректний формат URI.", + "url": "URL", + "url-pattern": "Некоректний формат URL.", + "url-required": "Потрібно вказати URL.", + "url-max-length": "URL має бути меншим за 256", + "user-info-uri": "URI інформації про користувача", + "user-info-uri-required": "Потрібно вказати URI інформації про користувача.", + "username-max-length": "Ім'я користувача має бути меншим за 256", + "user-name-attribute-name": "Ключ атрибуту імені користувача", + "user-name-attribute-name-required": "Потрібно вказати ключ атрибуту імені користувача", + "protocol": "Протокол", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "Увімкнути налаштування OAuth 2.0", + "disable": "Вимкнути налаштування OAuth 2.0", + "edge": "Поширювати на Edge", + "edge-enable": "Увімкнути поширення на Edge", + "edge-disable": "Вимкнути поширення на Edge", + "domains": "Домени", + "mobile-apps": "Мобільні застосунки", + "mobile-package": "Пакет застосунку", + "mobile-package-placeholder": "Напр.: my.example.app", + "mobile-package-hint": "Для Android: Ваш унікальний Application ID. Для iOS: ідентифікатор пакета продукту.", + "mobile-package-unique": "Пакет застосунку має бути унікальним.", + "mobile-package-required": "Потрібно вказати пакет застосунку.", + "mobile-package-max-length": "Пакет застосунку має бути меншим за 256", + "mobile-package-spaces": "Пакет застосунку не повинен містити пробілів", + "mobile-app-secret": "Application secret", + "mobile-app-secret-hint": "Рядок у кодуванні Base64, що представляє щонайменше 512 біт даних.", + "mobile-app-secret-required": "Потрібно вказати Application secret.", + "mobile-app-secret-min-length": "Application secret має містити щонайменше 512 біт даних.", + "mobile-app-secret-base64": "Application secret має бути у форматі base64.", + "invalid-mobile-app-secret": "Application secret має містити лише буквено-цифрові символи та мати довжину від 16 до 2048 символів.", + "copy-mobile-app-secret": "Копіювати Application secret", + "delete-mobile-app": "Видалити інформацію про застосунок", + "providers": "Постачальники", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "Усі платформи", + "smtp-provider": "Постачальник SMTP", + "allowed-platforms": "Дозволені платформи", + "authentication": "Автентифікація", + "basic": "Базові", + "provider": "Постачальник", + "redirect-url": "Redirect URI", + "domain-name": "Доменне ім'я", + "domain-name-required": "Потрібно вказати доменне ім'я", + "redirect-url-template": "Redirect URI template", + "microsoft-tenant-id": "Directory (tenant) Id", + "microsoft-tenant-id-required": "Потрібно вказати Directory (tenant) Id", + "token-uri": "Token URI", + "token-uri-required": "Потрібно вказати Token URI", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "Користувацький", + "generate-access-token": "Згенерувати токен доступу", + "update-access-token": "Оновити токен доступу", + "access-token-status": "Статус токена доступу:", + "token-status-generated": "згенеровано", + "token-status-not-generated": "не згенеровано" + }, + "smpp-provider": { + "smpp-version": "Версія SMPP", + "smpp-host": "Хост SMPP", + "smpp-host-required": "Потрібно вказати хост SMPP", + "smpp-port": "Порт SMPP", + "smpp-port-required": "Потрібно вказати порт SMPP", + "system-id": "System ID", + "system-id-required": "Потрібно вказати System ID", + "password": "Пароль", + "password-required": "Потрібно вказати пароль", + "type-settings": "Налаштування типу", + "source-settings": "Налаштування відправника", + "destination-settings": "Налаштування отримувача", + "additional-settings": "Додаткові налаштування", + "system-type": "Тип системи", + "bind-type": "Тип bind", + "service-type": "Тип сервісу", + "source-address": "Адреса відправника", + "source-ton": "TON відправника", + "source-npi": "NPI відправника", + "destination-ton": "TON отримувача (Тип номера)", + "destination-npi": "NPI отримувача (Ідентифікація плану нумерації)", + "address-range": "Діапазон адрес", + "coding-scheme": "Схема кодування", + "bind-type-tx": "Передавач", + "bind-type-rx": "Приймач", + "bind-type-trx": "Трансивер", + "ton-unknown": "Невідомо", + "ton-international": "Міжнародний", + "ton-national": "Національний", + "ton-network-specific": "Специфічний для мережі", + "ton-subscriber-number": "Абонентський номер", + "ton-alphanumeric": "Буквено-цифровий", + "ton-abbreviated": "Скорочений", + "npi-unknown": "0 - Невідомо", + "npi-isdn": "1 - ISDN/телефонний план нумерації (E163/E164)", + "npi-data-numbering-plan": "3 - План нумерації даних (X.121)", + "npi-telex-numbering-plan": "4 - План нумерації телекса (F.69)", + "npi-land-mobile": "6 - Наземний мобільний зв'язок (E.212)", + "npi-national-numbering-plan": "8 - Національний план нумерації", + "npi-private-numbering-plan": "9 - Приватний план нумерації", + "npi-ermes-numbering-plan": "10 - План нумерації ERMES (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - Інтернет (IP)", + "npi-wap-client-id": "18 - WAP Client Id (буде визначено WAP Forum)", + "scheme-smsc": "0 - Стандартний алфавіт SMSC (ASCII для короткого й довгого коду та GSM для безкоштовних номерів)", + "scheme-ia5": "1 - IA5 (ASCII для короткого й довгого коду, Latin 9 для безкоштовних номерів (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - Невизначений октет (8-bit binary)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - Невизначений октет (8-bit binary)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - Кирилиця (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Латиниця/іврит (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - Кодування піктограм", + "scheme-music-codes": "10 - Музичні коди (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - Розширений Kanji JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - Корейський графічний набір символів (KS C 5601/KS X 1001)" + }, + "queue-select-name": "Вибрати назву черги", + "queue-name": "Назва", + "queue-name-required": "Потрібно вказати назву черги!", + "queues": "Черги", + "queue-partitions": "Розділи", + "queue-submit-strategy": "Стратегія надсилання", + "queue-processing-strategy": "Стратегія обробки", + "queue-configuration": "Конфігурація черги", + "repository-settings": "Налаштування репозиторію", + "repository": "Репозиторій", + "repository-url": "URL репозиторію", + "repository-url-required": "Потрібно вказати URL репозиторію.", + "default-branch": "Назва гілки за замовчуванням", + "repository-read-only": "Лише для читання", + "show-merge-commits": "Показувати merge-коміти", + "authentication-settings": "Налаштування автентифікації", + "auth-method": "Метод автентифікації", + "auth-method-username-password": "Пароль / токен доступу", + "auth-method-username-password-hint": "Користувачі GitHub мають використовувати токени з правами запису до репозиторію.", + "auth-method-private-key": "Приватний ключ", + "password-access-token": "Пароль / токен доступу", + "change-password-access-token": "Змінити пароль / токен доступу", + "private-key": "Приватний ключ", + "drop-private-key-file-or": "Перетягніть і відпустіть файл приватного ключа або", + "passphrase": "Парольна фраза", + "enter-passphrase": "Ввести парольну фразу", + "change-passphrase": "Змінити парольну фразу", + "check-access": "Перевірити доступ", + "check-repository-access-success": "Доступ до репозиторію успішно перевірено!", + "delete-repository-settings-title": "Ви впевнені, що хочете видалити налаштування репозиторію?", + "delete-repository-settings-text": "Будьте обережні: після підтвердження налаштування репозиторію буде видалено, а функція контролю версій стане недоступною.", + "auto-commit-settings": "Налаштування авто-коміту", + "auto-commit": "Авто-коміт", + "auto-commit-entities": "Сутності для авто-коміту", + "no-auto-commit-entities-prompt": "Для авто-коміту не налаштовано жодної сутності", + "delete-auto-commit-settings-title": "Ви впевнені, що хочете видалити налаштування авто-коміту?", + "delete-auto-commit-settings-text": "Будьте обережні: після підтвердження налаштування авто-коміту буде видалено, а авто-коміт буде вимкнено для всіх сутностей.", + "mobile-app": { + "mobile-app": "Мобільний застосунок", + "mobile-app-qr-code-widget-settings": "Налаштування віджета QR-коду мобільного застосунку", + "applications": "Застосунки", + "default": "За замовчуванням", + "custom": "Користувацький", + "android": "Android", + "ios": "iOS", + "appearance": "Вигляд", + "appearance-on-home-page": "Відображення на домашній сторінці", + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "badges": "Значки", + "label": "Маркування", + "label-required": "Потрібно вказати маркування", + "label-max-length": "Маркування має бути не довшим за 50 символів", + "right": "Праворуч", + "left": "Ліворуч", + "set": "Встановити", + "preview": "Попередній перегляд", + "connect-mobile-app": "Підключити мобільний застосунок", + "use-system-settings": "Використовувати системні налаштування" + }, + "2fa": { + "2fa": "Двофакторна автентифікація", + "available-providers": "Доступні постачальники", + "available-providers-required": "Потрібно налаштувати щонайменше одного постачальника 2FA.", + "issuer-name": "Назва емітента", + "issuer-name-required": "Потрібно вказати назву емітента.", + "max-verification-failures-before-user-lockout": "Максимальна кількість невдалих перевірок перед блокуванням користувача", + "max-verification-failures-before-user-lockout-pattern": "Максимальна кількість невдалих перевірок має бути додатним цілим числом.", + "number-of-checking-attempts": "Кількість спроб перевірки", + "number-of-checking-attempts-pattern": "Кількість спроб перевірки має бути додатним цілим числом.", + "number-of-checking-attempts-required": "Потрібно вказати кількість спроб перевірки.", + "number-of-codes": "Кількість кодів", + "number-of-codes-pattern": "Кількість кодів має бути додатним цілим числом.", + "number-of-codes-required": "Потрібно вказати кількість кодів.", + "provider": "Постачальник", + "retry-verification-code-period": "Період повторної спроби коду підтвердження", + "retry-verification-code-period-pattern": "Мінімальний період — 5 с", + "retry-verification-code-period-required": "Потрібно вказати період повторної спроби коду підтвердження.", + "total-allowed-time-for-verification": "Загальний дозволений час для підтвердження", + "total-allowed-time-for-verification-pattern": "Мінімальний загальний дозволений час — 60 с", + "total-allowed-time-for-verification-required": "Потрібно вказати загальний дозволений час.", + "use-system-two-factor-auth-settings": "Використовувати системні налаштування двофакторної автентифікації", + "verification-code-check-rate-limit": "Ліміт частоти перевірок коду підтвердження", + "verification-code-lifetime": "Термін дії коду підтвердження", + "verification-code-lifetime-pattern": "Термін дії коду підтвердження має бути додатним цілим числом.", + "verification-code-lifetime-required": "Потрібно вказати термін дії коду підтвердження.", + "verification-message-template": "Шаблон повідомлення підтвердження", + "verification-limitations": "Обмеження підтвердження", + "verification-message-template-pattern": "Повідомлення підтвердження має містити шаблон: ${code}", + "verification-message-template-required": "Потрібно вказати шаблон повідомлення підтвердження.", + "within-time": "Протягом часу", + "within-time-pattern": "Час має бути додатним цілим числом.", + "within-time-required": "Потрібно вказати час.", + "force-2fa": "Примусово вимагати двофакторну автентифікацію", + "enforce-for": "Вимагати для" + }, + "jwt": { + "security-settings": "Налаштування безпеки JWT", + "issuer-name": "Назва емітента", + "issuer-name-required": "Потрібно вказати назву емітента.", + "signings-key": "Ключ підпису", + "signings-key-hint": "Рядок у кодуванні Base64, що представляє щонайменше 512 біт даних.", + "signings-key-required": "Потрібно вказати ключ підпису.", + "signings-key-min-length": "Ключ підпису має містити щонайменше 512 біт даних.", + "signings-key-base64": "Ключ підпису має бути у форматі base64.", + "expiration-time": "Час закінчення дії токена (с)", + "expiration-time-required": "Потрібно вказати час закінчення дії токена.", + "expiration-time-max": "Максимально дозволений час — 2147483647 секунд (68 років).", + "expiration-time-min": "Мінімальний час — 60 секунд (1 хвилина).", + "refresh-expiration-time": "Час закінчення дії refresh токена (с)", + "refresh-expiration-time-required": "Потрібно вказати час закінчення дії refresh токена.", + "refresh-expiration-time-max": "Максимально дозволений час — 2147483647 секунд (68 років).", + "refresh-expiration-time-min": "Мінімальний час — 900 секунд (15 хвилин).", + "refresh-expiration-time-less-token": "Час дії refresh токена має бути більшим за час дії токена.", + "generate-key": "Згенерувати ключ", + "info-header": "Усім користувачам потрібно буде увійти знову", + "info-message": "Зміна ключа підпису JWT призведе до недійсності всіх виданих токенів. Усім користувачам потрібно буде увійти знову. Це також вплине на скрипти, які використовують Rest API/Websockets." + }, + "resources": "Ресурси", + "notifications": "Сповіщення", + "notifications-settings": "Налаштування сповіщень", + "slack-api-token": "Slack API токен", + "slack": "Slack", + "slack-settings": "Налаштування Slack", + "mobile-settings": "Налаштування мобільного застосунку", + "firebase-service-account-file": "JSON-файл облікових даних сервісного облікового запису Firebase", + "select-firebase-service-account-file": "Перетягніть і відпустіть файл облікових даних сервісного облікового запису Firebase або " + }, + "alarm": { + "alarm": "Тривога", + "alarm-list": "Список тривог", + "alarms": "Тривоги", + "all-alarms": "Усі тривоги", + "select-alarm": "Вибрати тривогу", + "no-alarms-matching": "Не знайдено тривог, що відповідають '{{entity}}'.", + "alarm-required": "Потрібно вказати тривогу", + "alarm-filter": "Фільтр тривог", + "filter": "Фільтр", + "alarm-status": "Статус тривоги", + "alarm-status-list": "Список статусів тривог", + "any-status": "Будь-який статус", + "search-status": { + "ANY": "Будь-який", + "ACTIVE": "Активна", + "CLEARED": "Очищена", + "ACK": "Підтверджена", + "UNACK": "Непідтверджена" + }, + "display-status": { + "ACTIVE_UNACK": "Активна непідтверджена", + "ACTIVE_ACK": "Активна підтверджена", + "CLEARED_UNACK": "Очищена непідтверджена", + "CLEARED_ACK": "Очищена підтверджена" + }, + "no-alarms-prompt": "Тривог не знайдено", + "created-time": "Час створення", + "type": "Тип", + "severity": "Серйозність", + "originator": "Ініціатор", + "originator-type": "Тип ініціатора", + "details": "Деталі", + "originator-label": "Маркування ініціатора", + "assign": "Призначити", + "assignments": "Призначення", + "assignee": "Призначений", + "assignee-id": "ID призначеного", + "assignee-first-name": "Ім'я призначеного", + "assignee-last-name": "Прізвище призначеного", + "assignee-email": "Email призначеного", + "unassigned": "Не призначено", + "user-deleted": "Користувача видалено", + "assignee-not-set": "Усі", + "status": "Статус", + "alarm-details": "Деталі тривоги", + "start-time": "Час початку", + "assign-time": "Час призначення", + "end-time": "Час завершення", + "ack-time": "Час підтвердження", + "clear-time": "Час очищення", + "duration": "Тривалість", + "alarm-severity": "Серйозність тривоги", + "alarm-severity-list": "Список рівнів серйозності тривоги", + "any-severity": "Будь-яка серйозність", + "severity-critical": "Критична", + "severity-major": "Висока", + "severity-minor": "Низька", + "severity-warning": "Попередження", + "severity-indeterminate": "Невизначена", + "acknowledge": "Підтвердити", + "clear": "Очистити", + "delete": "Видалити", + "search": "Пошук тривог", + "selected-alarms": "Вибрано { count, plural, =1 {1 тривогу} other {# тривог} }", + "no-data": "Немає даних для відображення", + "polling-interval": "Інтервал опитування тривог (с)", + "polling-interval-required": "Потрібно вказати інтервал опитування тривог.", + "min-polling-interval-message": "Дозволено інтервал опитування щонайменше 1 с.", + "aknowledge-alarms-title": "Підтвердити { count, plural, =1 {1 тривогу} other {# тривог} }", + "aknowledge-alarms-text": "Ви впевнені, що хочете підтвердити { count, plural, =1 {1 тривогу} other {# тривог} }?", + "aknowledge-alarm-title": "Підтвердити тривогу", + "aknowledge-alarm-text": "Ви впевнені, що хочете підтвердити тривогу?", + "selected-alarms-are-acknowledged": "Вибрані тривоги вже підтверджено", + "clear-alarms-title": "Очистити { count, plural, =1 {1 тривогу} other {# тривог} }", + "clear-alarms-text": "Ви впевнені, що хочете очистити { count, plural, =1 {1 тривогу} other {# тривог} }?", + "clear-alarm-title": "Очистити тривогу", + "clear-alarm-text": "Ви впевнені, що хочете очистити тривогу?", + "delete-alarms-title": "Видалити { count, plural, =1 {1 тривогу} other {# тривог} }", + "delete-alarms-text": "Ви впевнені, що хочете видалити { count, plural, =1 {1 тривогу} other {# тривог} }?", + "selected-alarms-are-cleared": "Вибрані тривоги вже очищено", + "alarm-status-filter": "Фільтр статусу тривоги", + "alarm-filter-title": "Фільтр тривог", + "assigned": "Призначено", + "filter-title": "Фільтр", + "max-count-load": "Максимальна кількість тривог для завантаження (0 — без обмежень)", + "max-count-load-required": "Потрібно вказати максимальну кількість тривог для завантаження.", + "max-count-load-error-min": "Мінімальне значення — 0.", + "fetch-size": "Розмір вибірки", + "fetch-size-required": "Потрібно вказати розмір вибірки.", + "fetch-size-error-min": "Мінімальне значення — 10.", + "alarm-types": "Типи тривог", + "alarm-type-list": "Список типів тривог", + "any-type": "Будь-який тип", + "assigned-to-current-user": "Призначено поточному користувачу", + "assigned-to-me": "Призначено мені", + "search-propagated-alarms": "Пошук поширених тривог", + "comments": "Коментарі тривоги", + "show-more": "Показати більше", + "additional-info": "Додаткова інформація", + "alarm-type": "Тип тривоги", + "enter-alarm-type": "Ввести тип тривоги", + "no-alarm-types-matching": "Не знайдено типів тривог, що відповідають '{{entitySubtype}}'.", + "alarm-type-list-empty": "Типи тривог не вибрано.", + "system-comments": { + "acked-by-user": "Тривогу підтверджено користувачем {{userName}}", + "cleared-by-user": "Тривогу очищено користувачем {{userName}}", + "assigned-to-user": "Тривогу призначено користувачем {{userName}} користувачу {{assigneeName}}", + "unassigned-to-user": "Призначення тривоги скасовано користувачем {{userName}}", + "unassigned-from-deleted-user": "Призначення тривоги скасовано, оскільки користувача {{userName}} було видалено", + "comment-deleted": "Користувач {{userName}} видалив свій коментар", + "severity-changed": "Серйозність тривоги оновлено з {{oldSeverity}} на {{newSeverity}}" + } + }, + "alarm-activity": { + "add": "Додати коментар...", + "alarm-comment": "Коментар тривоги", + "comments": "Коментарі", + "delete-alarm-comment": "Ви хочете видалити цей коментар?", + "refresh": "Оновити", + "oldest-first": "Спочатку старіші", + "newest-first": "Спочатку новіші", + "activity": "Активність", + "export": "Експортувати в CSV", + "author": "Автор", + "created-date": "Дата створення", + "edited-date": "Дата редагування", + "text": "Текст", + "system": "Система" + }, + "alias": { + "add": "Додати псевдонім", + "edit": "Редагувати псевдонім", + "name": "Назва псевдоніма", + "name-required": "Потрібно вказати назву псевдоніма", + "duplicate-alias": "Псевдонім з такою самою назвою вже існує.", + "filter-type-single-entity": "Одна сутність", + "filter-type-entity-list": "Список сутностей", + "filter-type-entity-name": "Назва сутності", + "filter-type-entity-type": "Тип сутності", + "filter-type-state-entity": "Сутність зі стану дашборду", + "filter-type-state-entity-description": "Сутність, взята з параметрів стану дашборду", + "filter-type-asset-type": "Тип активу", + "filter-type-asset-type-description": "Активи типу '{{assetTypes}}'", + "filter-type-asset-type-and-name-description": "Активи типу '{{assetTypes}}' та з назвою, що починається з '{{prefix}}'", + "filter-type-device-type": "Тип пристрою", + "filter-type-device-type-description": "Пристрої типу '{{deviceTypes}}'", + "filter-type-device-type-and-name-description": "Пристрої типу '{{deviceTypes}}' та з назвою, що починається з '{{prefix}}'", + "filter-type-entity-view-type": "Тип фрагмента сутності", + "filter-type-entity-view-type-description": "Фрагменти сутностей типу '{{entityViewTypes}}'", + "filter-type-entity-view-type-and-name-description": "Фрагменти сутностей типу '{{entityViewTypes}}' та з назвою, що починається з '{{prefix}}'", + "filter-type-edge-type": "Тип Edge", + "filter-type-edge-type-description": "Edges типу '{{edgeTypes}}'", + "filter-type-edge-type-and-name-description": "Edges типу '{{edgeTypes}}' та з назвою, що починається з '{{prefix}}'", + "filter-type-relations-query": "Запит зв'язків", + "filter-type-relations-query-description": "{{entities}}, що мають зв'язок {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-edge-search-query": "Запит пошуку Edge", + "filter-type-edge-search-query-description": "Edges з типами {{edgeTypes}}, що мають зв'язок {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-asset-search-query": "Запит пошуку активів", + "filter-type-asset-search-query-description": "Активи з типами {{assetTypes}}, що мають зв'язок {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-device-search-query": "Запит пошуку пристроїв", + "filter-type-device-search-query-description": "Пристрої з типами {{deviceTypes}}, що мають зв'язок {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-entity-view-search-query": "Запит пошуку фрагментів сутностей", + "filter-type-entity-view-search-query-description": "Фрагмент сутностей з типами {{entityViewTypes}}, що мають зв'язок {{relationType}} {{direction}} {{rootEntity}}", + "filter-type-apiUsageState": "Стан використання API", + "entity-filter": "Фільтр сутностей", + "resolve-multiple": "Визначати як кілька сутностей", + "resolve-multiple-hint": "Увімкніть, щоб відображати дані з усіх відфільтрованих сутностей одночасно. \nЯкщо вимкнено, віджет показує дані лише для вибраної сутності.", + "filter-type": "Тип фільтра", + "filter-type-required": "Потрібно вказати тип фільтра.", + "entity-filter-no-entity-matched": "Не знайдено сутностей, що відповідають указаному фільтру.", + "no-entity-filter-specified": "Фільтр сутностей не задано", + "root-state-entity": "Використовувати сутність стану дашборду як кореневу", + "last-level-relation": "Отримувати лише зв'язок останнього рівня", + "root-entity": "Коренева сутність", + "state-entity-parameter-name": "Назва параметра стану сутності", + "default-state-entity": "Сутність стану за замовчуванням", + "default-entity-parameter-name": "За замовчуванням", + "query-options": "Параметри запиту", + "max-relation-level": "Максимальний рівень зв'язків", + "unlimited-level": "Без обмежень", + "state-entity": "Сутність стану дашборду", + "all-entities": "Усі сутності", + "any-relation": "будь-який" + }, + "asset": { + "asset": "Актив", + "assets": "Активи", + "management": "Керування активами", + "view-assets": "Переглянути активи", + "add": "Додати актив", + "asset-type-max-length": "Тип активу має бути меншим за 256", + "assign-to-customer": "Призначити клієнту", + "assign-asset-to-customer": "Призначити актив(и) клієнту", + "assign-asset-to-customer-text": "Будь ласка, виберіть активи, які потрібно призначити клієнту", + "no-assets-text": "Активів не знайдено", + "assign-to-customer-text": "Будь ласка, виберіть клієнта, якому потрібно призначити актив(и)", + "public": "Публічний", + "assignedToCustomer": "Призначено клієнту", + "make-public": "Зробити актив публічним", + "make-private": "Зробити актив приватним", + "unassign-from-customer": "Скасувати призначення клієнту", + "delete": "Видалити актив", + "asset-public": "Актив є публічним", + "asset-type": "Тип активу", + "asset-type-required": "Потрібно вказати тип активу.", + "select-asset-type": "Вибрати тип активу", + "enter-asset-type": "Ввести профіль активу", + "any-asset": "Будь-який актив", + "no-asset-types-matching": "Не знайдено типів активів, що відповідають '{{entitySubtype}}'.", + "asset-type-list-empty": "Типи активів не вибрано.", + "asset-types": "Типи активів", + "name": "Назва", + "name-required": "Потрібно вказати назву.", + "name-max-length": "Назва має бути меншою за 256", + "label-max-length": "Маркування має бути меншим за 256", + "description": "Опис", + "description-required": "Потрібно вказати опис.", + "type": "Тип", + "type-required": "Потрібно вказати тип.", + "details": "Деталі", + "events": "Події", + "add-asset-text": "Додати новий актив", + "asset-details": "Деталі активу", + "assign-assets": "Призначити активи", + "assign-assets-text": "Призначити { count, plural, =1 {1 актив} other {# активів} } клієнту", + "assign-asset-to-edge-title": "Призначити актив(и) для Edge", + "assign-asset-to-edge-text": "Будь ласка, виберіть активи, які потрібно призначити для edge", + "delete-assets": "Видалити активи", + "unassign-assets": "Скасувати призначення активів", + "unassign-assets-action-title": "Скасувати призначення { count, plural, =1 {1 активу} other {# активів} } клієнту", + "assign-new-asset": "Призначити новий актив", + "delete-asset-title": "Ви впевнені, що хочете видалити актив '{{assetName}}'?", + "delete-asset-text": "Будьте обережні: після підтвердження актив і всі пов'язані дані буде неможливо відновити.", + "delete-assets-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 актив} other {# активів} }?", + "delete-assets-action-title": "Видалити { count, plural, =1 {1 актив} other {# активів} }", + "delete-assets-text": "Будьте обережні: після підтвердження всі вибрані активи буде видалено, а всі пов'язані дані буде неможливо відновити.", + "make-public-asset-title": "Ви впевнені, що хочете зробити актив '{{assetName}}' публічним?", + "make-public-asset-text": "Після підтвердження актив і всі його дані стануть публічними та доступними іншим.", + "make-private-asset-title": "Ви впевнені, що хочете зробити актив '{{assetName}}' приватним?", + "make-private-asset-text": "Після підтвердження актив і всі його дані стануть приватними та не будуть доступні іншим.", + "unassign-asset-title": "Ви впевнені, що хочете скасувати призначення активу '{{assetName}}'?", + "unassign-asset-text": "Після підтвердження призначення активу буде скасовано, і він не буде доступний клієнту.", + "unassign-asset": "Скасувати призначення активу", + "unassign-assets-title": "Ви впевнені, що хочете скасувати призначення { count, plural, =1 {1 активу} other {# активів} }?", + "unassign-assets-text": "Після підтвердження призначення всіх вибраних активів буде скасовано, і вони не будуть доступні клієнту.", + "copyId": "Копіювати Id активу", + "idCopiedMessage": "Id активу скопійовано в буфер обміну", + "select-asset": "Вибрати актив", + "no-assets-matching": "Не знайдено активів, що відповідають '{{entity}}'.", + "asset-required": "Потрібно вказати актив.", + "name-starts-with": "Вираз назви активу", + "help-text": "Використовуйте '%' за потребою: '%asset_name_contains%', '%asset_name_ends', 'asset_starts_with%'.", + "search": "Пошук активів", + "import": "Імпортувати активи", + "asset-file": "Файл активів", + "label": "Маркування", + "assign-asset-to-edge": "Призначити актив(и) для Edge", + "unassign-asset-from-edge": "Скасувати призначення активу", + "unassign-asset-from-edge-title": "Ви впевнені, що хочете скасувати призначення активу '{{assetName}}'?", + "unassign-asset-from-edge-text": "Після підтвердження призначення активу буде скасовано, і він не буде доступний для edge.", + "unassign-assets-from-edge-title": "Ви впевнені, що хочете скасувати призначення { count, plural, =1 {1 активу} other {# активів} }?", + "unassign-assets-from-edge-text": "Після підтвердження призначення всіх вибраних активів буде скасовано, і вони не будуть доступні для edge.", + "selected-assets": "Вибрано { count, plural, =1 {1 актив} other {# активів} }" + }, + "attribute": { + "attributes": "Атрибути", + "latest-telemetry": "Остання телеметрія", + "no-latest-telemetry": "Останньої телеметрії немає", + "attributes-scope": "Область атрибутів сутності", + "scope-telemetry": "Телеметрія", + "scope-latest-telemetry": "Остання телеметрія", + "scope-client": "Клієнтські атрибути", + "scope-server": "Серверні атрибути", + "scope-shared": "Спільні атрибути", + "scope-client-short": "Клієнт", + "scope-server-short": "Сервер", + "scope-shared-short": "Спільні", + "scope-latest-short": "Остання", + "scope-any": "Будь-яка", + "add": "Додати атрибут", + "key": "Ключ", + "key-max-length": "Ключ має бути меншим за 256", + "last-update-time": "Час останнього оновлення", + "key-required": "Потрібно вказати ключ атрибуту.", + "value": "Значення", + "value-required": "Потрібно вказати значення атрибуту.", + "telemetry-key-required": "Потрібно вказати ключ телеметрії", + "telemetry-value-required": "Потрібно вказати значення телеметрії", + "delete-attributes-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 атрибут} other {# атрибутів} }?", + "delete-attributes-text": "Будьте обережні: після підтвердження всі вибрані атрибути буде видалено.", + "delete-attributes": "Видалити атрибути", + "enter-attribute-value": "Ввести значення атрибуту", + "show-on-widget": "Показувати на віджеті", + "widget-mode": "Режим віджета", + "next-widget": "Наступний віджет", + "prev-widget": "Попередній віджет", + "add-to-dashboard": "Додати до дашборду", + "add-widget-to-dashboard": "Додати віджет до дашборду", + "selected-attributes": "Вибрано { count, plural, =1 {1 атрибут} other {# атрибутів} }", + "selected-telemetry": "Вибрано { count, plural, =1 {1 одиницю телеметрії} other {# юнітів телеметрії} }", + "no-attributes-text": "Атрибутів не знайдено", + "no-telemetry-text": "Телеметрії не знайдено", + "copy-key": "Копіювати ключ", + "add-telemetry": "Додати телеметрію", + "copy-value": "Копіювати значення", + "delete-timeseries": { + "start-time": "Час початку", + "ends-on": "Завершується", + "strategy": "Стратегія", + "delete-strategy": "Стратегія видалення", + "all-data": "Видалити всі дані", + "all-data-except-latest-value": "Видалити всі дані, крім останнього значення", + "latest-value": "Видалити останнє значення", + "all-data-for-time-period": "Видалити всі дані за період часу", + "rewrite-latest-value": "Перезаписати останнє значення" + } + }, + "api-usage": { + "api-features": "Можливості API", + "api-usage": "Використання API", + "alarm": "Тривога", + "alarms-created": "Створені тривоги", + "queue-stats": "Статистика черги", + "processing-failures-and-timeouts": "Збої обробки та тайм-аути", + "exceptions": "Винятки", + "alarms-created-daily-activity": "Щоденна активність створення тривог", + "alarms-created-hourly-activity": "Погодинна активність створення тривог", + "alarms-created-monthly-activity": "Щомісячна активність створення тривог", + "data-points": "Точки даних", + "data-points-storage-days": "Дні зберігання точок даних", + "data-points-storage-days-hourly-activity": "Погодинна активність днів зберігання точок даних", + "data-points-storage-days-daily-activity": "Щоденна активність днів зберігання точок даних", + "data-points-storage-days-monthly-activity": "Щомісячна активність днів зберігання точок даних", + "device-api": "API пристроїв", + "email": "Email", + "email-messages": "Повідомлення Email", + "email-messages-daily-activity": "Щоденна активність повідомлень Email", + "email-messages-monthly-activity": "Щомісячна активність повідомлень Email", + "executions": "Виконання", + "scripts": "Скрипти", + "scripts-hourly-activity": "Погодинна активність скриптів", + "scripts-daily-activity": "Щоденна активність скриптів", + "scripts-monthly-activity": "Щомісячна активність скриптів", + "javascript": "JavaScript", + "javascript-executions": "Виконання JavaScript", + "tbel": "TBEL", + "tbel-executions": "Виконання TBEL", + "latest-error": "Остання помилка", + "messages": "Повідомлення", + "notifications": "Сповіщення", + "notifications-email-sms": "Сповіщення (Email/SMS)", + "notifications-hourly-activity": "Погодинна активність сповіщень", + "permanent-failures": "${entityName} Перманентні збої", + "permanent-timeouts": "${entityName} Перманентні тайм-аути", + "processing-failures": "${entityName} Збої обробки", + "processing-timeouts": "${entityName} Тайм-аути обробки", + "rule-chain": "Ланцюг правил", + "rule-engine": "Механізм правил", + "rule-engine-executions": "Виконання механізму правил", + "rule-engine-hourly-activity": "Погодинна активність механізму правил", + "rule-engine-daily-activity": "Щоденна активність механізму правил", + "rule-engine-monthly-activity": "Щомісячна активність механізму правил", + "rule-engine-statistics": "Статистика механізму правил", + "rule-node": "Вузол правила", + "sms": "SMS", + "sms-messages": "SMS-повідомлення", + "sms-messages-hourly-activity": "Погодинна активність SMS-повідомлень", + "sms-messages-daily-activity": "Щоденна активність SMS-повідомлень", + "sms-messages-monthly-activity": "Щомісячна активність SMS-повідомлень", + "successful": "${entityName} Успішно", + "telemetry": "Телеметрія", + "telemetry-persistence": "Збереження телеметрії", + "telemetry-persistence-daily-activity": "Щоденна активність збереження телеметрії", + "telemetry-persistence-hourly-activity": "Погодинна активність збереження телеметрії", + "telemetry-persistence-monthly-activity": "Щомісячна активність збереження телеметрії", + "transport": "Транспорт", + "transport-msg-hourly-activity": "Погодинна активність транспортних повідомлень", + "transport-msg-daily-activity": "Щоденна активність транспортних повідомлень", + "transport-msg-monthly-activity": "Щомісячна активність транспортних повідомлень", + "transport-daily-activity": "Щоденна активність транспорту", + "transport-data-points": "Транспортні точки даних", + "transport-data-points-hourly-activity": "Погодинна активність транспортних точок даних", + "transport-data-points-daily-activity": "Щоденна активність транспортних точок даних", + "transport-data-points-monthly-activity": "Щомісячна активність транспортних точок даних", + "view-details": "Переглянути деталі", + "view-statistics": "Переглянути статистику", + "transport-messages": "Транспортні повідомлення", + "transport-messages-hourly-activity": "Погодинна активність транспортних повідомлень", + "transport-data-point-hourly-activity": "Погодинна активність транспортних точок даних", + "javascript-function-executions": "Виконання функцій JavaScript", + "javascript-function-executions-hourly-activity": "Погодинна активність виконання функцій JavaScript", + "javascript-function-executions-daily-activity": "Щоденна активність виконання функцій JavaScript", + "javascript-function-executions-monthly-activity": "Щомісячна активність виконання функцій JavaScript", + "tbel-function-executions": "Виконання функцій TBEL", + "tbel-function-executions-hourly-activity": "Погодинна активність виконання функцій TBEL", + "tbel-function-executions-daily-activity": "Щоденна активність виконання функцій TBEL", + "tbel-function-executions-monthly-activity": "Щомісячна активність виконання функцій TBEL", + "created-reports": "Створені звіти", + "created-reports-hourly-activity": "Погодинна активність створення звітів", + "created-reports-daily-activity": "Щоденна активність створення звітів", + "created-reports-monthly-activity": "Щомісячна активність створення звітів", + "emails": "Emails", + "emails-hourly-activity": "Emails погодинна активність", + "emails-daily-activity": "Emails щоденна активність", + "emails-monthly-activity": "Emails щомісячна активність", + "status": { + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "warning": "Попередження" + } + }, + "api-limit": { + "cassandra-write-queries-core": "Запити запису Cassandra для Rest API", + "cassandra-read-queries-core": "Запити читання Cassandra телеметрії для Rest API та WS", + "cassandra-write-queries-rule-engine": "Запити запису Cassandra телеметрії механізму правил", + "cassandra-read-queries-rule-engine": "Запити читання Cassandra телеметрії механізму правил", + "cassandra-write-queries-monolith": "Запити запису Cassandra телеметрії Monolith", + "cassandra-read-queries-monolith": "Запити читання Cassandra телеметрії Monolith", + "entity-version-creation": "Створення версії сутності", + "entity-version-load": "Завантаження версії сутності", + "notification-requests": "Запити сповіщень", + "notification-requests-per-rule": "Запити сповіщень на правило", + "rest-api-requests": "Запити REST API", + "rest-api-requests-per-customer": "Запити REST API на клієнта", + "transport-messages": "Транспортні повідомлення", + "transport-messages-per-device": "Транспортні повідомлення на пристрій", + "transport-messages-per-gateway": "Транспортні повідомлення на gateway", + "transport-messages-per-gateway-device": "Транспортні повідомлення на gateway пристрій", + "ws-updates-per-session": "Оновлення WS на сеанс", + "edge-events": "Події Edge", + "edge-events-per-edge": "Події Edge на Edge", + "edge-uplink-messages": "Повідомлення uplink Edge", + "edge-uplink-messages-per-edge": "Повідомлення uplink Edge на Edge" + }, + "api-key": { + "api-key": "API-ключ", + "api-keys": "API-ключі", + "delete-api-key-title": "Ви впевнені, що хочете видалити API-ключ '{{name}}'?", + "delete-api-key-text": "Будьте обережні: після підтвердження ключ буде неможливо відновити.", + "delete-api-keys-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 API-ключ} other {# API-ключів} }?", + "delete-api-keys-text": "Будьте обережні: після підтвердження всі вибрані ключі буде неможливо відновити.", + "expiration-date": "Дата закінчення дії", + "date": "дата", + "description": "Опис", + "disable": "Вимкнути", + "edit-description": "Редагувати опис", + "enable": "Увімкнути API-ключ ", + "expiration-time": "Час закінчення дії", + "expiration-time-never": "Ніколи", + "expiration-time-custom": "Користувацький", + "generate": "Згенерувати", + "generate-title": "Згенерувати API-ключ", + "generate-text": "Примітка: API-ключ успадковує дозволи користувача, для якого його створено.", + "generated-api-key-title": "API-ключ згенеровано. Перевірмо підключення!", + "generated-api-key-copy": "Обов'язково скопіюйте та збережіть Ваш API-ключ зараз, оскільки Ви більше не зможете його побачити.", + "generated-api-key-command": "Скористайтеся наведеними нижче інструкціями, щоб перевірити підключення. У результаті Ви маєте отримати інформацію про поточного користувача:", + "generated-api-key-insecure-url": "Виконання команд через незахищене HTTP-з'єднання надішле Ваш API-ключ без шифрування, що робить його вразливим до перехоплення.", + "list": "{ count, plural, =1 {Один API-ключ} other {Список із # API-ключів} }", + "manage": "Керувати", + "manage-api-keys": "Керувати API-ключами", + "no-found": "API-ключів не знайдено", + "selected-api-keys": "Вибрано { count, plural, =1 {1 API-ключ} other {# API-ключів} }", + "search": "Пошук API-ключів", + "status": "Статус", + "status-active": "Активний", + "status-inactive": "Неактивний", + "status-expired": "Термін дії минув" + }, + "audit-log": { + "audit": "Аудит", + "audit-logs": "Журнали аудиту", + "timestamp": "Мітка часу", + "entity-type": "Тип сутності", + "entity-name": "Назва сутності", + "user": "Користувач", + "type": "Тип", + "status": "Статус", + "details": "Деталі", + "type-added": "Додано", + "type-deleted": "Видалено", + "type-updated": "Оновлено", + "type-attributes-updated": "Атрибути оновлено", + "type-attributes-deleted": "Атрибути видалено", + "type-rpc-call": "Виклик RPC", + "type-credentials-updated": "Облікові дані оновлено", + "type-assigned-to-customer": "Призначено клієнту", + "type-unassigned-from-customer": "Скасовано призначення клієнту", + "type-assigned-to-edge": "Призначено для Edge", + "type-unassigned-from-edge": "Скасовано призначення для Edge", + "type-activated": "Активовано", + "type-suspended": "Призупинено", + "type-credentials-read": "Облікові дані прочитано", + "type-attributes-read": "Атрибути прочитано", + "type-relation-add-or-update": "Зв'язок оновлено", + "type-relation-delete": "Зв'язок видалено", + "type-relations-delete": "Усі зв'язки видалено", + "type-alarm-ack": "Тривогу підтверджено", + "type-alarm-clear": "Тривогу очищено", + "type-alarm-delete": "Тривогу видалено", + "type-alarm-assign": "Тривогу призначено", + "type-alarm-unassign": "Призначення тривоги скасовано", + "type-added-comment": "Коментар додано", + "type-updated-comment": "Коментар оновлено", + "type-deleted-comment": "Коментар видалено", + "type-login": "Вхід", + "type-logout": "Вихід", + "type-lockout": "Блокування", + "status-success": "Успішно", + "status-failure": "Неуспішно", + "audit-log-details": "Деталі журналу аудиту", + "no-audit-logs-prompt": "Записів не знайдено", + "action-data": "Дані дії", + "failure-details": "Деталі помилки", + "search": "Пошук журналів аудиту", + "clear-search": "Очистити пошук", + "type-assigned-from-tenant": "Призначено від тенанта", + "type-assigned-to-tenant": "Призначено тенанту", + "type-provision-success": "Пристрій підготовлено", + "type-provision-failure": "Не вдалося підготувати пристрій", + "type-timeseries-updated": "Телеметрію оновлено", + "type-timeseries-deleted": "Телеметрію видалено", + "type-sms-sent": "SMS надіслано", + "any-type": "Будь-який тип", + "audit-log-filter-title": "Фільтр журналу аудиту", + "filter-title": "Фільтр", + "filter-types": "Типи журналу аудиту" + }, + "debug-settings": { + "label": "Конфігурація налагодження", + "on-failure": "Лише збої (24/7)", + "all-messages": "Усі повідомлення ({{time}})", + "failures": "Збої", + "entity": "сутність", + "hint": { + "main-limited": "Буде записано не більше ніж {{msg}} налагоджувальних повідомлень {{entity}} за {{time}}.", + "on-failure": "Записувати лише повідомлення про помилки.", + "all-messages": "Записувати всі налагоджувальні повідомлення." + } + }, + "calculated-fields": { + "expression": "Вираз", + "no-found": "Розрахункових полів не знайдено", + "list": "{ count, plural, =1 {Одне розрахункове поле} other {Список із # розрахункових полів} }", + "add": "Додати розрахункове поле", + "selected-fields": "Вибрано { count, plural, =1 {1 розрахункове поле} other {# розрахункових полів} }", + "type": { + "simple": "Просте", + "simple-hint": "Простий арифметичний розрахунок на основі вхідних аргументів.", + "script": "Скрипт", + "script-hint": "Розрахунок для визначених аргументів із використанням скрипту TBEL.", + "geofencing": "Геозони", + "geofencing-hint": "Оцінювання GPS-позиції сутності та переходів відносно налаштованих груп геозон.", + "propagation": "Поширення", + "propagation-hint": "Поширення даних на батьківські або дочірні сутності на основі напряму та типу зв'язку.", + "related-entities-aggregation": "Агрегація пов'язаних сутностей", + "related-entities-aggregation-hint": "Агрегація останніх даних із пов'язаних сутностей.", + "time-series-data-aggregation": "Агрегація даних часових рядів", + "time-series-data-aggregation-hint": "Агрегація історичних даних поточної сутності." + }, + "preview": "Попередній перегляд", + "arguments": "Аргументи", + "decimals-by-default": "Десяткові знаки за замовчуванням", + "debugging": "Налагодження розрахункових полів", + "calculated-field-details": "Деталі розрахункового поля", + "argument-name": "Назва аргументу", + "name": "Назва", + "datasource": "Джерело даних", + "add-argument": "Додати аргумент", + "test-script-function": "Тестувати функцію скрипту", + "no-arguments": "Потрібен щонайменше один аргумент.", + "argument-settings": "Налаштування аргументу", + "argument-current": "Поточна сутність", + "argument-current-tenant": "Поточний тенант", + "argument-device": "Пристрій", + "argument-asset": "Актив", + "argument-customer": "Клієнт", + "argument-tenant": "Поточний тенант", + "argument-owner": "Поточний власник", + "argument-relation-query": "Пов'язані сутності", + "argument-type": "Тип аргументу", + "attribute": "Атрибут", + "copy-argument-name": "Копіювати назву аргументу", + "timeseries-key": "Ключ часового ряду", + "device-name": "Назва пристрою", + "latest-telemetry": "Остання телеметрія", + "rolling": "Ковзне вікно часового ряду", + "attribute-scope": "Область атрибуту", + "server-attributes": "Серверні атрибути", + "client-attributes": "Клієнтські атрибути", + "shared-attributes": "Спільні атрибути", + "attribute-key": "Ключ атрибуту", + "default-value": "Значення за замовчуванням", + "default-value-required": "Потрібно вказати значення за замовчуванням.", + "limit": "Максимум значень", + "time-window": "Часове вікно", + "customer-name": "Назва клієнта", + "asset-name": "Назва активу", + "timeseries": "Часовий ряд", + "output": "Вихідні дані", + "output-hint": "Визначає, як обробляються вихідні дані.", + "create": "Створити нове розрахункове поле", + "file": "Файл розрахункового поля", + "invalid-file-error": "Некоректний формат файлу. Будь ласка, переконайтеся, що файл є валідним JSON-файлом.", + "import": "Імпортувати розрахункове поле", + "export": "Експортувати розрахункове поле", + "export-failed-error": "Не вдалося експортувати розрахункове поле: {{error}}", + "output-type": "Тип вихідних даних", + "delete-title": "Ви впевнені, що хочете видалити розрахункове поле '{{title}}'?", + "delete-text": "Будьте обережні: після підтвердження розрахункове поле та всі пов'язані дані буде неможливо відновити.", + "delete-multiple-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 розрахункове поле} other {# розрахункових полів} }?", + "delete-multiple-text": "Будьте обережні: після підтвердження всі вибрані розрахункові поля буде видалено, а всі пов'язані дані буде неможливо відновити.", + "test-with-this-message": "Тестувати з цим повідомленням", + "use-latest-timestamp": "Використовувати останню мітку часу", + "entity-coordinates": "Координати сутності", + "latitude-time-series-key": "Ключ часового ряду широти", + "latitude-time-series-key-required": "Потрібно вказати ключ часового ряду широти.", + "longitude-time-series-key": "Ключ часового ряду довготи", + "longitude-time-series-key-required": "Потрібно вказати ключ часового ряду довготи.", + "geofencing-zone-groups": "Групи геозон", + "geofencing-zone-groups-settings": "Налаштування груп геозон", + "target-zone": "Цільова зона", + "perimeter-key": "Ключ периметра", + "report-strategy": "Стратегія звітування", + "no-zone-configured": "Потрібна щонайменше одна зона.", + "no-zone-configured-required": "Потрібно налаштувати щонайменше одну групу зон.", + "add-zone-group": "Додати групу зон", + "report-transition-event-only": "Лише події переходу", + "report-presence-status-only": "Лише статус присутності", + "report-transition-event-and-presence": "Статус присутності та події переходу", + "perimeter-attribute-key": "Ключ атрибуту периметра", + "perimeter-attribute-key-required": "Потрібно вказати ключ атрибуту периметра.", + "perimeter-attribute-key-pattern": "Некоректний ключ атрибуту периметра.", + "entity-zone-relationship": "Шлях від сутності до зон", + "direction": "Напрям зв'язку", + "direction-from": "Від сутності до зони", + "direction-to": "Від зони до сутності", + "relation-type": "Тип зв'язку", + "create-relation-with-matched-zones": "Створювати зв'язки для вихідної сутності зі знайденими зонами", + "relation-level": "Рівень зв'язку", + "fetch-last-available-level": "Отримувати лише останній доступний рівень", + "zone-group-refresh-interval": "Інтервал оновлення груп зон", + "copy-zone-group-name": "Копіювати назву групи зон", + "open-details-page": "Відкрити сторінку деталей сутності", + "level": "Рівень", + "direction-level": "Напрям", + "direction-up": "Вгору", + "direction-up-parent": "Вгору до батьківської", + "direction-down": "Вниз", + "direction-down-child": "Вниз до дочірньої", + "add-level": "Додати рівень", + "delete-level": "Видалити рівень", + "no-level": "Рівні не налаштовано", + "levels-required": "Потрібно налаштувати щонайменше один рівень.", + "max-allowed-levels-error": "Рівень зв'язку перевищує максимально дозволений.", + "propagation-path-related-entities": "Шлях поширення до пов'язаних сутностей", + "propagate-type": { + "arguments-only": "Лише аргументи", + "expression-result": "Результат розрахунку" + }, + "script": "Скрипт", + "data-propagate": "Дані для поширення", + "output-key": "Вихідний ключ", + "copy-output-key": "Копіювати вихідний ключ", + "aggregation-path-related-entities": "Шлях агрегації до пов'язаних сутностей", + "deduplication-interval": "Інтервал дедуплікації", + "deduplication-interval-min": "Інтервал дедуплікації має бути щонайменше {{ sec }} секунд.", + "deduplication-interval-hint": "Мінімальний час між агрегаціями телеметрії.", + "deduplication-interval-required": "Потрібно вказати інтервал дедуплікації.", + "calculated-field-filter-title": "Фільтр розрахункових полів", + "filter-title": "Фільтр", + "calculated-field-types": "Типи розрахункових полів", + "events": "Події", + "any-type": "Будь-який тип", + "metrics": { + "metrics": "Метрики", + "metrics-empty": "Потрібно налаштувати щонайменше одну метрику.", + "metric-name": "Назва метрики", + "metric-name-required": "Потрібно вказати назву метрики.", + "metric-name-pattern": "Некоректна назва метрики.", + "metric-name-duplicate": "Метрика з такою назвою вже існує.", + "metric-name-max-length": "Назва метрики має бути меншою за 256 символів.", + "metric-name-forbidden": "Назва метрики зарезервована і не може використовуватися.", + "copy-metric-name": "Копіювати назву метрики", + "argument-name": "Назва аргументу", + "aggregation": "Агрегація", + "aggregation-type": { + "avg": "Середнє", + "min": "Мінімум", + "max": "Максимум", "sum": "Сума", - "count": "Рахувати", - "none": "Відсутня" - }, - "admin": { - "settings": "Налаштування", - "general": "Загальне", - "general-settings": "Загальні налаштування", - "outgoing-mail": "Поштовий сервер", - "outgoing-mail-settings": "Налаштування сервера вихідної пошти", - "system-settings": "Налаштування системи", - "test-mail-sent": "Тестовий лист успішно відправлено!", - "base-url": "Базова URL-адреса", - "base-url-required": "Необхідно вказати базову URL-адресу.", - "mail-from": "Електронна адреса", - "mail-from-required": "Необхідно вказати електронну адресу.", - "smtp-protocol": "Протокол SMTP", - "smtp-host": "Хост SMTP", - "smtp-host-required": "Необхідно вказати хост SMTP.", - "smtp-port": "SMTP-порт", - "smtp-port-required": "Необхідно вказати SMTP-порт.", - "smtp-port-invalid": "Це не схоже на дійсний SMTP-порт.", - "timeout-msec": "Час очікування (msec)", - "timeout-required": "Необхідно вказати час очікування.", - "timeout-invalid": "Це не схоже на правильний час очікування.", - "enable-tls": "Увімкнути TLS", - "tls-version" : "Версія TLS", - "send-test-mail": "Надіслати тестове повідомлення", - "use-system-mail-settings": "Використовувати параметри системного поштового сервера", - "mail-templates": "Шаблони електронної пошти", - "mail-template-settings": "Налаштування шаблонів електронної пошти", - "use-system-mail-template-settings": "Використовувати шаблони системної електронної пошти", - "mail-template": { - "mail-template": "Шаблон електронної пошти", - "test": "Тестове повідомлення", - "activation": "Повідомлення про активацію рахунку", - "account-activated": "Обліковий запис активовано", - "reset-password": "Відновити повідомлення пароля", - "password-was-reset": "Пароль було надіслано повідомленням" - }, - "mail-subject": "Тема повідомлення", - "mail-body": "Вміст повідомлення", - "security-settings": "Налаштування безпеки", - "password-policy": "Політика щодо паролів", - "minimum-password-length": "Мінімальна довжина пароля", - "minimum-password-length-required": "Потрібна мінімальна довжина пароля", - "minimum-password-length-range": "Мінімальна довжина пароля повинна бути в межах від 5 до 50", - "minimum-uppercase-letters": "Мінімальна кількість великих літер", - "minimum-uppercase-letters-range": "Мінімальна кількість великих літер не може бути негативною", - "minimum-lowercase-letters": "Мінімальна кількість малих літер", - "minimum-lowercase-letters-range": "Мінімальна кількість малих літер не може бути негативною", - "minimum-digits": "Мінімальна кількість цифр", - "minimum-digits-range": "Мінімальна кількість цифр не може бути негативною", - "minimum-special-characters": "Мінімальна кількість спеціальних символів", - "minimum-special-characters-range": "Мінімальна кількість спеціальних символів не може бути негативною", - "password-expiration-period-days": "Термін дії пароля в днях", - "password-expiration-period-days-range": "Термін дії пароля в днях не може бути негативним", - "password-reuse-frequency-days": "Частота повторного використання пароля в днях", - "password-reuse-frequency-days-range": "Частота повторного використання пароля в днях не може бути негативною", - "general-policy": "Загальна політика", - "max-failed-login-attempts": "Максимальна кількість невдалих спроб входу, перш ніж обліковий запис заблоковано", - "minimum-max-failed-login-attempts-range": "Максимальна кількість невдалих спроб входу не може бути негативною", - "user-lockout-notification-email": "У разі блокування облікового запису користувача, надішліть сповіщення на електронну пошту", - "smpp-provider": { - "smpp-version": "SMPP версія", - "smpp-host": "SMPP хост", - "smpp-host-required": "Необхідно вказати хост SMPP.", - "smpp-port": "SMPP порт", - "smpp-port-required": "Необхідно вказати порт SMPP.", - "system-id": "Id системи", - "system-id-required": "Необхідно вказати ID системи.", - "password": "Пароль", - "password-required": "Необхідно вказати пароль.", - "type-settings": "Налаштування типів", - "source-settings": "Налаштування джерела", - "destination-settings": "Налаштування призначення", - "additional-settings": "Додаткові налаштування", - "system-type": "Тип системи", - "bind-type": "Тип зв'язування", - "service-type": "Тип обслуговування", - "source-address": "Адреса джерела", - "source-ton": "Тип номера джерела", - "source-npi": "Ідентифікація плану нумерації джерела", - "destination-ton": "Тип номера призначення", - "destination-npi": "Ідентифікація плану нумерації призначення", - "address-range": "Діапазон адрес", - "coding-scheme": "Схема кодування" - }, - "resources": "Ресурси" - }, - "alarm": { - "alarm": "Сигнал тривоги", - "alarms": "Сигнали тривоги", - "select-alarm": "Вибрати сигнал тривоги", - "no-alarms-matching": "Сигналів тривоги '{{entity}}' не знайдено.", - "alarm-required": "Необхідно вказати сигнал тривоги", - "alarm-status": "Статус сигналу тривоги", - "search-status": { - "ANY": "Будь які", - "ACTIVE": "Активні", - "CLEARED": "Неактивні", - "ACK": "Прийняті", - "UNACK": "Неприйняті" - }, - "display-status": { - "ACTIVE_UNACK": "Активні та неприйняті", - "ACTIVE_ACK": "Активні та прийняті", - "CLEARED_UNACK": "Неактивні та неприйняті", - "CLEARED_ACK": "Неактивні та прийняті" - }, - "no-alarms-prompt": "Сигналів тривоги не знайдено", - "created-time": "Час створення", - "type": "Тип", - "severity": "Серйозність", - "originator": "Ініціатор", - "originator-type": "Тип ініціатору", - "details": "Деталі", - "status": "Статус", - "alarm-details": "Деталі сигналу тривоги", - "start-time": "Початок", - "end-time": "Кінець", - "ack-time": "Час прийняття", - "clear-time": "Час деактивації", - "severity-critical": "Критичні", - "severity-major": "Важливі", - "severity-minor": "Неважливі", - "severity-warning": "Попередження", - "severity-indeterminate": "Невизначені", - "acknowledge": "Прийняти", - "clear": "Деактивувати", - "search": "Шукати сигнали тривоги", - "selected-alarms": "{ count, plural, =1 {1 сигнал тривоги} other {# сигнали тривоги} } вибрані", - "no-data": "Немає даних для відображення", - "polling-interval": "Інтервал опитування (сек)", - "polling-interval-required": "Необхідно вказати інтервал опитування.", - "min-polling-interval-message": "Дозволяється щонайменше 1 секунда інтервалу очікування.", - "aknowledge-alarms-title": "Підтвердити { count, plural, =1 {1 сигнал тривоги} other {# сигнали тривоги} }", - "aknowledge-alarms-text": "Ви впевнені, що хочете підтвердити { count, plural, =1 {1 сигнал тривоги} other {# сигнали тривоги} }?", - "aknowledge-alarm-title": "Підтвердити сигнал тривоги", - "aknowledge-alarm-text": "Ви впевнені, що хочете підтвердити сигнал тривоги?", - "clear-alarms-title": "Деактивувати { count, plural, =1 {1 сигнал тривоги} other {# сигнали тривоги} }", - "clear-alarms-text": "Ви впевнені, що хочете деактивувати { count, plural, =1 {1 сигнал тривоги} other {# сигнали тривоги} }?", - "clear-alarm-title": "Деактивувати сигнал тривоги", - "clear-alarm-text": "Ви впевнені, що хочете деактивувати сигнал тривоги?", - "alarm-status-filter": "Фільтр статусу сигналу тривоги", - "max-count-load": "Максимальна кількість сигналів тривоги для завантаження (0 - необмежено)", - "max-count-load-required": "Необхідно вказати максимальну кількість сигналів тривоги для завантаження.", - "max-count-load-error-min": "Мінімальне значення 0.", - "fetch-size": "Розмір пакету для завантаження", - "fetch-size-required": "Необхідно вказати розмір пакету для завантаження.", - "fetch-size-error-min": "Мінімальне значення 10." - }, - "alias": { - "add": "Додати псевдонім ", - "edit": "Редагувати псевдонім", - "name": "Назва", - "name-required": "Необхідно вказати псевдонім", - "duplicate-alias": "Псевдонім з такою назвою вже існує.", - "filter-type-single-entity": "Єдина сутність", - "filter-type-entity-group": "Група сутностей", - "filter-type-entity-list": "Список сутностей", - "filter-type-entity-name": "Назва сутності", - "filter-type-entity-group-list": "Список груп сутностей", - "filter-type-entity-group-name": "Назва групи сутностей", - "filter-type-state-entity": "Сутність з стану панелі пристроїв", - "filter-type-state-entity-description": "Сутність, взята з параметрів стану панелі пристроїв", - "filter-type-asset-type": "Тип активу", - "filter-type-asset-type-description": "Тип активів '{{assetTypes}}'", - "filter-type-asset-type-and-name-description": "Активи з типом '{{assetTypes}}' та назвою, що починається з '{{prefix}}'", - "filter-type-device-type": "Тип пристрою", - "filter-type-device-type-description": "Тип пристроїв '{{deviceTypes}}'", - "filter-type-device-type-and-name-description": "Пристрої з типом '{{deviceTypes}}' з назвою, що починається з '{{prefix}}'", - "filter-type-entity-view-type": "Тип перегляду сутності", - "filter-type-entity-view-type-description": "Перегляд сутності з типом '{{entityViewTypes}}'", - "filter-type-entity-view-type-and-name-description": "Перегляд сутності з типом '{{entityViewTypes}}' та назвою, що починається з '{{prefix}}'", - "filter-type-relations-query": "Запит відносин", - "filter-type-relations-query-description": "{{entities}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", - "filter-type-asset-search-query": "Запит пошуку активу", - "filter-type-asset-search-query-description": "Активи з типами {{assetTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", - "filter-type-device-search-query": "Запит пошуку пристрою", - "filter-type-device-search-query-description": "Пристрої з типами {{deviceTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", - "filter-type-entity-view-search-query": "Запит пошуку переглядів сутностей", - "filter-type-entity-view-search-query-description": "Перегляд сутності з типами {{entityViewTypes}}, які мають {{relationType}} відношення {{direction}} {{rootEntity}}", - "entity-filter": "Фільтр сутності", - "resolve-multiple": "Як декілька сутностей", - "filter-type": "Тип фільтра", - "filter-type-required": "Необхідно вказати тип фільтра.", - "entity-filter-no-entity-matched": "Не знайдено жодних сутностей, які відповідають вказаному фільтру.", - "no-entity-filter-specified": "Фільтр сутностей не вказано", - "root-state-entity": "Використовувати сутність стану як кореневу", - "last-level-relation": "Використовувати лише відношення останнього рівня", - "group-state-entity": "Використовувати групу сутностей стану як кореневу", - "root-entity": "Коренева сутність", - "state-entity-parameter-name": "Параметр сутності стану", - "default-state-entity": "Сутність стану за замовчуванням", - "default-state-entity-group": "Група сутностей стану за замовчуванням", - "default-entity-parameter-name": "За замовчуванням", - "max-relation-level": "Максимальна глибина відносин", - "unlimited-level": "Необмежена глибина", - "state-entity": "Сутність стану панелі пристроїв", - "entities-of-group-state-entity": "Сутності із групи сутностей стану", - "all-entities": "Всі сутності", - "any-relation": "не вказано" - }, - "asset": { - "asset": "Актив", - "assets": "Активи", - "management": "Управління активами", - "view-assets": "Переглянути активи", - "add": "Додати активи", - "assign-to-customer": "Надати клієнту", - "assign-asset-to-customer": "Надати активи клієнту", - "assign-asset-to-customer-text": "Будь ласка, виберіть ресурси, призначені для клієнта", - "no-assets-text": "Не знайдено активів", - "assign-to-customer-text": "Будь ласка, виберіть клієнта, щоб надати активи", - "public": "Публічно", - "assignedToCustomer": "Наданий клієнту", - "make-public": "Зробити актив(-и) публічним(-и)", - "make-private": "Зробити актив(-и) приватним(-и)", - "unassign-from-customer": "Позбавити клієнта", - "delete": "Видалити актив", - "asset-public": "Актив є загальнодоступним", - "asset-type": "Тип активу", - "asset-type-required": "Необхідно вказати тип активу.", - "select-asset-type": "Виберіть тип активу", - "enter-asset-type": "Введіть тип активу", - "any-asset": "Будь-який актив", - "no-asset-types-matching": "Не знайдено жодних активів, що відповідають даному типу '{{entitySubtype}}'.", - "asset-type-list-empty": "Не вибрано жодного типу активів.", - "asset-types": "Типи активів", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "type": "Тип", - "type-required": "Необхідно вказати тип.", - "details": "Подробиці", - "events": "Події", - "add-asset-text": "Додати новий актив", - "asset-details": "Інформація про актив", - "assign-assets": "Надати активи", - "assign-assets-text": "Надати { count, plural, =1 {1 актив} other {# активи} } клієнту", - "delete-assets": "Видалити активи", - "unassign-assets": "Позбавити активів", - "unassign-assets-action-title": "Позбавити { count, plural, =1 {1 актив} other {# активи} } клієнта", - "assign-new-asset": "Надати новий актив", - "delete-asset-title": "Ви впевнені, що хочете видалити актив '{{assetName}}'?", - "delete-asset-text": "Будьте обережні, після підтвердження, актив і всі пов'язані з ним дані буде втрачено", - "delete-assets-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 актив} other {# активи} }?", - "delete-assets-action-title": "Видалити{ count, plural, =1 {1 актив} other {# активи} }", - "delete-assets-text": "Будьте обережні, після підтвердження всі вибрані об'єкти буде видалено, і всі пов'язані з ними дані буде втрачено.", - "make-public-asset-title": "Ви дійсно хочете, щоб актив '{{assetName}}' був загальнодоступним?", - "make-public-asset-text": "Після підтвердження, актив і всі його дані будуть доступними для інших.", - "make-private-asset-title": "Ви впевнені, що хочете зробити актив {{assetName}} приватним?", - "make-private-asset-text": "Після підтвердження, актив та всі його дані будуть приватними та не будуть доступні іншим.", - "unassign-asset-title": "Ви впевнені, що хочете позбавити активу '{{assetName}}'?", - "unassign-asset-text": "Після підтвердження клієнт буде позбавлений активу. Дані активу не будуть доступні клієнту.", - "unassign-asset": "Позбавити активу", - "unassign-assets-title": "Ви впевнені, що хочете позбавити активів { count, plural, =1 {1 актив} other {# активи} }?", - "unassign-assets-text": "Після підтвердження, клієнт буде позбавлений усіх вибраних активів. Дані активів не будуть доступні клієнту.", - "copyId": "Копіювати Id активу", - "idCopiedMessage": "Id активу був скопійований у буфер обміну", - "select-asset": "Виберіть актив", - "no-assets-matching": "Не знайдено жодних активів, що відповідають '{{entity}}'.", - "asset-required": "Необхідно вказати актив", - "name-starts-with": "Назва активу починається з", - "selected-assets": "{ count, plural, =1 {1 актив} other {# активи} } selected", - "search": "Пошук активів", - "select-group-to-add": "Виберіть цільову групу, щоб додати вибрані активи", - "select-group-to-move": "Виберіть цільову групу для переміщення вибраних активів", - "remove-assets-from-group": "Ви впевнені, що хочете видалити { count, plural, =1 {1 актив} other {# актив} } з групи '{entityGroup}'?", - "group": "Група активів", - "list-of-groups": "{ count, plural, =1 {Одна група активів} other {Список # груп активів} }", - "group-name-starts-with": "Групи активів, назви яких починаються з '{{prefix}}'", - "import": "Імпортувати активи", - "asset-file": "Файл з активами", - "label": "Мітка" - }, - "attribute": { - "attributes": "Атрибути", - "latest-telemetry": "Остання телеметрія", - "attributes-scope": "Область видимості атрибутів", - "scope-telemetry": "Телеметрія", - "scope-latest-telemetry": "Остання телеметрія", - "scope-client": "Клієнтські атрибути", - "scope-server": "Серверні атрибути", - "scope-shared": "Спільні атрибути", - "add": "Додати атрибут", - "add-attribute-prompt": "Будь ласка, додайте атрибут", + "count": "Кількість", + "count-unique": "Кількість унікальних" + }, + "filtered": "Відфільтровано", + "value-source": "Джерело значення", + "value-source-hint": "Визначає, як отримується значення для агрегації.", + "value-source-type": { "key": "Ключ", - "last-update-time": "Останнє оновлення", - "key-required": "Необхідно вказати ключ атрибута.", - "value": "Значення", - "value-required": "Необхідно вказати значення атрибута.", - "delete-attributes-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 attribute} other {# attributes} }?", - "delete-attributes-text": "Будьте обережні, після підтвердження, всі виділені атрибути будуть видалені.", - "delete-attributes": "Видалити атрибути", - "enter-attribute-value": "Введіть значення атрибута", - "show-on-widget": "Показати на віджеті", - "widget-mode": "Режим віджетів", - "next-widget": "Наступний віджет", - "prev-widget": "Попередній віджет", - "add-to-dashboard": "Додати до інформаційної панелі", - "add-widget-to-dashboard": "Додати віджет до інформаційної панелі", - "selected-attributes": "{ count, plural, =1 {1 attribute} other {# attributes} } selected ...вибрані вибрати", - "selected-telemetry": "{ count, plural, =1 {1 telemetry unit} other {# telemetry units} } selected" + "function": "Функція" + }, + "no-metrics-configured": "Потрібна щонайменше одна метрика.", + "add-metric": "Додати метрику", + "max-metrics": "Досягнуто максимальної кількості метрик.", + "metric-settings": "Налаштування метрики", + "filter": "Фільтр", + "filter-hint": "Увімкніть, щоб фільтрувати сутності під час агрегації. Функція фільтра має повертати булеве значення та може використовувати всі налаштовані аргументи." }, - "api-usage": { - "api-usage": "Використання API" - }, - "audit-log": { - "audit": "Операція", - "audit-logs": "Журнал операцій", - "timestamp": "Тимчасова позначка", - "entity-type": "Тип одиниці", - "entity-name": "Назва організації", - "user": "Користувач", - "type": "Тип", - "status": "Статус", - "details": "Подробиці", - "type-added": "Додано", - "type-deleted": "Вилучено", - "type-updated": "Оновлено", - "type-attributes-updated": "Атрибути оновлені", - "type-attributes-deleted": "Атрибути видалені", - "type-rpc-call": "RPC дзвінок", - "type-credentials-updated": "Авторизаційні дані оновлено", - "type-assigned-to-customer": "Призначено клієнту", - "type-unassigned-from-customer": "Позбавлено від клієнта", - "type-activated": "Активовано", - "type-suspended": "Призупинено", - "type-credentials-read": "Авторизаційні дані прочитані", - "type-attributes-read": "Атрибути читаються", - "type-added-to-entity-group": "Додано до групи", - "type-removed-from-entity-group": "Вилучено з групи", - "type-relation-add-or-update": "Відношення оновлено", - "type-relation-delete": "Відношення видалено", - "type-relations-delete": "Всі відношення видалено", - "type-alarm-ack": "Визнано", - "type-alarm-clear": "Очищено", - "type-login": "Вхід", - "type-logout": "Вихід", - "type-lockout": "Заблокований", - "type-rest-api-rule-engine-call": "Rule engine REST API call", - "status-success": "Успішно", - "status-failure": "Невдало", - "audit-log-details": "Подробиці журналу операцій", - "no-audit-logs-prompt": "Жодних журналів операцій не знайдено", - "action-data": "Дані про дії", - "failure-details": "Невдалі подробиці", - "search": "Пошук журналів перевірки", - "clear-search": "Очистити пошук" - }, - "confirm-on-exit": { - "message": "У вас є незбережені зміни. Ви впевнені, що хочете залишити цю сторінку?", - "html-message": "У вас є незбережені зміни.
Ви впевнені, що хочете залишити цю сторінку?", - "title": "Незбережені зміни" - }, - "contact": { - "country": "Країна", - "city": "Місто", - "state": "Штат / Провінція", - "postal-code": "Поштовий індекс", - "postal-code-invalid": "Неправильний формат поштового індексу.", - "address": "Адреса", - "address2": "Адреса 2", - "phone": "Телефон", - "email": "Електронна пошта", - "no-address": "Немає адреси" - }, - "common": { - "username": "Ім'я користувача", - "password": "Пароль", - "enter-username": "Введіть ім'я користувача", - "enter-password": "Введіть пароль", - "enter-search": "Введіть пошук", - "created-time": "Час створення" - }, - "converter": { - "converter": "Перетворювач даних", - "converters": "Перетворювачі даних", - "select-converter": "Виберіть перетворювач даних", - "no-converters-matching": "Не має перетворювачів даних, які відповідають '{{entity}}'.", - "converter-required": "Необхідно вказати перетворювач даних", - "delete": "Видалити перетворювач даних", - "management": "Управління перетворювачами даних", - "add-converter-text": "Додати новий перетворювач даних", - "no-converters-text": "Перетворювачів даних не знайдено", - "selected-converters": "{ count, plural, =1 {1 перетворювач даних} other {# перетворювачі даних} } вибраний", - "delete-converter-title": "Ви впевнені, що хочете видалити перетворювач даних '{{converterName}}'?", - "delete-converter-text": "Будьте обережні, після підтвердження, перетворювач даних та всі пов'язані з ним дані,стануть недоступними).", - "delete-converters-title": "Ви впевнені, що хочете видалити{ count, plural, =1 {1 перетворювач даних} other {# перетворювачі даних} }?", - "delete-converters-action-title": "Видалити { count, plural, =1 {1 перетворювач даних} other {# перетворювачі даних} }", - "delete-converters-text": "Будьте обережні, після підтвердження всі вибрані перетворювачі даних буде видалено, і всі пов'язані з ними дані буде втрачено.", - "events": "Події", - "add": "Додати перетворювач даних", - "converter-details": "Подробиці про перетворювач даних", - "details": "Подробиці", - "copyId": "Копіювати Id перетворювача даних", - "idCopiedMessage": "Id перетворювача даних було скопійовано у буфер обміну", - "debug-mode": "Режим налагодження", - "name": "Ім'я", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "decoder": "Декодер", - "encoder": "Кодер", - "test-decoder-fuction": "Тестування функції декодера", - "test-encoder-fuction": "Тестування функції кодера", - "decoder-input-params": "Вхідні параметри декодера", - "encoder-input-params": "Вхідні параметри кодера", - "payload": "Вхідне повідомлення", - "payload-content-type": "Тип контенту вхідного повідомлення", - "payload-content": "Зміст вхідного повідомлення", - "message": "Повідомлення", - "message-type": "Тип повідомлення", - "message-type-required": "Необхідно вказати тип повідомлення", - "test": "Тест", - "metadata": "Метадані", - "metadata-required": "Записи метаданих не можуть бути порожніми.", - "integration-metadata": "Метадані інтеграції", - "integration-metadata-required": "Параметри метаданих інтеграції не можуть бути порожніми.", - "output": "Вихідні дані", - "import": "Імпорт перетворювача даних", - "export": "Експорт перетворювача даних", - "export-failed-error": "Неможливо експортувати перетворювач даних: {{помилка}}", - "create-new-converter": "Створити новий перетворювач даних", - "converter-file": "Файл перетворювача даних", - "invalid-converter-file-error": "Неможливо імпортувати перетворювач даних: недійсна структура даних перетворювача.", - "type": "Тип", - "type-required": "Необхідно вказати тип.", - "type-uplink": "Від пристрою", - "type-downlink": "До пристрою" - }, - "content-type": { - "json": "Json", - "text": "Текст", - "binary": "Бінарний (Base64)" - }, - "customer": { - "customer": "Клієнт", - "customers": "Клієнти", - "management": "Клієнтський менеджмент", - "dashboard": "Інформаційна панель клієнта", - "dashboards": "Інформаційні панелі клієнта", - "devices": "Пристрої клієнта", - "entity-views": "Представлення сутностей", - "assets": "Клієнтські активи", - "public-dashboards": "Публічні інформаційні панелі", - "public-devices": "Публічні пристрої", - "public-assets": "Публічні активи", - "public-entity-views": "Публічне представлення сутностей 440", - "add": "Додати клієнта", - "delete": "Видалити клієнта", - "manage-customer-users": "Керування користувачами клієнта", - "manage-customer-devices": "Керування пристроями клієнта", - "manage-customer-dashboards": "Керування інформаційними панелями клієнта", - "manage-public-devices": "Керувати загальнодоступними пристроями", - "manage-public-dashboards": "Керування загальнодоступними інформаційними панелями", - "manage-customer-assets": "Керування активами клієнта", - "manage-public-assets": "Керування загальнодоступними активами", - "add-customer-text": "Додати нового клієнта", - "no-customers-text": "Клієнтів не знайдено", - "customer-details": "Інформація про клієнта", - "delete-customer-title": "Ви впевнені, що хочете видалити клієнта '{{customerTitle}}'?", - "delete-customer-text": "Будьте обережні, після підтвердження, клієнт та всі пов'язані з ним дані, стануть недоступними.", - "delete-customers-title": "Ви впевнені, що хочете видалити {count, plural, =1 {1 клієнт} other {# клієнти} }?", - "delete-customers-action-title": "Видалити{ count, plural, =1 {1 клієнт} other {# клієнти} }", - "delete-customers-text": "Будьте обережні, після підтвердження, всі вибрані клієнти будуть видалені і всі пов'язані з ними дані, стануть недоступними.", - "manage-users": "Керування користувачами", - "manage-assets": "Керування активами", - "manage-devices": "Керування пристроями", - "manage-dashboards": "Керування інформаційними панелями", - "title": "Назва", - "title-required": "Необхідно вказати назву.", - "description": "Опис", - "details": "Подробиці", - "events": "Події", - "copyId": "Копіювати Id клієнта", - "idCopiedMessage": "Id клієнта було скопійовано в буфер обміну", - "select-customer": "Виберіть клієнта", - "no-customers-matching": "Клієнтів, які відповідають '{{entity}}' не знайдено.", - "customer-required": "Необхідно вказати клієнта", - "selected-customers": "{ count, plural, =1 {1 клієнт} other {# клієнти} } вибрано", - "search": "Пошук клієнтів", - "select-group-to-add": "Виберіть цільову групу, щоб додати вибраних клієнтів", - "select-group-to-move": "Виберіть цільову групу для переміщення вибраних клієнтів", - "remove-customers-from-group": "Ви впевнені, що хочете видалити{ count, plural, =1 {1 клієнт} other {# клієнти} } з групи'{entityGroup}'?", - "group": "Група клієнтів", - "list-of-groups": "{ count, plural, =1 {Одна група клієнтів} other {Список # груп клієнтів} }", - "group-name-starts-with": "Групи клієнтів, імена яких починаються з '{{prefix}}'", - "select-default-customer": "Вибрати клієнта за замовчуванням", - "default-customer": "Клієнт за замовчуванням", - "default-customer-required": "Необхідно вказати клієнта за замовчуванням для налагодження панелі візуалізації на рівні замовника", - "allow-white-labeling": "Дозволити брендування" - }, - "custom-translation": { - "custom-translation": "Переклад для користувача", - "translation-map": "Карта перекладу", - "key": "Ключ перекладу", - "import": "Імпорт перекладу", - "export": "Експорт перекладу", - "export-data": "Дані про експорт перекладу", - "import-data": "Дані про імпорт перекладу", - "translation-file": "Файл перекладу", - "invalid-translation-file-error": "Неможливо імпортувати файл перекладу: недійсна структура даних перекладу.", - "custom-translation-hint": "Визначте індивідуальний переклад в JSON нижче. Цей JSON перезапише переклад за замовчуванням. Натисніть 'Завантажити файл перекладу', щоб отримати існуючий переклад. Ви також можете скористатись завантаженим файлом як посиланням на наявні пари параметрів перекладу ключ-значення.", - "download-locale-file": "Завантажити файл перекладу" - }, - "datetime": { - "date-from": "Дата від", - "time-from": "Час від", - "date-to": "Дата до", - "time-to": "Час до" - }, - "dashboard": { - "dashboard": "Панель приладів", - "dashboards": "Панелі приладів", - "management": "Управління панеллю приладів", - "view-dashboards": "Переглянути панелі приладів", - "add": "Додати панель приладів", - "assign-dashboard-to-customer": "Призначити панель(і) приладів замовнику", - "assign-dashboard-to-customer-text": "Будь ласка, виберіть панелі пристроїв, щоб призначити їх клієнту", - "assign-to-customer-text": "Виберіть клієнта, щоб призначити панелі пристроїв", - "assign-to-customer": "Призначити клієнту", - "unassign-from-customer": "Позбавити клієнта", - "make-public": "Зробити панель приладів публічною", - "make-private": "Зробити панель приладів приватною", - "manage-assigned-customers": "Керування призначеними клієнтами", - "assigned-customers": "Призначені клієнтам", - "assign-to-customers": "Призначити панелі приладів клієнтам", - "assign-to-customers-text": "Виберіть клієнтів для призначення панелей приладів", - "unassign-from-customers": "Позбавити клієнтів призначених панелей приладів", - "unassign-from-customers-text": "Виберіть клієнтів для позбавлення їх призначених панелей приладів", - "no-dashboards-text": "Панелі приладів не знайдені", - "no-widgets": "Не налаштовано жодних віджетів", - "add-widget": "Додати новий віджет", - "title": "Назва", - "select-widget-title": "Вибрати віджет", - "copyId": "Копіювати ідентифікатор панелі приладів", - "idCopiedMessage": "Ідентифікатор панелі приладів скопійовано в буфер обміну", - "select-widget-subtitle": "Список доступних типів віджетів", - "delete": "Видалити панель приладів", - "title-required": "Необхідно вказати назву.", - "description": "Опис", - "details": "подробиці", - "dashboard-details": "Подробиці панелі приладів", - "add-dashboard-text": "Додати нову панель приладів", - "assign-dashboards": "Призначити панель приладів", - "assign-new-dashboard": "Призначити нову панель приладів", - "assign-dashboards-text": "Призначити { count, plural, =1 {1 панель приладів} other {# панелі приладів} } користувачам", - "unassign-dashboards-action-text": "Позбавити { count, plural, =1 {1 палелі приладів} other {# панелей приладів} } клієнтів", - "delete-dashboards": "Видалити панель приладів", - "unassign-dashboards": "Позбавити панелей приладів", - "unassign-dashboards-action-title": "Позбавити { count, plural, =1 {1 палелі приладів} other {# панелей приладів} } клієнтів", - "delete-dashboard-title": "Ви впевнені, що хочете видалити панель приладів '{{назва панелі приладів}}'?", - "delete-dashboard-text": "Будьте обережні, після підтвердження, панель приладів і всі пов'язані з нею дані стануть недоступними.", - "delete-dashboards-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 панель приладів} other {# панелі приладів} }?", - "delete-dashboards-action-title": "Видалити { count, plural, =1 {1 панель приладів} other {# панелі приладів} }", - "delete-dashboards-text": "Будьте обережні, після підтвердження, всі вибрані панелі приладів буде видалено, і всі пов'язані з ними дані стануть недоступними.", - "unassign-dashboard-title": "Ви впевнені, що хочете позбавити панелі приладів '{{назва інформаційної панелі}}'?", - "unassign-dashboard-text": "Після підтвердження, клієнт буде позбавлений панелі приладів. Панель приладів і пов'язані з нею дані будуть недоступні клієнтові.", - "unassign-dashboard": "Позбавити панелі приладів", - "unassign-dashboards-title": "Ви впевнені, що хочете позбавити { count, plural, =1 {1 панелі приладів} other {# панелей приладів} }?", - "unassign-dashboards-text": "Після підтвердження, клієнт буде позбавлений усіх вибраних панелей приладів і даних, які з ними пов'язані.", - "public-dashboard-title": "Панель приладів тепер публічна", - "public-dashboard-text": "Ваша панель приладів {{dashboardTitle}} тепер публічна і доступна іншим link:", - "public-dashboard-notice": "Note: Не забудьте зробити спільні пристрої загальнодоступними, щоб отримати доступ до їхніх даних.", - "make-private-dashboard-title": "Ви впевнені, що хочете зробити панель приладів '{{назва панелі приладів}}' приватною?", - "make-private-dashboard-text": "Після підтвердження панель приладів стане приватною і не буде доступною іншим.", - "make-private-dashboard": "Зробити панель приладів приватною", - "socialshare-text": "'{{dashboardTitle}}' powered by ThingsBoard", - "socialshare-title": "'{{dashboardTitle}}' powered by ThingsBoard", - "select-dashboard": "Вибрати панель приладів", - "no-dashboards-matching": "Не знайдено жодних панелей приладів '{{entity}}' які відповідають.", - "dashboard-required": "Необхідно вказати панель приладів.", - "select-existing": "Виберіть існуючу панель приладів", - "create-new": "Створити нову панель приладів", - "new-dashboard-title": "Нова назва панелі приладів", - "open-dashboard": "Відрити панель приладів", - "set-background": "Встановити фон", - "background-color": "Колір фону", - "background-image": "Фонове зображення", - "background-size-mode": "Режим фонового розміру", - "no-image": "Не вибрано жодного зображення", - "drop-image": "Перетягніть зображення або клацніть, щоб вибрати файл для завантаження.", - "settings": "Налаштування", - "columns-count": "Кількість стовпців", - "columns-count-required": "Необхідно вказати кількість стовпців.", - "min-columns-count-message": "Дозволений мінімум - 10 стовпців.", - "max-columns-count-message": "Дозволений максимум - 1000 стовпців.", - "widgets-margins": "Відступ між віджетами", - "horizontal-margin": "Горизонтальний відступ", - "horizontal-margin-required": "Необхідно вказати горизонтальний відступ.", - "min-horizontal-margin-message": "Допустиме мінімальне значення горизонтального відступу - 0.", - "max-horizontal-margin-message": "Допустиме максимальне значення горизонтального відступу - 50.", - "vertical-margin": "Вертикальний відступ", - "vertical-margin-required": "Необхідно вказати вертикальний відступ.", - "min-vertical-margin-message": "Допустиме мінімальне значення вертикального відступу - 0.", - "max-vertical-margin-message": "Допустиме максимальне значення вертикального відступу - 50.", - "autofill-height": "Висота автоматичного заповнення макета", - "mobile-layout": "Налаштування макета для мобільних пристроїв", - "mobile-row-height": "Висота рядка для мобільних пристроїв, px", - "mobile-row-height-required": "Потрібно вказати значення висоти рядка для мобільних пристроїв.", - "min-mobile-row-height-message": "Допустиме мінімальне значення висоти рядка для мобільних пристроїв - 5 пікселів.", - "max-mobile-row-height-message": "Допустиме максимальне значення висоти рядка для мобільних пристроїв - 200 пікселів.", - "display-title": "Відображати назву панелі візуалізації", - "toolbar-always-open": "Тримайте панель візуалізації відкритою", - "title-color": "Колір назви", - "display-dashboards-selection": "Відображення вибору панелей візуалізації", - "display-entities-selection": "Вибір відображення сутності", - "display-dashboard-timewindow": "Відобразити налаштування часового проміжку", - "display-dashboard-export": "Відображення експорту", - "import": "Імпортувати панель візуалізації", - "export": "Експортувати панель візуалізації", - "export-failed-error": "Неможливо експортувати панель візуалізації: {{error}}", - "export-pdf": "Експортувати як PDF", - "export-png": "Експортувати як PNG", - "export-jpg": "Експортувати як JPEG", - "export-json-config": "Експортувати конфігурацію JSON", - "download-dashboard-progress": "Генерування панелі візуалізації {{reportType}} ...", - "create-new-dashboard": "Створити нову панель візуалізації", - "dashboard-file": "Файл панелі візуалізації", - "invalid-dashboard-file-error": "Неможливо імпортувати панель візуалізації: неправильна структура даних панелі візуалізації.", - "dashboard-import-missing-aliases-title": "Configure aliases used by imported dashboard Налаштування псевдонімів, що використовуються імпортованою панеллю візуалізації", - "create-new-widget": "Створити новий віджет", - "import-widget": "Імпортувати віджет", - "widget-file": "Файл віджета", - "invalid-widget-file-error": "Неможливо імпортувати віджет: неправильна структура даних віджета.", - "widget-import-missing-aliases-title": "Налаштувати псевдоніми, що використовуються імпортованим віджетом", - "open-toolbar": "Відкрити панель інструментів", - "close-toolbar": "Закрити панель інструментів", - "configuration-error": "Помилка конфігурації", - "alias-resolution-error-title": "Помилка конфігурації псевдонімів панелі візуалізації", - "invalid-aliases-config": "Неможливо знайти пристрої, які відповідають певному фільтру псевдонімів.
Зверніться до свого адміністратора, щоб вирішити цю проблему.", - "select-devices": "Вибрати пристрої", - "assignedToCustomer": "Призначений клієнту", - "assignedToCustomers": "Призначений клієнтам", - "public": "Публічно", - "public-link": "Публічне посилання", - "copy-public-link": "Копіювати публічне посилання", - "public-link-copied-message": "Публічне посилання було скопійоване в буфер обміну панелі візуалізації", - "manage-states": "Керування станами панелі візуалізації", - "states": "Стани панелі візуалізації", - "search-states": "Пошук станів панелі візуалізації", - "selected-states": "{ count, plural, =1 {1 dashboard state} other {# dashboard states} } вибрано", - "edit-state": "Редагувати стан панелі візуалізації", - "delete-state": "Видалити стан панелі візуалізації ", - "add-state": "Додати стан панелі візуалізації", - "state": "Стан панелі візуалізації", - "state-name": "Ім'я", - "state-name-required": "Необхідно вказати назву стану панелі візуалізації.", - "state-id": "Стан Id", - "state-id-required": "Необхідно вказати id стану панелі візуалізації.", - "state-id-exists": "Стан інформаційної панелі з таким id вже існує.", - "is-root-state": "Основний стан", - "delete-state-title": "Видалити стан панелі візуалізації", - "delete-state-text": "Ви впевнені, що хочете видалити стан панелі візуалізації з іменем '{{stateName}}'?", - "show-details": "Показати деталі", - "hide-details": "Приховати деталі", - "select-state": "Виберіть цільовий стан", - "state-controller": "Контролер стану" - }, - "datakey": { - "settings": "Налаштування", - "advanced": "Додатково", - "label": "Мітка", - "color": "Колір", - "units": "Спеціальний символ, який показує наступне значення", - "decimals": "Кількість цифр після плаваючої точки", - "data-generation-func": "Функція генерації даних", - "use-data-post-processing-func": "Використовувати функцію пост-обробки даних", - "configuration": "Конфігурація ключа даних", - "timeseries": "Телеметрія", - "attributes": "Атрибути", - "entity-field": "Поле сутності", - "alarm": "Поля сигналу тривоги", - "timeseries-required": "Необхідно вказати Телеметрія.", - "timeseries-or-attributes-required": "Необхідно вказати телеметрію/атрибути.", - "maximum-timeseries-or-attributes": "Максимальні { count, plural, =1 {1 телеметрія/атрибут дозволені.} other {# телеметрія/атрибути дозволені} }", - "alarm-fields-required": "Необхідно вказати поля сигналу тривоги.", - "function-types": "Типи функцій", - "function-types-required": "Необхідно вказати типи функцій.", - "maximum-function-types": "Maximum { count, plural, =1 {1 function type is allowed.} other {# function types are allowed} }", - "time-description": "мітка часу поточного значення;", - "value-description": "поточне значення;", - "prev-value-description": "результат попереднього виклику функції;", - "time-prev-description": "мітка часу попереднього значення;", - "prev-orig-value-description": "оригінальне попереднє значення;" - }, - "datasource": { - "type": "Тип джерела даних", - "name": "Назва", - "add-datasource-prompt": "Додайте джерело даних" - }, - "details": { - "details": "Деталі", - "edit-mode": "Режим редагування", - "edit-json": "Редагувати JSON", - "toggle-edit-mode": "Перемкнути режим редагування" - }, - "device": { - "device": "Пристрій", - "device-required": "Необхідно вказати пристрій.", - "devices": "Пристрої", - "management": "Управління пристроєм", - "view-devices": "Перегляд пристроїв", - "device-alias": "Псевдонім пристрою", - "aliases": "Псевдонім пристроїв", - "no-alias-matching": "'{{alias}}' не знайдено.", - "no-aliases-found": "Псевдонімів не знайдено.", - "no-key-matching": "'{{key}}' не знайдено.", - "no-keys-found": "Ключі не знайдено.", - "create-new-alias": "Створити новий!", - "create-new-key": "Створити новий!", - "duplicate-alias-error": "Псевдонім '{{alias}}' вже існує.
Псевдоніми пристроїв повинні бути унікальними на панелі візуалізації.", - "configure-alias": "Налаштувати псевдонім '{{alias}}'", - "no-devices-matching": "Не знайдено жодних пристроїв, які відповідають '{{entity}}'.", - "alias": "Псевдонім", - "alias-required": "Необхідно вказати псевдонім пристрою.", - "remove-alias": "Видалити псевдонім пристрою", - "add-alias": "Додати псевдонім пристрою", - "name-starts-with": "Назва пристрою починається з", - "device-list": "Список пристроїв", - "use-device-name-filter": "Використати фільтр", - "device-list-empty": "Не вибрано жодного пристрою.", - "device-name-filter-required": "Необхідно вказати назву фільтра пристрою.", - "device-name-filter-no-device-matched": "Не знайдено жодних пристроїв, що починаються з '{{device}}'.", - "add": "Додати пристрій", - "assign-to-customer": "Призначити клієнту", - "assign-device-to-customer": "Призначити пристрій(-ої) клієнту", - "assign-device-to-customer-text": "Виберіть пристрої, які слід призначити клієнту", - "make-public": "Зробити пристрій публічним", - "make-private": "Зробити пристрій приватним", - "no-devices-text": "Не знайдено жодного пристрою", - "assign-to-customer-text": "Виберіть клієнта для призначення пристрою (їв)", - "device-details": "Деталі пристрою", - "add-device-text": "Додати новий пристрій", - "credentials": "Авторизаційні дані", - "manage-credentials": "Керування авторизаційними даними", - "delete": "Видалити пристрій", - "assign-devices": "Призначити пристрої", - "assign-devices-text": "Призначити { count, plural, =1 {1 пристрій} few {# пристрої} many {# пристроїв} other {# пристрій} } клієнту", - "delete-devices": "Видалити пристрої", - "unassign-from-customer": "Позбавити клієнта пристроїв", - "unassign-devices": "Позбавити пристроїв", - "unassign-devices-action-title": "Позбавити клієнта { count, plural, =1 {1 пристрою} other {# пристроїв} }", - "assign-new-device": "Призначити новий пристрій", - "make-public-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' публічним?", - "make-public-device-text": "Після підтвердження пристрій і всі його дані будуть публічними та доступними для інших.", - "make-private-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' приватним?", - "make-private-device-text": "Після підтвердження пристрій і всі його дані будуть приватними та недоступними для інших.", - "view-credentials": "Переглянути авторизаційні дані", - "delete-device-title": "Ви впевнені, що хочете видалити пристрій '{{deviceName}}'?", - "delete-device-text": "Будьте обережні, після підтвердження, пристрій і всі пов'язані з ним дані стануть недоступними.", - "delete-devices-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 пристрій} few {# пристрої} many {# пристроїв} other {# пристрій} }?", - "delete-devices-action-title": "Видалити { count, plural, =1 {1 пристрій} few {# пристрої} many {# пристроїв} other {# пристрій} }", - "delete-devices-text": "Будьте обережні, після підтвердження, всі вибрані пристрої будуть видалені, і всі пов'язані з ними дані стануть недоступними.", - "unassign-device-title": "Ви впевнені, що хочете позбавити пристрою '{{deviceName}}'?", - "unassign-device-text": "Після підтвердження, клієнт буде позбавлений пристрою.", - "unassign-device": "Позбавити пристою", - "unassign-devices-title": "Ви впевненені, що хочете позбавити { count, plural, =1 {1 пристрою} other {# пристроїв} }?", - "unassign-devices-text": "Після підтвердження, клієнт буде позбавлений пристрою і пристрій стане не доступним клієнту", - "device-credentials": "Авторизаційні дані прстрою", - "credentials-type": "Тип авторизаційних даних", - "access-token": "Маркер доступу", - "access-token-required": "Необхідно вказати маркер доступу.", - "access-token-invalid": "Маркер доступу має бути від 1 до 32 символів.", - "secret": "Секрет", - "secret-required": "Необхідно вказати секрет.", - "device-type": "Тип пристрою", - "device-type-required": "Необхідно вказати тип пристрою.", - "select-device-type": "Виберіть тип пристрою", - "enter-device-type": "Введіть тип пристрою", - "any-device": "Будь-який пристрій", - "no-device-types-matching": "Не знайдено типів пристроїв, які відповідають '{{entitySubtype}}'.", - "device-type-list-empty": "Не вибрано типів пристроїв.", - "device-types": "Типи пристрою", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "events": "Події", - "details": "Деталі", - "copyId": "Копіювати Id пристрою", - "copyAccessToken": "Копіювати маркер доступу", - "idCopiedMessage": "Id пристрою скопійовано в буфер обміну", - "accessTokenCopiedMessage": "Маркер доступу до пристрою скопійовано в буфер обміну", - "assignedToCustomer": "Призначений клієнту", - "unable-delete-device-alias-title": "Неможливо видалити псевдонім пристрою", - "unable-delete-device-alias-text": "Псевдонім пристрою '{{deviceAlias}}' не може бути видалений, оскільки він використовується наступним(и) віджетом(ами):
{{widgetsList}}", - "is-gateway": "Шлюз", - "public": "Публічно", - "device-public": "Пристрій є публічним", - "select-device": "Виберіть пристрій", - "import": "Імпортувати пристрої", - "device-file": "Файл з пристроями", - "selected-devices": "{ count, plural, =1 {1 пристрій} few {# пристрої} many {# пристроїв} other {# пристрій} } вибрано", - "search": "Шукати пристрої", - "select-group-to-add": "Виберіть цільову групу, щоб додати вибраний пристрій", - "select-group-to-move": "Виберіть цільову групу для переміщення вибраних пристроїв", - "remove-devices-from-group": "Ви впевнені, що хочете видалити { count, plural, =1 {1 пристрій} few {# пристрої} many {# пристроїв} other {# пристрій} } з групи '{entityGroup}'?", - "group": "Група пристроїв", - "list-of-groups": "{ count, plural, =1 {Одна група пристроїв} other {Список # груп пристроїв} }", - "group-name-starts-with": "Групи пристроїв, назви яких починаються з '{{prefix}}'" - }, - "asset-profile": { - "asset-profile": "Профіль активу", - "asset-profiles": "Профілі активів" - }, - "device-profile": { - "device-profile": "Профіль пристрою", - "device-profiles": "Профілі пристроїв" - }, - "dialog": { - "close": "Закрити діалогове вікно" - }, - "direction": { - "column": "Колонка", - "row": "Рядок" + "output-strategy": { + "strategy": "Стратегія", + "process-right-away": "Обробляти одразу", + "process-rule-chains": "Обробляти через ланцюги правил", + "save-time-series": "Зберігати у часові ряди", + "save-database": "Зберігати в базу даних", + "save-latest-values": "Зберігати останні значення", + "send-web-sockets": "Надсилати до WebSockets", + "save-calculated-fields": "Надсилати до розрахункових полів", + "update-attribute-only-on-value-change": "Оновлювати атрибут лише при зміні значення", + "send-attributes-updated-notification": "Надсилати сповіщення про оновлення атрибутів", + "ttl": "Користувацький TTL", + "ttl-required": "Потрібно вказати TTL", + "ttl-min": "Дозволено лише мінімальний TTL 0", + "processing-parameters": "Параметри обробки", + "hint": { + "strategy": "Керує тим, чи обробляється результат одразу, чи надсилається до ланцюга правил для додаткової обробки.", + "processing-options": "Параметри обробки", + "update-attribute-only-on-value-change": "Оновлює атрибут для кожного вхідного повідомлення незалежно від того, чи змінилося значення. Це збільшує використання API та знижує продуктивність.", + "update-attribute-only-on-value-change-enabled": "Оновлює атрибут лише коли значення змінюється. Якщо значення не змінилося, мітки часу не оновлюються і сповіщення не надсилаються.", + "send-attributes-updated-notification": "Надсилає подію Attributes Updated до ланцюга правил за замовчуванням.", + "save-time-series": "Зберігає дані часових рядів у таблиці ts_kv в базі даних.", + "save-database": "Зберігає дані атрибутів у базі даних.", + "save-latest-values": "Оновлює дані часових рядів у таблиці ts_kv_latest в базі даних, якщо нове значення є більш актуальним.", + "send-web-sockets-attribute": "Сповіщає підписки WebSocket про оновлення даних атрибутів.", + "send-web-sockets-time-series": "Сповіщає підписки WebSocket про оновлення даних часових рядів.", + "save-calculated-fields-attribute": "Сповіщає розрахункові поля про оновлення даних атрибутів.", + "save-calculated-fields-time-series": "Сповіщає розрахункові поля про оновлення даних часових рядів.", + "ttl": "Визначає період зберігання даних часових рядів. Якщо вимкнено, використовується TTL профілю тенанта." + } }, - "edge": { - "management": "Керування Edge", - "instances": "Інстанси", - "rulechain-templates": "Шаблони ланцюжків правил" + "aggregate-interval-type": "Тип інтервалу агрегації", + "aggregate-interval-value": "Значення інтервалу агрегації", + "aggregate-interval-value-required": "Потрібно вказати значення інтервалу агрегації.", + "aggregate-interval-value-min": "Значення інтервалу агрегації має бути щонайменше { sec, plural, =0 {0 с} =1 {1 с} other {# с} }.", + "aggregate-interval-value-step-multiple-of": "Значення інтервалу агрегації має бути дільником або кратним 1 добі.", + "aggregate-period": { + "hour": "Година", + "day": "День", + "week": "Тиждень (Пн - Нд)", + "week-sun-sat": "Тиждень (Нд - Сб)", + "month": "Місяць", + "quarter": "Квартал", + "year": "Рік", + "custom": "Користувацький" + }, + "aggregate-period-hint-offset": "Ваш інтервал агрегації буде: {{ interval }}", + "aggregate-period-hint-offset-and-so-on": "Ваш інтервал агрегації буде: {{ interval }} тощо.", + "entity-aggregation": { + "argument-hint": "Дані буде отримано з поточної сутності.", + "argument-title-hint": "Визначає вхідні аргументи, що використовуються для агрегації.", + "argument-setting-hint": "Остання телеметрія — єдиний доступний тип аргументу для цього розрахункового поля.", + "aggregation-interval": "Інтервал агрегації", + "aggregation-interval-hint": "Визначає, як часто виконується агрегація. Приклад: кожна 1 година агрегує дані о 00:00, 01:00, 02:00 тощо. Результати агрегації зберігаються з міткою часу, що відповідає початку інтервалу агрегації.", + "apply-offset": "Застосувати зсув до інтервалу агрегації", + "apply-offset-hint": "Визначає, на скільки зсунути початок кожного періоду агрегації (наприклад, +10 хвилин — 00:10, 01:10).", + "offset-value": "Значення зсуву", + "offset-value-required": "Потрібно вказати значення зсуву.", + "offset-value-min": "Значення зсуву має бути додатним цілим числом.", + "offset-value-max": "Значення зсуву має бути меншим за значення інтервалу агрегації.", + "wait-delay": "Застосувати тайм-аут очікування для затриманої телеметрії", + "wait-delay-hint": "Визначає, як довго чекати на затриману телеметрію після завершення інтервалу. Якщо така телеметрія надійде, результат для цього інтервалу буде перераховано.", + "duration": "Тривалість", + "duration-required": "Потрібно вказати тривалість.", + "duration-min": "Тривалість має бути щонайменше 1 хвилина.", + "duration-hint": "Як довго чекати на затримані дані після завершення інтервалу.", + "produce-intermediate-result": "Формувати проміжний результат", + "produce-intermediate-result-hint": "Обчислює метрики протягом поточного інтервалу, щоб сформувати проміжний результат. Оновлення відбуваються не частіше ніж один раз на {{ time }}." + }, + "hint": { + "arguments-simple-with-rolling": "Розрахункове поле типу 'Просте' не повинно містити ключів типу 'Ковзне вікно часового ряду'.", + "arguments-propagate-arguments-with-rolling": "Тип 'Ковзне вікно часового ряду' несумісний із поширенням 'Лише аргументи'.", + "arguments-propagate-argument-entity-type": "Тип сутності несумісний із поширенням 'Лише аргументи'.", + "arguments-propagate-argument-must-current-entity": "Потрібно налаштувати щонайменше один аргумент із типом вихідної сутності 'Поточна сутність'.", + "arguments-empty": "Потрібно вказати щонайменше один аргумент.", + "expression-required": "Потрібно вказати вираз.", + "expression-invalid": "Некоректний вираз", + "expression-max-length": "Довжина виразу має бути меншою за 255 символів.", + "argument-name-required": "Потрібно вказати назву аргументу.", + "argument-name-pattern": "Некоректна назва аргументу.", + "argument-name-duplicate": "Аргумент з такою назвою вже існує.", + "argument-name-max-length": "Назва аргументу має бути меншою за 256 символів.", + "argument-name-forbidden": "Назва аргументу зарезервована і не може використовуватися.", + "output-key-required": "Потрібно вказати вихідний ключ.", + "output-key-pattern": "Некоректний вихідний ключ.", + "output-key-duplicate": "Ключ з такою назвою вже існує.", + "output-key-max-length": "Вихідний ключ має бути меншим за 256 символів.", + "output-key-forbidden": "Вихідний ключ зарезервований і не може використовуватися.", + "entity-type-required": "Потрібно вказати тип сутності", + "name-required": "Потрібно вказати назву.", + "name-pattern": "Некоректна назва.", + "name-duplicate": "Назва з таким значенням уже існує.", + "name-max-length": "Назва має бути меншою за 256 символів.", + "name-forbidden": "Назва зарезервована і не може використовуватися.", + "argument-type-required": "Потрібно вказати тип аргументу.", + "max-args": "Досягнуто максимальної кількості аргументів.", + "decimals-range": "Десяткові знаки за замовчуванням мають бути числом від 0 до 15.", + "expression": "Вираз за замовчуванням демонструє, як перетворити температуру з Фаренгейта на Цельсій.", + "arguments-entity-not-found": "Цільову сутність аргументу не знайдено.", + "use-latest-timestamp": "Якщо увімкнено, розраховане значення буде збережено з використанням найновішої мітки часу з телеметрії аргументів замість часу сервера.", + "entity-coordinates": "Вкажіть ключі часових рядів, які надають GPS-координати сутності (широта та довгота).", + "geofencing-zone-groups": "Визначте одну або кілька груп геозон для перевірки (наприклад, 'allowedZones', 'restrictedZones'). Кожна група має мати унікальну назву, яка використовується як префікс для вихідних ключів телеметрії розрахункового поля.", + "perimeter-attribute-key": "Встановіть ключ атрибуту, який містить визначення периметра геозони. Периметр завжди береться з серверних атрибутів сутності зони.", + "report-strategy": "Статус присутності показує, чи перебуває сутність наразі INSIDE або OUTSIDE групи зон. Події переходу повідомляють, коли сутність ENTERED або LEFT групу зон.", + "create-relation-with-matched-zones": "Автоматично створювати та підтримувати зв'язки між сутністю та зонами, у яких вона наразі знаходиться. Зв'язки видаляються, коли сутність залишає зону, і створюються, коли вона входить у нову.", + "relation-type-required": "Потрібно вказати тип зв'язку.", + "relation-level-required": "Потрібно вказати рівень зв'язку.", + "relation-level-min": "Мінімальне значення рівня зв'язку — 1.", + "relation-level-max": "Максимальне значення рівня зв'язку — {{max}}.", + "geofencing-empty": "Потрібно налаштувати щонайменше одну групу геозон.", + "geofencing-entity-not-found": "Цільову сутність геозонування не знайдено.", + "max-geofencing-zone": "Досягнуто максимальної кількості геозон.", + "zone-group-refresh-interval": "Визначає, як часто оновлюються групи геозон, налаштовані через пов'язані сутності.", + "zone-group-refresh-interval-required": "Потрібно вказати інтервал оновлення груп геозон.", + "zone-group-refresh-interval-min": "Інтервал оновлення груп геозон має бути щонайменше {{ min }} секунд.", + "propagation-path-related-entities": "Визначає прямий одно-рівневий шлях до пов'язаної сутності на основі вибраного напряму та типу зв'язку. Підтримуються лише зв'язки між сутностями пристрою, активу, клієнта та тенанта. Максимальна кількість сутностей, що визначаються за шляхом зв'язку, — {{ max }}.", + "data-propagate": "Визначає дані, які потрібно поширити з аргументів, налаштованих нижче. 'Лише аргументи' використовує отримані дані безпосередньо, а 'Результат розрахунку' обчислює нове значення на основі цих даних.", + "aggregation-path-related-entities": "Визначає одно-рівневий шлях агрегації через прямі зв'язки з батьківськими або дочірніми сутностями на основі напряму та типу зв'язку. Підтримуються лише зв'язки між сутностями пристрою, активу, клієнта та тенанта. Максимальна кількість сутностей, що визначаються за шляхом зв'язку, — {{ max }}.", + "arguments-aggregation": "Визначає вхідні аргументи, що використовуються для фільтрації та агрегації.", + "setting-arguments-aggregation": "Дані буде отримано з пов'язаних сутностей, налаштованих у шляху агрегації.", + "metrics": "Визначає метрики, агреговані на основі налаштованих аргументів.", + "entity-aggregation-metrics": "Визначає метрики, агреговані на основі налаштованих аргументів за вказаними часовими інтервалами.", + "import-invalid-calculated-field-type": "Не вдалося імпортувати розрахункове поле: некоректна структура розрахункового поля.", + "simple-expression-title": "Арифметичний вираз, що визначає, як обчислюється розраховане значення.", + "script-title": "Скрипт TBEL, що визначає логіку розрахунку та вихідні значення.", + "simple-arguments": "Арифметичний вираз, що визначає, як обчислюється розраховане значення.", + "script-arguments": "Визначає вхідні аргументи, доступні для скрипту." + } + }, + "alarm-rule": { + "alarm-rules-tab": "Правила тривог", + "alarm-rule": "Правило тривоги", + "alarm-rules": "Правила тривог", + "alarm-rules-old": "Старі", + "alarm-rules-actual": "Актуальні", + "severities": "Рівні серйозності", + "cleared": "Умова очищення", + "delete-title": "Ви впевнені, що хочете видалити правило тривоги '{{title}}'?", + "delete-text": "Будьте обережні: після підтвердження правило тривоги та всі пов'язані дані буде неможливо відновити.", + "delete-multiple-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 правило тривоги} other {# правил тривог} }?", + "delete-multiple-text": "Будьте обережні: після підтвердження всі вибрані правила тривог буде видалено, а всі пов'язані дані буде неможливо відновити.", + "create": "Створити нове правило тривоги", + "add": "Додати правило тривоги", + "copy": "Клонувати конфігурацію правила тривоги", + "details": "Деталі правила тривоги", + "no-found": "Правил тривог не знайдено", + "list": "{ count, plural, =1 {Одне правило тривоги} other {Список із # правил тривог} }", + "selected-fields": "Вибрано { count, plural, =1 {1 правило тривоги} other {# правил тривог} }", + "import": "Імпортувати правило тривоги", + "file": "Файл правила тривоги", + "export": "Експортувати правило тривоги", + "export-failed-error": "Не вдалося експортувати правило тривоги: {{error}}", + "entity-type": "Тип сутності", + "entity-type-required": "Потрібно вказати тип сутності.", + "alarm-type": "Тип тривоги", + "alarm-type-hint": "Унікальний ідентифікатор (наприклад, HighTempAlarm) у межах ініціатора тривоги (Пристрій, Актив тощо) для запобігання конфліктам.", + "alarm-type-required": "Потрібно вказати тип тривоги.", + "alarm-type-pattern": "Некоректний тип тривоги.", + "alarm-type-max-length": "Тип тривоги має бути меншим за 256 символів.", + "clear-alarm": "Очистити тривогу", + "value-argument": "Аргумент", + "value-argument-required": "Потрібно вказати аргумент.", + "static-settings": "Статичні налаштування", + "configuration": "Конфігурація", + "static-schedule": "Статичний", + "dynamic-schedule": "Динамічний", + "operation-and": "ТА", + "operation-or": "АБО", + "condition-during": "Протягом {{during}}", + "condition-during-dynamic": "Протягом \"{{ attribute }}\"", + "condition-repeat-times": "Повторюється { count, plural, =1 {1 раз} other {# разів} }", + "condition-repeat-times-dynamic": "Повторюється \"{{ attribute }}\" разів", + "filter-preview": "Попередній перегляд фільтра", + "condition-settings": "Налаштування умов", + "static": "Статичний", + "dynamic": "Динамічний", + "argument-filters": "Фільтри аргументів", + "argument-name": "Назва аргументу", + "value-type": "Тип значення", + "general": "Загальні", + "filters": "Фільтри", + "date-time-hint": "Аргумент має бути в мілісекундах епохи. Приклад: 1698839340000 дорівнює 2023-11-01 12:49:00 UTC.", + "operation": "Операція", + "value-source": "Джерело значення", + "value": "Значення", + "ignore-case": "Ігнорувати регістр", + "condition": "Умова", + "script": "Скрипт", + "add-filter": "Додати фільтр аргументу", + "edit-filter": "Фільтр аргументу", + "remove-filter": "Вилучити фільтр аргументу", + "no-filter": "Потрібен щонайменше один фільтр.", + "conditions": { + "simple": "Проста", + "duration": "Тривалість", + "repeating": "Повторювана" + }, + "schedule-title": "Розклад", + "edit-schedule": "Редагувати розклад тривоги", + "schedule-type": "Тип планувальника", + "schedule-type-required": "Потрібно вказати тип планувальника.", + "schedule": { + "any-time": "Активна весь час", + "specific-time": "Активна у визначений час", + "custom": "Користувацький" + }, + "schedule-day": { + "monday": "Понеділок", + "tuesday": "Вівторок", + "wednesday": "Середа", + "thursday": "Четвер", + "friday": "П'ятниця", + "saturday": "Субота", + "sunday": "Неділя" + }, + "schedule-days": "Дні", + "schedule-time": "Час", + "schedule-time-from": "Від", + "schedule-time-to": "До", + "schedule-days-of-week-required": "Потрібно вибрати щонайменше один день тижня.", + "tbel": "TBEL", + "expression-type": { + "simple": "Простий", + "script": "Скрипт" + }, + "operation-type": { + "and": "ТА", + "or": "Або" + }, + "filter-predicate-type": { + "string": "Рядок", + "numeric": "Числовий", + "boolean": "Логічний", + "complex": "Комплексний" + }, + "alarm-rule-additional-info": "Додаткова інформація", + "edit-alarm-rule-additional-info": "Редагувати додаткову інформацію", + "alarm-rule-additional-info-placeholder": "Будь ласка, надайте тут Ваші коментарі та уточнення, щоб відображати їх у деталях тривоги в розділі \"Додаткова інформація\"", + "alarm-rule-additional-info-hint": "Підказка: використовуйте ${Назва аргументу}, щоб підставляти значення аргументів, які використовуються в умові правила тривоги.", + "alarm-rule-additional-info-icon-hint": "Використовуйте назву аргументу, щоб підставляти значення аргументів, які використовуються в умові правила тривоги.", + "alarm-rule-mobile-dashboard": "Мобільний дашборд", + "alarm-rule-mobile-dashboard-hint": "Використовується мобільним застосунком як дашборд деталей тривоги.", + "alarm-rule-no-mobile-dashboard": "Дашборд не вибрано", + "alarm-rule-condition": "Умова правила тривоги", + "enter-alarm-rule-condition-prompt": "Додати умову", + "enter-alarm-rule-clear-condition-prompt": "Додати умову очищення", + "edit-alarm-rule-condition": "Умова тривоги", + "condition-type": "Тип умови", + "condition-type-hint": "Параметри \"Тривалість\" і \"Повторювана\" недоступні, коли у фільтрі використовується операція \"відсутній протягом\".", + "select-alarm-severity": "Вибрати серйозність тривоги", + "add-create-alarm-rule-prompt": "Потрібна щонайменше одна умова спрацювання.", + "add-create-alarm-rule": "Додати умову спрацювання", + "add-clear-alarm-rule": "Додати умову очищення", + "condition-duration": "Тривалість умови", + "condition-duration-value": "Значення тривалості", + "condition-duration-time-unit": "Одиниця часу", + "condition-duration-value-range": "Значення тривалості має бути в діапазоні від 1 до 2147483647.", + "condition-duration-value-pattern": "Значення тривалості має бути цілим числом.", + "condition-duration-value-required": "Потрібно вказати значення тривалості.", + "condition-duration-time-unit-required": "Потрібно вказати одиницю часу.", + "condition-repeating-value": "Кількість подій", + "condition-repeating-value-hint": "Оновлення будь-якого аргументу правила тривоги буде зараховано як подію", + "condition-repeating-value-range": "Кількість подій має бути в діапазоні від 1 до 2147483647.", + "condition-repeating-value-pattern": "Кількість подій має бути цілим числом.", + "condition-repeating-value-required": "Потрібно вказати кількість подій.", + "create-conditions": "Умови спрацювання", + "clear-condition": "Умова очищення", + "no-clear-alarm-rule": "Умову очищення не налаштовано.", + "advanced-settings": "Розширені налаштування", + "propagate-alarm": "Поширювати тривогу на пов'язані сутності", + "alarm-rule-relation-types-list": "Типи зв'язків", + "alarm-rule-relation-types-list-hint": "Визначає типи зв'язків для фільтрації пов'язаних сутностей. Якщо не задано, тривога буде поширена на всі пов'язані сутності.", + "propagate-alarm-to-owner": "Поширювати тривогу на власника сутності (Клієнт або Тенант)", + "propagate-alarm-to-tenant": "Поширювати тривогу на тенанта", + "alarm-rule-filter-title": "Фільтр правил тривог", + "filter-title": "Фільтр", + "debugging": "Налагодження правила тривоги", + "any-type": "Будь-який тип", + "enter-alarm-rule-type": "Ввести тип тривоги", + "no-alarm-rule-types-matching": "Не знайдено типів тривог, що відповідають '{{entitySubtype}}'.", + "alarm-rule-type-list-empty": "Типи тривог не вибрано.", + "alarm-rule-type-list": "Список типів тривог", + "alarm-rule-entity-list": "Список сутностей", + "missing-for": "відсутній протягом", + "time-unit": "Одиниця", + "mode": "Режим", + "type": "Тип", + "value-required": "Потрібно вказати значення.", + "min-value": "Значення має бути 1 або більше.", + "argument-in-use": "Аргумент використовується як загальний аргумент.", + "import-invalid-alarm-rule-type": "Не вдалося імпортувати правило тривоги: некоректна структура правила тривоги.", + "no-filter-preview": "Фільтр не задано", + "filter-operation": { + "and": "ТА", + "or": "Або" + } + }, + "ai-models": { + "ai-models": "AI-моделі", + "ai-model": "AI-модель", + "model": "Модель", + "name": "Назва", + "ai-provider": "AI-провайдер", + "no-found": "AI-моделей не знайдено", + "list": "{ count, plural, =1 {Одна модель} other {Список із # моделей} }", + "selected-fields": "{ count, plural, =1 {Вибрано 1 модель} other {Вибрано #} }", + "add": "Додати модель", + "delete-model-title": "Ви впевнені, що хочете видалити модель '{{modelName}}'?", + "delete-model-text": "Будьте обережні: після підтвердження модель і всі пов'язані дані буде неможливо відновити.", + "delete-models-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 модель} other {# моделей} }?", + "delete-models-text": "Будьте обережні: після підтвердження всі вибрані моделі буде видалено, а всі пов'язані дані буде неможливо відновити.", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models", + "ollama": "Ollama" + }, + "name-required": "Потрібно вказати назву.", + "name-max-length": "Назва має містити не більше ніж 255 символів.", + "provider": "Провайдер", + "api-key": "API-ключ", + "api-key-required": "Потрібно вказати API-ключ.", + "api-key-open-ai-required": "Потрібно вказати API-ключ під час використання офіційного OpenAI API.", + "project-id": "ID проєкту", + "project-id-required": "Потрібно вказати ID проєкту", + "location": "Розташування", + "location-required": "Потрібно вказати розташування.", + "service-account-key-file": "Файл ключа службового облікового запису", + "service-account-key-file-required": "Потрібно вказати файл ключа службового облікового запису.", + "no-file": "Файл не вибрано.", + "drop-file": "Перетягніть файл або натисніть, щоб вибрати файл для завантаження.", + "personal-access-token": "Персональний токен доступу", + "personal-access-token-required": "Потрібно вказати персональний токен доступу.", + "configuration": "Конфігурація", + "model-id": "Model ID", + "model-id-required": "Потрібно вказати Model ID.", + "deployment-name": "Назва розгортання", + "deployment-name-required": "Потрібно вказати назву розгортання", + "set": "Встановити", + "region": "Регіон", + "region-required": "Потрібно вказати регіон.", + "access-key-id": "Access key ID", + "access-key-id-required": "Потрібно вказати Access key ID.", + "secret-access-key": "Секретний ключ доступу", + "secret-access-key-required": "Потрібно вказати секретний ключ доступу.", + "temperature": "Температура", + "temperature-hint": "Налаштовує рівень випадковості у вихідних даних моделі. Вищі значення збільшують випадковість, нижчі — зменшують її.", + "temperature-min": "Має бути 0 або більше.", + "top-p": "Top P", + "top-p-hint": "Створює пул найбільш імовірних токенів, з яких модель обирає. Вищі значення створюють більший і різноманітніший пул, нижчі — менший.", + "top-p-min-max": "Має бути більше ніж 0 і не більше ніж 1.", + "top-k": "Top K", + "top-k-hint": "Обмежує вибір моделі фіксованим набором \"K\" найімовірніших токенів.", + "top-k-min": "Має бути 0 або більше.", + "presence-penalty": "Штраф присутності", + "presence-penalty-hint": "Застосовує фіксований штраф до ймовірності токена, якщо він уже з'являвся в тексті.", + "frequency-penalty": "Штраф частоти", + "frequency-penalty-hint": "Застосовує штраф до ймовірності токена, що збільшується залежно від його частоти в тексті.", + "max-output-tokens": "Максимальна кількість вихідних токенів", + "max-output-tokens-hint": "Встановлює максимальну кількість токенів, які \nмодель може згенерувати в одній відповіді.", + "context-length": "Довжина контексту", + "context-length-hint": "Визначає розмір вікна контексту в токенах. Це значення задає загальне обмеження пам'яті моделі, включно з введенням користувача та згенерованою відповіддю.", + "endpoint": "Endpoint", + "endpoint-required": "Endpoint is required.", + "baseurl": "Base URL", + "baseurl-required": "Base URL є обов'язковим.", + "service-version": "Версія сервісу", + "check-connectivity": "Перевірити підключення", + "check-connectivity-success": "Тестовий запит виконано успішно", + "check-connectivity-failed": "Тестовий запит не виконано", + "no-model-matching": "Не знайдено моделей, що відповідають '{{entity}}'.", + "model-required": "Потрібно вказати модель.", + "no-model-text": "Моделей не знайдено.", + "authentication": "Автентифікація", + "authentication-basic-hint": "Використовує стандартну HTTP Basic автентифікацію. Ім'я користувача та пароль буде об'єднано, закодовано Base64 і надіслано в заголовку \"Authorization\" з кожним запитом до сервера Ollama.", + "authentication-token-hint": "Використовує автентифікацію Bearer токеном. Наданий токен буде надіслано безпосередньо в заголовку \"Authorization\" з кожним запитом до сервера Ollama.", + "authentication-type": { + "none": "Немає", + "basic": "Basic", + "token": "Токен" + }, + "username": "Ім'я користувача", + "username-required": "Потрібно вказати ім'я користувача.", + "password": "Пароль", + "password-required": "Потрібно вказати пароль.", + "token": "Токен", + "token-required": "Потрібно вказати токен." + }, + "confirm-on-exit": { + "message": "У Вас є незбережені зміни. Ви впевнені, що хочете покинути цю сторінку?", + "html-message": "У Вас є незбережені зміни.
Ви впевнені, що хочете покинути цю сторінку?", + "title": "Незбережені зміни" + }, + "contact": { + "country": "Країна", + "country-required": "Потрібно вказати країну.", + "country-object-required": "Будь ласка, виберіть дійсну країну зі списку.", + "city": "Місто", + "state": "Штат / Провінція", + "postal-code": "Zip / Поштовий індекс", + "postal-code-invalid": "Некоректний формат Zip / Поштового індексу.", + "address": "Адреса", + "address2": "Адреса 2", + "phone": "Телефон", + "email": "Email", + "no-address": "Адресу не вказано", + "no-country-found": "Країни не знайдено.", + "no-country-matching": "Не знайдено країну, що відповідає '{{country}}'.", + "state-max-length": "Довжина штату має бути меншою за 256", + "phone-max-length": "Номер телефону має бути меншим за 256", + "city-max-length": "Вказане місто має бути меншим за 256" + }, + "common": { + "name": "Назва", + "type": "Тип", + "general": "Загальні", + "username": "Ім'я користувача", + "password": "Пароль", + "data": "Дані", + "timestamp": "Мітка часу", + "enter-username": "Ввести ім'я користувача", + "enter-password": "Ввести пароль", + "enter-search": "Ввести пошук", + "created-time": "Час створення", + "disabled": "Вимкнено", + "loading": "Завантаження...", + "proceed": "Продовжити", + "open-details-page": "Відкрити сторінку деталей", + "not-found": "Не знайдено", + "value": "Значення", + "documentation": "Документація", + "time-left": "Залишилося {{time}}", + "output": "Вихідні дані", + "sort-asc": "За зростанням", + "sort-desc": "За спаданням", + "suffix": { + "s": "с", + "ms": "мс" + }, + "hint": { + "name-required": "Потрібно вказати назву.", + "name-pattern": "Некоректна назва.", + "name-max-length": "Назва має бути меншою за 256 символів.", + "title-required": "Потрібно вказати заголовок.", + "title-pattern": "Некоректний заголовок.", + "title-max-length": "Заголовок має бути меншим за 256 символів.", + "key-required": "Потрібно вказати ключ.", + "key-pattern": "Некоректний ключ.", + "key-max-length": "Ключ має бути меншим за 256 символів." + }, + "required-fields": "Відсутні обов'язкові поля" + }, + "content-type": { + "json": "Json", + "text": "Текст", + "binary": "Бінарний (Base64)" + }, + "color": { + "color": "Колір" + }, + "customer": { + "customer": "Клієнт", + "customers": "Клієнти", + "management": "Керування клієнтами", + "dashboard": "Дашборд клієнта", + "dashboards": "Дашборди клієнта", + "devices": "Пристрої клієнта", + "entity-views": "Фрагмент сутностей клієнта", + "assets": "Активи клієнта", + "public-dashboards": "Публічні Дашборди", + "public-devices": "Публічні Пристрої", + "public-assets": "Публічні Активи", + "public-entity-views": "Публічні фрагменти сутностей", + "add": "Додати клієнта", + "delete": "Видалити клієнта", + "manage-customer-users": "Керувати користувачами клієнта", + "manage-customer-devices": "Керувати пристроями клієнта", + "manage-customer-dashboards": "Керувати дашбордами клієнта", + "manage-public-devices": "Керувати публічними пристроями", + "manage-public-dashboards": "Керувати публічними дашбордами", + "manage-customer-assets": "Керувати активами клієнта", + "manage-customer-edges": "Керувати edge клієнта", + "manage-public-assets": "Керувати публічними активами", + "add-customer-text": "Додати нового клієнта", + "no-customers-text": "Клієнтів не знайдено", + "customer-details": "Деталі клієнта", + "delete-customer-title": "Ви впевнені, що хочете видалити клієнта '{{customerTitle}}'?", + "delete-customer-text": "Будьте обережні, після підтвердження клієнт і всі пов'язані дані будуть неможливі для відновлення.", + "delete-customers-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 клієнта} other {# клієнтів} }?", + "delete-customers-action-title": "Видалити { count, plural, =1 {1 клієнта} other {# клієнтів} }", + "delete-customers-text": "Будьте обережні, після підтвердження всі вибрані клієнти будуть видалені, і всі пов'язані дані будуть неможливі для відновлення.", + "manage-users": "Керувати користувачами", + "manage-assets": "Керувати активами", + "manage-devices": "Керувати пристроями", + "manage-dashboards": "Керувати дашбордами", + "title": "Назва", + "title-required": "Назва є обов'язковою.", + "title-max-length": "Назва має бути коротшою за 256 символів", + "description": "Опис", + "details": "Деталі", + "events": "Події", + "copyId": "Копіювати Id клієнта", + "idCopiedMessage": "Id клієнта скопійовано в буфер обміну", + "select-customer": "Вибрати клієнта", + "no-customers-matching": "Клієнтів, що відповідають '{{entity}}', не знайдено.", + "customer-required": "Клієнт є обов'язковим.", + "select-default-customer": "Вибрати стандартного клієнта", + "default-customer": "Стандартний клієнт", + "default-customer-required": "Стандартний клієнт є обов'язковим для налагодження дашбордів на рівні тенанта", + "search": "Шукати клієнтів", + "selected-customers": "{ count, plural, =1 {1 клієнт} other {# клієнтів} } вибрано", + "edges": "Екземпляри edge клієнта", + "manage-edges": "Керувати екземплярами edge" + }, + "css-size": { + "size-value-required": "Значення розміру є обов'язковим", + "invalid-size-value": "Невірне значення розміру" + }, + "date": { + "last-update-n-ago": "Останнє оновлення N тому", + "last-update-n-ago-text": "Останнє оновлення {{ agoText }}", + "custom-date": "Користувацька дата", + "format": "Формат", + "preview": "Попередній перегляд", + "auto": "Авто", + "time-granularity-formats": "Формати гранулярності часу", + "unit-year": "Роки", + "unit-month": "Місяці", + "unit-day": "Дні", + "unit-hour": "Години", + "unit-minute": "Хвилини", + "unit-second": "Секунди", + "unit-millisecond": "Мілісекунди" + }, + "datetime": { + "date-from": "Дата з", + "time-from": "Час з", + "date-to": "Дата до", + "time-to": "Час до", + "from": "З", + "to": "До" + }, + "dashboard": { + "dashboard": "Дашборд", + "dashboards": "Дашборди", + "management": "Керування дашбордами", + "view-dashboards": "Перегляд дашбордів", + "add": "Додати дашборд", + "assign-dashboard-to-customer": "Призначити дашборд(и) клієнту", + "assign-dashboard-to-customer-text": "Будь ласка, виберіть дашборди для призначення клієнту", + "assign-to-customer-text": "Будь ласка, виберіть клієнта для призначення дашборду(ів)", + "assign-to-customer": "Призначити клієнту", + "unassign-from-customer": "Зняти призначення з клієнта", + "make-public": "Зробити дашборд публічним", + "make-private": "Зробити дашборд приватним", + "manage-assigned-customers": "Керувати призначеними клієнтами", + "assigned-customers": "Призначені клієнти", + "assign-to-customers": "Призначити дашборд(и) клієнтам", + "assign-to-customers-text": "Будь ласка, виберіть клієнтів для призначення дашборду(ів)", + "unassign-from-customers": "Зняти призначення дашборду(ів) з клієнтів", + "unassign-from-customers-text": "Будь ласка, виберіть клієнтів, щоб зняти призначення з дашборду(ів)", + "no-dashboards-text": "Дашбордів не знайдено", + "no-widgets": "Віджети не налаштовані", + "add-widget": "Додати новий віджет", + "add-widget-button-text": "Додати віджет", + "title": "Назва", + "image": "Зображення дашборду", + "mobile-app-settings": "Налаштування мобільного додатку", + "mobile-order": "Порядок дашбордів у мобільному додатку", + "mobile-hide": "Сховати дашборд у мобільному додатку", + "update-image": "Оновити зображення дашборду", + "update-new-version": "Завантажити нову версію", + "upload-file-to-update": "Завантажити файл для оновлення", + "take-screenshot": "Зробити скріншот", + "select-widget-title": "Вибрати віджет", + "select-widget-value": "{{title}}: вибрати віджет", + "select-widget-subtitle": "Список доступних типів віджетів", + "delete": "Видалити дашборд", + "title-required": "Назва є обов'язковою", + "title-max-length": "Назва має бути коротшою за 256 символів", + "description": "Опис", + "details": "Деталі", + "dashboard-details": "Деталі дашборду", + "add-dashboard-text": "Додати новий дашборд", + "assign-dashboards": "Призначити дашборди", + "assign-new-dashboard": "Призначити новий дашборд", + "assign-dashboards-text": "Призначити { count, plural, =1 {1 дашборд} other {# дашбордів} } клієнтам", + "unassign-dashboards-action-text": "Зняти призначення { count, plural, =1 {1 дашборд} other {# дашбордів} } з клієнтів", + "delete-dashboards": "Видалити дашборди", + "unassign-dashboards": "Зняти призначення з дашбордів", + "unassign-dashboards-action-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 дашборд} other {# дашбордів} } з клієнта?", + "delete-dashboard-title": "Ви впевнені, що хочете видалити дашборд '{{dashboardTitle}}'?", + "delete-dashboard-text": "Будьте обережні, після підтвердження дашборд і всі пов'язані дані будуть неможливі для відновлення.", + "delete-dashboards-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 дашборд} other {# дашбордів} }?", + "delete-dashboards-action-title": "Видалити { count, plural, =1 {1 дашборд} other {# дашбордів} }", + "delete-dashboards-text": "Будьте обережні, після підтвердження всі вибрані дашборди будуть видалені, і всі пов'язані дані будуть неможливі для відновлення.", + "unassign-dashboard-title": "Ви впевнені, що хочете зняти призначення дашборду '{{dashboardTitle}}'?", + "unassign-dashboard-text": "Після підтвердження дашборд буде знятий з призначення і більше не буде доступний клієнту.", + "unassign-dashboard": "Зняти призначення з дашборду", + "unassign-dashboards-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 дашборд} other {# дашбордів} }?", + "unassign-dashboards-text": "Після підтвердження всі вибрані дашборди будуть зняті з призначення і більше не будуть доступні клієнту.", + "public-dashboard-title": "Дашборд тепер публічний", + "public-dashboard-text": "Ваш дашборд {{dashboardTitle}} тепер публічний і доступний за наступним публічним посиланням:", + "public-dashboard-notice": "Примітка: Не забувайте зробити пов'язані пристрої публічними, щоб отримати доступ до їхніх даних.", + "make-private-dashboard-title": "Ви впевнені, що хочете зробити дашборд '{{dashboardTitle}}' приватним?", + "make-private-dashboard-text": "Після підтвердження дашборд стане приватним і більше не буде доступний іншим.", + "make-private-dashboard": "Зробити дашборд приватним", + "socialshare-text": "'{{dashboardTitle}}' за підтримки ThingsBoard", + "socialshare-title": "'{{dashboardTitle}}' за підтримки ThingsBoard", + "select-dashboard": "Вибрати дашборд", + "no-dashboards-matching": "Дашбордів, що відповідають '{{entity}}', не знайдено.", + "dashboard-required": "Дашборд є обов'язковим.", + "select-existing": "Вибрати існуючий дашборд", + "create-new": "Створити новий дашборд", + "new-dashboard-title": "Назва нового дашборду", + "open-dashboard": "Відкрити дашборд", + "set-background": "Встановити фон", + "background-color": "Колір фону", + "background-image": "Зображення фону", + "background-size-mode": "Режим розміру фону", + "no-image": "Зображення не вибрано", + "empty-image": "Без зображення", + "drop-image": "Перетягніть зображення або натисніть, щоб вибрати файл для завантаження.", + "maximum-upload-file-size": "Максимальний розмір файлу для завантаження: {{ size }}", + "cannot-upload-file": "Не вдалося завантажити файл", + "settings": "Налаштування", + "move-all-widgets": "Перемістити всі віджети", + "move-by": "Перемістити на", + "cols": "стовпці", + "rows": "рядки", + "layout": "Макет", + "layout-type-default": "За замовчуванням", + "layout-type-scada": "SCADA", + "layout-type-divider": "Роздільник", + "layout-settings-type": "Налаштування макету: {{ type }} точка розбиття", + "columns-count": "Кількість стовпців", + "columns-count-required": "Кількість стовпців є обов'язковою.", + "min-columns-count-message": "Допускається лише мінімальна кількість стовпців — 10.", + "max-columns-count-message": "Допускається лише максимальна кількість стовпців — 1000.", + "min-layout-width": "Мінімальна ширина макету", + "columns-suffix": "стовпці", + "widgets-margins": "Межі між віджетами", + "margin-required": "Значення межі є обов'язковим.", + "min-margin-message": "Допускається лише мінімальна межа — 0.", + "max-margin-message": "Допускається лише максимальна межа — 50.", + "horizontal-margin": "Горизонтальна межа", + "horizontal-margin-required": "Значення горизонтальної межі є обов'язковим.", + "min-horizontal-margin-message": "Допускається лише мінімальна горизонтальна межа — 0.", + "max-horizontal-margin-message": "Допускається лише максимальна горизонтальна межа — 50.", + "vertical-margin": "Вертикальна межа", + "vertical-margin-required": "Значення вертикальної межі є обов'язковим.", + "min-vertical-margin-message": "Допускається лише мінімальна вертикальна межа — 0.", + "max-vertical-margin-message": "Допускається лише максимальна вертикальна межа — 50.", + "apply-outer-margin": "Застосувати межу до бокових частин макету", + "autofill-height": "Автоматично заповнювати висоту макету", + "mobile-layout": "Налаштування макету для мобільних пристроїв", + "mobile-row-height": "Висота ряду для мобільного макету", + "mobile-row-height-required": "Значення висоти ряду для мобільного макету є обов'язковим.", + "min-mobile-row-height-message": "Допускається лише 5 пікселів як мінімальне значення висоти ряду для мобільного макету.", + "max-mobile-row-height-message": "Допускається лише 200 пікселів як максимальне значення висоти ряду для мобільного макету.", + "row-height": "Висота ряду", + "row-height-required": "Значення висоти ряду є обов'язковим.", + "min-row-height-message": "Допускається лише 5 пікселів як мінімальне значення висоти ряду.", + "max-row-height-message": "Допускається лише 200 пікселів як максимальне значення висоти ряду.", + "display-first-in-mobile-view": "Відображати першим у мобільному перегляді", + "title-settings": "Налаштування заголовка", + "display-title": "Відображати заголовок дашборду", + "title-color": "Колір заголовка", + "toolbar-settings": "Налаштування панелі інструментів", + "hide-toolbar": "Приховати панель інструментів", + "toolbar-always-open": "Тримати панель інструментів відкритою", + "display-dashboards-selection": "Відображати вибір дашбордів", + "display-entities-selection": "Відображати вибір сутностей", + "display-filters": "Відображати фільтри", + "display-dashboard-timewindow": "Відображати вікно часу для дашборду", + "display-dashboard-export": "Відображати експорт дашборду", + "display-update-dashboard-image": "Відображати оновлене зображення дашборду", + "dashboard-logo-settings": "Налаштування логотипу дашборду", + "display-dashboard-logo": "Відображати логотип в режимі повного екрану дашборду", + "dashboard-logo-image": "Зображення логотипу дашборду", + "advanced-settings": "Розширені налаштування", + "dashboard-css": "CSS дашборду", + "import": "Імпортувати дашборд", + "export": "Експортувати дашборд", + "export-failed-error": "Не вдалося експортувати дашборд: {{error}}", + "export-prompt": "Вбудувати зображення дашборду та ресурси", + "create-new-dashboard": "Створити новий дашборд", + "dashboard-file": "Файл дашборду", + "invalid-dashboard-file-error": "Не вдалося імпортувати дашборд: Невірна структура даних дашборду.", + "dashboard-import-missing-aliases-title": "Налаштуйте псевдоніми, що використовуються в імпортованому дашборді", + "create-new-widget": "Створити новий віджет", + "import-widget": "Імпортувати віджет", + "widget-file": "Файл віджету", + "invalid-widget-file-error": "Не вдалося імпортувати віджет: Невірна структура даних віджету.", + "widget-import-missing-aliases-title": "Налаштуйте псевдоніми, що використовуються в імпортованому віджеті", + "open-toolbar": "Відкрити панель інструментів дашборду", + "close-toolbar": "Закрити панель інструментів", + "configuration-error": "Помилка конфігурації", + "alias-resolution-error-title": "Помилка конфігурації псевдонімів дашборду", + "invalid-aliases-config": "Не вдалося знайти пристрої, що відповідають деяким з фільтрів псевдонімів.
Будь ласка, зв'яжіться з вашим адміністратором для вирішення цієї проблеми.", + "select-devices": "Вибрати пристрої", + "assignedToCustomer": "Призначено клієнту", + "assignedToCustomers": "Призначено клієнтам", + "public": "Публічний", + "copyId": "Копіювати id дашборду", + "idCopiedMessage": "Id дашборду скопійовано в буфер обміну", + "public-link": "Публічне посилання", + "copy-public-link": "Копіювати публічне посилання", + "public-link-copied-message": "Публічне посилання дашборду скопійовано в буфер обміну", + "manage-states": "Керувати станами дашборду", + "states": "Стан дашборду", + "states-short": "Стан", + "search-states": "Шукати стани дашборду", + "selected-states": "{ count, plural, =1 {1 стан дашборду} other {# станів дашборду} } вибрано", + "edit-state": "Редагувати стан дашборду", + "delete-state": "Видалити стан дашборду", + "add-state": "Додати стан дашборду", + "no-states-text": "Станів не знайдено", + "state": "Стан дашборду", + "state-name": "Назва", + "state-name-required": "Назва стану дашборду є обов'язковою.", + "state-id": "Id стану", + "state-id-required": "Id стану дашборду є обов'язковим.", + "state-id-exists": "Стан дашборду з таким id вже існує.", + "is-root-state": "Кореневий стан", + "delete-state-title": "Видалити стан дашборду", + "delete-state-text": "Ви впевнені, що хочете видалити стан дашборду з назвою '{{stateName}}'?", + "show-details": "Показати деталі", + "hide-details": "Сховати деталі", + "select-state": "Вибрати цільовий стан", + "state-controller": "Контролер стану", + "state-controller-default": "статичний (застарілий)", + "search": "Шукати дашборди", + "selected-dashboards": "{ count, plural, =1 {1 дашборд} other {# дашбордів} } вибрано", + "home-dashboard": "Домашній дашборд", + "home-dashboard-hide-toolbar": "Сховати панель інструментів домашнього дашборду", + "unassign-dashboard-from-edge-text": "Після підтвердження дашборд буде знятий з призначення і не буде доступний через edge.", + "unassign-dashboards-from-edge-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 дашборду} other {# дашбордів} }?", + "unassign-dashboards-from-edge-text": "Після підтвердження всі вибрані дашборди будуть зняті з призначення і не будуть доступні через edge.", + "assign-dashboard-to-edge": "Призначити дашборд(и) до Edge", + "assign-dashboard-to-edge-text": "Будь ласка, виберіть дашборди для призначення до edge", + "non-existent-dashboard-state-error": "Стан дашборду з id \"{{ stateId }}\" не знайдено", + "edit-mode": "Режим редагування", + "duplicate-state-action": "Дублювати стан", + "breakpoint-value": "Точка розбиття ({{ value }})", + "breakpoints-id": { + "default": "За замовчуванням", + "xs": "Мобільний (xs)", + "sm": "Планшет (sm)", + "md": "Ноутбук (md)", + "lg": "Десктоп (lg)", + "xl": "Десктоп (xl)" + }, + "view-format-type-grid": "Сітка", + "view-format-type-list": "Список", + "view-format": "Формат перегляду" + }, + "datakey": { + "settings": "Налаштування", + "general": "Загальні", + "advanced": "Розширені", + "key": "Ключ", + "keys": "Ключі", + "label": "Маркування", + "color": "Колір", + "units": "Спеціальний символ для відображення поруч зі значенням", + "decimals": "Кількість знаків після коми", + "data-generation-func": "Функція генерації даних", + "use-data-post-processing-func": "Використовувати функцію післяобробки даних", + "configuration": "Конфігурація ключа даних", + "timeseries": "Часові ряди", + "attributes": "Атрибути", + "entity-field": "Поле сутності", + "alarm": "Поля тривог", + "timeseries-required": "Часові ряди сутності є обов'язковими.", + "timeseries-or-attributes-required": "Часові ряди/атрибути сутності є обов'язковими.", + "alarm-fields-timeseries-or-attributes-required": "Поля тривог або часові ряди/атрибути сутності є обов'язковими.", + "maximum-timeseries-or-attributes": "Максимум { count, plural, =1 {1 часовий ряд/атрибут дозволено.} other {# часових рядів/атрибутів дозволено} }", + "alarm-fields-required": "Поля тривог є обов'язковими.", + "function-types": "Типи функцій", + "function-type": "Тип функції", + "function-types-required": "Типи функцій є обов'язковими.", + "data-keys": "Ключі даних", + "data-key": "Ключ даних", + "data-keys-required": "Ключі даних є обов'язковими.", + "data-key-required": "Ключ даних є обов'язковим.", + "alarm-keys": "Ключі даних тривог", + "alarm-key": "Ключ даних тривоги", + "alarm-key-functions": "Функції ключів тривог", + "alarm-key-function": "Функція ключа тривоги", + "latest-keys": "Останні ключі даних", + "latest-key": "Останній ключ даних", + "latest-key-functions": "Функції останнього ключа", + "latest-key-function": "Функція останнього ключа", + "timeseries-keys": "Ключі даних часових рядів", + "timeseries-key": "Ключ даних часового ряду", + "timeseries-key-functions": "Функції ключів даних часового ряду", + "timeseries-key-function": "Функція ключа даних часового ряду", + "maximum-function-types": "Максимум { count, plural, =1 {1 тип функції дозволено.} other {# типів функцій дозволено} }", + "time-description": "часова позначка поточного значення;", + "value-description": "поточне значення;", + "prev-value-description": "результат попереднього виклику функції;", + "time-prev-description": "часова позначка попереднього значення;", + "prev-orig-value-description": "оригінальне попереднє значення;", + "aggregation": "Агрегація", + "aggregation-type-hint-common": "Для підвищення продуктивності обчислення агрегованих значень доступні лише для фіксованих часових інтервалів, таких як \"поточний день\", \"поточний місяць\" тощо, і недоступні для інтервалів ковзаючого вікна, таких як 'останні 30 хвилин' або 'останні 24 години'.", + "aggregation-type-none-hint": "Взяти останнє значення.", + "aggregation-type-min-hint": "Знайти мінімальне значення серед точок даних у вибраному часовому інтервалі.", + "aggregation-type-max-hint": "Знайти максимальне значення серед точок даних у вибраному часовому інтервалі.", + "aggregation-type-avg-hint": "Обчислити середнє значення серед точок даних у вибраному часовому інтервалі.", + "aggregation-type-sum-hint": "Підсумувати всі значення точок даних у вибраному часовому інтервалі.", + "aggregation-type-count-hint": "Загальна кількість точок даних у вибраному часовому інтервалі.", + "delta-calculation": "Обчислення дельти", + "enable-delta-calculation": "Увімкнути обчислення дельти", + "enable-delta-calculation-hint": "Коли увімкнено, значення ключа даних обчислюється на основі агрегованих значень для вибраного часового інтервалу і вказаного періоду порівняння. Для підвищення продуктивності обчислення дельти доступне тільки для історичних часових інтервалів і не для значень у реальному часі. Наприклад, можна обчислити дельту між споживанням енергії за вчорашній день і споживанням енергії за позавчорашній день.", + "delta-calculation-result": "Результат обчислення дельти", + "delta-calculation-result-previous-value": "Попереднє значення", + "delta-calculation-result-delta-absolute": "Дельта (абсолютна)", + "delta-calculation-result-delta-percent": "Дельта (у відсотках)", + "source": "Джерело", + "latest": "Останнє", + "latest-value": "Останнє значення", + "delta": "дельта", + "percent": "відсоток", + "absolute": "абсолютне" + }, + "datasource": { + "type": "Тип джерела даних", + "name": "Назва", + "label": "Маркування", + "add-datasource-prompt": "Будь ласка, додайте джерело даних" + }, + "details": { + "details": "Деталі", + "edit-mode": "Режим редагування", + "edit-json": "Редагувати JSON", + "toggle-edit-mode": "Перемикати режим редагування" + }, + "device": { + "device": "Пристрій", + "device-required": "Пристрій є обов'язковим.", + "devices": "Пристрої", + "management": "Керування пристроями", + "view-devices": "Перегляд пристроїв", + "device-alias": "Псевдонім пристрою", + "device-type-max-length": "Тип пристрою має бути коротшим за 256 символів", + "aliases": "Псевдоніми пристроїв", + "no-alias-matching": "'{{alias}}' не знайдено.", + "no-aliases-found": "Псевдонімів не знайдено.", + "no-key-matching": "'{{key}}' не знайдено.", + "no-keys-found": "Ключів не знайдено.", + "create-new-alias": "Створити новий!", + "create-new-key": "Створити новий!", + "duplicate-alias-error": "Знайдено дубльований псевдонім '{{alias}}'.
Псевдоніми пристроїв повинні бути унікальними в межах дашборду.", + "configure-alias": "Налаштувати псевдонім '{{alias}}'", + "no-devices-matching": "Пристроїв, що відповідають '{{entity}}', не знайдено.", + "alias": "Псевдонім", + "alias-required": "Псевдонім пристрою є обов'язковим.", + "remove-alias": "Видалити псевдонім пристрою", + "add-alias": "Додати псевдонім пристрою", + "name-starts-with": "Вираз імені пристрою", + "help-text": "Використовуйте '%' за потребою: '%device_name_contains%', '%device_name_ends', 'device_starts_with%'.", + "device-list": "Список пристроїв", + "use-device-name-filter": "Використовувати фільтр", + "device-list-empty": "Не вибрано жодного пристрою.", + "device-name-filter-required": "Фільтр за назвою пристрою є обов'язковим.", + "device-name-filter-no-device-matched": "Пристроїв, що починаються з '{{device}}', не знайдено.", + "add": "Додати пристрій", + "assign-to-customer": "Призначити клієнту", + "assign-device-to-customer": "Призначити пристрій(і) клієнту", + "assign-device-to-customer-text": "Будь ласка, виберіть пристрої для призначення клієнту", + "make-public": "Зробити пристрій публічним", + "make-private": "Зробити пристрій приватним", + "no-devices-text": "Пристроїв не знайдено", + "assign-to-customer-text": "Будь ласка, виберіть клієнта для призначення пристрою(ів)", + "device-details": "Деталі пристрою", + "add-device-text": "Додати новий пристрій", + "credentials": "Облікові дані", + "manage-credentials": "Керувати обліковими даними", + "delete": "Видалити пристрій", + "assign-devices": "Призначити пристрої", + "assign-devices-text": "Призначити { count, plural, =1 {1 пристрій} other {# пристроїв} } клієнту", + "delete-devices": "Видалити пристрої", + "unassign-from-customer": "Зняти призначення з клієнта", + "unassign-devices": "Зняти призначення з пристроїв", + "unassign-devices-action-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 пристрій} other {# пристроїв} } з клієнта?", + "unassign-device-from-edge-title": "Ви впевнені, що хочете зняти призначення пристрою '{{deviceName}}'?", + "unassign-device-from-edge-text": "Після підтвердження пристрій буде знятий з призначення і не буде доступний через edge.", + "unassign-devices-from-edge": "Зняти призначення пристроїв з edge", + "assign-new-device": "Призначити новий пристрій", + "make-public-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' публічним?", + "make-public-device-text": "Після підтвердження пристрій і всі його дані стануть публічними і доступними для інших.", + "make-private-device-title": "Ви впевнені, що хочете зробити пристрій '{{deviceName}}' приватним?", + "make-private-device-text": "Після підтвердження пристрій і всі його дані стануть приватними і не будуть доступні для інших.", + "view-credentials": "Переглянути облікові дані", + "delete-device-title": "Ви впевнені, що хочете видалити пристрій '{{deviceName}}'?", + "delete-device-text": "Будьте обережні, після підтвердження пристрій і всі пов'язані дані будуть неможливі для відновлення.", + "delete-devices-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 пристрій} other {# пристроїв} }?", + "delete-devices-action-title": "Видалити { count, plural, =1 {1 пристрій} other {# пристроїв} }", + "delete-devices-text": "Будьте обережні, після підтвердження всі вибрані пристрої будуть видалені, і всі пов'язані дані будуть неможливі для відновлення.", + "unassign-device-title": "Ви впевнені, що хочете зняти призначення пристрою '{{deviceName}}'?", + "unassign-device-text": "Після підтвердження пристрій буде знятий з призначення і не буде доступний клієнту.", + "unassign-device": "Зняти призначення з пристрою", + "unassign-devices-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 пристрій} other {# пристроїв} }?", + "unassign-devices-text": "Після підтвердження всі вибрані пристрої будуть зняті з призначення і не будуть доступні клієнту.", + "device-credentials": "Облікові дані пристрою", + "loading-device-credentials": "Завантаження облікових даних пристрою...", + "credentials-type": "Тип облікових даних", + "access-token": "Токен доступу", + "access-token-required": "Токен доступу є обов'язковим.", + "access-token-invalid": "Довжина токена доступу повинна бути від 1 до 32 символів.", + "certificate-pem-format": "Сертифікат у форматі PEM", + "certificate-pem-format-required": "Сертифікат є обов'язковим.", + "copy-access-token": "Копіювати токен доступу", + "copy-certificate": "Копіювати сертифікат", + "copy-client-id": "Копіювати ID клієнта", + "copy-user-name": "Копіювати ім'я користувача", + "copy-password": "Копіювати пароль", + "generate-client-id": "Згенерувати ID клієнта", + "generate-user-name": "Згенерувати ім'я користувача", + "generate-password": "Згенерувати пароль", + "generate-access-token": "Згенерувати токен доступу", + "lwm2m-security-config": { + "identity": "Ідентифікатор клієнта", + "identity-required": "Ідентифікатор клієнта є обов'язковим.", + "identity-tooltip": "Ідентифікатор PSK є довільним ідентифікатором PSK до 128 байтів, як описано в стандарті [RFC7925].\nІдентифікатор PSK ПОВИНЕН бути спочатку перетворений у рядок символів і потім закодований в октети з використанням UTF-8.", + "client-key": "Ключ клієнта", + "client-key-required": "Ключ клієнта є обов'язковим.", + "client-key-tooltip-prk": "Публічний ключ або id RPK має бути у стандарті [RFC7250] і закодований у формат Base64!", + "client-key-tooltip-psk": "Ключ PSK має бути у стандарті [RFC4279] і форматі HexDec: 32, 64, 128 символів!", + "endpoint": "Endpoint назва клієнта", + "endpoint-required": "Endpoint назва клієнта є обов'язковою.", + "client-public-key": "Публічний ключ клієнта", + "client-public-key-hint": "Якщо публічний ключ клієнта порожній, буде використано довірений сертифікат", + "client-public-key-tooltip": "Публічний ключ X509 має бути у DER-кодованому форматі X509v3 і підтримувати виключно алгоритм EC, а потім закодований у форматі Base64!", + "mode": "Режим налаштувань безпеки", + "client-tab": "Конфігурація безпеки клієнта", + "client-certificate": "Сертифікат клієнта", + "bootstrap-tab": "Bootstrap клієнт", + "bootstrap-server": "Bootstrap сервер", + "lwm2m-server": "LwM2M сервер", + "client-reboot": "Тригер оновлення реєстрації", + "bootstrap-reboot": "Тригер запиту Bootstrap", + "client-publicKey-or-id": "Публічний ключ клієнта або ID", + "client-publicKey-or-id-required": "Публічний ключ клієнта або ID є обов'язковим.", + "client-publicKey-or-id-tooltip-psk": "Ідентифікатор PSK є довільним ідентифікатором PSK до 128 байтів, як описано в стандарті [RFC7925].\nІдентифікатор PSK ПОВИНЕН бути спочатку перетворений у рядок символів і потім закодований в октети з використанням UTF-8.", + "client-publicKey-or-id-tooltip-rpk": "Публічний ключ або ID RPK має бути у стандарті [RFC7250] і закодований у формат Base64!", + "client-publicKey-or-id-tooltip-x509": "Публічний ключ X509 має бути у DER-кодованому форматі X509v3 і підтримувати виключно алгоритм EC, а потім закодований у форматі Base64", + "client-secret-key": "Секретний ключ клієнта", + "client-secret-key-required": "Секретний ключ клієнта є обов'язковим.", + "client-secret-key-tooltip-psk": "Ключ PSK має бути у стандарті [RFC4279] і форматі HexDec: 32, 64, 128 символів!", + "client-secret-key-tooltip-prk": "Секретний ключ RPK має бути у форматі PKCS_8 (DER-кодування, стандарт [RFC5958]) і потім закодований у форматі Base64!", + "client-secret-key-tooltip-x509": "Секретний ключ X509 має бути у форматі PKCS_8 (DER-кодування, стандарт [RFC5958]) і потім закодований у форматі Base64!" + }, + "client-id": "ID клієнта", + "client-id-pattern": "Містить недійсний символ.", + "user-name": "Ім'я користувача", + "user-name-required": "Ім'я користувача є обов'язковим.", + "client-id-or-user-name-necessary": "ID клієнта та/або ім'я користувача є необхідними", + "password": "Пароль", + "secret": "Секрет", + "secret-required": "Секрет є обов'язковим.", + "device-type": "Профіль пристрою", + "device-type-required": "Тип пристрою є обов'язковим.", + "select-device-type": "Вибрати тип пристрою", + "enter-device-type": "Ввести профіль пристрою", + "any-device": "Будь-який пристрій", + "no-device-types-matching": "Не знайдено профілів пристроїв, що відповідають '{{entitySubtype}}'.", + "device-type-list-empty": "Не вибрано профілі пристроїв!", + "device-profile-type-list-empty": "Потрібно вибрати хоча б один профіль пристрою.", + "device-types": "Типи пристроїв", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "name-max-length": "Назва має бути коротшою за 256 символів", + "label-max-length": "Маркування має бути коротшою за 256 символів", + "description": "Опис", + "label": "Маркування", + "events": "Події", + "details": "Деталі", + "copyId": "Копіювати ID пристрою", + "copyAccessToken": "Копіювати токен доступу", + "copy-mqtt-authentication": "Копіювати MQTT облікові дані", + "idCopiedMessage": "ID пристрою скопійовано в буфер обміну", + "accessTokenCopiedMessage": "Токен доступу пристрою скопійовано в буфер обміну", + "mqtt-authentication-copied-message": "MQTT автентифікація пристрою скопійована в буфер обміну", + "assignedToCustomer": "Призначено клієнту", + "unable-delete-device-alias-title": "Не вдалося видалити псевдонім пристрою", + "unable-delete-device-alias-text": "Псевдонім пристрою '{{deviceAlias}}' не можна видалити, оскільки він використовується наступними віджетами:
{{widgetsList}}", + "is-gateway": "Це gateway", + "overwrite-activity-time": "Перезаписати час активності для підключеного пристрою", + "device-filter-title": "Фільтр пристроїв", + "filter-title": "Фільтр", + "device-state": "Стан пристрою", + "state": "Стан", + "any": "Будь-який", + "active": "Активний", + "inactive": "Неактивний", + "public": "Публічний", + "device-public": "Пристрій публічний", + "select-device": "Вибрати пристрій", + "import": "Імпортувати пристрій", + "device-file": "Файл пристрою", + "search": "Шукати пристрої", + "selected-devices": "{ count, plural, =1 {1 пристрій} other {# пристроїв} } вибрано", + "device-configuration": "Конфігурація пристрою", + "transport-configuration": "Конфігурація транспорту", + "wizard": { + "device-details": "Деталі пристрою" + }, + "unassign-devices-from-edge-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 пристрій} other {# пристроїв} }?", + "unassign-devices-from-edge-text": "Після підтвердження всі вибрані пристрої будуть зняті з призначення і не будуть доступні через edge.", + "time": "Час", + "connectivity": { + "check-connectivity": "Перевірити підключення", + "device-created-check-connectivity": "Пристрій створено. Давайте перевіримо підключення!", + "loading-check-connectivity-command": "Завантаження команд перевірки підключення...", + "use-following-instructions": "Використовуйте наступні інструкції для надсилання телеметрії від імені пристрою за допомогою оболонки", + "execute-following-command": "Виконайте наступну команду", + "install-curl-windows": "Починаючи з Windows 10 b17063, cURL доступний за замовчуванням", + "install-curl-macos": "Починаючи з Mac OS X 10.2 6C115 (Jaguar), cURL доступний за замовчуванням", + "install-mqtt-windows": "Використовуйте інструкції для завантаження, встановлення, налаштування та запуску mosquitto_pub", + "install-coap-client": "Використовуйте інструкції для завантаження, встановлення, налаштування та запуску coap-client", + "install-necessary-client-tools": "Встановіть необхідні інструменти клієнта", + "mqtts-x509-command": "Використовуйте наступну документацію для підключення пристрою через MQTT з авторизацією X509", + "coaps-x509-command": "Використовуйте наступну документацію для підключення пристрою через CoAP через DTLS з авторизацією X509", + "snmp-command": "Використовуйте наступну документацію для підключення пристрою через SNMP.", + "sparkplug-command": "Використовуйте наступну документацію для підключення пристрою через MQTT Sparkplug.", + "lwm2m-command": "Використовуйте наступну документацію для підключення пристрою через LWM2M." + } + }, + "dynamic-form": { + "property": { + "properties": "Властивості", + "property": "Властивість", + "id": "ID", + "name": "Назва", + "type": "Тип", + "type-text": "Текст", + "type-password": "Пароль", + "type-textarea": "Текстове поле", + "type-number": "Число", + "type-switch": "Перемикач", + "type-select": "Вибір", + "type-radios": "Радіокнопки", + "type-datetime": "Дата/Час", + "type-image": "Зображення", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "Колір", + "type-color-settings": "Налаштування кольору", + "type-font": "Шрифт", + "type-units": "Одиниці вимірювання", + "type-icon": "Іконка", + "type-fieldset": "Поле групи", + "type-array": "Масив", + "type-html-section": "HTML секція", + "group-title": "Заголовок групи", + "no-properties": "Не налаштовано властивостей", + "add-property": "Додати властивість", + "property-settings": "Налаштування властивості", + "remove-property": "Видалити властивість", + "default-value": "За замовчуванням", + "value-required": "Значення є обов'язковим", + "number-settings": "Налаштування числа", + "min": "Мінімум", + "max": "Максимум", + "step": "Крок", + "selected-options-limit": "Обмеження вибраних опцій", + "advanced-ui-settings": "Розширені налаштування UI", + "disable-on-property": "Вимкнути за властивістю", + "disable-on-property-none": "Ні (поле завжди доступне)", + "display-condition-function": "Функція умови відображення", + "sub-label": "Підмаркування", + "vertical-divider-after": "Вертикальний роздільник після", + "input-field-suffix": "Суфікс поля введення", + "property-row-classes": "Класи рядка властивості", + "property-field-classes": "Класи поля властивості", + "not-unique-property-ids-error": "ID властивостей повинні бути унікальними!", + "enable-multiple-select": "Увімкнути множинний вибір", + "allow-empty-select-option": "Дозволити порожню опцію", + "select-options": "Вибір опцій", + "not-unique-select-option-value-error": "Значення опцій вибору повинні бути унікальними!", + "value": "Значення", + "label": "Маркування", + "add-option": "Додати опцію", + "no-options": "Не налаштовано опцій", + "remove-option": "Видалити опцію", + "textarea-rows": "Ряди текстової області", + "help-id": "ID допомоги", + "buttons-direction": "Напрямок кнопок", + "direction-row": "Рядок", + "direction-column": "Стовпчик", + "radio-button-options": "Опції радіокнопок", + "datetime-type": "Тип поля Дата/Час", + "datetime-type-date": "Дата", + "datetime-type-time": "Час", + "datetime-type-datetime": "Дата/Час", + "enable-clear-button": "Увімкнути кнопку очищення", + "html-section-settings": "Налаштування HTML секції", + "html-section-classes": "Класи HTML секції", + "html-section-content": "Вміст HTML секції", + "array-item": "Елемент масиву", + "item-type": "Тип елемента", + "item-name": "Назва елемента", + "no-items": "Немає елементів", + "support-unit-conversion": "Підтримка перетворення одиниць" + }, + "clear-form": "Очистити форму", + "clear-form-prompt": "Ви впевнені, що хочете видалити всі властивості форми?", + "import-form": "Імпортувати форму з JSON", + "export-form": "Експортувати форму в JSON", + "json-file": "Файл JSON", + "json-content": "Вміст JSON", + "invalid-form-json-file-error": "Не вдалося імпортувати форму з JSON: Невірна структура даних JSON форми." + }, + "asset-profile": { + "asset-profile": "Профіль активу", + "asset-profiles": "Профілі активів", + "all-asset-profiles": "Усі", + "add": "Додати профіль активу", + "edit": "Редагувати профіль активу", + "asset-profile-details": "Деталі профілю активу", + "no-asset-profiles-text": "Профілі активів не знайдені", + "search": "Шукати профілі активів", + "selected-asset-profiles": "{ count, plural, =1 {1 профіль активу} other {# профілів активів} } вибрано", + "no-asset-profiles-matching": "Не знайдено профіля активу, що відповідає '{{entity}}'.", + "asset-profile-required": "Профіль активу є обов'язковим", + "idCopiedMessage": "ID профілю активу скопійовано в буфер обміну", + "set-default": "Зробити профіль активу за замовчуванням", + "delete": "Видалити профіль активу", + "copyId": "Копіювати ID профілю активу", + "name-max-length": "Назва має бути коротшою за 256 символів", + "new-device-profile-name": "Назва профілю активу", + "new-device-profile-name-required": "Назва профілю активу є обов'язковою.", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "image": "Зображення профілю активу", + "description": "Опис", + "default": "За замовчуванням", + "default-rule-chain": "Ланцюг правил за замовчуванням", + "default-edge-rule-chain": "Ланцюг правил edge за замовчуванням", + "default-edge-rule-chain-hint": "Використовується на edge як ланцюг правил для обробки вхідних даних для активів цього профілю активу", + "mobile-dashboard": "Мобільний дашборд", + "mobile-dashboard-hint": "Використовується мобільним додатком як дашборд деталей активу", + "select-queue-hint": "Виберіть зі спадного списку.", + "delete-asset-profile-title": "Ви впевнені, що хочете видалити профіль активу '{{assetProfileName}}'?", + "delete-asset-profile-text": "Будьте обережні, після підтвердження профіль активу і всі пов'язані дані будуть неможливі для відновлення.", + "delete-asset-profiles-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 профіль активу} other {# профілів активів} }?", + "delete-asset-profiles-text": "Будьте обережні, після підтвердження всі вибрані профілі активів будуть видалені, і всі пов'язані дані будуть неможливі для відновлення.", + "set-default-asset-profile-title": "Ви впевнені, що хочете зробити профіль активу '{{assetProfileName}}' профілем за замовчуванням?", + "set-default-asset-profile-text": "Після підтвердження профіль активу буде позначено як профіль за замовчуванням і буде використовуватися для нових активів, для яких не вказано профіль.", + "no-asset-profiles-found": "Профілі активів не знайдено.", + "create-new-asset-profile": "Створити новий!", + "create-asset-profile": "Створити новий профіль активу", + "import": "Імпортувати профіль активу", + "export": "Експортувати профіль активу", + "export-failed-error": "Не вдалося експортувати профіль активу: {{error}}", + "asset-profile-file": "Файл профілю активу", + "invalid-asset-profile-file-error": "Не вдалося імпортувати профіль активу: Невірна структура даних профілю активу." + }, + "device-profile": { + "device-profile": "Профіль пристрою", + "device-profiles": "Профілі пристроїв", + "all-device-profiles": "Усі", + "add": "Додати профіль пристрою", + "edit": "Редагувати профіль пристрою", + "device-profile-details": "Деталі профілю пристрою", + "no-device-profiles-text": "Профілі пристроїв не знайдені", + "search": "Шукати профілі пристроїв", + "selected-device-profiles": "{ count, plural, =1 {1 профіль пристрою} other {# профілів пристроїв} } вибрано", + "no-device-profiles-matching": "Не знайдено профілю пристрою, що відповідає '{{entity}}'.", + "device-profile-required": "Профіль пристрою є обов'язковим", + "idCopiedMessage": "ID профілю пристрою скопійовано в буфер обміну", + "set-default": "Зробити профіль пристрою за замовчуванням", + "delete": "Видалити профіль пристрою", + "copyId": "Копіювати ID профілю пристрою", + "name-max-length": "Назва має бути коротшою за 256 символів", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "type": "Тип профілю", + "type-required": "Тип профілю є обов'язковим.", + "type-default": "За замовчуванням", + "image": "Зображення профілю пристрою", + "transport-type": "Тип транспорту", + "transport-type-required": "Тип транспорту є обов'язковим.", + "transport-type-default": "За замовчуванням", + "transport-type-default-hint": "Підтримує базовий MQTT, HTTP та CoAP транспорт", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "Увімкнення розширених налаштувань транспорту MQTT", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "Увімкнення розширених налаштувань транспорту CoAP", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "Тип транспорту LWM2M", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "Вказати конфігурацію транспорту SNMP", + "transport-type-http": "HTTP", + "description": "Опис", + "default": "За замовчуванням", + "profile-configuration": "Конфігурація профілю", + "transport-configuration": "Конфігурація транспорту", + "default-rule-chain": "Ланцюг правил за замовчуванням", + "default-edge-rule-chain": "Ланцюг правил edge за замовчуванням", + "default-edge-rule-chain-hint": "Використовується на edge як ланцюг правил для обробки вхідних даних для пристроїв цього профілю пристрою", + "mobile-dashboard": "Мобільний дашборд", + "mobile-dashboard-hint": "Використовується мобільним додатком як дашборд деталей пристрою", + "select-queue-hint": "Виберіть зі спадного списку.", + "delete-device-profile-title": "Ви впевнені, що хочете видалити профіль пристрою '{{deviceProfileName}}'?", + "delete-device-profile-text": "Будьте обережні, після підтвердження профіль пристрою та всі пов'язані дані, включаючи пов'язані OTA оновлення, будуть неможливі для відновлення.", + "delete-device-profiles-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 профіль пристрою} other {# профілів пристроїв} }?", + "delete-device-profiles-text": "Будьте обережні, після підтвердження всі вибрані профілі пристроїв будуть видалені, і всі пов'язані дані, включаючи пов'язані OTA оновлення, будуть неможливі для відновлення.", + "set-default-device-profile-title": "Ви впевнені, що хочете зробити профіль пристрою '{{deviceProfileName}}' профілем за замовчуванням?", + "set-default-device-profile-text": "Після підтвердження профіль пристрою буде позначено як профіль за замовчуванням і буде використовуватися для нових пристроїв, для яких не вказано профіль.", + "no-device-profiles-found": "Профілі пристроїв не знайдено.", + "create-new-device-profile": "Створити новий!", + "mqtt-device-topic-filters": "MQTT фільтри тем пристроїв", + "mqtt-device-topic-filters-unique": "MQTT фільтри тем пристроїв повинні бути унікальними.", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) вузол.", + "mqtt-device-topic-filters-spark-plug-hint": "Дозволяє підключення від вузлів EoN з payload та форматом тем Sparkplug B.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "Метрики SparkPlug для збереження як атрибути.", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "Назви метрик SparkPlug, які будуть збережені як атрибути пристрою. Інші метрики будуть збережені як телеметрія пристрою.", + "mqtt-device-payload-type": "MQTT payload пристрою", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "Увімкнути сумісність з іншими форматами payload.", + "mqtt-enable-compatibility-with-json-payload-format-hint": "Коли увімкнено, платформа використовуватиме формат payload Protobuf за замовчуванням. Якщо парсинг не вдасться, платформа спробує використовувати формат JSON. Це корисно для зворотної сумісності під час оновлень прошивки. Наприклад, перше випуск прошивки використовує JSON, а нове — Protobuf. Під час процесу оновлення прошивки для флоту пристроїв потрібно підтримувати одночасно обидва формати. Режим сумісності може знижувати продуктивність, тому рекомендується вимкнути цей режим після оновлення всіх пристроїв.", + "mqtt-use-json-format-for-default-downlink-topics": "Використовувати формат JSON для тем для передачі вниз за замовчуванням", + "mqtt-use-json-format-for-default-downlink-topics-hint": "Коли увімкнено, платформа використовуватиме формат JSON для передачі атрибутів і RPC через наступні теми: v1/devices/me/attributes/response/$request_id, v1/devices/me/attributes, v1/devices/me/rpc/request/$request_id, v1/devices/me/rpc/response/$request_id. Це налаштування не впливає на підписки атрибутів і rpc, що відправляються за новими (v2) темами: v2/a/res/$request_id, v2/a, v2/r/req/$request_id, v2/r/res/$request_id. Де $request_id є цілим числом ідентифікатора запиту.", + "mqtt-send-ack-on-validation-exception": "Надіслати PUBACK при помилці перевірки повідомлення", + "mqtt-send-ack-on-validation-exception-hint": "За замовчуванням платформа закриває MQTT сесію при помилці перевірки повідомлення. Коли увімкнено, платформа надішле підтвердження публікації замість закриття сесії.", + "mqtt-protocol-version": "Версія протоколу", + "snmp-add-mapping": "Додати SNMP відображення", + "snmp-mapping-not-configured": "Не налаштовано відображення OID до часового ряду/телеметрії", + "snmp-timseries-or-attribute-name": "Часовий ряд/атрибут для відображення", + "snmp-timseries-or-attribute-type": "Тип часового ряду/атрибута для відображення", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "Тип payload є обов'язковим.", + "coap-device-type": "Тип пристрою CoAP", + "coap-device-payload-type": "Payload пристрою CoAP", + "coap-device-type-required": "Тип пристрою CoAP є обов'язковим.", + "coap-device-type-default": "За замовчуванням", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "Підтримуються одиночні [+] та багаторівневі [#] підстановочні знаки.", + "telemetry-topic-filter": "Фільтр теми телеметрії", + "telemetry-topic-filter-required": "Фільтр теми телеметрії є обов'язковим.", + "attributes-topic-filter": "Фільтр теми публікації атрибутів", + "attributes-subscribe-topic-filter": "Фільтр теми підписки на атрибути", + "attributes-topic-filter-required": "Фільтр теми публікації атрибутів є обов'язковим.", + "attributes-subscribe-topic-filter-required": "Фільтр теми підписки на атрибути є обов'язковим.", + "telemetry-proto-schema": "Схема proto телеметрії", + "telemetry-proto-schema-required": "Схема proto телеметрії є обов'язковою.", + "attributes-proto-schema": "Схема proto атрибутів", + "attributes-proto-schema-required": "Схема proto атрибутів є обов'язковою.", + "rpc-response-proto-schema": "Схема proto відповіді RPC", + "rpc-response-proto-schema-required": "Схема proto відповіді RPC є обов'язковою.", + "rpc-response-topic-filter": "Фільтр теми відповіді RPC", + "rpc-response-topic-filter-required": "Фільтр теми відповіді RPC є обов'язковим.", + "rpc-request-proto-schema": "Схема proto запиту RPC", + "rpc-request-proto-schema-required": "Схема proto запиту RPC є обов'язковою.", + "rpc-request-proto-schema-hint": "Повідомлення запиту RPC повинно завжди містити поля: string method = 1; int32 requestId = 2; та params = 3 будь-якого типу даних.", + "not-valid-pattern-topic-filter": "Невірний шаблон фільтра теми", + "not-valid-single-character": "Невірне використання символу одиночного рівня підстановки", + "not-valid-multi-character": "Невірне використання багаторівневого символу підстановки", + "single-level-wildcards-hint": "[+] підходить для будь-якого рівня фільтра теми. Наприклад: v1/devices/+/telemetry або +/devices/+/attributes.", + "multi-level-wildcards-hint": "[#] може замінити сам фільтр теми і має бути останнім символом теми. Наприклад: # або v1/devices/me/#.", + "alarm-rules": "Правила тривог", + "alarm-rules-with-count": "Правила тривог ({{count}})", + "no-alarm-rules": "Правила тривог не налаштовані", + "add-alarm-rule": "Додати правило тривоги", + "edit-alarm-rule": "Редагувати правило тривоги", + "alarm-type": "Тип тривоги", + "alarm-type-required": "Тип тривоги є обов'язковим.", + "alarm-type-unique": "Тип тривоги має бути унікальним у межах правил тривог для цього профілю пристрою.", + "alarm-type-max-length": "Тип тривоги має бути коротшим за 256 символів", + "create-alarm-pattern": "Створити тривогу {{alarmType}}", + "create-alarm-rules": "Створити правила тривог", + "no-create-alarm-rules": "Не налаштовано умов створення", + "add-create-alarm-rule-prompt": "Будь ласка, додайте правило створення тривоги", + "clear-alarm-rule": "Очистити правило тривоги", + "no-clear-alarm-rule": "Не налаштовано умов очищення", + "add-create-alarm-rule": "Додати умову створення", + "add-clear-alarm-rule": "Додати умову очищення", + "select-alarm-severity": "Вибрати серйозність тривоги", + "alarm-severity-required": "Серйозність тривоги є обов'язковою.", + "condition-duration": "Тривалість умови", + "condition-duration-value": "Значення тривалості", + "condition-duration-time-unit": "Одиниця часу", + "condition-duration-value-range": "Значення тривалості повинно бути в діапазоні від 1 до 2147483647.", + "condition-duration-value-pattern": "Значення тривалості повинно бути цілими числами.", + "condition-duration-value-required": "Значення тривалості є обов'язковим.", + "condition-duration-time-unit-required": "Одиниця часу є обов'язковою.", + "advanced-settings": "Розширені налаштування", + "alarm-rule-additional-info": "Додаткова інформація", + "edit-alarm-rule-additional-info": "Редагувати додаткову інформацію", + "alarm-rule-additional-info-placeholder": "Будь ласка, надайте ваші коментарі та коригування, щоб відобразити їх у деталях тривоги в розділі Додаткової інформації", + "alarm-rule-additional-info-hint": "Підказка: використовуйте ${keyName} для заміни значень атрибутів або ключів телеметрії, що використовуються в умові правила тривоги.", + "alarm-rule-mobile-dashboard": "Мобільний дашборд", + "alarm-rule-mobile-dashboard-hint": "Використовується мобільним додатком як дашборд деталей тривоги", + "alarm-rule-no-mobile-dashboard": "Дашборд не вибрано", + "propagate-alarm": "Пропагувати тривогу до пов'язаних сутностей", + "alarm-rule-relation-types-list": "Типи зв'язків", + "alarm-rule-relation-types-list-hint": "Визначає типи зв'язків для фільтрації пов'язаних сутностей. Якщо не задано, тривога буде пропагуватися до всіх пов'язаних сутностей.", + "propagate-alarm-to-owner": "Пропагувати тривогу до власника сутності (Клієнт або Тенант)", + "propagate-alarm-to-tenant": "Пропагувати тривогу до Тенанта", + "alarm-rule-condition": "Умова правила тривоги", + "enter-alarm-rule-condition-prompt": "Будь ласка, додайте умову правила тривоги", + "edit-alarm-rule-condition": "Редагувати умову правила тривоги", + "device-provisioning": "Постачання пристрою", + "provision-strategy": "Стратегія постачання", + "provision-strategy-required": "Стратегія постачання є обов'язковою.", + "provision-strategy-disabled": "Вимкнено", + "provision-strategy-created-new": "Дозволити створювати нові пристрої", + "provision-strategy-check-pre-provisioned": "Перевіряти на попередньо постачені пристрої", + "provision-device-key": "Ключ пристрою для постачання", + "provision-device-key-required": "Ключ пристрою для постачання є обов'язковим.", + "copy-provision-key": "Копіювати ключ постачання", + "provision-key-copied-message": "Ключ постачання скопійовано в буфер обміну", + "provision-device-secret": "Секрет пристрою для постачання", + "provision-device-secret-required": "Секрет пристрою для постачання є обов'язковим.", + "copy-provision-secret": "Копіювати секрет постачання", + "provision-secret-copied-message": "Секрет постачання скопійовано в буфер обміну", + "provision-strategy-x509": { + "certificate-chain": "Ланцюг сертифікатів X509", + "certificate-chain-hint": "Стратегія сертифікатів X.509 використовується для постачання пристроїв за допомогою клієнтських сертифікатів у двосторонньому TLS-з'єднанні.", + "allow-create-new-devices": "Створити нові пристрої", + "allow-create-new-devices-hint": "Якщо вибрано, нові пристрої будуть створюватися, а клієнтський сертифікат буде використовуватися як облікові дані пристрою.", + "certificate-value": "Сертифікат у форматі PEM", + "certificate-value-required": "Сертифікат у форматі PEM є обов'язковим", + "cn-regex-variable": "Змінна регулярного виразу CN", + "cn-regex-variable-required": "Змінна регулярного виразу CN є обов'язковою", + "cn-regex-variable-hint": "Необхідно для отримання імені пристрою з загального імені X509 сертифікату пристрою." + }, + "condition": "Умова", + "condition-type": "Тип умови", + "condition-type-simple": "Проста", + "condition-type-duration": "Тривалість", + "condition-during": "Протягом {{during}}", + "condition-during-dynamic": "Протягом \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "Повторювана", + "condition-type-required": "Тип умови є обов'язковим.", + "condition-repeating-value": "Кількість подій", + "condition-repeating-value-range": "Кількість подій повинна бути в діапазоні від 1 до 2147483647.", + "condition-repeating-value-pattern": "Кількість подій повинна бути цілими числами.", + "condition-repeating-value-required": "Кількість подій є обов'язковою.", + "condition-repeat-times": "Повторюється { count, plural, =1 {1 раз} other {# разів} }", + "condition-repeat-times-dynamic": "Повторюється \"{ attribute }\" ({ count, plural, =1 {1 раз} other {# разів} })", + "schedule-type": "Тип планувальника", + "schedule-type-required": "Тип планувальника є обов'язковим.", + "schedule": "Розклад", + "edit-schedule": "Редагувати розклад тривоги", + "schedule-any-time": "Активний завжди", + "schedule-specific-time": "Активний у конкретний час", + "schedule-custom": "Користувацький", + "schedule-day": { + "monday": "Понеділок", + "tuesday": "Вівторок", + "wednesday": "Середа", + "thursday": "Четвер", + "friday": "П’ятниця", + "saturday": "Субота", + "sunday": "Неділя" + }, + "schedule-days": "Дні", + "schedule-time": "Час", + "schedule-time-from": "З", + "schedule-time-to": "До", + "schedule-days-of-week-required": "Потрібно вибрати хоча б один день тижня.", + "create-device-profile": "Створити новий профіль пристрою", + "import": "Імпортувати профіль пристрою", + "export": "Експортувати профіль пристрою", + "export-failed-error": "Не вдалося експортувати профіль пристрою: {{error}}", + "device-profile-file": "Файл профілю пристрою", + "invalid-device-profile-file-error": "Не вдалося імпортувати профіль пристрою: Невірна структура даних профілю пристрою.", + "power-saving-mode": "Режим енергозбереження", + "power-saving-mode-type": { + "default": "Використовувати режим енергозбереження профілю пристрою", + "psm": "Режим енергозбереження (PSM)", + "drx": "Переривчастий прийом (DRX)", + "edrx": "Розширений переривчастий прийом (eDRX)" }, + "edrx-cycle": "Цикл eDRX", + "edrx-cycle-required": "Цикл eDRX є обов'язковим.", + "edrx-cycle-pattern": "Цикл eDRX повинен бути додатним цілим числом.", + "edrx-cycle-min": "Мінімальна кількість циклів eDRX — {{ min }} секунд.", + "paging-transmission-window": "Вікно передачі пагінгу", + "paging-transmission-window-required": "Вікно передачі пагінгу є обов'язковим.", + "paging-transmission-window-pattern": "Вікно передачі пагінгу повинно бути додатним цілим числом.", + "paging-transmission-window-min": "Мінімальна кількість вікон передачі пагінгу — {{ min }} секунд.", + "psm-activity-timer": "Таймер активності PSM", + "psm-activity-timer-required": "Таймер активності PSM є обов'язковим.", + "psm-activity-timer-pattern": "Таймер активності PSM повинен бути додатним цілим числом.", + "psm-activity-timer-min": "Мінімальна кількість таймерів активності PSM — {{ min }} секунд.", + "lwm2m": { + "object-list": "Список об'єктів", + "object-list-empty": "Не вибрано жодних об'єктів.", + "no-objects-found": "Об'єкти не знайдено.", + "no-objects-matching": "Не знайдено об'єктів, що відповідають '{{object}}'.", + "model-tab": "Модель LWM2M", + "add-new-instances": "Додати нові екземпляри", + "instances-list": "Список екземплярів", + "instances-list-required": "Список екземплярів є обов'язковим.", + "instance-id-pattern": "ID екземпляра повинен бути додатним цілим числом.", + "instance-id-max": "Максимальне значення ID екземпляра {{max}}.", + "instance": "Екземпляр", + "resource-label": "#ID Назва ресурсу", + "observe-label": "Спостереження", + "attribute-label": "Атрибут", + "telemetry-label": "Телеметрія", + "edit-observe-select": "Для редагування спостереження виберіть телеметрію або атрибут", + "edit-attributes-select": "Для редагування атрибутів виберіть телеметрію або атрибут", + "no-attributes-set": "Атрибути не налаштовані", + "key-name": "Назва ключа", + "key-name-required": "Назва ключа є обов'язковою", + "attribute-name": "Назва атрибута", + "attribute-name-required": "Назва атрибута є обов'язковою.", + "attribute-value": "Значення атрибута", + "attribute-value-required": "Значення атрибута є обов'язковим.", + "attribute-value-pattern": "Значення атрибута повинно бути додатним цілим числом.", + "edit-attributes": "Редагувати атрибути: {{ name }}", + "view-attributes": "Переглянути атрибути: {{ name }}", + "add-attribute": "Додати атрибут", + "edit-attribute": "Редагувати атрибут", + "view-attribute": "Переглянути атрибут", + "remove-attribute": "Видалити атрибут", + "delete-server-text": "Будьте обережні, після підтвердження конфігурація сервера стане неможливою для відновлення.", + "delete-server-title": "Ви впевнені, що хочете видалити сервер?", + "mode": "Режим налаштування безпеки", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap Server (ShortId...)", + "lwm2m-server-legend": "LwM2M Server (ShortId...)", + "server": "Сервер", + "short-id": "Короткий ID сервера", + "short-id-tooltip": "Короткий ID сервера. Використовується як посилання для асоціації інстанції об'єкта сервера.\nЦей ідентифікатор унікально ідентифікує кожен LwM2M Server, налаштований для LwM2M Client.\nРесурс ПОВИНЕН бути встановлений, коли ресурс Bootstrap-Server має значення 'false'.\nЗначення ID:0 і ID:65535 не повинні використовуватися для ідентифікації LwM2M Server.", + "short-id-tooltip-bootstrap": "Короткий ID сервера. Використовується як посилання для асоціації інстанції об'єкта сервера.\nЦей ідентифікатор унікально ідентифікує кожен LwM2M Server, налаштований для LwM2M Client.\nРесурс ПОВИНЕН бути встановлений, коли ресурс Bootstrap-Server має значення 'false'.", + "short-id-required": "Короткий ID сервера є обов'язковим.", + "short-id-range": "Короткий ID сервера повинен бути в діапазоні від {{ min }} до {{ max }}.", + "short-id-pattern": "Короткий ID сервера повинен бути додатним цілим числом.", + "short-id-pattern-bs": "Короткий ID сервера повинен бути тільки null", + "lifetime": "Тривалість реєстрації клієнта", + "lifetime-required": "Тривалість реєстрації клієнта є обов'язковою.", + "lifetime-pattern": "Тривалість реєстрації клієнта повинна бути додатним цілим числом.", + "default-min-period": "Мінімальний період між двома повідомленнями (с)", + "default-min-period-tooltip": "Значення за замовчуванням, яке LwM2M Client повинен використовувати для мінімального періоду спостереження за відсутності цього параметра в спостереженні.", + "default-min-period-required": "Мінімальний період є обов'язковим.", + "default-min-period-pattern": "Мінімальний період повинен бути додатним цілим числом.", + "notification-storing": "Зберігання повідомлень, коли вимкнено або офлайн", + "binding": "Зв'язування", + "binding-type": { + "u": "U: Клієнт доступний через UDP-зв'язок у будь-який час.", + "m": "M: Клієнт доступний через MQTT-зв'язок у будь-який час.", + "h": "H: Клієнт доступний через HTTP-зв'язок у будь-який час.", + "t": "T: Клієнт доступний через TCP-зв'язок у будь-який час.", + "s": "S: Клієнт доступний через SMS-зв'язок у будь-який час.", + "n": "N: Клієнт ПОВИНЕН відправляти відповідь на такий запит через Non-IP зв'язок (підтримується з LWM2M 1.1).", + "uq": "UQ: UDP-з'єднання в черговому режимі (не підтримується з LWM2M 1.1)", + "uqs": "UQS: активні з'єднання UDP та SMS; UDP в черговому режимі, SMS в стандартному режимі (не підтримується з LWM2M 1.1)", + "tq": "TQ: TCP-з'єднання в черговому режимі (не підтримується з LWM2M 1.1)", + "tqs": "TQS: активні з'єднання TCP та SMS; TCP в черговому режимі, SMS в стандартному режимі (не підтримується з LWM2M 1.1)", + "sq": "SQ: SMS-з'єднання в черговому режимі (не підтримується з LWM2M 1.1)" + }, + "binding-tooltip": "Це список у ресурсі \"binding\" об'єкта LwM2M сервера - /1/x/7.\nВказує підтримувані режими зв'язку в LwM2M Client.\nЦе значення повинно бути таким самим, як значення в ресурсі «Підтримувані зв'язки та режими» в об'єкті пристрою (/3/0/16).\nХоча підтримуються кілька транспортів, лише один транспорт можна використовувати під час усієї транспортної сесії.\nНаприклад, коли UDP та SMS підтримуються одночасно, LwM2M Client і LwM2M Server можуть вибрати зв'язок через UDP або SMS під час усієї транспортної сесії.", + "bootstrap-server": "Bootstrap сервер", + "lwm2m-server": "LwM2M сервер", + "include-bootstrap-server": "Включити оновлення Bootstrap сервера", + "bootstrap-update-title": "Ви вже налаштували Bootstrap сервер. Ви впевнені, що хочете виключити оновлення?", + "bootstrap-update-text": "Будьте обережні, після підтвердження конфігурація Bootstrap сервера стане неможливою для відновлення.", + "server-host": "Хост", + "server-host-required": "Хост є обов'язковим.", + "server-port": "Порт", + "server-port-required": "Порт є обов'язковим.", + "server-port-pattern": "Порт повинен бути додатним цілим числом.", + "server-port-range": "Порт повинен бути в діапазоні від 1 до 65535.", + "server-public-key": "Публічний ключ сервера", + "server-public-key-required": "Публічний ключ сервера є обов'язковим.", + "client-hold-off-time": "Час затримки клієнта", + "client-hold-off-time-required": "Час затримки клієнта є обов'язковим.", + "client-hold-off-time-pattern": "Час затримки клієнта повинен бути додатним цілим числом.", + "client-hold-off-time-tooltip": "Час затримки клієнта для використання тільки з Bootstrap-Server", + "account-after-timeout": "Обліковий запис після тайм-ауту", + "account-after-timeout-required": "Обліковий запис після тайм-ауту є обов'язковим.", + "account-after-timeout-pattern": "Обліковий запис після тайм-ауту повинен бути додатним цілим числом.", + "account-after-timeout-tooltip": "Обліковий запис після тайм-ауту, вказаний цим ресурсом на Bootstrap-Server.", + "server-type": "Тип сервера", + "add-new-server-title": "Додати нову конфігурацію сервера", + "add-server-config": "Додати конфігурацію сервера", + "add-lwm2m-server-config": "Додати LwM2M сервер", + "no-config-servers": "Не налаштовано серверів", + "others-tab": "Інші налаштування", + "ota-update": "Оновлення OTA", + "use-object-19-for-ota-update": "Використовувати Об'єкт 19 для метаданих OTA файлу (контрольна сума, розмір, версія, ім'я)", + "use-object-19-for-ota-update-hint": "Використовувати ресурс ObjectId = 19 для оновлень OTA: FirmWare → InstanceId = 65534, SoftWare → InstanceId = 65535. Формат даних — JSON, закодований в Base64. Цей JSON містить метадані OTA файлу (інформація про файл): \"Контрольна сума\" (SHA256). Додаткові поля: \"Назва\" (ім'я OTA), \"Версія\" (версія OTA), \"Ім'я файлу\" (ім'я файлу для зберігання OTA на клієнті), \"Розмір файлу\" (розмір OTA в байтах).", + "client-strategy": "Стратегія клієнта при підключенні", + "client-strategy-label": "Стратегія", + "client-strategy-only-observe": "Тільки спостереження за запитом до клієнта після початкового підключення", + "client-strategy-read-all": "Читати всі ресурси та спостереження за запитом до клієнта після реєстрації", + "fw-update": "Оновлення прошивки", + "fw-update-strategy": "Стратегія оновлення прошивки", + "fw-update-strategy-data": "Підштовхнути оновлення прошивки як бінарний файл за допомогою Об'єкта 19 і Ресурсу 0 (Дані)", + "fw-update-strategy-package": "Підштовхнути оновлення прошивки як бінарний файл за допомогою Об'єкта 5 і Ресурсу 0 (Пакет)", + "fw-update-strategy-package-uri": "Автоматично генерувати унікальне CoAP посилання для завантаження пакету та підштовхнути оновлення прошивки як Об'єкт 5 і Ресурс 1 (URI пакету)", + "sw-update": "Оновлення програмного забезпечення", + "sw-update-strategy": "Стратегія оновлення програмного забезпечення", + "sw-update-strategy-package": "Підштовхнути бінарний файл за допомогою Об'єкта 9 і Ресурсу 2 (Пакет)", + "sw-update-strategy-package-uri": "Автоматично генерувати унікальне CoAP посилання для завантаження пакету та підштовхнути оновлення програмного забезпечення за допомогою Об'єкта 9 і Ресурсу 3 (URI пакету)", + "fw-update-resource": "CoAP ресурс для оновлення прошивки", + "fw-update-resource-required": "CoAP ресурс для оновлення прошивки є обов'язковим.", + "sw-update-resource": "CoAP ресурс для оновлення програмного забезпечення", + "sw-update-resource-required": "CoAP ресурс для оновлення програмного забезпечення є обов'язковим.", + "config-json-tab": "Конфігурація JSON профілю пристрою", + "attributes-name": { + "min-period": "Мінімальний період", + "max-period": "Максимальний період", + "greater-than": "Більше ніж", + "less-than": "Менше ніж", + "step": "Крок", + "min-evaluation-period": "Мінімальний період оцінки", + "max-evaluation-period": "Максимальний період оцінки" + }, + "default-object-id": "Версія за замовчуванням об'єкта (Атрибут)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1", + "v1-2": "1.2" + }, + "observe-strategy": { + "observe-strategy": "Стратегія спостереження", + "single": "Один", + "single-description": "Один запит спостереження для кожного ресурсу (вища точність, більше трафіку в мережі)", + "composite-all": "Комбіноване все", + "composite-all-description": "Усі ресурси спостерігаються за допомогою одного комбінованого запиту спостереження (більш ефективно, менше гнучкості)", + "composite-by-object": "Комбіноване за об'єктами", + "composite-by-object-description": "Ресурси групуються за типом об'єкта і спостерігаються за допомогою окремих комбінованих запитів спостереження (збалансований підхід)" + }, + "init-attr-tel-as-obs-strategy": "Ініціалізувати атрибути та телеметрію за допомогою стратегії спостереження", + "init-attr-tel-as-obs-strategy-hint": "Якщо false - атрибути та телеметрія ініціалізуються шляхом послідовного зчитування їх значень.\\nЯкщо true - атрибути та телеметрія ініціалізуються шляхом підписки на їх значення за допомогою стратегії спостереження." + }, + "snmp": { + "add-communication-config": "Додати конфігурацію зв'язку", + "add-mapping": "Додати відображення", + "authentication-passphrase": "Пароль автентифікації", + "authentication-passphrase-required": "Пароль автентифікації є обов'язковим.", + "authentication-protocol": "Протокол автентифікації", + "authentication-protocol-required": "Протокол автентифікації є обов'язковим.", + "communication-configs": "Конфігурації зв'язку", + "community": "Рядок спільноти", + "community-required": "Рядок спільноти є обов'язковим.", + "context-name": "Назва контексту", + "data-key": "Ключ даних", + "data-key-required": "Ключ даних є обов'язковим.", + "data-type": "Тип даних", + "data-type-required": "Тип даних є обов'язковим.", + "engine-id": "ID двигуна", + "host": "Хост", + "host-required": "Хост є обов'язковим.", + "oid": "OID", + "oid-pattern": "Невірний формат OID.", + "oid-required": "OID є обов'язковим.", + "please-add-communication-config": "Будь ласка, додайте конфігурацію зв'язку", + "please-add-mapping-config": "Будь ласка, додайте конфігурацію відображення", + "port": "Порт", + "port-format": "Невірний формат порту.", + "port-required": "Порт є обов'язковим.", + "privacy-passphrase": "Пароль конфіденційності", + "privacy-passphrase-required": "Пароль конфіденційності є обов'язковим.", + "privacy-protocol": "Протокол конфіденційності", + "privacy-protocol-required": "Протокол конфіденційності є обов'язковим.", + "protocol-version": "Версія протоколу", + "protocol-version-required": "Версія протоколу є обов'язковою.", + "querying-frequency": "Частота запитів, мс", + "querying-frequency-invalid-format": "Частота запитів повинна бути додатним цілим числом.", + "querying-frequency-required": "Частота запитів є обов'язковою.", + "retries": "Повторні спроби", + "retries-invalid-format": "Повторні спроби повинні бути додатним цілим числом.", + "retries-required": "Повторні спроби є обов'язковими.", + "scope": "Область", + "scope-required": "Область є обов'язковою.", + "security-name": "Назва безпеки", + "security-name-required": "Назва безпеки є обов'язковою.", + "timeout-ms": "Тайм-аут, мс", + "timeout-ms-invalid-format": "Тайм-аут повинен бути додатним цілим числом.", + "timeout-ms-required": "Тайм-аут є обов'язковим.", + "user-name": "Ім'я користувача", + "user-name-required": "Ім'я користувача є обов'язковим." + } + }, + "dialog": { + "close": "Закрити діалог", + "error-message-title": "Повідомлення про помилку:", + "error-details-title": "Деталі помилки" + }, + "direction": { + "column": "Стовпчик", + "row": "Рядок" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Екземпляри Edge", + "instances": "Екземпляри", + "edge-file": "Файл Edge", + "name-max-length": "Назва має бути коротшою за 256 символів", + "label-max-length": "Маркування має бути коротшою за 256 символів", + "type-max-length": "Тип має бути коротшим за 256 символів", + "management": "Управління Edge", + "no-edges-matching": "Не знайдено edge, що відповідає '{{entity}}'.", + "add": "Додати Edge", + "no-edges-text": "Edge не знайдено", + "edge-details": "Деталі Edge", + "add-edge-text": "Додати новий Edge", + "delete": "Видалити Edge", + "delete-edge-title": "Ви впевнені, що хочете видалити Edge '{{edgeName}}'?", + "delete-edge-text": "Будьте обережні, після підтвердження Edge і всі пов'язані дані будуть неможливі для відновлення.", + "delete-edges-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 Edge} other {# Edge} }?", + "delete-edges-text": "Будьте обережні, після підтвердження всі вибрані Edge будуть видалені, і всі пов'язані дані будуть неможливі для відновлення.", + "name": "Назва", + "name-starts-with": "Назва Edge починається з", + "name-required": "Назва є обов'язковою.", + "description": "Опис", + "details": "Деталі", + "events": "Події", + "copy-id": "Копіювати ID Edge", + "id-copied-message": "ID Edge скопійовано в буфер обміну", + "sync": "Синхронізувати Edge", + "edge-required": "Edge є обов'язковим", + "edge-type": "Тип Edge", + "edge-type-required": "Тип Edge є обов'язковим.", + "event-action": "Дія події", + "entity-id": "ID сутності", + "select-edge-type": "Вибрати тип Edge", + "assign-to-customer": "Призначити клієнту", + "assign-to-customer-text": "Будь ласка, виберіть клієнта для призначення Edge", + "assign-edge-to-customer": "Призначити Edge клієнту", + "assign-edge-to-customer-text": "Будь ласка, виберіть Edge для призначення клієнту", + "assignedToCustomer": "Призначено клієнту", + "edge-public": "Edge публічний", + "assigned-to-customer": "Призначено: {{customerTitle}}", + "unassign-from-customer": "Зняти призначення від клієнта", + "unassign-edge-title": "Ви впевнені, що хочете зняти призначення Edge '{{edgeName}}'?", + "unassign-edge-text": "Після підтвердження Edge буде знято з призначення і він не буде доступний клієнту.", + "unassign-edges-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 Edge} other {# Edge} }?", + "unassign-edges-text": "Після підтвердження всі вибрані Edge будуть зняті з призначення і не будуть доступні клієнту.", + "make-public": "Зробити Edge публічним", + "make-public-edge-title": "Ви впевнені, що хочете зробити Edge '{{edgeName}}' публічним?", + "make-public-edge-text": "Після підтвердження Edge і всі його дані стануть публічними і будуть доступні для інших.", + "make-private": "Зробити Edge приватним", + "public": "Публічний", + "make-private-edge-title": "Ви впевнені, що хочете зробити Edge '{{edgeName}}' приватним?", + "make-private-edge-text": "Після підтвердження Edge і всі його дані стануть приватними і не будуть доступні для інших.", + "import": "Імпортувати Edge", + "install-connect-instructions": "Інструкції з встановлення та підключення", + "install-connect-instructions-edge-created": "Edge створено! Перевірте інструкції з встановлення та підключення", + "loading-edge-instructions": "Завантаження інструкцій для Edge...", + "label": "Маркування", + "load-entity-error": "Не вдалося завантажити дані. Сутність була видалена.", + "assign-new-edge": "Призначити новий Edge", + "unassign-from-edge": "Зняти призначення з Edge", + "edge-key": "Ключ Edge", + "copy-edge-key": "Копіювати ключ Edge", + "edge-key-copied-message": "Ключ Edge скопійовано в буфер обміну", + "edge-secret": "Секрет Edge", + "copy-edge-secret": "Копіювати секрет Edge", + "edge-secret-copied-message": "Секрет Edge скопійовано в буфер обміну", + "manage-assets": "Управління активами", + "manage-devices": "Управління пристроями", + "manage-entity-views": "Управління поданнями сутностей", + "manage-dashboards": "Управління дашбордами", + "manage-rulechains": "Управління ланцюгами правил", + "assets": "Активи Edge", + "devices": "Пристрої Edge", + "entity-views": "Подання сутностей Edge", + "dashboard": "Дашборд Edge", + "dashboards": "Дашборди Edge", + "rulechain-templates": "Шаблони ланцюгів правил", + "edge-rulechain-templates": "Шаблони ланцюгів правил Edge", + "rulechains": "Ланцюги правил Edge", + "search": "Шукати edge", + "selected-edges": "{ count, plural, =1 {1 edge} other {# edge} } вибрано", + "any-edge": "Будь-який edge", + "no-edge-types-matching": "Не знайдено типів edge, що відповідають '{{entitySubtype}}'.", + "edge-type-list-empty": "Не вибрано типів edge.", + "edge-types": "Типи edge", + "enter-edge-type": "Введіть тип edge", + "deployed": "Розгорнуто", + "pending": "Очікує", + "downlinks": "Низхідні з'єднання", + "no-downlinks-prompt": "Низхідні з'єднання не знайдено", + "sync-process-started-successfully": "Процес синхронізації розпочато успішно!", + "missing-related-rule-chains-title": "Edge має відсутні пов'язані ланцюги правил", + "missing-related-rule-chains-text": "Призначені для edge ланцюги правил використовують вузли правил, які пересилають повідомлення до ланцюгів правил, що не призначені цьому edge.

Список відсутніх ланцюгів правил:
{{missingRuleChains}}", + "widget-datasource-error": "Цей віджет підтримує тільки джерело даних EDGE сутності", + "upgrade-instructions": "Інструкції з оновлення", + "connected": "Підключено", + "disconnected": "Відключено" + }, + "edge-event": { + "type-dashboard": "Дашборд", + "type-asset": "Актив", + "type-device": "Пристрій", + "type-device-profile": "Профіль пристрою", + "type-asset-profile": "Профіль активу", + "type-entity-view": "Подання сутності", + "type-alarm": "Тривога", + "type-rule-chain": "Ланцюг правил", + "type-rule-chain-metadata": "Метадані ланцюга правил", + "type-edge": "Edge", + "type-user": "Користувач", + "type-tenant": "Тенант", + "type-tenant-profile": "Профіль тенанта", + "type-customer": "Клієнт", + "type-relation": "Зв'язок", + "type-widgets-bundle": "Набір віджетів", + "type-widgets-type": "Тип віджетів", + "type-admin-settings": "Налаштування адміністратора", + "type-ota-package": "Пакет OTA", + "type-queue": "Черга", + "action-type-added": "Додано", + "action-type-deleted": "Видалено", + "action-type-updated": "Оновлено", + "action-type-post-attributes": "Пост атрибути", + "action-type-attributes-updated": "Атрибути оновлені", + "action-type-attributes-deleted": "Атрибути видалені", + "action-type-timeseries-updated": "Часові ряди оновлені", + "action-type-credentials-updated": "Облікові дані оновлені", + "action-type-assigned-to-customer": "Призначено клієнту", + "action-type-unassigned-from-customer": "Знято з призначення клієнту", + "action-type-relation-add-or-update": "Додати або оновити зв'язок", + "action-type-relation-deleted": "Зв'язок видалено", + "action-type-rpc-call": "Виклик RPC", + "action-type-alarm-ack": "Підтвердження тривоги", + "action-type-alarm-clear": "Очищення тривоги", + "action-type-alarm-assigned": "Призначено тривогу", + "action-type-alarm-unassigned": "Знято призначення тривоги", + "action-type-assigned-to-edge": "Призначено Edge", + "action-type-unassigned-from-edge": "Знято з призначення Edge", + "action-type-credentials-request": "Запит облікових даних", + "action-type-entity-merge-request": "Запит на об'єднання сутностей" + }, + "error": { + "unable-to-connect": "Не вдалося підключитися до сервера! Будь ласка, перевірте ваше підключення до Інтернету.", + "unhandled-error-code": "Невідома помилка коду: {{errorCode}}", + "unknown-error": "Невідома помилка" + }, + "entity": { + "entity": "Сутність", + "entities": "Сутності", + "entities-count": "Кількість сутностей", + "alarms-count": "Кількість тривог", + "aliases": "Псевдоніми сутностей", + "aliases-short": "Псевдоніми", + "entity-alias": "Псевдонім сутності", + "unable-delete-entity-alias-title": "Не вдалося видалити псевдонім сутності", + "unable-delete-entity-alias-text": "Псевдонім сутності '{{entityAlias}}' не можна видалити, оскільки він використовується наступними віджетами:
{{widgetsList}}", + "duplicate-alias-error": "Знайдено дубльований псевдонім '{{alias}}'.
Псевдоніми сутностей повинні бути унікальними в межах дашборду.", + "missing-entity-filter-error": "Відсутній фільтр для псевдоніма '{{alias}}'.", + "configure-alias": "Налаштувати псевдонім '{{alias}}'", + "alias": "Псевдонім", + "alias-required": "Псевдонім сутності є обов'язковим.", + "remove-alias": "Видалити псевдонім сутності", + "add-alias": "Додати псевдонім сутності", + "edit-alias": "Редагувати псевдонім сутності", + "entity-list": "Список сутностей", + "entity-type": "Тип сутності", + "entity-types": "Типи сутностей", + "entity-type-list": "Список типів сутностей", + "any-entity": "Будь-яка сутність", + "add-entity-type": "Додати тип сутності", + "enter-entity-type": "Введіть тип сутності", + "no-entities-matching": "Не знайдено сутностей, що відповідають '{{entity}}'.", + "no-entities-text": "Сутностей не знайдено", + "no-entity-types-matching": "Не знайдено типів сутностей, що відповідають '{{entityType}}'.", + "name-starts-with": "Вираз для назви", + "help-text": "Використовуйте '%' за потребою: '%entity_name_contains%', '%entity_name_ends', 'entity_starts_with%'.", + "use-entity-name-filter": "Використовувати фільтр", + "entity-list-empty": "Не вибрано сутностей.", + "entity-type-list-required": "Потрібно вибрати хоча б один тип сутності.", + "entity-name-filter-required": "Фільтр за назвою сутності є обов'язковим.", + "entity-name-filter-no-entity-matched": "Не знайдено сутностей, що починаються з '{{entity}}'.", + "all-subtypes": "Усі", + "select-entities": "Вибрати сутності", + "no-aliases-found": "Псевдоніми не знайдені.", + "no-alias-matching": "'{{alias}}' не знайдено.", + "create-new-alias": "Створити новий!", + "create-new": "Створити новий", + "key": "Ключ", + "key-name": "Назва ключа", + "no-keys-found": "Ключі не знайдені.", + "no-key-matching": "'{{key}}' не знайдено.", + "create-new-key": "Створити новий!", + "type": "Тип", + "type-required": "Тип сутності є обов'язковим.", + "type-device": "Пристрій", + "type-devices": "Пристрої", + "list-of-devices": "{ count, plural, =1 {Один пристрій} other {Список # пристроїв} }", + "device-name-starts-with": "Пристрої, назви яких починаються з '{{prefix}}'", + "type-device-profile": "Профіль пристрою", + "type-device-profiles": "Профілі пристроїв", + "clear-selected-profiles": "Очистити вибрані профілі", + "list-of-device-profiles": "{ count, plural, =1 {Один профіль пристрою} other {Список # профілів пристроїв} }", + "device-profile-name-starts-with": "Профілі пристроїв, назви яких починаються з '{{prefix}}'", + "type-asset-profile": "Профіль активу", + "type-asset-profiles": "Профілі активів", + "list-of-asset-profiles": "{ count, plural, =1 {Один профіль активу} other {Список # профілів активів} }", + "asset-profile-name-starts-with": "Профілі активів, назви яких починаються з '{{prefix}}'", + "type-asset": "Актив", + "type-assets": "Активи", + "list-of-assets": "{ count, plural, =1 {Один актив} other {Список # активів} }", + "asset-name-starts-with": "Активи, назви яких починаються з '{{prefix}}'", + "type-entity-view": "Подання сутності", + "type-entity-views": "Подання сутностей", + "list-of-entity-views": "{ count, plural, =1 {Одне подання сутності} other {Список # подань сутностей} }", + "entity-view-name-starts-with": "Подання сутностей, назви яких починаються з '{{prefix}}'", + "type-rule": "Правило", + "type-rules": "Правила", + "list-of-rules": "{ count, plural, =1 {Одне правило} other {Список # правил} }", + "rule-name-starts-with": "Правила, назви яких починаються з '{{prefix}}'", + "type-plugin": "Плагін", + "type-plugins": "Плагіни", + "list-of-plugins": "{ count, plural, =1 {Один плагін} other {Список # плагінів} }", + "plugin-name-starts-with": "Плагіни, назви яких починаються з '{{prefix}}'", + "type-tenant": "Тенант", + "type-tenants": "Тенанти", + "list-of-tenants": "{ count, plural, =1 {Один тенант} other {Список # тенантів} }", + "tenant-name-starts-with": "Тенанти, назви яких починаються з '{{prefix}}'", + "type-tenant-profile": "Профіль тенанта", + "type-tenant-profiles": "Профілі тенантів", + "list-of-tenant-profiles": "{ count, plural, =1 {Один профіль тенанта} other {Список # профілів тенантів} }", + "tenant-profile-name-starts-with": "Профілі тенантів, назви яких починаються з '{{prefix}}'", + "type-customer": "Клієнт", + "type-customers": "Клієнти", + "list-of-customers": "{ count, plural, =1 {Один клієнт} other {Список # клієнтів} }", + "customer-name-starts-with": "Клієнти, назви яких починаються з '{{prefix}}'", + "type-user": "Користувач", + "type-users": "Користувачі", + "list-of-users": "{ count, plural, =1 {Один користувач} other {Список # користувачів} }", + "user-name-starts-with": "Користувачі, назви яких починаються з '{{prefix}}'", + "type-dashboard": "Дашборд", + "type-dashboards": "Дашборди", + "list-of-dashboards": "{ count, plural, =1 {Один дашборд} other {Список # дашбордів} }", + "dashboard-name-starts-with": "Дашборди, назви яких починаються з '{{prefix}}'", + "type-alarm": "Тривога", + "type-alarms": "Тривоги", + "list-of-alarms": "{ count, plural, =1 {Одна тривога} other {Список # тривог} }", + "alarm-name-starts-with": "Тривоги, назви яких починаються з '{{prefix}}'", + "type-rulechain": "Ланцюг правил", + "type-rulechains": "Ланцюги правил", + "list-of-rulechains": "{ count, plural, =1 {Один ланцюг правил} other {Список # ланцюгів правил} }", + "rulechain-name-starts-with": "Ланцюги правил, назви яких починаються з '{{prefix}}'", + "type-rulenode": "Вузол правила", + "type-rulenodes": "Вузли правил", + "list-of-rulenodes": "{ count, plural, =1 {Один вузол правила} other {Список # вузлів правил} }", + "rulenode-name-starts-with": "Вузли правил, назви яких починаються з '{{prefix}}'", + "type-api-key": "Ключ API", + "type-api-keys": "Ключі API", + "type-current-customer": "Поточний клієнт", + "type-current-tenant": "Поточний тенант", + "type-current-user": "Поточний користувач", + "type-current-user-owner": "Поточний власник користувача", + "type-calculated-field": "Розрахункове поле", + "type-calculated-fields": "Розрахункові поля", + "type-ai-model": "Модель AI", + "type-ai-models": "Моделі AI", + "type-widgets-bundle": "Набір віджетів", + "type-widgets-bundles": "Набори віджетів", + "list-of-widgets-bundles": "{ count, plural, =1 {Один набір віджетів} other {Список # наборів віджетів} }", + "type-widget": "Віджет", + "type-widgets": "Віджети", + "list-of-widgets": "{ count, plural, =1 {Один віджет} other {Список # віджетів} }", + "search": "Шукати сутності", + "selected-entities": "{ count, plural, =1 {1 сутність} other {# сутностей} } вибрано", + "entity-name": "Назва сутності", + "entity-label": "Маркування сутності", + "details": "Деталі сутності", + "no-entities-prompt": "Сутності не знайдено", + "no-data": "Немає даних для відображення", + "show-all-columns": "Показати все", + "columns-to-display": "Стовпчики для відображення", + "type-api-usage-state": "Стан використання API", + "type-edge": "Edge", + "type-edges": "Edges", + "list-of-edges": "{ count, plural, =1 {One edge} other {List of # edges} }", + "edge-name-starts-with": "Edges whose names start with '{{prefix}}'", + "version-conflict": { + "message": "Ви хочете перезаписати наявну версію чи відхилити зміни та завантажити останню версію?", + "link": "Ви можете завантажити вашу версію {{entityType}} за допомогою цього", + "overwrite": "Перезаписати версію", + "discard": "Відхилити зміни" + }, + "type-tb-resource": "Ресурс", + "type-tb-resources": "Ресурси", + "list-of-tb-resources": "{ count, plural, =1 {Один ресурс} other {Список # ресурсів} }", + "type-ota-package": "Пакет OTA", + "type-ota-packages": "Пакети OTA", + "list-of-ota-packages": "{ count, plural, =1 {Один пакет OTA} other {Список # пакетів OTA} }", + "type-rpc": "RPC", + "type-queue": "Черга", + "type-queue-stats": "Статистика черги", + "type-queues-stats": "Статистика черг", + "type-notification": "Сповіщення", + "type-notification-rule": "Правило сповіщення", + "type-notification-rules": "Правила сповіщень", + "list-of-notification-rules": "{ count, plural, =1 {Одне правило сповіщення} other {Список # правил сповіщень} }", + "type-notification-target": "Одержувач сповіщення", + "type-notification-targets": "Одержувачі сповіщень", + "list-of-notification-targets": "{ count, plural, =1 {Один одержувач сповіщення} other {Список # одержувачів сповіщень} }", + "type-notification-request": "Запит сповіщення", + "type-notification-template": "Шаблон сповіщення", + "type-notification-templates": "Шаблони сповіщень", + "list-of-notification-templates": "{ count, plural, =1 {Один шаблон сповіщення} other {Список # шаблонів сповіщень} }", + "link": "посилання", + "type-oauth2-client": "Клієнт OAuth 2.0", + "type-oauth2-clients": "Клієнти OAuth 2.0", + "list-of-oauth2-clients": "{ count, plural, =1 {Один клієнт OAuth 2.0} other {Список # клієнтів OAuth 2.0} }", + "type-domain": "Домен", + "type-domains": "Домени", + "list-of-domains": "{ count, plural, =1 {Один домен} other {Список # доменів} }", + "type-mobile-app": "Мобільний застосунок", + "type-mobile-apps": "Мобільні застосунки", + "list-of-mobile-apps": "{ count, plural, =1 {Один мобільний застосунок} other {Список # мобільних застосунків} }", + "type-mobile-app-bundle": "Мобільний набір", + "type-mobile-app-bundles": "Мобільні набори", + "list-of-mobile-app-bundles": "{ count, plural, =1 {Один мобільний набір} other {Список # мобільних наборів} }", + "limit-reached": "Досягнуто ліміту", + "limit-reached-text": "Ви досягли ліміту {{ entities }}. Щоб додати більше, будь ласка, попросіть Вашого Системного адміністратора збільшити Ваш ліміт для {{ entity }}.", + "request-limit-increase": "Запросити збільшення ліміту", + "request-sysadmin-text": "Ви є Системним адміністратором?", + "login-here": "Увійти тут", + "to-increase-limit": "щоб збільшити ліміт.", + "increase-limit-request-sent-title": "Ми надіслали автоматичний запит Вашому Системному адміністратору на збільшення ліміту", + "increase-limit-request-sent-text": "Будь ласка, дайте їм трохи часу, щоб переглянути запит і оновити налаштування. Можливо, Вам потрібно буде оновити цю сторінку, щоб побачити зміни." + }, + "entity-field": { + "created-time": "Час створення", + "name": "Назва", + "type": "Тип", + "first-name": "Ім'я", + "last-name": "Прізвище", + "email": "Email", + "title": "Заголовок", + "country": "Країна", + "state": "Штат", + "city": "Місто", + "address": "Адреса", + "address2": "Адреса 2", + "zip": "Поштовий індекс", + "phone": "Телефон", + "label": "Маркування", + "queue-name": "Назва черги", + "service-id": "Id сервісу", + "owner-name": "Назва власника", + "owner-type": "Тип власника" + }, + "entity-view": { + "entity-view": "Фрагмент сутності", + "entity-view-required": "Фрагмент сутності є обов'язковим.", + "entity-views": "Фрагменти сутностей", + "management": "Управління фрагментами сутностей", + "view-entity-views": "Переглянути фрагменти сутностей", + "entity-view-alias": "Псевдонім фрагмента сутності", + "aliases": "Псевдоніми фрагментів сутностей", + "no-alias-matching": "'{{alias}}' не знайдено.", + "no-aliases-found": "Псевдоніми не знайдені.", + "no-key-matching": "'{{key}}' не знайдено.", + "no-keys-found": "Ключі не знайдені.", + "create-new-alias": "Створити новий!", + "create-new-key": "Створити новий!", + "duplicate-alias-error": "Знайдено дубльований псевдонім '{{alias}}'.
Псевдоніми фрагментів сутностей повинні бути унікальними в межах дашборду.", + "configure-alias": "Налаштувати псевдонім '{{alias}}'", + "no-entity-views-matching": "Не знайдено фрагментів сутностей, що відповідають '{{entity}}'.", + "public": "Публічний", + "alias": "Псевдонім", + "alias-required": "Псевдонім фрагмента сутності є обов'язковим.", + "remove-alias": "Видалити псевдонім фрагмента сутності", + "add-alias": "Додати псевдонім для фрагмента сутності", + "name-starts-with": "Вираз для назви фрагмента сутності", + "help-text": "Використовуйте '%' за потребою: '%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with%'.", + "entity-view-list": "Список фрагментів сутностей", + "use-entity-view-name-filter": "Використовувати фільтр", + "entity-view-list-empty": "Фрагменти сутностей не вибрано.", + "entity-view-name-filter-required": "Фільтр назви фрагмента сутності є обов'язковим.", + "entity-view-name-filter-no-entity-view-matched": "Не знайдено фрагментів сутностей, що починаються з '{{entityView}}'.", + "add": "Додати фрагмент сутності", + "entity-view-public": "Фрагмент сутності є публічним", + "assign-to-customer": "Призначити клієнту", + "assign-entity-view-to-customer": "Призначити фрагмент сутності клієнту", + "assign-entity-view-to-customer-text": "Будь ласка, виберіть фрагменти сутностей для призначення клієнту", + "no-entity-views-text": "Фрагменти сутностей не знайдено", + "assign-to-customer-text": "Будь ласка, виберіть клієнта для призначення фрагмента сутності", + "entity-view-details": "Деталі фрагмента сутності", + "add-entity-view-text": "Додати новий фрагмент сутності", + "delete": "Видалити фрагмент сутності", + "assign-entity-views": "Призначити фрагменти сутностей", + "assign-entity-views-text": "Призначити { count, plural, =1 {1 фрагмент сутності} other {# фрагменти сутностей} } клієнту", + "delete-entity-views": "Видалити фрагменти сутностей", + "make-public": "Зробити фрагмент сутності публічним", + "make-private": "Зробити фрагмент сутності приватним", + "unassign-from-customer": "Зняти призначення від клієнта", + "unassign-entity-views": "Зняти призначення фрагментів сутностей", + "unassign-entity-views-action-title": "Зняти призначення { count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} } від клієнта", + "assign-new-entity-view": "Призначити новий фрагмент сутності", + "delete-entity-view-title": "Ви впевнені, що хочете видалити фрагмент сутності '{{entityViewName}}'?", + "delete-entity-view-text": "Будьте обережні, після підтвердження фрагмент сутності і всі пов'язані дані будуть неможливі для відновлення.", + "delete-entity-views-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} }?", + "delete-entity-views-action-title": "Видалити { count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} }", + "delete-entity-views-text": "Будьте обережні, після підтвердження всі вибрані фрагменти сутностей будуть видалені і всі пов'язані дані будуть неможливі для відновлення.", + "make-public-entity-view-title": "Ви впевнені, що хочете зробити фрагмент сутності '{{entityViewName}}' публічним?", + "make-public-entity-view-text": "Після підтвердження фрагмент сутності і всі його дані стануть публічними та доступними для інших.", + "make-private-entity-view-title": "Ви впевнені, що хочете зробити фрагмент сутності '{{entityViewName}}' приватним?", + "make-private-entity-view-text": "Після підтвердження фрагмент сутності і всі його дані стануть приватними та не будуть доступними для інших.", + "unassign-entity-view-title": "Ви впевнені, що хочете зняти призначення фрагмента сутності '{{entityViewName}}'?", + "unassign-entity-view-text": "Після підтвердження призначення фрагмента сутності буде знято і воно не буде доступне для клієнта.", + "unassign-entity-view": "Зняти призначення фрагмента сутності", + "unassign-entity-views-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 фрагмента сутності} other {# фрагменти сутностей} }?", + "unassign-entity-views-text": "Після підтвердження призначення всіх вибраних фрагментів сутностей буде знято і вони не будуть доступні для клієнта.", + "entity-view-type": "Тип фрагмента сутності", + "entity-view-type-required": "Тип фрагмента сутності є обов'язковим.", + "select-entity-view-type": "Вибрати тип фрагмента сутності", + "enter-entity-view-type": "Ввести тип фрагмента сутності", + "any-entity-view": "Будь-який фрагмент сутності", + "no-entity-view-types-matching": "Не знайдено типів фрагментів сутностей, що відповідають '{{entitySubtype}}'.", + "entity-view-type-list-empty": "Типи фрагментів сутностей не вибрано.", + "entity-view-types": "Типи фрагментів сутностей", + "created-time": "Час створення", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "name-max-length": "Назва має бути коротшою за 256 символів", + "type-max-length": "Тип фрагмента сутності має бути коротшим за 256 символів", + "description": "Опис", + "events": "Події", + "details": "Деталі", + "copyId": "Копіювати Id фрагмента сутності", + "idCopiedMessage": "Id фрагмента сутності скопійовано в буфер обміну", + "assignedToCustomer": "Призначено клієнту", + "unable-entity-view-device-alias-title": "Не вдалося видалити псевдонім фрагмента сутності", + "unable-entity-view-device-alias-text": "Псевдонім пристрою '{{entityViewAlias}}' не можна видалити, оскільки він використовується наступними віджетами:
{{widgetsList}}", + "select-entity-view": "Вибрати фрагмент сутності", + "start-ts": "Час початку", + "end-ts": "Час завершення", + "date-limits": "Обмеження дат", + "client-attributes": "Клієнтські атрибути", + "shared-attributes": "Спільні атрибути", + "server-attributes": "Серверні атрибути", + "timeseries": "Часові ряди", + "client-attributes-placeholder": "Клієнтські атрибути", + "shared-attributes-placeholder": "Спільні атрибути", + "server-attributes-placeholder": "Серверні атрибути", + "timeseries-placeholder": "Часові ряди", + "target-entity": "Цільова сутність", + "attributes-propagation": "Поширення атрибутів", + "attributes-propagation-hint": "Фрагмент сутності автоматично копіюватиме вказані атрибути з цільової сутності щоразу, коли Ви зберігаєте або оновлюєте цей фрагмент сутності. З міркувань продуктивності атрибути цільової сутності не поширюються до фрагмента сутності при кожній зміні атрибутів. Ви можете увімкнути автоматичне поширення, налаштувавши вузол правила \"copy to view\" у Вашому ланцюгу правил і зв'язавши повідомлення \"Post attributes\" та \"Attributes Updated\" з новим вузлом правила.", + "timeseries-data": "Дані часових рядів", + "timeseries-data-hint": "Налаштуйте ключі даних часових рядів цільової сутності, які будуть доступні для фрагмента сутності. Ці дані часових рядів доступні лише для читання.", + "search": "Шукати фрагменти сутностей", + "selected-entity-views": "{ count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} } вибрано", + "assign-entity-view-to-edge": "Призначити Фрагмент сутності(ей) до Edge", + "assign-entity-view-to-edge-text": "Будь ласка, виберіть фрагменти сутностей для призначення до edge", + "unassign-entity-view-from-edge-title": "Ви впевнені, що хочете зняти призначення фрагмента сутності '{{entityViewName}}'?", + "unassign-entity-view-from-edge-text": "Після підтвердження призначення фрагмента сутності буде знято і воно не буде доступне для edge.", + "unassign-entity-views-from-edge-action-title": "Зняти призначення { count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} } від edge", + "unassign-entity-view-from-edge": "Зняти призначення фрагмента сутності", + "unassign-entity-views-from-edge-title": "Ви впевнені, що хочете зняти призначення { count, plural, =1 {1 фрагмент сутності} other {# фрагментів сутностей} }?", + "unassign-entity-views-from-edge-text": "Після підтвердження призначення всіх вибраних фрагментів сутностей буде знято і вони не будуть доступні для edge." + }, + "event": { + "event-type": "Тип події", + "events-filter": "Фільтр подій", + "clean-events": "Очистити події", + "type-error": "Помилка", + "type-lc-event": "Подія життєвого циклу", + "type-stats": "Статистика", + "type-debug-rule-node": "Налагодження", + "type-debug-rule-chain": "Налагодження", + "type-debug-calculated-field": "Налагодження", + "arguments": "Аргументи", + "result": "Результат", + "no-events-prompt": "Події не знайдено", + "error": "Помилка", + "alarm": "Тривога", + "event-time": "Час події", + "server": "Сервер", + "body": "Тіло", + "method": "Метод", + "type": "Тип", + "metadata": "Метадані", + "message": "Повідомлення", + "message-id": "Id повідомлення", + "copy-message-id": "Копіювати Id повідомлення", + "message-type": "Тип повідомлення", + "data-type": "Тип даних", + "relation-type": "Тип зв'язку", + "data": "Дані", + "event": "Подія", + "status": "Статус", + "success": "Успішно", + "failed": "Не вдалося", + "messages-processed": "Оброблено повідомлень", + "max-messages-processed": "Максимум оброблених повідомлень", + "min-messages-processed": "Мінімум оброблених повідомлень", + "errors-occurred": "Сталося помилок", + "max-errors-occurred": "Максимум помилок", + "min-errors-occurred": "Мінімум помилок", + "min-value": "Мінімальне значення — 0.", + "all-events": "Усі", + "has-error": "Є помилка", + "entity-id": "Id сутності", + "copy-entity-id": "Копіювати Id сутності", + "entity-type": "Тип сутності", + "clear-filter": "Очистити фільтр", + "clear-request-title": "Очистити всі події", + "clear-request-text": "Ви впевнені, що хочете очистити всі події?", + "started": "Запущено", + "updated": "Оновлено", + "stopped": "Зупинено" + }, + "extension": { + "extensions": "Розширення", + "selected-extensions": "{ count, plural, =1 {1 розширення} other {# розширень} } вибрано", + "type": "Тип", + "key": "Ключ", + "value": "Значення", + "id": "Id", + "extension-id": "Id розширення", + "extension-type": "Тип розширення", + "transformer-json": "JSON *", + "unique-id-required": "Поточний Id розширення вже існує.", + "delete": "Видалити розширення", + "add": "Додати розширення", + "edit": "Редагувати розширення", + "delete-extension-title": "Ви впевнені, що хочете видалити розширення '{{extensionId}}'?", + "delete-extension-text": "Будьте обережні, після підтвердження розширення і всі пов'язані дані будуть неможливі для відновлення.", + "delete-extensions-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 розширення} other {# розширень} }?", + "delete-extensions-text": "Будьте обережні, після підтвердження всі вибрані розширення буде видалено.", + "converters": "Конвертери даних", + "converter-id": "Id конвертера даних", + "configuration": "Конфігурація", + "converter-configurations": "Конфігурації конвертера даних", + "token": "Токен безпеки", + "add-converter": "Додати конвертер даних", + "add-config": "Додати конфігурацію конвертера даних", + "device-name-expression": "Вираз назви пристрою", + "device-type-expression": "Вираз типу пристрою", + "custom": "Користувацький", + "to-double": "До Double", + "transformer": "Трансформер", + "json-required": "JSON трансформера є обов'язковим.", + "json-parse": "Не вдалося розібрати JSON трансформера.", + "attributes": "Атрибути", + "add-attribute": "Додати атрибут", + "add-map": "Додати елемент зіставлення", + "timeseries": "Часовий ряд", + "add-timeseries": "Додати часовий ряд", + "field-required": "Поле є обов'язковим", + "brokers": "Брокери", + "add-broker": "Додати брокера", + "host": "Хост", + "port": "Порт", + "port-range": "Порт має бути в діапазоні від 1 до 65535.", + "ssl": "Ssl", + "credentials": "Облікові дані", + "username": "Ім'я користувача", + "password": "Пароль", + "retry-interval": "Інтервал повторної спроби в мілісекундах", + "anonymous": "Анонімний", + "basic": "Basic", + "pem": "PEM", + "ca-cert": "Файл сертифіката CA *", + "private-key": "Файл приватного ключа *", + "cert": "Файл сертифіката *", + "no-file": "Файл не вибрано.", + "drop-file": "Перетягніть файл або натисніть, щоб вибрати файл для завантаження.", + "mapping": "Зіставлення", + "topic-filter": "Фільтр топіків", + "converter-type": "Тип конвертера", + "converter-json": "JSON", + "json-name-expression": "Вираз назви пристрою в JSON", + "topic-name-expression": "Вираз назви пристрою в топіку", + "json-type-expression": "Вираз типу пристрою в JSON", + "topic-type-expression": "Вираз типу пристрою в топіку", + "attribute-key-expression": "Вираз ключа атрибуту", + "attr-json-key-expression": "Вираз ключа атрибуту в JSON", + "attr-topic-key-expression": "Вираз ключа атрибуту в топіку", + "request-id-expression": "Вираз ID запиту", + "request-id-json-expression": "Вираз ID запиту в JSON", + "request-id-topic-expression": "Вираз ID запиту в топіку", + "response-topic-expression": "Вираз топіка відповіді", + "value-expression": "Вираз значення", + "topic": "Топік", + "timeout": "Тайм-аут у мілісекундах", + "converter-json-required": "Json конвертера є обов'язковим.", + "converter-json-parse": "Не вдалося розібрати json конвертера.", + "filter-expression": "Вираз фільтра", + "connect-requests": "Запити підключення", + "add-connect-request": "Додати запит підключення", + "disconnect-requests": "Запити відключення", + "add-disconnect-request": "Додати запит відключення", + "attribute-requests": "Запити атрибутів", + "add-attribute-request": "Додати запит атрибута", + "attribute-updates": "Оновлення атрибутів", + "add-attribute-update": "Додати оновлення атрибута", + "server-side-rpc": "RPC на стороні сервера", + "add-server-side-rpc-request": "Додати RPC-запит на стороні сервера", + "device-name-filter": "Фільтр назви пристрою", + "attribute-filter": "Фільтр атрибутів", + "method-filter": "Фільтр методу", + "request-topic-expression": "Вираз топіка запиту", + "response-timeout": "Тайм-аут відповіді в мілісекундах", + "topic-expression": "Вираз топіка", + "client-scope": "Область клієнта", + "add-device": "Додати пристрій", + "opc-server": "Сервери", + "opc-add-server": "Додати сервер", + "opc-add-server-prompt": "Будь ласка, додайте сервер", + "opc-application-name": "Назва застосунку", + "opc-application-uri": "URI застосунку", + "opc-scan-period-in-seconds": "Період сканування в секундах", + "opc-security": "Безпека", + "opc-identity": "Ідентифікація", + "opc-keystore": "Сховище ключів", + "opc-type": "Тип", + "opc-keystore-type": "Тип", + "opc-keystore-location": "Розташування *", + "opc-keystore-password": "Пароль", + "opc-keystore-alias": "Псевдонім", + "opc-keystore-key-password": "Пароль ключа", + "opc-device-node-pattern": "Шаблон вузла пристрою", + "opc-device-name-pattern": "Шаблон назви пристрою", + "modbus-server": "Сервери/підлеглі", + "modbus-add-server": "Додати сервер/підлеглий", + "modbus-add-server-prompt": "Будь ласка, додайте сервер/підлеглий", + "modbus-transport": "Транспорт", + "modbus-tcp-reconnect": "Автоматично перепідключатися", + "modbus-rtu-over-tcp": "RTU через TCP", + "modbus-port-name": "Назва послідовного порту", + "modbus-encoding": "Кодування", + "modbus-parity": "Парність", + "modbus-baudrate": "Швидкість передавання", + "modbus-databits": "Біти даних", + "modbus-stopbits": "Стоп-біти", + "modbus-databits-range": "Біти даних мають бути в діапазоні від 7 до 8.", + "modbus-stopbits-range": "Стоп-біти мають бути в діапазоні від 1 до 2.", + "modbus-unit-id": "Unit ID", + "modbus-unit-id-range": "Unit ID має бути в діапазоні від 1 до 247.", + "modbus-device-name": "Назва пристрою", + "modbus-poll-period": "Період опитування (мс)", + "modbus-attributes-poll-period": "Період опитування атрибутів (мс)", + "modbus-timeseries-poll-period": "Період опитування часових рядів (мс)", + "modbus-poll-period-range": "Період опитування має бути додатним значенням.", + "modbus-tag": "Тег", + "modbus-function": "Функція", + "modbus-register-address": "Адреса регістра", + "modbus-register-address-range": "Адреса регістра має бути в діапазоні від 0 до 65535.", + "modbus-register-bit-index": "Індекс біта", + "modbus-register-bit-index-range": "Індекс біта має бути в діапазоні від 0 до 15.", + "modbus-register-count": "Кількість регістрів", + "modbus-register-count-range": "Кількість регістрів має бути додатним значенням.", + "modbus-byte-order": "Порядок байтів", + "sync": { + "status": "Статус", + "sync": "Синхронізовано", + "not-sync": "Не синхронізовано", + "last-sync-time": "Час останньої синхронізації", + "not-available": "Недоступно" + }, + "export-extensions-configuration": "Експортувати конфігурацію розширень", + "import-extensions-configuration": "Імпортувати конфігурацію розширень", + "import-extensions": "Імпортувати розширення", + "import-extension": "Імпортувати розширення", + "export-extension": "Експортувати розширення", + "file": "Файл розширень", + "invalid-file-error": "Неприпустимий файл розширення" + }, + "feature": { + "advanced-features": "Розширені можливості" + }, + "filter": { + "add": "Додати фільтр", + "edit": "Редагувати фільтр", + "name": "Назва фільтра", + "name-required": "Назва фільтра є обов'язковою.", + "duplicate-filter": "Фільтр з такою назвою вже існує.", + "filters": "Фільтри", + "unable-delete-filter-title": "Не вдалося видалити фільтр", + "unable-delete-filter-text": "Фільтр '{{filter}}' не можна видалити, оскільки він використовується наступними віджетами:
{{widgetsList}}", + "duplicate-filter-error": "Знайдено дубльований фільтр '{{filter}}'.
Фільтри повинні бути унікальними в межах дашборду.", + "missing-key-filters-error": "Відсутні фільтри ключів для фільтра '{{filter}}'.", + "filter": "Фільтр", + "editable": "Доступний для редагування", + "editable-hint": "Дозволити користувачу змінювати значення фільтра в дашбордах.", + "no-filters-found": "Фільтри не знайдено.", + "no-filter-text": "Фільтр не вказано", + "add-filter-prompt": "Будь ласка, додайте фільтр", + "no-filter-matching": "'{{filter}}' не знайдено.", + "create-new-filter": "Створити новий!", + "create-new": "Створити новий", + "filter-required": "Фільтр є обов'язковим.", + "operation": { + "operation": "Операція", + "equal": "дорівнює", + "not-equal": "не дорівнює", + "starts-with": "починається з", + "ends-with": "закінчується на", + "contains": "містить", + "not-contains": "не містить", + "greater": "більше ніж", + "less": "менше ніж", + "greater-or-equal": "більше або дорівнює", + "less-or-equal": "менше або дорівнює", + "and": "і", + "or": "або", + "in": "в", + "not-in": "не в" + }, + "ignore-case": "ігнорувати регістр", + "value": "Значення", + "remove-filter": "Видалити фільтр", + "duplicate-filter-action": "Дублювати фільтр", + "preview": "Попередній перегляд фільтра", + "no-filters": "Фільтри не налаштовано", + "add-filter": "Додати фільтр", + "add-complex-filter": "Додати складний фільтр", + "add-complex": "Додати складний", + "complex-filter": "Складний фільтр", + "edit-complex-filter": "Редагувати складний фільтр", + "edit-filter-user-params": "Редагувати параметри користувача предиката фільтра", + "filter-user-params": "Параметри користувача предиката фільтра", + "user-parameters": "Параметри користувача", + "display-label": "Маркування для відображення", + "custom-label": "Користувацьке маркування", + "custom-label-hint": "Увімкніть, щоб задати власне маркування для фільтра. Якщо вимкнено, маркування буде згенеровано автоматично.", + "order-priority": "Порядок відображення", + "key-filter": "Фільтр ключа", + "key-filters": "Фільтри ключів", + "key-name": "Назва ключа", + "key-name-required": "Назва ключа є обов'язковою.", + "key-type": { + "key-type": "Тип ключа", + "attribute": "Атрибут", + "timeseries": "Часовий ряд", + "entity-field": "Поле сутності", + "constant": "Константа", + "client-attribute": "Клієнтський атрибут", + "server-attribute": "Серверний атрибут", + "shared-attribute": "Спільний атрибут" + }, + "value-type": { + "value-type": "Тип значення", + "string": "Рядок", + "numeric": "Числовий", + "boolean": "Логічний", + "date-time": "Дата й час" + }, + "value-type-required": "Тип значення ключа є обов'язковим.", + "key-value-type-change-title": "Ви впевнені, що хочете змінити тип значення ключа?", + "key-value-type-change-message": "Якщо ви підтвердите новий тип значення, всі введені фільтри ключів будуть видалені.", + "no-key-filters": "Фільтри ключів не налаштовано", + "add-key-filter": "Додати фільтр ключа", + "remove-key-filter": "Видалити фільтр ключа", + "edit-key-filter": "Редагувати фільтр ключа", + "date": "Дата", + "time": "Час", + "current-tenant": "Поточний орендар", + "current-customer": "Поточний клієнт", + "current-user": "Поточний користувач", + "current-device": "Поточний пристрій", + "default-value": "Типове значення", + "default-comma-separated-values": "Типові значення через кому", + "dynamic-source-type": "Тип динамічного джерела", + "dynamic-value": "Динамічне значення", + "no-dynamic-value": "Немає динамічного значення", + "source-attribute": "Атрибут джерела", + "switch-to-dynamic-value": "Перейти до динамічного значення", + "switch-to-default-value": "Перейти до типового значення", + "inherit-owner": "Спадкувати від власника", + "source-attribute-not-set": "Якщо атрибут джерела не налаштовано", + "unit": "Одиниця" + }, + "fullscreen": { + "expand": "Розгорнути на весь екран", + "exit": "Вийти з повноекранного режиму", + "toggle": "Переключити повноекранний режим", + "fullscreen": "Повноекранний режим" + }, + "function": { + "function": "Функція" + }, + "gateway": { + "gateway-name": "Назва Gateway", + "gateway-name-required": "Потрібно вказати назву Gateway.", + "gateways": "Gateways", + "create-new-gateway": "Створити новий Gateway", + "create-new-gateway-text": "Ви впевнені, що хочете створити новий Gateway з назвою: '{{gatewayName}}'?", + "launch-command": "Команда запуску", + "no-gateway-found": "Gateway не знайдено.", + "no-gateway-matching": " '{{item}}' не знайдено." + }, + "grid": { + "delete-item-title": "Ви впевнені, що хочете видалити цей елемент?", + "delete-item-text": "Будьте обережні, після підтвердження цей елемент і всі пов'язані дані стануть відновленими.", + "delete-items-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 елемент} other {# елементів} }?", + "delete-items-action-title": "Видалити { count, plural, =1 {1 елемент} other {# елементів} }", + "delete-items-text": "Будьте обережні, після підтвердження всі вибрані елементи будуть видалені, і всі пов'язані дані стануть відновленими.", + "add-item-text": "Додати новий елемент", + "no-items-text": "Елементи не знайдено", + "item-details": "Деталі елемента", + "delete-item": "Видалити елемент", + "delete-items": "Видалити елементи", + "scroll-to-top": "Прокрутити до верху" + }, + "help": { + "goto-help-page": "Перейти на сторінку допомоги", + "show-help": "Показати допомогу" + }, + "home": { + "home": "Головна", + "profile": "Профіль", + "logout": "Вийти", + "menu": "Меню", + "avatar": "Аватар", + "open-user-menu": "Відкрити меню користувача" + }, + "file-input": { + "browse-file": "Переглянути файл", + "browse-files": "Переглянути файли" + }, + "image": { + "gallery": "Галерея зображень", + "search": "Шукати зображення", + "selected-images": "{ count, plural, =1 {1 зображення} other {# зображень} } вибрано", + "created-time": "Час створення", + "name": "Назва", + "name-required": "Потрібно вказати назву.", + "resolution": "Роздільна здатність", + "size": "Розмір", + "system": "Система", + "download-image": "Завантажити зображення", + "export-image": "Експортувати зображення в JSON", + "import-image": "Імпортувати зображення з JSON", + "upload-image": "Вивантажити зображення", + "edit-image": "Редагувати зображення", + "image-details": "Деталі зображення", + "no-images": "Зображень не знайдено", + "delete-image": "Видалити зображення", + "delete-image-title": "Ви впевнені, що хочете видалити зображення '{{imageTitle}}'?", + "delete-image-text": "Будьте обережні, після підтвердження зображення буде неможливо відновити.", + "delete-images-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 зображення} other {# зображень} }?", + "delete-images-text": "Будьте обережні, після підтвердження всі вибрані зображення буде видалено, і всі пов'язані дані буде неможливо відновити.", + "list-mode": "Перегляд списком", + "grid-mode": "Перегляд сіткою", + "image-preview": "Попередній перегляд зображення", + "update-image": "Оновити зображення", + "export-failed-error": "Не вдалося експортувати зображення: {{error}}", + "image-json-file": "JSON-файл зображення", + "invalid-image-json-file-error": "Не вдалося імпортувати зображення з JSON: Некоректна структура даних JSON зображення.", + "image-is-in-use": "Зображення використовується іншими сутностями", + "images-are-in-use": "Зображення використовуються іншими сутностями", + "image-is-in-use-text": "Зображення '{{title}}' не було видалено, оскільки воно використовується такими сутностями:", + "images-are-in-use-text": "Не всі зображення було видалено, оскільки вони використовуються іншими сутностями.
Ви можете переглянути пов'язані сутності, натиснувши кнопку Посилання у відповідному рядку зображення.
Якщо Ви все ще хочете видалити ці зображення, виберіть їх у таблиці нижче та натисніть кнопку Видалити вибране.", + "delete-image-in-use-text": "Якщо Ви все ще хочете видалити зображення, натисніть кнопку Видалити все одно.", + "system-entities": "Системні сутності:", + "entities": "сутності:", + "references": "Посилання", + "include-system-images": "Включити системні зображення", + "clear-image": "Очистити зображення", + "no-image": "Немає зображення", + "no-image-selected": "Зображення не вибрано", + "browse-from-gallery": "Вибрати з галереї", + "set-link": "Встановити посилання", + "image-link": "Посилання на зображення", + "link": "Посилання", + "copy-image-link": "Копіювати посилання на зображення", + "embed-image": "Вбудувати зображення", + "embed-to-html": "Вбудувати в HTML", + "embed-to-html-hint": "Ця функція зробить посилання доступним будь-якому неавторизованому користувачу.", + "embed-to-html-text": "Використовуючи наведений нижче фрагмент коду, Ви можете вбудувати зображення у компоненти на основі звичайного HTML.
До таких компонентів належать віджети HTML-картки, функції вмісту комірки тощо.", + "embed-to-angular-template": "Вбудувати в шаблон Angular HTML", + "embed-to-angular-template-text": "Використовуючи наведений нижче фрагмент коду, Ви можете вбудувати зображення в шаблон Angular HTML, який використовуватиметься для компонентів.
До таких компонентів належать віджет Markdown, розділ HTML у редакторі віджетів, користувацькі дії тощо." + }, + "image-input": { + "drop-images-or": "Перетягніть зображення або", + "drag-and-drop": "Перетягнути та відпустити", + "or": "або", + "browse": "Переглянути", + "no-images": "Зображення не вибрано", + "images": "зображення" + }, + "import": { + "no-file": "Файл не вибрано", + "drop-file": "Перетягніть JSON файл або натисніть для вибору файлу для завантаження.", + "drop-json-file-or": "Перетягніть JSON файл або", + "drop-file-csv": "Перетягніть CSV файл або натисніть для вибору файлу для завантаження.", + "drop-file-csv-or": "Перетягніть CSV файл або", + "column-value": "Значення", + "column-title": "Назва", + "column-example": "Приклад значення даних", + "column-key": "Ключ атрибуту/телеметрії", + "credentials": "Облікові дані", + "csv-delimiter": "Роздільник CSV", + "csv-first-line-header": "Перша лінія містить назви стовпців", + "csv-update-data": "Оновити атрибути/телеметрію", + "details": "Деталі", + "import-csv-number-columns-error": "Файл має містити хоча б два стовпці", + "import-csv-invalid-format-error": "Невірний формат файлу. Лінія: '{{line}}'", + "column-type": { + "name": "Назва", + "type": "Тип", + "label": "Маркування", + "column-type": "Тип стовпця", + "client-attribute": "Атрибут клієнта", + "shared-attribute": "Спільний атрибут", + "server-attribute": "Атрибут сервера", + "timeseries": "Часовий ряд", + "entity-field": "Поле сутності", + "access-token": "Токен доступу", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT Client ID", + "user-name": "MQTT ім'я користувача", + "password": "MQTT пароль" + }, + "lwm2m": { + "client-endpoint": "LwM2M endpoint ім'я клієнта", + "security-config-mode": "Режим конфігурації безпеки LwM2M", + "client-identity": "Ідентичність клієнта LwM2M", + "client-key": "Ключ клієнта LwM2M", + "client-cert": "Публічний ключ клієнта LwM2M", + "bootstrap-server-security-mode": "Режим безпеки bootstrap сервера LwM2M", + "bootstrap-server-secret-key": "Секретний ключ bootstrap сервера LwM2M", + "bootstrap-server-public-key-id": "Публічний ключ або ID bootstrap сервера LwM2M", + "lwm2m-server-security-mode": "Режим безпеки сервера LwM2M", + "lwm2m-server-secret-key": "Секретний ключ сервера LwM2M", + "lwm2m-server-public-key-id": "Публічний ключ або ID сервера LwM2M" + }, + "snmp": { + "host": "SNMP хост", + "port": "SNMP порт", + "version": "Версія SNMP (v1, v2c або v3)", + "community-string": "Строка спільноти SNMP" + }, + "isgateway": "Є Gateway", + "activity-time-from-gateway-device": "Час активності з gateway пристрою", + "description": "Опис", + "routing-key": "Ключ Edge", + "secret": "Секрет Edge" + }, + "stepper-text": { + "select-file": "Виберіть файл", + "configuration": "Імпорт конфігурації", + "column-type": "Виберіть типи стовпців", + "creat-entities": "Створення нових сутностей" + }, + "message": { + "create-entities": "{{count}} нових сутностей успішно створено.", + "update-entities": "{{count}} сутностей успішно оновлено.", + "error-entities": "Сталася помилка при створенні {{count}} сутностей." + } + }, + "scada": { + "symbols": "SCADA символи", + "search": "Шукати символ", + "selected-symbols": "{ count, plural, =1 {Вибрано 1 символ} other {Вибрано #} }", + "download-symbol": "Завантажити символ SCADA", + "export-symbol": "Експортувати символ SCADA в JSON", + "import-symbol": "Імпортувати символ SCADA з JSON", + "upload-symbol": "Вивантажити символ SCADA", + "update-symbol": "Оновити символ SCADA", + "edit-symbol": "Редагувати символ SCADA", + "symbol-details": "Деталі символу SCADA", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "Символів не знайдено", + "show-hidden-elements": "Показати приховані елементи", + "hide-hidden-elements": "Приховати приховані елементи", + "delete-symbol": "Видалити символ SCADA", + "delete-symbol-title": "Ви впевнені, що хочете видалити символ SCADA '{{imageTitle}}'?", + "delete-symbol-text": "Будьте обережні, після підтвердження символ SCADA стане невідновлюваним.", + "delete-symbols-title": "{ count, plural, =1 {Ви впевнені, що хочете видалити 1 символ SCADA?} other {Ви впевнені, що хочете видалити вибране (#)?} }", + "delete-symbols-text": "Будьте обережні, після підтвердження всі вибрані символи SCADA будуть видалені, а всі пов'язані дані стануть невідновлюваними.", + "include-system-symbols": "Включити системні символи", + "symbol-preview": "Попередній перегляд символу", + "general": "Загальні", + "tags": "Теги", + "properties": "Властивості", + "title": "Назва", + "description": "Опис", + "search-tags": "Шукати теги", + "widget-size": "Розмір віджета", + "cols": "стовпці", + "rows": "рядки", + "state-render-function": "Функція рендерингу стану", + "preview": "Попередній перегляд", + "preview-widget-action-text": "Дію віджета '{{type}}' успішно виконано!", + "no-symbol": "Немає символу SCADA", + "no-symbol-selected": "Символ SCADA не вибрано", + "clear-symbol": "Очистити символ SCADA", + "browse-symbol-from-gallery": "Переглянути символ SCADA в галереї", + "zoom-in": "Збільшити", + "zoom-out": "Зменшити", + "create-widget": "Створити віджет", + "create-widget-from-symbol": "Створити віджет із символу SCADA", + "hidden": "приховано", + "tag": { + "tag": "Тег", + "on-click-action": "Дія при натисканні", + "no-tags": "Теги не налаштовано", + "delete-tag-text": "Ви впевнені, що хочете видалити тег
{{tag}} з елемента {{elementType}}?", + "update-tag": "Оновити тег", + "enter-tag": "Введіть тег", + "tag-settings": "Налаштування тегів", + "remove-tag": "Видалити тег", + "add-tag": "Додати тег" + }, + "behavior": { + "behavior": "Поведінка", + "id": "Id", + "name": "Назва", + "type": "Тип", + "no-behaviors": "Поведінку не налаштовано", + "add-behavior": "Додати поведінку", + "type-action": "Дія", + "type-value": "Значення", + "type-widget-action": "Дія віджета", + "behavior-settings": "Налаштування поведінки", + "remove-behavior": "Видалити поведінку", + "hint": "Підказка", + "group-title": "Назва групи", + "value-type": "Тип значення", + "default-value": "Значення за замовчуванням", + "true-label": "Маркування для True", + "false-label": "Маркування для False", + "state-label": "Маркування стану", + "default-payload": "Payload за замовчуванням", + "not-unique-behavior-ids-error": "Ids поведінки мають бути унікальними!", + "default-settings": "Налаштування за замовчуванням" + }, + "symbol": { + "symbol": "Символ SCADA", + "fluid-presence": "Наявність рідини", + "fluid-presence-hint": "Вказує, чи присутня рідина в трубі.", + "fluid-present": "Рідина присутня", + "present": "Присутня", + "absent": "Відсутня", + "flow-presence": "Наявність потоку", + "flow-presence-hint": "Вказує, чи тече рідина в трубі.", + "flow-present": "Потік присутній", + "flow-direction": "Напрямок потоку", + "flow-direction-hint": "Вказує напрямок потоку рідини.", + "forward": "Прямий", + "reverse": "Зворотний", + "flow-animation-speed": "Швидкість анімації потоку", + "flow-animation-speed-hint": "Значення типу double, що вказує швидкість анімації потоку. 1 - нормальна швидкість, 0 - без анімації, < 1 - повільніша анімація, > 1 - швидша анімація.", + "leak": "Витік", + "leak-hint": "Вказує, чи присутній витік у трубі.", + "leak-present": "Витік присутній", + "fluid-color": "Колір рідини", + "pipe-color": "Колір труби", + "horizontal-pipe": "Горизонтальна труба", + "vertical-pipe": "Вертикальна труба", + "horizontal-fluid-color": "Колір рідини для горизонтальної", + "vertical-fluid-color": "Колір рідини для вертикальної", + "left-pipe": "Ліва труба", + "right-pipe": "Права труба", + "top-pipe": "Верхня труба", + "bottom-pipe": "Нижня труба", + "left-fluid-color": "Колір рідини для лівої", + "right-fluid-color": "Колір рідини для правої", + "top-fluid-color": "Колір рідини для верхньої", + "bottom-fluid-color": "Колір рідини для нижньої", + "display": "Відображення", + "display-format": "Формат відображення", + "value": "Значення", + "decimals": "Десяткові знаки", + "units": "Одиниці", + "flow-meter-value-hint": "Значення типу double, що відображається на дисплеї витратоміра", + "value-hint": "Значення типу double, що вказує поточне значення", + "running": "Працює", + "running-hint": "Вказує, чи компонент перебуває у робочому стані.", + "warning-state": "Стан попередження", + "warning": "Попередження", + "warning-click": "Клік у стані попередження", + "warning-state-hint": "Вказує, чи компонент перебуває у стані попередження.", + "critical-state": "Критичний стан", + "critical": "Критичний", + "critical-click": "Клік у критичному стані", + "critical-state-hint": "Вказує, чи компонент перебуває у критичному стані.", + "critical-state-animation": "Анімація критичного стану", + "critical-state-animation-hint": "Чи потрібно увімкнути анімацію миготіння, коли компонент перебуває у критичному стані.", + "warning-critical-state-animation": "Анімація стану попередження/критичного", + "warning-critical-state-animation-hint": "Чи потрібно увімкнути анімацію миготіння, коли компонент перебуває у стані попередження або критичному стані.", + "animation": "Анімація", + "broken": "Несправний", + "broken-hint": "Вказує, чи компонент є несправним.", + "on-display-click": "При натисканні на дисплей", + "on-display-click-hint": "Дія, що виконується, коли користувач натискає на дисплей.", + "pipe": "Труба", + "default-border-color": "Колір межі за замовчуванням", + "active-border-color": "Колір активної межі", + "warning-border-color": "Колір межі попередження", + "critical-border-color": "Колір критичної межі", + "background-color": "Колір фону", + "rotation-animation-speed": "Швидкість анімації обертання", + "rotation-animation-speed-hint": "Значення типу double, що вказує швидкість анімації обертання. 1 - нормальна швидкість, 0 - без анімації, < 1 - повільніша анімація, > 1 - швидша анімація.", + "on-click": "При натисканні", + "on-click-hint": "Дія, що виконується, коли користувач натискає на компонент.", + "connectors-positions": "Позиції конекторів", + "right-connector": "Правий конектор", + "right-top-connector": "Правий верхній конектор", + "right-bottom-connector": "Правий нижній конектор", + "left-connector": "Лівий конектор", + "left-top-connector": "Лівий верхній конектор", + "left-bottom-connector": "Лівий нижній конектор", + "top-left-connector": "Верхній лівий конектор", + "top-right-connector": "Верхній правий конектор", + "top-connector": "Верхній конектор", + "bottom-connector": "Нижній конектор", + "running-color": "Колір у робочому стані", + "stopped-color": "Колір у зупиненому стані", + "stopped": "Зупинено", + "warning-color": "Колір попередження", + "critical-color": "Колір критичного стану", + "opened": "Відкрито", + "opened-hint": "Вказує, чи компонент перебуває у відкритому стані.", + "open": "Відкрити", + "open-hint": "Дія, що виконується, коли користувач натискає, щоб відкрити компонент.", + "close": "Закрити", + "close-hint": "Дія, що виконується, коли користувач натискає, щоб закрити компонент.", + "close-state-animation": "Анімація закритого стану", + "close-state-animation-hint": "Чи потрібно увімкнути анімацію миготіння, коли компонент перебуває у закритому стані.", + "opened-color": "Колір у відкритому стані", + "closed-color": "Колір у закритому стані", + "opened-rotation-angle": "Кут обертання у відкритому стані", + "closed-rotation-angle": "Кут обертання у закритому стані", + "tank-capacity": "Місткість резервуара", + "tank-capacity-hint": "Значення типу double, що вказує загальну місткість резервуара.", + "current-volume": "Поточний об'єм", + "current-volume-hint": "Значення типу double, що вказує поточний зайнятий об'єм.", + "tank-color": "Колір резервуара", + "value-box": "Поле значення", + "value-text": "Текст значення", + "scale": "Шкала", + "transparent-mode": "Прозорий режим", + "major-ticks": "Основні поділки", + "intervals": "Інтервали", + "major-ticks-color": "Колір основних поділок", + "normal": "Нормальний", + "minor-ticks": "Додаткові поділки", + "minor-ticks-color": "Колір додаткових поділок", + "temperature": "Температура", + "temperature-hint": "Значення типу double, що вказує поточну температуру.", + "update-temperature": "Оновити температуру", + "update-temperature-hint": "Дія, що виконується, коли користувач натискає, щоб змінити поточну температуру.", + "run": "Запустити", + "run-hint": "Дія, що виконується, коли користувач натискає, щоб запустити компонент.", + "stop": "Зупинити", + "stop-hint": "Дія, що виконується, коли користувач натискає, щоб зупинити компонент.", + "temperature-step": "Крок зміни температури", + "heat-pump-color": "Колір теплового насоса", + "power-button-background": "Фон кнопки живлення", + "value-box-background": "Фон поля значення", + "value-units": "Одиниці значення", + "enable-units-scale": "Увімкнути одиниці на шкалі", + "filtration-mode": "Режим фільтрації", + "filtration-mode-hint": "Ціле значення, що вказує поточний режим фільтрації.", + "filtration-mode-update": "Оновити стан режиму фільтрації", + "filtration-mode-update-hint": "Дія, що виконується, коли користувач натискає, щоб змінити поточний режим фільтрації.", + "filter-mode": "Фільтрація", + "waste-mode": "Злив", + "backwash-mode": "Зворотне промивання", + "recirculate-mode": "Рециркуляція", + "rinse-mode": "Ополіскування", + "closed-mode": "Закрито", + "sand-filter-color": "Колір піщаного фільтра", + "mode-box-background": "Фон поля режиму", + "border-color": "Колір межі", + "label-color": "Колір мітки", + "water-leak-hint": "Вказує, чи є витік.", + "default-color": "Колір за замовчуванням", + "leak-color": "Колір витоку", + "full-value": "Повне значення", + "full-value-hint": "Значення типу double, що вказує повне значення.", + "label": "Маркування", + "icon": "Іконка", + "button-color": "Колір кнопки", + "on-label": "'On' текст маркування", + "off-label": "'Off' текст маркування", + "arrow-presence": "Наявність стрілки", + "arrow-presence-hint": "Вказує, чи присутня стрілка в конекторі.", + "arrow-present": "Стрілка присутня", + "arrow-direction": "Напрямок потоку", + "arrow-direction-hint": "Вказує напрямок потоку.", + "flow-animation": "Наявність потоку", + "flow-animation-hint": "Вказує, чи тече рідина в конекторі.", + "flow": "Потік", + "flow-line": "Лінія", + "flow-line-style": "Стиль лінії", + "flow-style-hint": "Встановіть значення Dash і Gap так, щоб їхня сума ділилася на 100 без остачі для ідеальної синхронізації анімації.", + "flow-dash-cap": "Кінцівка штриха", + "dash-cap-butt": "Пласка", + "dash-cap-round": "Округла", + "dash-cap-square": "Квадратна", + "dash": "Штрих", + "gap": "Проміжок", + "main-line": "Основна лінія", + "line": "Лінія", + "line-color": "Колір лінії", + "arrow-color": "Колір стрілки", + "target-value": "Цільове значення", + "target-value-hint": "Вказує цільову точку на шкалі.", + "min-max-value": "Мінімальне та максимальне значення", + "min-value": "Мін", + "max-value": "Макс", + "progress-bar": "Смуга прогресу", + "progress-arrow": "Стрілка прогресу", + "warning-scale-color": "Колір шкали попередження", + "critical-scale-color": "Колір шкали критичного стану", + "scale-color": "Колір шкали", + "target": "Ціль", + "high-warning-state": "Високий стан попередження", + "show-high-warning-scale": "Показати шкалу високого попередження", + "high-warning-scale": "Шкала високого попередження", + "high-warning-state-hint": "Значення типу double вказує діапазон високого попередження до високого критичного або максимального значення.", + "low-warning-state": "Низький стан попередження", + "show-low-warning-scale": "Показати шкалу низького попередження", + "low-warning-scale": "Шкала низького попередження", + "low-warning-state-hint": "Значення типу double вказує діапазон низького попередження до низького критичного або мінімального значення.", + "high-critical-state": "Високий критичний стан", + "show-high-critical-scale": "Показати шкалу високого критичного стану", + "high-critical-scale": "Шкала високого критичного стану", + "high-critical-state-hint": "Значення типу double вказує діапазон високого критичного стану до максимального значення шкали.", + "low-critical-state": "Низький критичний стан", + "show-low-critical-scale": "Показати низький критичний стан", + "low-critical-scale": "Низький критичний стан", + "low-critical-state-hint": "Значення типу double вказує діапазон низького критичного стану до мінімального значення шкали.", + "filter-color": "Колір фільтра", + "colors": "Кольори", + "indicator-colors": "Кольори індикаторів", + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "on": "УВІМК", + "off": "ВИМК", + "on-off-state": "Стан увімкнено/вимкнено", + "on-off-state-hint": "Вказує, чи компонент перебуває у стані увімкнено або вимкнено.", + "on-update-state": "Оновити стан на увімкнено", + "on-update-state-hint": "Дія, що виконується, коли користувач натискає, щоб оновити стан на увімкнено.", + "off-update-state": "Оновити стан на вимкнено", + "off-update-state-hint": "Дія, що виконується, коли користувач натискає, щоб оновити стан на вимкнено.", + "voltage": "Напруга", + "input-voltage": "Вхідна напруга", + "input-voltage-hint": "Значення типу double вказує значення вхідної напруги.", + "output-voltage": "Вихідна напруга", + "output-voltage-hint": "Значення типу double вказує значення вихідної напруги.", + "first-phase-voltage": "Напруга першої фази", + "second-phase-voltage": "Напруга другої фази", + "third-phase-voltage": "Напруга третьої фази", + "phase-voltage-hint": "Значення типу double вказує значення напруги для поточної фази", + "voltage-hint": "Значення типу double вказує поточну напругу", + "current-voltage-color": "Колір поточної напруги", + "phase-indicator-color": "Колір індикатора фази", + "measured": "Виміряно", + "measured-hint": "Значення типу double вказує споживання енергії в кіловат-годинах", + "day-rate": "Денний тариф", + "night-rate": "Нічний тариф", + "off-peak-rate": "Непіковий тариф", + "peak-rate": "Піковий тариф", + "export-rate": "Експортний тариф", + "operating-mode": "Режим роботи", + "bypass-mode": "Обхід", + "operating-mode-hint": "Ціле значення, що вказує поточний режим operating mode (0 - OFF, 1 - ON, 2 - BYPASS)", + "connected": "Підключено", + "connected-hint": "Вказує, чи компонент перебуває у підключеному стані.", + "disconnected": "Відключено", + "indicator": "Індикатор", + "operation-mode": "Режим роботи", + "operation-mode-hint": "Вказує, чи інвертор перебуває в режимі мережі або інвертора.", + "operation-mode-indicators-color": "Колір індикаторів режиму роботи", + "mains-on-mode": "Мережа увімкнена", + "inverter-on-mode": "Інвертор увімкнений", + "charging-mode": "Режим заряджання", + "charging-mode-hint": "Ціле значення, що вказує поточний режим заряджання (1 - Bulk, 2 - Absorption, 3 - Float)", + "charging-mode-indicators-color": "Колір індикаторів режиму заряджання", + "inverter-faults": "Несправності", + "inverter-fault-indicators-color": "Колір індикаторів несправностей", + "overload-fault": "Перевантаження", + "overload-fault-hint": "Вказує, чи інвертор перебуває в стані перевантаження.", + "low-battery-fault": "Низький заряд батареї", + "low-battery-fault-hint": "Вказує, чи батарея надмірно розряджена.", + "temperature-fault": "Температура", + "temperature-fault-hint": "Вказує, чи є висока температура в інверторі.", + "triangle": "Трикутник", + "socket": "Розетка", + "left-button": "Ліва кнопка", + "right-button": "Права кнопка", + "alarm-colors": "Кольори тривоги", + "hook-color": "Колір крюка" + } + }, + "item": { + "selected": "Вибрано" + }, + "js-func": { + "no-return-error": "Функція має повертати значення!", + "return-type-mismatch": "Функція має повертати значення типу '{{type}}'!", + "tidy": "Упорядкувати", + "mini": "Міні", + "modules": "Модулі", + "remove-module": "Видалити модуль", + "no-modules": "Модулі не налаштовано", + "add-module": "Додати модуль", + "module-alias": "Аліас", + "invalid-module-alias-name": "Некоректна назва аліаса", + "module-resource": "Ресурс JS модуля", + "not-unique-module-aliases-error": "Аліаси модулів мають бути унікальними!", + "show-module-info": "Показати інформацію про модуль", + "show-module-source-code": "Показати вихідний код модуля", + "module-members": "Експортовані елементи модуля", + "module-no-members": "Модуль не має експортованих елементів", + "module-load-error": "Помилка завантаження модуля", + "source-code": "Вихідний код", + "source-code-load-error": "Помилка завантаження вихідного коду", + "no-js-module-text": "JS модулі не знайдено", + "no-js-module-matching": "Не знайдено JS модулів, що відповідають '{{module}}'." + }, + "key-val": { + "key": "Ключ", + "value": "Значення", + "remove-entry": "Видалити запис", + "add-entry": "Додати запис", + "no-data": "Немає записів" + }, + "layout": { + "layout": "Макет", + "layouts": "Макети", + "manage": "Керувати макетами", + "settings": "Налаштування макета", + "color": "Колір", + "main": "Основний", + "right": "Правий", + "left": "Лівий", + "select": "Виберіть цільовий макет", + "percentage-width": "Ширина у відсотках (%)", + "fixed-width": "Фіксована ширина (px)", + "left-width": "Ліва колонка (%)", + "right-width": "Права колонка (%)", + "pick-fixed-side": "Фіксована сторона: ", + "layout-fixed-width": "Фіксована ширина (px)", + "value-min-error": "Значення має бути більше ніж {{min}}{{unit}}", + "value-max-error": "Значення має бути менше ніж {{max}}{{unit}}", + "layout-fixed-width-required": "Фіксована ширина є обов'язковою", + "right-width-percentage-required": "Права ширина у відсотках є обов'язковою", + "left-width-percentage-required": "Ліва ширина у відсотках є обов'язковою", + "divider": "Роздільник", + "right-side": "Макет правої сторони", + "left-side": "Макет лівої сторони", + "add-new-breakpoint": "Додати новий breakpoint", + "breakpoint": "Breakpoint", + "breakpoints": "Breakpoints", + "copy-from": "Копіювати з", + "size": "Розмір", + "delete-breakpoint-title": "Ви впевнені, що хочете видалити breakpoint '{{name}}'?", + "delete-breakpoint-text": "Зверніть увагу: після підтвердження breakpoint стане невідновлюваним, а налаштування буде повернуто до breakpoint за замовчуванням." + }, + "legend": { + "direction": "Напрямок", + "position": "Позиція", + "show-values": "Показувати значення", + "min-option": "Мін", + "max-option": "Макс", + "average-option": "Середнє", + "total-option": "Загалом", + "latest-option": "Останнє", + "sort-legend": "Сортувати ключі даних у легенді", + "show-max": "Показувати максимальне значення", + "show-min": "Показувати мінімальне значення", + "show-avg": "Показувати середнє значення", + "show-total": "Показувати загальне значення", + "show-latest": "Показувати останнє значення", + "settings": "Налаштування легенди", + "min": "мін", + "max": "макс", + "avg": "сер", + "total": "заг", + "latest": "ост", + "Min": "Мін", + "Max": "Макс", + "Avg": "Сер", + "Total": "Заг", + "Latest": "Ост", + "comparison-time-ago": { + "previousInterval": "(попередній інтервал)", + "customInterval": "(власний інтервал)", + "days": "(день тому)", + "weeks": "(тиждень тому)", + "months": "(місяць тому)", + "years": "(рік тому)" + }, + "column-title": "Назва колонки", + "label": "Маркування", + "value": "Значення" + }, + "login": { + "login": "Вхід", + "request-password-reset": "Запросити скидання пароля", + "reset-password": "Скинути пароль", + "create-password": "Створити пароль", + "two-factor-authentication": "Двофакторна автентифікація", + "passwords-mismatch-error": "Введені паролі мають збігатися!", + "password-again": "Пароль ще раз", + "sign-in": "Увійти", + "username": "Ім'я користувача (email)", + "remember-me": "Запам'ятати мене", + "forgot-password": "Забули пароль?", + "password-reset": "Скидання пароля", + "expired-password-reset-message": "Ваш пароль вичерпав термін дії! \nБудь ласка, введіть новий пароль.", + "new-password": "Новий пароль", + "new-password-again": "Підтвердити новий пароль", + "password-link-sent-message": "Посилання для скидання надіслано", + "email": "Email", + "invalid-email-format": "Некоректний формат email.", + "sign-in-with": "Увійти за допомогою {{name}}", + "sign-in-to-your-account": "Увійти до Вашого облікового запису", + "or": "або", + "error": "Помилка входу", + "verify-your-identity": "Підтвердити Вашу особу", + "select-way-to-verify": "Виберіть спосіб підтвердження", + "resend-code": "Повторно надіслати код", + "resend-code-wait": "Повторно надіслати код через { time, plural, =1 {1 с} other {# с} }", + "try-another-way": "Спробувати інший спосіб", + "totp-auth-description": "Будь ласка, введіть код безпеки з Вашого застосунку автентифікації.", + "totp-auth-placeholder": "Код", + "sms-auth-description": "Код безпеки було надіслано на Ваш телефон: {{contact}}.", + "sms-auth-placeholder": "SMS код", + "email-auth-description": "Код безпеки було надіслано на Вашу адресу email: {{contact}}.", + "email-auth-placeholder": "Email код", + "backup-code-auth-description": "Будь ласка, введіть один із Ваших резервних кодів.", + "backup-code-auth-placeholder": "Резервний код", + "activation-link-expired": "Посилання активації втратило чинність", + "activation-link-expired-message": "Посилання для активації Вашого профілю втратило чинність. Ви можете повернутися на сторінку входу, щоб отримати новий email.", + "reset-password-link-expired": "Посилання для скидання пароля втратило чинність", + "reset-password-link-expired-message": "Посилання для скидання Вашого пароля втратило чинність. Ви можете повернутися на сторінку входу, щоб отримати новий email.", + "two-fa": "Двофакторна автентифікація", + "two-fa-required": "Двофакторна автентифікація є обов'язковою", + "set-up-verification-method": "Налаштуйте метод підтвердження, щоб продовжити", + "set-up-verification-method-login": "Налаштуйте метод підтвердження або увійдіть", + "enable-authenticator-app": "Увімкнути застосунок автентифікації", + "enable-authenticator-app-description": "Будь ласка, введіть код безпеки із Вашого застосунку автентифікації", + "enable-authenticator-sms": "Увімкнути SMS-автентифікатор", + "enable-authenticator-sms-description": "Введіть 6-цифровий код, який ми щойно надіслали на ", + "enable-authenticator-email": "Увімкнути email-автентифікатор", + "enable-authenticator-email-description": "Код безпеки було надіслано на Вашу email адресу ", + "enter-key-manually": "або введіть цей 32-цифровий ключ вручну:", + "continue": "Продовжити", + "confirm": "Підтвердити", + "authenticator-app-success": "Застосунок автентифікації успішно увімкнено", + "authenticator-app-success-description": "Наступного разу під час входу Вам потрібно буде надати код двофакторної автентифікації", + "authenticator-sms-success": "SMS-автентифікатор успішно увімкнено", + "authenticator-sms-success-description": "Наступного разу під час входу Вам буде запропоновано ввести код безпеки, який буде надіслано на номер телефону", + "authenticator-email-success": "Email автентифікатор успішно увімкнено", + "authenticator-email-success-description": "Наступного разу під час входу Вам буде запропоновано ввести код безпеки, який буде надіслано на Вашу email адресу", + "authenticator-backup-code-success": "Резервний код успішно увімкнено", + "authenticator-backup-code-success-description": "Наступного разу під час входу Вам буде запропоновано ввести код безпеки або використати один із резервних кодів.", + "add-verification-method": "Додати метод підтвердження", + "get-backup-code": "Отримати резервний код", + "copy-key": "Копіювати ключ", + "send-code": "Надіслати код", + "email-label": "Email", + "email-description": "Введіть email, щоб використовувати його як Ваш автентифікатор.", + "sms-description": "Введіть номер телефону, щоб використовувати його як Ваш автентифікатор.", + "backup-code-description": "Роздрукуйте коди, щоб вони були під рукою, коли Вам потрібно буде використати їх для входу у Ваш обліковий запис. Ви можете використати кожен резервний код один раз.", + "backup-code-warn": "Щойно Ви покинете цю сторінку, ці коди не можна буде показати знову. Зберігайте їх безпечно, використовуючи варіанти нижче.", + "download-txt": "Завантажити (txt)", + "print": "Друкувати", + "verification-code": "6-цифровий код", + "verification-code-invalid": "Некоректний формат коду підтвердження", + "verification-code-incorrect": "Код підтвердження неправильний", + "verification-code-many-request": "Занадто багато запитів на перевірку коду підтвердження", + "scan-qr-code": "Скануйте цей QR код за допомогою Вашого застосунку підтвердження", + "phone-input": { + "phone-input-label": "Номер телефону", + "phone-input-required": "Номер телефону є обов'язковим", + "phone-input-validation": "Номер телефону некоректний або неможливий", + "phone-input-pattern": "Некоректний номер телефону. Має бути у форматі E.164, наприклад {{phoneNumber}}", + "phone-input-hint": "Номер телефону у форматі E.164, наприклад {{phoneNumber}}" + } + }, + "mobile": { + "add-application": "Додати застосунок", + "app-id": "App Site Association ID", + "app-id-required": "App Site Association ID є обов'язковим", + "app-id-pattern": "Некоректний формат App Site Association ID", + "app-store-link": "Посилання App Store", + "app-store-link-required": "Посилання App Store є обов'язковим", + "application-details": "Деталі застосунку", + "application-package": "Пакет застосунку", + "application-secret": "Application secret", + "application-secret-required": "Application secret є обов'язковим", + "application": "Застосунок", + "applications": "Застосунки", + "copy-app-id": "Копіювати App ID", + "copy-app-store-link": "Копіювати посилання App Store", + "copy-application-package": "Копіювати пакет застосунку", + "copy-application-secret": "Копіювати Application secret", + "copy-google-play-link": "Копіювати посилання Google Play", + "copy-sha256-certificate-fingerprints": "Копіювати відбитки сертифіката SHA256", + "delete-application": "Видалити застосунок", + "delete-application-button-text": "Я розумію наслідки, видалити застосунок", + "delete-application-text": "Цю дію неможливо скасувати. Це назавжди видалить Ваш застосунок.
Якщо Ви не хочете видаляти його назавжди, Ви можете призупинити застосунок тимчасово.
Щоб видалити застосунок у будь-якому разі, введіть \"{{phrase}}\" для підтвердження.", + "delete-application-title-short": "Ви впевнені, що хочете видалити застосунок '{{name}}'?", + "delete-application-text-short": "Будьте обережні, після підтвердження застосунки та всі пов'язані дані стануть невідновлюваними.", + "delete-application-phrase": "видалити застосунок", + "delete-applications-bundle-text": "Будьте обережні, після підтвердження мобільний набір і всі пов'язані дані стануть невідновлюваними.", + "delete-applications-bundle-title": "Ви впевнені, що хочете видалити мобільний набір '{{bundleName}}'?", + "generate-application-secret": "Згенерувати Application secret", + "google-play-link": "Посилання Google Play", + "google-play-link-required": "Посилання Google Play є обов'язковим", + "latest-version": "Остання версія", + "min-version": "Мінімальна версія", + "invalid-version-pattern": "Некоректний формат версії. Використовуйте формат: major.minor.patch (наприклад, 1.0.0).", + "mobile-center": "Мобільний центр", + "mobile-package": "Пакет застосунку", + "mobile-package-max-length": "Пакет застосунку має бути меншим за 256", + "mobile-package-required": "Пакет застосунку є обов'язковим.", + "mobile-package-pattern": "Некоректний формат пакета застосунку", + "mobile-package-title": "Назва застосунку", + "mobile-package-title-max-length": "Назва застосунку має бути меншою за 256", + "no-application": "Застосунків не знайдено", + "no-bundles": "Наборів не знайдено", + "platform-type": "Тип платформи", + "search-application": "Шукати застосунки", + "search-bundles": "Шукати набори", + "set": "Встановити", + "sha256-certificate-fingerprints": "Відбитки сертифіката SHA256", + "sha256-certificate-fingerprints-required": "Відбитки сертифіката SHA256 є обов'язковими", + "sha256-certificate-fingerprints-pattern": "Некоректний формат відбитка сертифіката SHA256", + "show-hidden-pages": "Показувати приховані сторінки", + "status": "Статус", + "status-type": { + "deprecated": "Застарілий", + "draft": "Чернетка", + "published": "Опубліковано", + "suspended": "Призупинено" + }, + "store-information": "Інформація магазину", + "version-information": "Інформація про версію", + "min-version-release-notes": "Нотатки до випуску мінімальної версії", + "latest-version-release-notes": "Нотатки до випуску останньої версії", + "bundle": "Набір", + "bundles": "Набори", + "add-bundle": "Додати набір", + "title": "Назва", + "title-required": "Назва є обов'язковою", + "title-cannot-contain-only-spaces": "Назва не може містити лише пробіли", + "title-max-length": "Назва має бути меншою за 256", + "oauth-clients": "Клієнти OAuth 2.0", + "android-app": "Застосунок Android", + "android-application": "Застосунок Android", + "ios-app": "Застосунок iOS", + "ios-application": "Застосунок iOS", + "invalid-store-link": "Некоректне посилання магазину", + "enable-oauth": "Увімкнути OAuth 2.0", + "enable-self-registration": "Увімкнути самореєстрацію", + "edit-bundle": "Редагувати набір", + "description": "Опис", + "basic-settings": "Базові налаштування", + "no-application-matching": "Не знайдено застосунків, що відповідають '{{entity}}'.", + "no-bundle-matching": "Не знайдено наборів, що відповідають '{{entity}}'.", + "application-required": "Застосунок є обов'язковим.", + "bundle-required": "Набір є обов'язковим.", + "no-application-text": "Застосунків не знайдено", + "no-bundle-text": "Набір не знайдено", + "layout": "Макет", + "pages": "Сторінки", + "hide-all-pages": "Приховати всі сторінки", + "reset-to-default-pages": "Скинути до сторінок за замовчуванням", + "add-specific-page": "Додати конкретну сторінку", + "visible": "Видимо", + "hidden": "Приховано", + "reset-to-page-default": "Скинути сторінку до значень за замовчуванням", + "mobile-599": "Mobile (max 599px)", + "tablet-959": "Tablet (max 959px)", + "max-element-number": "Максимальна кількість елементів", + "page-name": "Назва сторінки", + "page-name-required": "Назва сторінки є обов'язковою.", + "page-name-cannot-contain-only-spaces": "Назва сторінки не може містити лише пробіли.", + "page-name-max-length": "Назва сторінки має бути меншою за 256", + "page-type": "Тип сторінки", + "pages-types": { + "dashboard": "Дашборд", + "web-view": "Web view", + "custom": "Користувацька" + }, + "url": "URL", + "invalid-url-format": "Некоректний формат URL", + "path": "Шлях", + "invalid-path-format": "Некоректний формат шляху", + "custom-page": "Користувацька сторінка", + "edit-page": "Редагувати сторінку", + "edit-custom-page": "Редагувати користувацьку сторінку", + "delete-page": "Видалити сторінку", + "qr-code-widget": "Віджет QR коду", + "type-here": "Type hero", + "configuration-dialog": "Діалог конфігурації", + "configuration-app": "Застосунок конфігурації", + "configuration-step": { + "prepare-environment-title": "Підготуйте середовище розробки", + "prepare-environment-text": "Flutter ThingsBoard Mobile Application потребує Flutter SDK. Дотримуйтеся інструкцій, щоб налаштувати Flutter SDK.", + "get-source-code-title": "Отримайте вихідний код застосунку", + "get-source-code-text": "Ви можете отримати вихідний код Flutter ThingsBoard Mobile Application, клонуючи його з репозиторію GitHub:", + "configure-app-settings-title": "Налаштуйте параметри застосунку", + "configure-app-settings-text": "Завантажте файл конфігурації та розмістіть його в кореневому каталозі проєкту, який Ви клонували на попередньому кроці.", + "download-file": "Завантажити файл", + "run-app-title": "Запустіть застосунок", + "run-app-text": "Запустіть застосунок, як описано у Вашому IDE.\nЯкщо використовуєте термінал, запустіть застосунок за допомогою такої команди:", + "more-information": "Детальнішу інформацію можна знайти в нашій документації Getting Started.", + "getting-started": "Getting Started" + } + }, + "notification": { + "action-button": "Кнопка дії", + "action-type": "Тип дії", + "active": "Активний", + "add-notification-recipients-group": "Додати групу отримувачів сповіщень", + "add-notification-template": "Додати шаблон сповіщення", + "add-recipient": "Додати отримувача", + "add-recipients": "Додати отримувачів", + "add-rule": "Додати правило", + "add-stage": "Додати етап", + "add-template": "Додати шаблон", + "after": "Після", + "alarm-assignment-trigger-settings": "Налаштування тригера призначення тривоги", + "alarm-comment-trigger-settings": "Налаштування тригера коментаря до тривоги", + "alarm-trigger-settings": "Налаштування тригера тривоги", + "all": "Усі", + "api-feature-hint": "Якщо поле порожнє, тригер буде застосовано до всіх можливостей API", + "api-usage-trigger-settings": "Налаштування тригера використання API", + "new-platform-version-trigger-settings": "Налаштування тригера нової версії платформи", + "rate-limits-trigger-settings": "Налаштування тригера перевищення лімітів швидкості", + "task-processing-failure-trigger-settings": "Налаштування тригера збою обробки завдань", + "resources-shortage-trigger-settings": "Налаштування тригера нестачі ресурсів", + "at-least-one-should-be-selected": "Потрібно вибрати принаймні один", + "basic-settings": "Базові налаштування", + "button-text": "Текст кнопки", + "button-text-required": "Текст кнопки є обов'язковим", + "button-text-max-length": "Текст кнопки має містити не більше ніж {{ length }} символів", + "compose": "Скласти", + "conversation": "Розмова", + "conversation-required": "Розмова є обов'язковою", + "copy-notification-template": "Клонувати шаблон сповіщення", + "copy-rule": "Клонувати правило", + "copy-template": "Клонувати шаблон", + "create-new": "Створити новий", + "created": "Створено", + "customize-messages": "Налаштувати повідомлення", + "cpu-threshold": "Поріг CPU", + "delete-notification-text": "Будьте обережні, після підтвердження сповіщення стане невідновлюваним.", + "delete-notification-title": "Ви впевнені, що хочете видалити сповіщення?", + "delete-notifications-text": "Будьте обережні, після підтвердження сповіщення стануть невідновлюваними.", + "delete-notifications-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 сповіщення} other {# сповіщень} }?", + "delete-recipient-text": "Будьте обережні, після підтвердження отримувач стане невідновлюваним.", + "delete-recipient-title": "Ви впевнені, що хочете видалити отримувача '{{recipientName}}'?", + "delete-recipients-text": "Будьте обережні, після підтвердження отримувачі стануть невідновлюваними.", + "delete-recipients-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 отримувача} other {# отримувачів} }?", + "delete-request-text": "Будьте обережні, після підтвердження запит стане невідновлюваним.", + "delete-request-title": "Ви впевнені, що хочете видалити запит?", + "delete-requests-text": "Будьте обережні, після підтвердження запити стануть невідновлюваними.", + "delete-requests-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 запит} other {# запитів} }?", + "delete-rule-text": "Будьте обережні, після підтвердження правило стане невідновлюваним.", + "delete-rule-title": "Ви впевнені, що хочете видалити правило '{{ruleName}}'?", + "delete-rules-text": "Будьте обережні, після підтвердження правила стануть невідновлюваними.", + "delete-rules-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 правило} other {# правил} }?", + "delete-template-text": "Будьте обережні, після підтвердження шаблон стане невідновлюваним.", + "delete-template-title": "Ви впевнені, що хочете видалити шаблон '{{templateName}}'?", + "delete-templates-text": "Будьте обережні, після підтвердження шаблони стануть невідновлюваними.", + "delete-templates-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 шаблон} other {# шаблонів} }?", + "deleted": "Видалено", + "delivery-method": { + "delivery-method": "Спосіб доставлення", + "email": "Email", + "email-preview": "Попередній перегляд Email сповіщення", + "slack": "Slack", + "slack-preview": "Попередній перегляд сповіщення Slack", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Попередній перегляд сповіщення Microsoft Teams", + "sms": "SMS", + "sms-preview": "Попередній перегляд SMS сповіщення", + "web": "Веб", + "web-preview": "Попередній перегляд веб сповіщення", + "mobile-app": "Мобільний застосунок", + "mobile-app-preview": "Попередній перегляд сповіщення мобільного застосунку" + }, + "delivery-method-not-configure-click": "Спосіб доставлення не налаштовано. Натисніть, щоб налаштувати.", + "delivery-method-not-configure-contact": "Спосіб доставлення не налаштовано. Зверніться до Вашого системного адміністратора.", + "delivery-methods": "Способи доставлення", + "description": "Опис", + "device-activity-trigger-settings": "Налаштування тригера активності пристрою", + "device-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх пристроїв", + "device-profiles-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх профілів пристроїв", + "disabled": "Вимкнено", + "edge-trigger-settings": "Налаштування тригера Edge", + "edge-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх екземплярів Edge", + "edit-notification-recipients-group": "Редагувати групу отримувачів сповіщень", + "edit-notification-template": "Редагувати шаблон сповіщення", + "edit-rule": "Редагувати правило", + "edit-template": "Редагувати шаблон", + "enabled": "Увімкнено", + "entities-limit-trigger-settings": "Налаштування тригера ліміту сутностей", + "entity-action-trigger-settings": "Налаштування тригера дій сутності", + "entity-type": "Тип сутності", + "escalation-chain": "Ланцюг ескалації", + "failed-send": "Збої надсилання", + "fails": "{ count, plural, =1 {1 збій} other {# збоїв} }", + "filter": "Фільтр", + "first-recipient": "Перший отримувач", + "inactive": "Неактивний", + "inbox": "Вхідні", + "notification-inbox": "Сповіщення / Вхідні", + "input-field-support-templatization": "Поле введення підтримує шаблонізацію.", + "input-fields-support-templatization": "Поля введення підтримують шаблонізацію.", + "link": "Посилання", + "link-required": "Посилання є обов'язковим", + "link-max-length": "Посилання має містити не більше ніж {{ length }} символів", + "link-type": { + "dashboard": "Відкрити дашборд", + "link": "Відкрити посилання URL" + }, + "loading-notifications": "Завантаження сповіщень...", + "management": "Керування сповіщеннями", + "mark-all-as-read": "Позначити всі як прочитані", + "mark-as-read": "Позначити як прочитане", + "message": "Повідомлення", + "message-required": "Повідомлення є обов'язковим", + "message-max-length": "Повідомлення має містити не більше ніж {{ length }} символів", + "name": "Назва", + "name-required": "Назва є обов'язковою", + "new-notification": "Нове сповіщення", + "no-inbox-notification": "Сповіщень не знайдено", + "no-notification-request": "Немає запиту на сповіщення", + "no-notification-templates": "Шаблонів сповіщень не знайдено", + "no-notifications-yet": "Сповіщень ще немає", + "no-recipients-notification": "Немає отримувачів сповіщення", + "no-recipients-matching": "Не знайдено отримувачів, що відповідають '{{entity}}'.", + "no-recipients-text": "Отримувача не знайдено", + "no-rule": "Правила не налаштовано", + "no-rules-notification": "Немає правил сповіщення", + "no-severity-found": "Рівень важливості не знайдено", + "no-severity-matching": "'{{severity}}' не знайдено.", + "no-template-matching": "Не знайдено ресурсів, що відповідають '{{template}}'.", + "create-new-template": "Створити новий!", + "not-found-slack-recipient": "Отримувача Slack не знайдено", + "notification": "Сповіщення", + "notification-center": "Центр сповіщень", + "notification-tap-action": "Дія при натисканні на сповіщення", + "notification-tap-action-hint": "Якщо не увімкнено, буде використано дашборд тривог за замовчуванням", + "notify": "сповіщати", + "notify-again": "Сповістити знову", + "notify-alarm-action": { + "acknowledged": "Тривогу підтверджено", + "assigned": "Тривогу призначено", + "cleared": "Тривогу очищено", + "created": "Тривогу створено", + "severity-changed": "Рівень важливості тривоги змінено", + "unassigned": "Призначення тривоги скасовано" + }, + "notify-on": "Сповіщати про", + "notify-on-comment-update": "Сповіщати про оновлення коментаря", + "notify-on-required": "Поле \"Сповіщати про\" є обов'язковим", + "notify-on-unassign": "Сповіщати про скасування призначення", + "notify-only-user-comments": "Сповіщати лише про коментарі користувача", + "only-rule-chain-lifecycle-failures": "Лише збої життєвого циклу ланцюга правил", + "only-rule-node-lifecycle-failures": "Лише збої життєвого циклу вузла правила", + "platform-users": "Користувачі платформи", + "ram-threshold": "Поріг RAM", + "rate-limits": "Ліміти швидкості", + "rate-limits-hint": "Якщо поле порожнє, тригер буде застосовано до всіх лімітів швидкості", + "recipient": "Отримувач", + "recipient-group": "Група отримувачів", + "recipient-type": { + "affected-tenant-administrators": "Адміністратори ураженого тенанта", + "affected-user": "Зачеплений користувач", + "all-users": "Усі користувачі", + "customer-users": "Користувачі клієнта", + "system-administrators": "Системні адміністратори", + "tenant-administrators": "Адміністратори тенанта", + "user-filters": "Фільтр користувачів", + "user-list": "Список користувачів", + "users-entity-owner": "Користувачі власника сутності" + }, + "recipients": "Отримувачі", + "notification-recipient": "Отримувач сповіщення", + "notification-recipient-required": "Отримувач сповіщення є обов'язковим.", + "notification-recipients": "Сповіщення / Отримувачі", + "recipients-count": "{ count, plural, =1 {1 отримувач} other {# отримувачів} }", + "recipients-required": "Отримувачі є обов'язковими", + "refresh-allow-delivery-method": "Оновити дозволений спосіб доставлення", + "request-search": "Пошук запитів", + "request-status": { + "processing": "Обробляється", + "scheduled": "Заплановано", + "sent": "Надіслано" + }, + "review": "Перегляд", + "rule": "Правило", + "rule-chain-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх ланцюгів правил", + "rule-engine-events-trigger-settings": "Налаштування тригера подій механізму правил", + "rule-engine-filter": "Фільтр механізму правил", + "rule-name": "Назва правила", + "rule-name-required": "Назва є обов'язковою", + "rule-disable": "Вимкнути правило сповіщень", + "rule-enable": "Увімкнути правило сповіщень", + "rule-node-filter": "Фільтр вузла правила", + "rules": "Правила", + "notification-rules": "Сповіщення / Правила", + "scheduler-later": "Запланувати на потім", + "search-notification": "Шукати сповіщення", + "search-recipients": "Шукати отримувачів", + "search-rules": "Шукати правила", + "search-templates": "Шукати шаблони", + "see-documentation": "Переглянути документацію", + "selected-notifications": "{ count, plural, =1 {Вибрано 1 сповіщення} other {Вибрано #} }", + "selected-recipients": "{ count, plural, =1 {Вибрано 1 отримувача} other {Вибрано #} }", + "selected-requests": "{ count, plural, =1 {Вибрано 1 запит} other {Вибрано #} }", + "selected-rules": "{ count, plural, =1 {Вибрано 1 правило} other {Вибрано #} }", + "selected-template": "{ count, plural, =1 {Вибрано 1 шаблон} other {Вибрано #} }", + "send-notification": "Надіслати сповіщення", + "sent": "Надіслано", + "setup": "Налаштувати", + "notification-sent": "Сповіщення / Надіслані", + "set-entity-from-notification": "Встановити сутність зі сповіщення в стан дашборда", + "slack-chanel-type": "Тип каналу Slack", + "slack-chanel-types": { + "direct": "Пряме повідомлення", + "private-channel": "Приватний канал", + "public-channel": "Публічний канал" + }, + "start-from-scratch": "Почати з нуля", + "status": "Статус", + "stop-escalation-alarm-status-become": "Зупинити ескалацію, коли статус тривоги стане:", + "storage-threshold": "Поріг сховища", + "subject": "Тема", + "subject-required": "Тема є обов'язковою", + "subject-max-length": "Тема має містити не більше ніж {{ length }} символів", + "template": "Шаблон", + "template-name": "Назва шаблону", + "template-required": "Шаблон є обов'язковим", + "template-type": { + "alarm": "Тривога", + "alarm-assignment": "Призначення тривоги", + "alarm-comment": "Коментар до тривоги", + "api-usage-limit": "Ліміт використання API", + "device-activity": "Активність пристрою", + "entities-limit": "Ліміт сутностей", + "entities-limit-increase-request": "Запит на збільшення ліміту сутностей", + "entity-action": "Дія сутності", + "general": "Загальні", + "rule-engine-lifecycle-event": "Подія життєвого циклу механізму правил", + "rule-node": "Вузол правила", + "new-platform-version": "Нова версія платформи", + "rate-limits": "Перевищено ліміти швидкості", + "edge-communication-failure": "Збій зв'язку Edge", + "edge-connection": "Підключення Edge", + "task-processing-failure": "Збій обробки завдань", + "resources-shortage": "Нестача ресурсів" + }, + "templates": "Шаблони", + "notification-templates": "Сповіщення / Шаблони", + "tenant-profiles-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх профілів тенантів", + "tenants-list-rule-hint": "Якщо поле порожнє, тригер буде застосовано до всіх тенантів", + "threshold": "Поріг", + "theme-color": "Колір теми", + "time": "Час", + "track-rule-node-events": "Відстежувати події вузла правила", + "trigger": { + "alarm": "Тривога", + "alarm-assignment": "Призначення тривоги", + "alarm-comment": "Коментар до тривоги", + "api-usage-limit": "Ліміт використання API", + "device-activity": "Активність пристрою", + "entities-limit": "Ліміт сутностей", + "entity-action": "Дія сутності", + "rule-engine-lifecycle-event": "Подія життєвого циклу механізму правил", + "new-platform-version": "Нова версія платформи", + "rate-limits": "Перевищено ліміти швидкості", + "edge-connection": "Підключення Edge", + "edge-communication-failure": "Збій зв'язку Edge", + "task-processing-failure": "Збій обробки завдань", + "resources-shortage": "Нестача ресурсів", + "trigger": "Тригер", + "trigger-required": "Тригер є обов'язковим" + }, + "type": "Тип", + "unread": "Непрочитано", + "updated": "Оновлено", + "use-deprecated-webhook-connectors": "Використовувати застарілі конектори Webhook", + "use-old-api": "Використовувати старий API", + "use-template": "Використовувати шаблон", + "view-all": "Переглянути все", + "warning": "Попередження", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL є обов'язковим", + "workflow-url": "Workflow URL", + "workflow-url-required": "Workflow URL є обов'язковим", + "channel-name": "Назва каналу", + "channel-name-required": "Назва каналу є обов'язковою", + "settings": { + "notification-settings": "Налаштування сповіщень", + "reset-all": "Скинути всі налаштування", + "reset-all-title": "Ви впевнені, що хочете скинути форму?", + "reset-all-text": "Після підтвердження форма налаштувань буде скинута до значення за замовчуванням і збережена.", + "type": "Тип", + "enable-all": "Увімкнути все", + "disable-all": "Вимкнути все", + "delivery-not-configured": "Спосіб доставлення не налаштовано" + } + }, + "ota-update": { + "add": "Додати пакет", + "assign-firmware": "Призначена прошивка", + "assign-firmware-required": "Призначена прошивка є обов'язковою", + "assign-software": "Призначене програмне забезпечення", + "assign-software-required": "Призначене програмне забезпечення є обов'язковим", + "auto-generate-checksum": "Автоматично згенерувати контрольну суму", + "checksum": "Контрольна сума", + "checksum-hint": "Якщо контрольна сума порожня, її буде згенеровано автоматично", + "checksum-algorithm": "Алгоритм контрольної суми", + "checksum-copied-message": "Контрольну суму пакета було скопійовано в буфер обміну", + "change-firmware": "Зміна прошивки може спричинити оновлення { count, plural, =1 {1 пристрою} other {# пристроям} }.", + "change-software": "Зміна програмного забезпечення може спричинити оновлення { count, plural, =1 {1 пристрою} other {# пристроям} }.", + "change-ota-setting-title": "Ви впевнені, що хочете змінити налаштування OTA?", + "chose-compatible-device-profile": "Завантажений пакет буде доступний лише для пристроїв із вибраним профілем.", + "chose-firmware-distributed-device": "Виберіть прошивку, яка буде розповсюджуватися на пристрої", + "chose-software-distributed-device": "Виберіть програмне забезпечення, яке буде розповсюджуватися на пристрої", + "content-type": "Тип вмісту", + "copy-checksum": "Копіювати контрольну суму", + "copy-direct-url": "Копіювати прямий URL", + "copyId": "Копіювати Id пакета", + "copied": "Скопійовано!", + "delete": "Видалити пакет", + "delete-ota-update-text": "Будьте обережні, після підтвердження OTA оновлення стане невідновлюваним.", + "delete-ota-update-title": "Ви впевнені, що хочете видалити OTA оновлення '{{title}}'?", + "delete-ota-updates-text": "Будьте обережні, після підтвердження всі вибрані OTA оновлення буде видалено.", + "delete-ota-updates-title": "Ви впевнені, що хочете видалити вибране: { count, plural, =1 {1 OTA оновлення} other {# OTA оновлень} }?", + "description": "Опис", + "direct-url": "Прямий URL", + "direct-url-copied-message": "Прямий URL пакета було скопійовано в буфер обміну", + "direct-url-required": "Прямий URL є обов'язковим", + "download": "Завантажити пакет", + "drop-file": "Перетягніть файл пакета або натисніть, щоб вибрати файл для завантаження.", + "drop-package-file-or": "Перетягніть файл пакета або", + "file-name": "Назва файлу", + "file-size": "Розмір файлу", + "file-size-bytes": "Розмір файлу в байтах", + "idCopiedMessage": "Id пакета було скопійовано в буфер обміну", + "no-firmware-matching": "Не знайдено сумісних пакетів OTA оновлення Firmware, що відповідають '{{entity}}'.", + "no-firmware-text": "Сумісні пакети OTA оновлення Firmware не знайдено.", + "no-packages-text": "Пакетів не знайдено", + "no-software-matching": "Не знайдено сумісних пакетів OTA оновлення Software, що відповідають '{{entity}}'.", + "no-software-text": "Сумісні пакети OTA оновлення Software не знайдено.", + "ota-update": "OTA оновлення", + "ota-update-details": "Деталі OTA оновлення", + "ota-updates": "OTA оновлення", + "package-file": "Файл пакета", + "package-type": "Тип пакета", + "packages-repository": "Репозиторій пакетів", + "search": "Шукати пакети", + "selected-package": "{ count, plural, =1 {Вибрано 1 пакет} other {Вибрано #} }", + "title": "Назва", + "title-required": "Назва є обов'язковою.", + "title-max-length": "Назва має бути меншою за 256", + "types": { + "firmware": "Прошивка", + "software": "Програмне забезпечення" + }, + "upload-binary-file": "Вивантажити бінарний файл", + "use-external-url": "Використовувати зовнішній URL", + "version": "Версія", + "version-required": "Версія є обов'язковою.", + "version-tag": "Тег версії", + "version-tag-hint": "Користувацький тег має відповідати версії пакета, про яку повідомляє Ваш пристрій.", + "version-max-length": "Версія має бути меншою за 256", + "warning-after-save-no-edit": "Після вивантаження пакета Ви не зможете змінити назву, версію, профіль пристрою та тип пакета." + }, + "position": { + "top": "Верх", + "bottom": "Низ", + "left": "Ліворуч", + "right": "Праворуч" + }, + "profile": { + "profile": "Профіль", + "last-login-time": "Останній вхід", + "change-password": "Змінити пароль", + "current-password": "Поточний пароль", + "copy-jwt-token": "Копіювати JWT токен", + "jwt-token": "JWT токен", + "token-valid-till": "Токен дійсний до", + "tokenCopiedSuccessMessage": "JWT токен було скопійовано в буфер обміну", + "tokenCopiedWarnMessage": "JWT токен прострочено! Будь ласка, оновіть сторінку." + }, + "profiles": { + "profiles": "Профілі" + }, + "security": { + "security": "Безпека", + "general-settings": "Загальні налаштування безпеки", + "access-token": "Токен доступу", + "access-token-required": "Токен доступу є обов'язковим", + "clientId": "Client ID", + "clientId-required": "Client ID є обов'язковим", + "username": "Ім'я користувача", + "username-required": "Ім'я користувача є обов'язковим", + "ca-cert": "Сертифікат CA", + "2fa": { + "2fa": "Двофакторна автентифікація", + "2fa-description": "Двофакторна автентифікація захищає Ваш обліковий запис від несанкціонованого доступу. Все, що Вам потрібно зробити, — ввести код безпеки під час входу.", + "authenticate-with": "Ви можете автентифікуватися за допомогою:", + "disable-2fa-provider-text": "Вимкнення {{name}} зробить Ваш обліковий запис менш захищеним", + "disable-2fa-provider-title": "Ви впевнені, що хочете вимкнути {{name}}?", + "get-new-code": "Отримати новий код", + "main-2fa-method": "Використовувати як основний метод двофакторної автентифікації", + "dialog": { + "activation-step-description-email": "Наступного разу під час входу Вам буде запропоновано ввести код безпеки, який буде надіслано на Вашу email адресу.", + "activation-step-description-sms": "Наступного разу під час входу Вам буде запропоновано ввести код безпеки, який буде надіслано на номер телефону.", + "activation-step-description-totp": "Наступного разу під час входу Вам потрібно буде надати код двофакторної автентифікації.", + "activation-step-label": "Активація", + "backup-code-description": "Роздрукуйте коди, щоб вони були під рукою, коли Вам потрібно буде використати їх для входу у Ваш обліковий запис. Ви можете використати кожен резервний код один раз.", + "backup-code-warn": "Щойно Ви покинете цю сторінку, ці коди не можна буде показати знову. Зберігайте їх безпечно, використовуючи варіанти нижче.", + "download-txt": "Завантажити (txt)", + "email-step-description": "Введіть email, щоб використовувати його як Ваш автентифікатор.", + "email-step-label": "Email", + "enable-email-title": "Увімкнути email-автентифікатор", + "enable-sms-title": "Увімкнути SMS-автентифікатор", + "enable-totp-title": "Увімкнути застосунок автентифікації", + "enter-verification-code": "Введіть 6-цифровий код тут", + "get-backup-code-title": "Отримати резервний код", + "next": "Далі", + "scan-qr-code": "Скануйте цей QR код за допомогою Вашого застосунку підтвердження", + "send-code": "Надіслати код", + "sms-step-description": "Введіть номер телефону, щоб використовувати його як Ваш автентифікатор.", + "sms-step-label": "Номер телефону", + "success": "Успіх!", + "totp-step-description-install": "Ви можете встановити такі застосунки, як Google Authenticator, Authy або Duo.", + "totp-step-description-open": "Відкрийте застосунок автентифікації на Вашому мобільному телефоні.", + "totp-step-label": "Отримати застосунок", + "verification-code": "6-цифровий код", + "verification-code-invalid": "Некоректний формат коду підтвердження", + "verification-code-incorrect": "Код підтвердження неправильний", + "verification-code-many-request": "Занадто багато запитів на перевірку коду підтвердження", + "verification-step-description": "Введіть 6-цифровий код, який ми щойно надіслали на {{address}}", + "verification-step-label": "Підтвердження" + }, + "provider": { + "email": "Email", + "email-description": "Використовуйте код безпеки, надісланий на Вашу email адресу, для автентифікації.", + "email-hint": "Коди автентифікації надсилаються електронною поштою на {{ info }}", + "sms": "SMS", + "sms-description": "Використовуйте свій телефон для автентифікації. Ми надішлемо Вам код безпеки через SMS повідомлення під час входу.", + "sms-hint": "Коди автентифікації надсилаються текстовими повідомленнями на номер {{ info }}", + "totp": "Застосунок автентифікації", + "totp-description": "Використовуйте застосунки, такі як Google Authenticator, Authy або Duo на Вашому телефоні для автентифікації. Вони генерують код безпеки для входу.", + "totp-hint": "Застосунок автентифікації налаштовано для Вашого облікового запису", + "backup_code": "Резервний код", + "backup-code-description": "Ці одноразові коди дозволяють увійти в систему, коли Ви не маєте доступу до телефону, наприклад, під час подорожей.", + "backup-code-hint": "{{ info }} одноразових кодів активні на цей момент" + } + }, + "password-requirement": { + "at-least": "Щонайменше:", + "character": "{ count, plural, =1 {1 символ} other {# символи} }", + "digit": "{ count, plural, =1 {1 цифра} other {# цифр} }", + "password-tooltip-min-length": "Щонайменше {{minimumLength}} символів", + "password-tooltip-max-length": "Не більше {{maximumLength}} символів", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} великих літер", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} малих літер", + "password-tooltip-digit": "{{minimumDigits}} числа", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} спеціальний символ", + "incorrect-password-try-again": "Невірний пароль. Спробуйте ще раз", + "lowercase-letter": "{ count, plural, =1 {1 мала літера} other {# малих літер} }", + "new-passwords-not-match": "Новий пароль не співпадає", + "password-should-not-contain-spaces": "Ваш пароль не повинен містити пробіли", + "password-not-meet-requirements": "Пароль не відповідає вимогам", + "password-requirements": "Вимоги до паролю", + "password-should-difference": "Новий пароль має відрізнятися від поточного", + "special-character": "{ count, plural, =1 {1 спеціальний символ} other {# спеціальних символів} }", + "uppercase-letter": "{ count, plural, =1 {1 велика літера} other {# великих літер} }", + "at-most": "Не більше:" + } + }, + "relation": { + "relations": "Відносини", + "direction": "Напрямок", + "clear-relation-type": "Очистити тип відносин", + "search-direction": { + "FROM": "Від", + "TO": "До" + }, + "direction-type": { + "FROM": "від", + "TO": "до" + }, + "from-relations": "Вихідні відносини", + "to-relations": "Вхідні відносини", + "selected-relations": "{ count, plural, =1 {1 відношення} other {# відносин} } вибрано", + "type": "Тип", + "to-entity-type": "Тип сутності до", + "to-entity-name": "Назва сутності до", + "from-entity-type": "Тип сутності від", + "from-entity-name": "Назва сутності від", + "to-entity": "До сутності", + "from-entity": "Від сутності", + "delete": "Видалити відносини", + "relation-type": "Тип відносин", + "relation-type-required": "Тип відносин є обов'язковим.", + "relation-type-max-length": "Тип відносин має бути меншим за 256", + "any-relation-type": "Будь-який тип", + "add": "Додати відносини", + "edit": "Редагувати відносини", + "delete-to-relation-title": "Ви впевнені, що хочете видалити відносини до сутності '{{entityName}}'?", + "delete-to-relation-text": "Будьте обережні, після підтвердження сутність '{{entityName}}' буде розірвана від поточної сутності.", + "delete-to-relations-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 відношення} other {# відносин} }?", + "delete-to-relations-text": "Будьте обережні, після підтвердження всі вибрані відносини будуть видалені, і відповідні сутності будуть розірвані від поточної сутності.", + "delete-from-relation-title": "Ви впевнені, що хочете видалити відносини від сутності '{{entityName}}'?", + "delete-from-relation-text": "Будьте обережні, після підтвердження поточна сутність буде розірвана з сутністю '{{entityName}}'.", + "delete-from-relations-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 відношення} other {# відносин} }?", + "delete-from-relations-text": "Будьте обережні, після підтвердження всі вибрані відносини будуть видалені, і поточна сутність буде розірвана з відповідними сутностями.", + "remove-relation-filter": "Видалити фільтр відносин", + "remove-filter": "Видалити фільтр", + "add-relation-filter": "Додати фільтр відносин", + "any-relation": "Будь-яка відносина", + "relation-filters": "Фільтри відносин", + "relation-filter": "Фільтр відносин", + "additional-info": "Додаткова інформація (JSON)", + "invalid-additional-info": "Не вдалося розібрати додаткову інформацію JSON.", + "no-relations-text": "Відносин не знайдено", + "not": "Не", + "copy-type": "Копіювати тип" + }, + "resource": { + "add": "Додати ресурс", + "all-types": "Усі", + "copyId": "Копіювати Id ресурсу", + "delete": "Видалити ресурс", + "delete-resource-text": "Будьте обережні, після підтвердження ресурс стане невідновлюваним.", + "delete-resource-title": "Ви впевнені, що хочете видалити ресурс '{{resourceTitle}}'?", + "delete-resources-action-title": "Видалити { count, plural, =1 {1 ресурс} other {# ресурси} }", + "delete-resources-text": "Будь ласка, зверніть увагу, що вибрані ресурси, навіть якщо вони використовуються в профілях пристроїв, будуть видалені.", + "delete-resources-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 ресурс} other {# ресурси} }?", + "download": "Завантажити ресурс", + "drop-file": "Перетягніть файл ресурсу або натисніть, щоб вибрати файл для завантаження.", + "drop-resource-file-or": "Перетягніть файл ресурсу або", + "empty": "Ресурс порожній", + "file-name": "Назва файлу", + "idCopiedMessage": "Id ресурсу було скопійовано в буфер обміну", + "no-resource-matching": "Не знайдено ресурсу, що відповідає '{{widgetsBundle}}'.", + "no-resource-text": "Ресурсів не знайдено", + "open-widgets-bundle": "Відкрити набір віджетів", + "resource": "Ресурс", + "resource-file": "Файл ресурсу", + "resource-files": "Файли ресурсів", + "resource-library-details": "Деталі ресурсу", + "resource-type": "Тип ресурсу", + "resources-library": "Бібліотека ресурсів", + "search": "Шукати ресурси", + "selected-resources": "{ count, plural, =1 {1 ресурс} other {# ресурси} } вибрано", + "system": "Система", + "title": "Назва", + "title-required": "Назва є обов'язковою.", + "title-max-length": "Назва має бути меншою за 256", + "type": { + "jks": "JKS", + "js-module": "JS модуль", + "lwm2m-model": "Модель LWM2M", + "pkcs-12": "PKCS #12", + "general": "Загальний" + }, + "resource-sub-type": "Підтип", + "sub-type": { + "image": "Зображення", + "scada-symbol": "SCADA символ", + "extension": "Розширення", + "module": "Модуль" + }, + "resource-is-in-use": "Ресурс використовується іншими сутностями", + "resources-are-in-use": "Ресурси використовуються іншими сутностями", + "resource-is-in-use-text": "Ресурс '{{title}}' не було видалено, оскільки він використовується наступними сутностями:", + "resources-are-in-use-text": "Не всі ресурси були видалені, оскільки вони використовуються іншими сутностями.
Ви можете переглянути посилання на сутності, натиснувши кнопку Посилання в рядку відповідного ресурсу.
Якщо ви все ще хочете видалити ці ресурси, виберіть їх у таблиці нижче та натисніть кнопку Видалити вибране.", + "delete-resource-in-use-text": "Якщо ви все ще хочете видалити ресурс, натисніть кнопку Видалити все одно." + }, + "javascript": { + "add": "Додати JavaScript ресурс", + "delete": "Видалити JavaScript ресурс", + "delete-javascript-resource-text": "Будьте обережні, після підтвердження JavaScript ресурс стане невідновлюваним.", + "delete-javascript-resource-title": "Ви впевнені, що хочете видалити JavaScript ресурс '{{resourceTitle}}'?", + "delete-javascript-resources-action-title": "Видалити JavaScript { count, plural, =1 {1 ресурс} other {# ресурси} }", + "delete-javascript-resources-text": "Будь ласка, зверніть увагу, що вибрані JavaScript ресурси, навіть якщо вони використовуються в JavaScript функціях, будуть видалені.", + "delete-javascript-resources-title": "Ви впевнені, що хочете видалити JavaScript { count, plural, =1 {1 ресурс} other {# ресурси} }?", + "delete-javascript-resource-in-use-text": "Якщо ви все ще хочете видалити JavaScript ресурс, натисніть кнопку Видалити все одно.", + "download": "Завантажити JavaScript ресурс", + "upload-from-file": "Завантажити JavaScript з файлу", + "resource-file": "Файл JavaScript ресурсу", + "drop-file": "Перетягніть JavaScript файл або натисніть, щоб вибрати файл для завантаження.", + "drop-resource-file-or": "Перетягніть JavaScript файл або", + "javascript-library": "Бібліотека JavaScript", + "javascript-type": "Тип JavaScript", + "javascript-resource-details": "Деталі JavaScript ресурсу", + "javascript-resource-is-in-use": "JavaScript ресурс використовується іншими сутностями", + "javascript-resources-are-in-use": "JavaScript ресурси використовуються іншими сутностями", + "javascript-resource-is-in-use-text": "JavaScript ресурс '{{title}}' не було видалено, оскільки він використовується наступними сутностями:", + "javascript-resources-are-in-use-text": "Не всі JavaScript ресурси були видалені, оскільки вони використовуються іншими сутностями.
Ви можете переглянути посилання на сутності, натиснувши кнопку Посилання в рядку відповідного ресурсу.
Якщо ви все ще хочете видалити ці JavaScript ресурси, виберіть їх у таблиці нижче та натисніть кнопку Видалити вибрані.", + "search": "Шукати JavaScript ресурси", + "selected-javascript-resources": "{ count, plural, =1 {1 JavaScript ресурс} other {# JavaScript ресурси} } вибрано", + "no-javascript-resource-text": "JavaScript ресурси не знайдено", + "all-types": "Усі", + "module-script": "Модульний скрипт" + }, + "rpc": { "error": { - "unable-to-connect": "Неможливо підключитися до сервера! Перевірте підключення до Інтернету.", - "unhandled-error-code": "Неопрацьований помилковий код: {{errorCode}}", - "unknown-error": "Невідома помилка" - }, - "entity": { - "entity": "Сутність", - "entities": "Сутності", - "aliases": "Псевдоніми сутності", - "entity-alias": "Псевдонім сутності", - "unable-delete-entity-alias-title": "Неможливо видалити псевдонім сутності", - "unable-delete-entity-alias-text": "Псевдонім сутності '{{entityAlias}}' неможливо видалити, оскільки він використовується наступним віджетом(s):
{{widgetsList}}", - "duplicate-alias-error": "Знайдено повторюваний псевдонім '{{alias}}'.
Псевдоніми сутностей повинні бути унікальними на панелі візуалізації.", - "missing-entity-filter-error": "Відсутній фільтр для псевдоніма '{{alias}}'.", - "configure-alias": "Налаштувати '{{alias}}' псевдонім", - "alias": "Псевдонім", - "alias-required": "Необхідно вказати псевдонім сутності.", - "remove-alias": "Видалити псевдонім сутності", - "add-alias": "Додати псевдонім сутності", - "entity-list": "Список сутності", - "entity-type": "Тип сутності", - "entity-types": "Типи сутності", - "entity-type-list": "Список типу сутності", - "any-entity": "Будь-яка сутність", - "enter-entity-type": "Введіть тип сутності", - "no-entities-matching": "Не знайдено жожних сутностей, що відповідають '{{entity}}' що відповідають.", - "no-entity-types-matching": "Не знайдено жожних типів сутностей, що відповідають '{{entityType}}'.", - "name-starts-with": "Назва починається з", - "use-entity-name-filter": "Використовуйте фільтр", - "entity-list-empty": "Не вибрано жодних сутностей.", - "entity-type-list-required": "Потрібно вибрати принаймні один тип сутності.", - "entity-name-filter-required": "Необхідно задати фільтр за назвою.", - "entity-name-filter-no-entity-matched": "Не знайдено жодних сутностей, що починаються з '{{entity}}'.", - "all-subtypes": "Всі", - "select-entities": "Виберіть сутність", - "no-aliases-found": "Псевдонімів не знайдено.", - "no-alias-matching": "'{{alias}}' не знайдено.", - "create-new-alias": "Створити новий псевдонім!", - "key": "Ключ", - "key-name": "Назва ключа", - "no-keys-found": "Жодного ключа не знайдено.", - "no-key-matching": "'{{key}}' не знайдено.", - "create-new-key": "Створити новий ключ!", - "type": "Тип", - "type-required": "Необхідно задати тип сутності.", - "type-device": "Пристрій", - "type-devices": "Пристрої", - "list-of-devices": "{ count, plural, =1 {Один пристрій} other {Список # пристроїв} }", - "device-name-starts-with": "Пристрої, назви яких починаються з '{{prefix}}'", - "type-asset": "Актив", - "type-assets": "Активи", - "list-of-assets": "{ count, plural, =1 {Один актив} other {Список # активів} }", - "asset-name-starts-with": "Активи, назви яких починаються з '{{prefix}}'", - "type-entity-view": "Перегляд сутності", - "type-entity-views": "Перегляди сутності", - "list-of-entity-views": "{ count, plural, =1 {Один перегляд сутності} other {Список # переглядів сутності} }", - "entity-view-name-starts-with": "Перегляди сутностей, назви яких починаються з '{{prefix}}'", - "type-rule": "Правило", - "type-rules": "Правила", - "list-of-rules": "{ count, plural, =1 {Одне правило} other {Список # правил} }", - "rule-name-starts-with": "Правила, назви яких починаються з '{{prefix}}'", - "type-plugin": "Плагін", - "type-plugins": "Плагіни", - "list-of-plugins": "{ count, plural, =1 {Один плагін} other {Список # плагінів} }", - "plugin-name-starts-with": "Плагіни, назви яких починаються з '{{prefix}}'", - "type-tenant": "Власник", - "type-tenants": "Власники", - "list-of-tenants": "{ count, plural, =1 {Один власник} other {Список # власників} }", - "tenant-name-starts-with": "Власники, назви яких починаються з '{{prefix}}'", - "type-customer": "Клієнт", - "type-customers": "Клієнти", - "list-of-customers": "{ count, plural, =1 {Один клієнт} other {Список # клієнтів} }", - "customer-name-starts-with": "Клієнти, імена яких починаються з '{{prefix}}'", - "type-user": "Користувач", - "type-users": "Користувачі", - "list-of-users": "{ count, plural, =1 {Один користувач} other {Список # користувачів } }", - "user-name-starts-with": "Користувачі, імена яких починаються з '{{prefix}}'", - "type-dashboard": "Панель візуалізації", - "type-dashboards": "Панелі візуалізації", - "list-of-dashboards": "{ count, plural, =1 {Одна панель візуалізації} other {Список # панелей візуалізації} }", - "dashboard-name-starts-with": "Панелі візуалізації, назви яких починаються з '{{prefix}}'", - "type-alarm": "Сигнал тривоги", - "type-alarms": "Сигнали тривоги", - "list-of-alarms": "{ count, plural, =1 {Один сигнал тривоги} other {Список # сигналів тривоги} }", - "alarm-name-starts-with": "Сигнали тривоги, назви яких починаються '{{prefix}}'", - "type-rulechain": "Ланцюжок правил", - "type-rulechains": "Ланцюжки правил", - "list-of-rulechains": "{ count, plural, =1 {Один ланцюжок правил} other {Список # ланцюжків правил} }", - "rulechain-name-starts-with": "Правила ланцюжків, назви яких починаються '{{prefix}}'", - "type-scheduler-event": "Scheduler event", - "type-scheduler-events": "Scheduler events", - "list-of-scheduler-events": "{ count, plural, =1 {One scheduler event} other {List of # scheduler events} }", - "scheduler-event-name-starts-with": "Scheduler events whose names start with '{{prefix}}'", - "type-blob-entity": "Blob entity", - "type-blob-entities": "Blob entities", - "list-of-blob-entities": "{ count, plural, =1 {One blob entity} other {List of # blob entities} }", - "blob-entity-name-starts-with": "Blob entities whose names start with '{{prefix}}'", - "type-rulenode": "Правило", - "type-rulenodes": "Правила", - "list-of-rulenodes": "{ count, plural, =1 {Одне правило} other {Список # правил} }", - "rulenode-name-starts-with": "Список правил, назви яких починаються з '{{prefix}}'", - "type-current-customer": "Поточний клієнт", - "type-current-tenant": "Поточний власник", - "search": "Пошук сутностей", - "selected-entities": "{ count, plural, =1 {1 сутність} other {# сутності} } вибрано", - "entity-name": "Назва сутності", - "entity-label": "Мітка сутності", - "details": "Подробиці сутності", - "no-entities-prompt": "Сутності не знайдено", - "no-data": "Немає даних для відображення", - "columns-to-display": "Стовпці для відображення", - "type-entity-group": "Група сутностей", - "type-converter": "Перетворювач даних", - "type-converters": "Перетворювачі даних", - "list-of-converters": "{ count, plural, =1 {Однин перетворювач даних} other {Список # перетворювачів даних} }", - "converter-name-starts-with": "Перетворювачі даних, назви яких починаються з '{{prefix}}'", - "type-integration": "Інтеграція", - "type-integrations": "Інтеграції", - "list-of-integrations": "{ count, plural, =1 {Одна інтеграція} other {Список # інтеграцій} }", - "integration-name-starts-with": "Інтеграції, назви яких починаються з '{{prefix}}'" - }, - "entity-field": { - "created-time": "Час створення", - "name": "Назва", - "type": "Тип", - "first-name": "Ім'я", - "last-name": "Прізвище", - "email": "Електронна пошта", - "title": "Назва", - "country": "Країна", - "state": "Штат", - "city": "Місто", - "address": "Адреса", - "address2": "Адреса 2", - "zip": "Zip", - "phone": "Телефон", - "label": "Мітка" - }, - "entity-group": { - "entity-group": "Група сутності", - "details": "Деталі", - "columns": "Стовпці", - "add-column": "Додати стовпець", - "column-value": "Значення", - "column-value-required": "Необхідно вказати значення.", - "column-title": "Назва", - "default-sort-order": "Основний порядок сортування", - "default-sort-order-required": "Необхідно вказати основний порядок сортування.", - "hide-in-mobile-view": "Мобільний приховано", - "use-cell-style-function": "Використовувати функцію стилю комірки", - "use-cell-content-function": "Use cell content function", - "edit-column": "Редагувати стовпець", - "column-details": "Деталі стовпця", - "actions": "Дії", - "settings": "Налаштування", - "delete": "Видалити групу сутностей", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "add": "Додати групу сутностей", - "add-entity-group-text": "Додати нову групу сутностей", - "no-entity-groups-text": "Не знайдено жодних груп сутності", - "entity-group-details": "Деталі групи сутності", - "delete-entity-groups": "Видалити групи сутностей", - "delete-entity-group-title": "Ви впевнені, що хочете видалити групу сутності '{{entityGroupName}}'?", - "delete-entity-group-text": "Будьте обережні, після підтвердження, група сутностей і всі пов'язані з нею дані стануть недоступними.", - "delete-entity-groups-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 групу сутності} other {# групи сутностей} }?", - "delete-entity-groups-action-title": "Видалити { count, plural, =1 {1 групу сутності} other {# групи сутностей} }", - "delete-entity-groups-text": "Будьте обережні, після підтвердження, всі виділені групи сутностей і пов'язані з ними дані, стануть недоступними.", - "device-groups": "Групи пристроїв", - "asset-groups": "Групи активів", - "customer-groups": "Групи клієнтів", - "device-group": "Група пристроїв", - "asset-group": "Група активів", - "customer-group": "Група клієнтів", - "fetch-more": "Отримати більше", - "column-type": { - "column-type": "Тип стовпця", - "client-attribute": "Атрибут клієнта", - "shared-attribute": "Спільний атрибут", - "server-attribute": "Атрибут сервера", - "timeseries": "Телеметрія", - "entity-field": "Поле сутності" + "target-device-is-not-set": "Цільовий пристрій не встановлено!", + "invalid-target-entity": "RPC команди не підтримуються сутністю {{entityType}}.", + "failed-to-resolve-target-device": "Не вдалося визначити цільовий пристрій!", + "request-timeout": "Час очікування запиту", + "rpc-http-error": "Помилка: {{status}} - {{statusText}}" + } + }, + "rulechain": { + "rulechain": "Ланцюг правил", + "rulechain-events": "Події ланцюга правил", + "rulechains": "Ланцюги правил", + "root": "Корінь", + "delete": "Видалити ланцюг правил", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "name-max-length": "Назва має бути меншою за 256", + "description": "Опис", + "add": "Додати ланцюг правил", + "set-root": "Зробити ланцюг правил коренем", + "set-root-rulechain-title": "Ви впевнені, що хочете зробити ланцюг правил '{{ruleChainName}}' коренем?", + "set-root-rulechain-text": "Після підтвердження ланцюг правил стане коренем і буде обробляти всі вхідні транспортні повідомлення.", + "delete-rulechain-title": "Ви впевнені, що хочете видалити ланцюг правил '{{ruleChainName}}'?", + "delete-rulechain-text": "Будьте обережні, після підтвердження ланцюг правил та всі пов'язані дані стануть невідновлюваними.", + "delete-rulechains-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 ланцюг правил} other {# ланцюги правил} }?", + "delete-rulechains-action-title": "Видалити { count, plural, =1 {1 ланцюг правил} other {# ланцюги правил} }", + "delete-rulechains-text": "Будьте обережні, після підтвердження всі вибрані ланцюги правил будуть видалені, і всі пов'язані дані стануть невідновлюваними.", + "add-rulechain-text": "Додати новий ланцюг правил", + "no-rulechains-text": "Ланцюги правил не знайдено", + "rulechain-details": "Деталі ланцюга правил", + "details": "Деталі", + "events": "Події", + "system": "Система", + "import": "Імпортувати ланцюг правил", + "export": "Експортувати ланцюг правил", + "export-failed-error": "Не вдалося експортувати ланцюг правил: {{error}}", + "create-new-rulechain": "Створити новий ланцюг правил", + "rulechain-file": "Файл ланцюга правил", + "invalid-rulechain-file-error": "Не вдалося імпортувати ланцюг правил: Невірна структура даних ланцюга правил.", + "copyId": "Копіювати Id ланцюга правил", + "idCopiedMessage": "Id ланцюга правил було скопійовано в буфер обміну", + "select-rulechain": "Вибрати ланцюг правил", + "no-rulechains-matching": "Не знайдено ланцюгів правил, що відповідають '{{entity}}'.", + "rulechain-required": "Ланцюг правил є обов'язковим", + "management": "Управління правилами", + "debug-mode": "Режим налагодження", + "search": "Шукати ланцюги правил", + "selected-rulechains": "{ count, plural, =1 {1 ланцюг правил} other {# ланцюги правил} } вибрано", + "open-rulechain": "Відкрити ланцюг правил", + "edge-template-root": "Шаблон кореня", + "assign-to-edge": "Призначити на Edge", + "edge-rulechain": "Edge ланцюг правил", + "unassign-rulechain-from-edge-text": "Після підтвердження ланцюг правил буде розірвано, і він не буде доступний для Edge.", + "unassign-rulechains-from-edge-title": "Ви впевнені, що хочете розірвати призначення { count, plural, =1 {1 ланцюга правил} other {# ланцюгів правил} }?", + "unassign-rulechains-from-edge-text": "Після підтвердження всі вибрані ланцюги правил будуть розірвані і не будуть доступні для Edge.", + "assign-rulechain-to-edge-title": "Призначити ланцюг правил(а) на Edge", + "assign-rulechain-to-edge-text": "Будь ласка, виберіть ланцюги правил для призначення на Edge", + "set-edge-template-root-rulechain": "Зробити ланцюг правил коренем шаблону Edge", + "set-edge-template-root-rulechain-title": "Ви впевнені, що хочете зробити ланцюг правил '{{ruleChainName}}' коренем шаблону Edge?", + "set-edge-template-root-rulechain-text": "Після підтвердження ланцюг правил стане коренем шаблону Edge і буде основним ланцюгом правил для новостворених Edge.", + "invalid-rulechain-type-error": "Не вдалося імпортувати ланцюг правил: Невірний тип ланцюга правил. Очікуваний тип: {{expectedRuleChainType}}.", + "set-auto-assign-to-edge": "Призначити ланцюг правил на Edge під час створення", + "set-auto-assign-to-edge-title": "Ви впевнені, що хочете призначити ланцюг правил '{{ruleChainName}}' на Edge під час створення?", + "set-auto-assign-to-edge-text": "Після підтвердження ланцюг правил буде автоматично призначено на Edge під час створення.", + "unset-auto-assign-to-edge": "Не призначати ланцюг правил на Edge під час створення", + "unset-auto-assign-to-edge-title": "Ви впевнені, що не хочете призначати ланцюг правил '{{ruleChainName}}' на Edge під час створення?", + "unset-auto-assign-to-edge-text": "Після підтвердження ланцюг правил більше не буде автоматично призначено на Edge під час створення.", + "unassign-rulechain-title": "Ви впевнені, що хочете розірвати призначення ланцюга правил '{{ruleChainName}}'?", + "unassign-rulechains": "Розірвати призначення ланцюгів правил" + }, + "rulenode": { + "rule-node-events": "Події вузла правила", + "details": "Деталі", + "events": "Події", + "search": "Шукати вузли", + "open-node-library": "Відкрити бібліотеку вузлів", + "close-node-library": "Закрити бібліотеку вузлів", + "add": "Додати вузол правила", + "name": "Назва", + "name-required": "Назва є обов'язковою.", + "name-max-length": "Назва має бути меншою за 256", + "type": "Тип", + "rule-node-description": "Опис вузла правила", + "delete": "Видалити вузол правила", + "select-all-objects": "Вибрати всі вузли та з'єднання", + "deselect-all-objects": "Скасувати вибір усіх вузлів та з'єднань", + "delete-selected-objects": "Видалити вибрані вузли та з'єднання", + "delete-selected": "Видалити вибрані", + "create-nested-rulechain": "Створити вкладений ланцюг правил", + "select-all": "Вибрати все", + "copy-selected": "Копіювати вибрані", + "deselect-all": "Скасувати вибір усіх", + "rulenode-details": "Деталі вузла правила", + "debug-mode": "Режим налагодження", + "singleton": "Одиничний екземпляр", + "configuration": "Налаштування", + "link": "Посилання", + "link-details": "Деталі посилання вузла правила", + "add-link": "Додати посилання", + "link-label": "Маркування посилання", + "link-label-required": "Маркування посилання є обов'язковою.", + "custom-link-label": "Користувацька маркування посилання", + "custom-link-label-required": "Користувацька маркування посилання є обов'язковою.", + "link-labels": "Маркування посилань", + "link-labels-required": "Маркування посилань є обов'язковими.", + "no-link-labels-found": "Маркування посилань не знайдено", + "no-link-label-matching": "'{{label}}' не знайдено.", + "create-new-link-label": "Створити нову!", + "type-filter": "Фільтр", + "type-filter-details": "Фільтрує вхідні повідомлення за налаштованими умовами", + "type-enrichment": "Розширення", + "type-enrichment-details": "Додає додаткову інформацію в метадані повідомлення", + "type-transformation": "Трансформація", + "type-transformation-details": "Змінює вміст повідомлення та метадані", + "type-action": "Дія", + "type-action-details": "Виконує спеціальну дію", + "type-external": "Зовнішній", + "type-external-details": "Взаємодіє із зовнішньою системою", + "type-rule-chain": "Ланцюг правил", + "type-rule-chain-details": "Перенаправляє вхідні повідомлення до вказаного ланцюга правил", + "type-flow": "Потік", + "type-flow-details": "Організовує потік повідомлень", + "type-input": "Вхід", + "type-input-details": "Логічний вхід ланцюга правил, перенаправляє вхідні повідомлення до наступного пов'язаного вузла правила", + "type-unknown": "Невідомий", + "type-unknown-details": "Необроблений вузол правила", + "directive-is-not-loaded": "Визначена директива конфігурації '{{directiveName}}' недоступна.", + "ui-resources-load-error": "Не вдалося завантажити ресурси конфігурації інтерфейсу.", + "invalid-target-rulechain": "Не вдалося визначити цільовий ланцюг правил!", + "test-script-function": "Тестування функції скрипта", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", + "message": "Повідомлення", + "message-type": "Тип повідомлення", + "select-message-type": "Вибрати тип повідомлення", + "message-type-required": "Тип повідомлення є обов'язковим", + "metadata": "Метадані", + "metadata-required": "Записи метаданих не можуть бути порожніми.", + "output": "Вихід", + "test": "Тест", + "help": "Допомога", + "reset-debug-settings": "Скинути налаштування налагодження у всіх вузлах", + "test-with-this-message": "{{test}} з цим повідомленням", + "queue-hint": "Виберіть чергу для пересилання повідомлень в іншу чергу. За замовчуванням використовується черга 'Main'.", + "queue-singleton-hint": "Виберіть чергу для пересилання повідомлень у багатокористувацьких середовищах. За замовчуванням використовується черга 'Main'." + }, + "rule-node-config": { + "id": "Id", + "additional-info": "Додаткова інформація", + "advanced-settings": "Розширені налаштування", + "create-entity-if-not-exists": "Створити нову сутність, якщо вона не існує", + "create-entity-if-not-exists-hint": "Якщо увімкнено, буде створено нову сутність із заданими параметрами, якщо вона ще не існує. Для зв'язку буде використано наявні сутності як є.", + "select-device-connectivity-event": "Вибрати подію підключення пристрою", + "entity-name-pattern": "Шаблон назви", + "device-name-pattern": "Назва пристрою", + "asset-name-pattern": "Назва активу", + "entity-view-name-pattern": "Назва представлення сутності", + "customer-title-pattern": "Назва клієнта", + "dashboard-name-pattern": "Назва дашборда", + "user-name-pattern": "Адреса email користувача", + "edge-name-pattern": "Назва Edge", + "entity-name-pattern-required": "Потрібен шаблон назви", + "entity-name-pattern-hint": "Поле шаблону назви підтримує шаблонізацію. Використовуйте $[messageKey], щоб витягнути значення з повідомлення, і ${metadataKey}, щоб витягнути значення з метаданих.", + "copy-message-type": "Копіювати тип повідомлення", + "entity-type-pattern": "Шаблон типу", + "entity-type-pattern-required": "Потрібен шаблон типу", + "message-type-value": "Значення типу повідомлення", + "message-type-value-required": "Потрібне значення типу повідомлення", + "message-type-value-max-length": "Значення типу повідомлення має бути менше ніж 256", + "output-message-type": "Вихідний тип повідомлення", + "entity-cache-expiration": "Час закінчення терміну дії кешу сутностей (с)", + "entity-cache-expiration-hint": "Визначає максимальний інтервал часу, протягом якого дозволено зберігати знайдені записи сутностей. Значення 0 означає, що записи ніколи не закінчуватимуться.", + "entity-cache-expiration-required": "Потрібен час закінчення терміну дії кешу сутностей.", + "entity-cache-expiration-range": "Час закінчення терміну дії кешу сутностей має бути більшим або дорівнювати 0.", + "customer-name-pattern": "Назва клієнта", + "customer-name-pattern-required": "Потрібна назва клієнта", + "customer-name-pattern-hint": "Використовуйте $[messageKey], щоб витягнути значення з повідомлення, і ${metadataKey}, щоб витягнути значення з метаданих.", + "create-customer-if-not-exists": "Створити нового клієнта, якщо він не існує", + "unassign-from-customer": "Скасувати призначення для конкретного клієнта, якщо ініціатор — дашборд", + "unassign-from-customer-tooltip": "Лише дашборди можуть бути призначені кільком клієнтам одночасно. \nЯкщо ініціатор повідомлення — дашборд, Вам потрібно явно вказати назву клієнта, для якого слід скасувати призначення.", + "customer-cache-expiration": "Час закінчення терміну дії кешу клієнтів (с)", + "customer-cache-expiration-hint": "Визначає максимальний інтервал часу, протягом якого дозволено зберігати знайдені записи клієнтів. Значення 0 означає, що записи ніколи не закінчуватимуться.", + "customer-cache-expiration-required": "Потрібен час закінчення терміну дії кешу клієнтів.", + "customer-cache-expiration-range": "Час закінчення терміну дії кешу клієнтів має бути більшим або дорівнювати 0.", + "interval-start": "Початок інтервалу", + "interval-end": "Кінець інтервалу", + "time-unit": "Одиниця часу", + "fetch-mode": "Режим вибірки", + "order-by-timestamp": "Сортувати за часовою міткою", + "limit": "Ліміт", + "limit-hint": "Мінімальне значення ліміту — 2, максимальне — 1000. Якщо ви хочете отримати один запис, виберіть режим вибірки 'Перше' або 'Останнє'.", + "limit-required": "Ліміт є обов'язковим.", + "limit-range": "Ліміт має бути в діапазоні від 2 до 1000.", + "time-unit-milliseconds": "Мілісекунди", + "time-unit-seconds": "Секунди", + "time-unit-minutes": "Хвилини", + "time-unit-hours": "Години", + "time-unit-days": "Дні", + "time-value-range": "Допускається діапазон від 1 до 2147483647.", + "start-interval-value-required": "Початок інтервалу є обов'язковим.", + "end-interval-value-required": "Кінець інтервалу є обов'язковим.", + "filter": "Фільтр", + "switch": "Перемикач", + "math-templatization-tooltip": "Це поле підтримує темплейтизацію. Використовуйте $[messageKey] для витягнення значення з повідомлення та ${metadataKey} для витягнення значення з метаданих.", + "add-message-type": "Додати тип повідомлення", + "select-message-types-required": "Необхідно вибрати хоча б один тип повідомлення.", + "select-message-types": "Вибрати типи повідомлень", + "no-message-types-found": "Типи повідомлень не знайдено", + "no-message-type-matching": "'{{messageType}}' не знайдено.", + "create-new-message-type": "Створити новий.", + "message-types-required": "Типи повідомлень є обов'язковими.", + "client-attributes": "Атрибути клієнта", + "shared-attributes": "Спільні атрибути", + "server-attributes": "Атрибути сервера", + "attributes-keys": "Ключі атрибутів", + "attributes-keys-required": "Ключі атрибутів є обов'язковими", + "attributes-scope": "Область атрибутів", + "attributes-scope-value": "Значення області атрибутів", + "attributes-scope-value-copy": "Копіювати значення області атрибутів", + "attributes-scope-hint": "Використовуйте ключ метаданих 'scope' для динамічного встановлення області атрибута для кожного повідомлення. Якщо вказано, це перекриває область, встановлену в конфігурації.", + "notify-device": "Примусово надіслати сповіщення на пристрій", + "send-attributes-updated-notification": "Надіслати сповіщення про оновлені атрибути", + "send-attributes-updated-notification-hint": "Надіслати сповіщення про оновлені атрибути як окреме повідомлення до черги механізму правил.", + "send-attributes-deleted-notification": "Надіслати сповіщення про видалені атрибути", + "send-attributes-deleted-notification-hint": "Надіслати сповіщення про видалені атрибути як окреме повідомлення до черги механізму правил.", + "update-attributes-only-on-value-change": "Зберігати атрибути лише якщо значення змінюється", + "update-attributes-only-on-value-change-hint": "Оновлює атрибути при кожному вхідному повідомленні, незалежно від того, чи змінилося їх значення. Це збільшує використання API та знижує продуктивність.", + "update-attributes-only-on-value-change-hint-enabled": "Оновлює атрибути лише якщо їх значення змінилося. Якщо значення не змінюється, не буде оновлено мітку часу атрибуту і не буде надіслано сповіщення про зміну атрибуту.", + "fetch-credentials-to-metadata": "Отримати облікові дані до метаданих", + "notify-device-on-update-hint": "Якщо увімкнено, примусово надіслати сповіщення на пристрій про оновлення спільних атрибутів. Якщо вимкнено, поведінка сповіщення контролюється параметром 'notifyDevice' з метаданих вхідного повідомлення. Щоб вимкнути сповіщення, метадані повідомлення повинні містити параметр 'notifyDevice' зі значенням 'false'. У будь-якому іншому випадку буде надіслано сповіщення на пристрій.", + "notify-device-on-delete-hint": "Якщо увімкнено, примусово надіслати сповіщення на пристрій про видалення спільних атрибутів. Якщо вимкнено, поведінка сповіщення контролюється параметром 'notifyDevice' з метаданих вхідного повідомлення. Щоб увімкнути сповіщення, метадані повідомлення повинні містити параметр 'notifyDevice' зі значенням 'true'. У будь-якому іншому випадку сповіщення не буде надіслано на пристрій.", + "latest-timeseries": "Ключі даних останніх часових рядів", + "timeseries-keys": "Ключі часових рядів", + "timeseries-keys-required": "Необхідно вибрати хоча б один ключ часових рядів.", + "add-timeseries-key": "Додати ключ часових рядів", + "add-message-field": "Додати поле повідомлення", + "relation-search-parameters": "Параметри пошуку відносин", + "relation-parameters": "Параметри відносин", + "add-metadata-field": "Додати поле метаданих", + "data-keys": "Назви полів повідомлення", + "copy-from": "Копіювати з", + "data-to-metadata": "Дані в метадані", + "metadata-to-data": "Метадані в дані", + "use-regular-expression-hint": "Використовуйте регулярний вираз, щоб копіювати ключі за шаблоном.\n\nПоради та підказки:\nНатисніть 'Enter', щоб завершити введення назви поля.\nНатисніть 'Backspace', щоб видалити назву поля. Підтримуються кілька назв полів.", + "interval": "Інтервал", + "interval-required": "Інтервал є обов'язковим", + "interval-hint": "Інтервал дедуплікації в секундах.", + "interval-min-error": "Мінімально допустиме значення — 1", + "max-pending-msgs": "Максимум очікуючих повідомлень", + "max-pending-msgs-hint": "Максимальна кількість повідомлень, що зберігаються в пам'яті для кожного унікального id дедуплікації.", + "max-pending-msgs-required": "Максимум очікуючих повідомлень є обов'язковим", + "max-pending-msgs-max-error": "Максимально допустиме значення — 1000", + "max-pending-msgs-min-error": "Мінімально допустиме значення — 1", + "max-retries": "Максимум повторних спроб", + "max-retries-required": "Максимум повторних спроб є обов'язковим", + "max-retries-hint": "Максимальна кількість повторних спроб, щоб додати дедупліковані повідомлення в чергу. Між повторними спробами використовується затримка 10 секунд", + "max-retries-max-error": "Максимально допустиме значення — 100", + "max-retries-min-error": "Мінімально допустиме значення — 0", + "strategy": "Стратегія", + "strategy-required": "Стратегія є обов'язковою", + "strategy-all-hint": "Повертає всі повідомлення, що надійшли протягом періоду дедуплікації, як одне повідомлення JSON-масиву. Де кожен елемент є об'єктом із внутрішніми властивостями msg і metadata.", + "strategy-first-hint": "Повертає перше повідомлення, що надійшло протягом періоду дедуплікації.", + "strategy-last-hint": "Повертає останнє повідомлення, що надійшло протягом періоду дедуплікації.", + "first": "Перше", + "last": "Останнє", + "all": "Усі", + "output-msg-type-hint": "Тип повідомлення для результату дедуплікації.", + "queue-name-hint": "Назва черги, куди буде опубліковано результат дедуплікації.", + "keys": "Ключі", + "keys-required": "Ключі є обов'язковими", + "rename-keys-in": "Перейменувати ключі в", + "data": "Дані", + "message": "Повідомлення", + "metadata": "Метадані", + "current-key-name": "Поточна назва ключа", + "key-name-required": "Назва ключа є обов'язковою", + "new-key-name": "Нова назва ключа", + "new-key-name-required": "Нова назва ключа є обов'язковою", + "metadata-keys": "Назви полів метаданих", + "json-path-expression": "Вираз JSONPath", + "json-path-expression-required": "Вираз JSONPath є обов'язковим", + "json-path-expression-hint": "JSONPath визначає шлях до елемента або набору елементів у структурі JSON. '$' позначає корінь об'єкта або масиву.", + "relations-query": "Запит на відносини", + "device-relations-query": "Запит на відносини пристрою", + "max-relation-level": "Максимальний рівень відносин", + "max-relation-level-error": "Значення має бути більше за 0 або не визначене.", + "max-relation-level-invalid": "Значення має бути цілим числом.", + "relation-type": "Тип відносин", + "relation-type-pattern": "Шаблон типу відносин", + "relation-type-pattern-required": "Шаблон типу відносин є обов'язковим", + "relation-types-list": "Типи відносин для поширення", + "relation-types-list-hint": "Якщо типи відносин для поширення не вибрані, тривоги будуть поширюватися без фільтрації за типом відносин.", + "unlimited-level": "Необмежений рівень", + "latest-telemetry": "Остання телеметрія", + "add-telemetry-key": "Додати ключ телеметрії", + "delete-from": "Видалити з", + "use-regular-expression-delete-hint": "Використовуйте регулярний вираз для видалення ключів за шаблоном.\n\nПоради та підказки:\nНатисніть 'Enter', щоб завершити введення назви поля.\nНатисніть 'Backspace', щоб видалити назву поля.\nПідтримуються кілька назв полів.", + "fetch-into": "Отримати в", + "attr-mapping": "Відображення атрибутів:", + "source-attribute": "Ключ атрибута джерела", + "source-attribute-required": "Ключ атрибута джерела є обов'язковим.", + "source-telemetry": "Ключ телеметрії джерела", + "source-telemetry-required": "Ключ телеметрії джерела є обов'язковим.", + "target-key": "Цільовий ключ", + "target-key-required": "Цільовий ключ є обов'язковим.", + "attr-mapping-required": "Необхідно вказати хоча б один запис відображення.", + "fields-mapping": "Відображення полів", + "fields-mapping-hint": "Якщо поле повідомлення встановлено на $entityId, id відправника повідомлення буде збережено в зазначеному стовпці таблиці.", + "relations-query-config-direction-suffix": "відправник", + "profile-name": "Назва профілю", + "fetch-circle-parameter-info-from-metadata-hint": "Поле метаданих '{{perimeterKeyName}}' повинно бути визначене у наступному форматі: {\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "Поле метаданих '{{perimeterKeyName}}' повинно бути визначене у наступному форматі: [[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "Використовуйте $[messageKey] для витягнення значення з повідомлення та ${metadataKey} для витягнення значення з метаданих.", + "fields-mapping-required": "Необхідно вказати хоча б одне відображення поля.", + "at-least-one-field-required": "Необхідно вказати хоча б одне вхідне поле.", + "originator-fields-sv-map-hint": "Поля цільового ключа підтримують темплейтизацію. Використовуйте $[messageKey] для витягнення значення з повідомлення та ${metadataKey} для витягнення значення з метаданих.", + "sv-map-hint": "Тільки поля цільового ключа підтримують темплейтизацію. Використовуйте $[messageKey] для витягнення значення з повідомлення та ${metadataKey} для витягнення значення з метаданих.", + "source-field": "Джерело поля", + "source-field-required": "Джерело поля є обов'язковим.", + "originator-source": "Джерело ініціатора", + "new-originator": "Новий ініціатор", + "originator-customer": "Клієнт", + "originator-tenant": "Тенант", + "originator-related": "Пов'язана сутність", + "originator-alarm-originator": "Ініціатор тривоги", + "originator-entity": "Сутність за шаблоном назви", + "clone-message": "Клонувати повідомлення", + "transform": "Трансформувати", + "default-ttl": "За замовчуванням TTL", + "default-ttl-required": "TTL за замовчуванням є обов'язковим.", + "default-ttl-hint": "Вузол правила отримуватиме значення Time-to-Live (TTL) з метаданих повідомлення. Якщо значення не задано, використовується значення TTL, зазначене в конфігурації. Якщо значення встановлено на 0, TTL з конфігурації профілю тенанта буде застосовано.", + "default-ttl-zero-hint": "TTL не буде застосовуватися, якщо його значення встановлено на 0.", + "min-default-ttl-message": "Допустиме мінімальне значення TTL — 0.", + "generation-parameters": "Параметри генерації", + "message-count": "Ліміт згенерованих повідомлень (0 - без обмежень)", + "message-count-required": "Ліміт згенерованих повідомлень є обов'язковим.", + "min-message-count-message": "Допустиме мінімальне значення ліміту повідомлень — 0.", + "period-seconds": "Період в секундах", + "period-seconds-required": "Період є обов'язковим.", + "generation-frequency-seconds": "Частота генерації в секундах", + "generation-frequency-required": "Частота генерації є обов'язковою.", + "min-generation-frequency-message": "Допустима мінімальна частота генерації — 60 секунд.", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "Використовувати шаблон періоду в секундах", + "use-metadata-period-in-seconds-patterns-hint": "Якщо вибрано, вузол правила використовує шаблон інтервалу періоду в секундах з метаданих повідомлення або даних, припускаючи, що інтервали в секундах.", + "period-in-seconds-pattern": "Шаблон періоду в секундах", + "period-in-seconds-pattern-required": "Шаблон періоду в секундах є обов'язковим", + "min-period-seconds-message": "Допустимий мінімальний період — 60 секунд.", + "originator": "Ініціатор", + "message-body": "Тіло повідомлення", + "message-metadata": "Метадані повідомлення", + "generate": "Генерувати", + "current-rule-node": "Поточний вузол правила", + "current-tenant": "Поточний тенант", + "generator-function": "Функція генератора", + "test-generator-function": "Тестування функції генератора", + "generator": "Генератор", + "test-filter-function": "Тестування функції фільтра", + "test-switch-function": "Тестування функції перемикача", + "test-transformer-function": "Тестування функції трансформатора", + "transformer": "Трансформатор", + "alarm-create-condition": "Умова створення тривоги", + "test-condition-function": "Тестування функції умови", + "alarm-clear-condition": "Умова очищення тривоги", + "alarm-details-builder": "Конструктор деталей тривоги", + "test-details-function": "Тестування функції деталей", + "alarm-type": "Тип тривоги", + "select-entity-types": "Вибір типів сутностей", + "alarm-type-required": "Тип тривоги є обов'язковим.", + "alarm-severity": "Тяжкість тривоги", + "alarm-severity-required": "Тяжкість тривоги є обов'язковою", + "alarm-severity-pattern": "Шаблон тяжкості тривоги", + "alarm-status-filter": "Фільтр статусу тривоги", + "alarm-status-list-empty": "Список статусів тривог порожній", + "no-alarm-status-matching": "Не знайдено відповідних статусів тривог.", + "propagate": "Поширити тривогу на пов'язані сутності", + "propagate-to-owner": "Поширити тривогу на власника сутності (Клієнт або Тенант)", + "propagate-to-tenant": "Поширити тривогу на Тенанта", + "condition": "Умова", + "details": "Деталі", + "to-string": "До рядка", + "test-to-string-function": "Тестування функції до рядка", + "from-template": "З шаблону", + "from-template-required": "З шаблону є обов'язковим", + "message-to-metadata": "Повідомлення до метаданих", + "metadata-to-message": "Метадані до повідомлення", + "from-message": "З повідомлення", + "from-metadata": "З метаданих", + "to-template": "До", + "to-template-required": "Шаблон 'To' є обов'язковим", + "mail-address-list-template-hint": "Список адрес через кому, використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення", + "cc-template": "Cc", + "bcc-template": "Bcc", + "subject-template": "Тема", + "subject-template-required": "Шаблон теми є обов'язковим", + "body-template": "Тіло", + "body-template-required": "Шаблон тіла є обов'язковим", + "dynamic-mail-body-type": "Динамічний тип тіла пошти", + "mail-body-type": "Тип тіла пошти", + "body-type-template": "Шаблон типу тіла", + "reply-routing-configuration": "Конфігурація маршрутизації відповіді", + "rpc-reply-routing-configuration-hint": "Ці параметри конфігурації визначають імена ключів метаданих, що використовуються для ідентифікації сервісу, сесії та запиту для відправки відповіді назад.", + "reply-routing-configuration-hint": "Ці параметри конфігурації визначають імена ключів метаданих, що використовуються для ідентифікації сервісу та запиту для відправки відповіді назад.", + "request-id-metadata-attribute": "ID запиту", + "service-id-metadata-attribute": "ID сервісу", + "session-id-metadata-attribute": "ID сесії", + "timeout-sec": "Час очікування в секундах", + "timeout-required": "Час очікування є обов'язковим", + "min-timeout-message": "Допустиме мінімальне значення часу очікування — 0.", + "endpoint-url-pattern": "Шаблон URL кінцевої точки", + "endpoint-url-pattern-required": "Шаблон URL кінцевої точки є обов'язковим", + "request-method": "Метод запиту", + "use-simple-client-http-factory": "Використовувати просту HTTP фабрику клієнта", + "ignore-request-body": "Без тіла запиту", + "parse-to-plain-text": "Перетворити в простий текст", + "parse-to-plain-text-hint": "Якщо вибрано, повідомлення тіла запиту буде перетворено з рядка JSON на простий текст, наприклад, msg = \"Hello,\\t\"world\"\" буде перетворено в Hello, \"world\"", + "read-timeout": "Час очікування для читання в мілісекундах", + "read-timeout-hint": "Значення 0 означає нескінченний час очікування", + "max-parallel-requests-count": "Максимальна кількість паралельних запитів", + "max-parallel-requests-count-hint": "Значення 0 вказує на відсутність обмежень на паралельну обробку", + "max-response-size": "Максимальний розмір відповіді (в KB)", + "max-response-size-hint": "Максимальна кількість пам'яті, виділена для буферизації даних при декодуванні або кодуванні HTTP повідомлень, таких як JSON або XML корисні навантаження", + "headers": "Заголовки", + "headers-hint": "Використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення в полях заголовків/значень", + "header": "Заголовок", + "header-required": "Заголовок є обов'язковим", + "value": "Значення", + "value-required": "Значення є обов'язковим", + "topic-pattern": "Шаблон теми", + "key-pattern": "Шаблон ключа", + "key-pattern-hint": "Необов'язково. Якщо вказано дійсне значення номеру партиції, воно буде використано при відправці запису. Якщо партиція не вказана, буде використано ключ. Якщо жодне з цих значень не вказано, партиція буде призначена за принципом round-robin.", + "topic-pattern-required": "Шаблон теми є обов'язковим", + "topic": "Тема", + "topic-required": "Тема є обов'язковою", + "bootstrap-servers": "Bootstrap сервери", + "bootstrap-servers-required": "Значення bootstrap серверів є обов'язковим", + "other-properties": "Інші властивості", + "key": "Ключ", + "key-required": "Ключ є обов'язковим", + "retries": "Кількість автоматичних повторних спроб у разі невдачі", + "min-retries-message": "Допустиме мінімальне значення для повторних спроб — 0.", + "batch-size-bytes": "Розмір пакету в байтах", + "min-batch-size-bytes-message": "Допустиме мінімальне значення для розміру пакету — 0.", + "linger-ms": "Час для локального буферизації (мс)", + "min-linger-ms-message": "Допустиме мінімальне значення часу — 0 мс.", + "buffer-memory-bytes": "Максимальний розмір буфера клієнта в байтах", + "min-buffer-memory-message": "Допустиме мінімальне значення для розміру буфера — 0.", + "memory-buffer-size-range": "Розмір буфера пам'яті повинен бути між 0 і {{max}} КБ", + "acks": "Кількість підтверджень", + "topic-arn-pattern": "Шаблон ARN теми", + "topic-arn-pattern-required": "Шаблон ARN теми є обов'язковим", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID є обов'язковим", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key є обов'язковим", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region є обов'язковим", + "exchange-name-pattern": "Шаблон імені обміну", + "routing-key-pattern": "Шаблон ключа маршрутизації", + "message-properties": "Властивості повідомлення", + "host": "Хост", + "host-required": "Хост є обов'язковим", + "port": "Порт", + "port-required": "Порт є обов'язковим", + "port-range": "Порт повинен бути в діапазоні від 1 до 65535.", + "virtual-host": "Віртуальний хост", + "username": "Ім'я користувача", + "password": "Пароль", + "automatic-recovery": "Автоматичне відновлення", + "connection-timeout-ms": "Час очікування підключення (мс)", + "min-connection-timeout-ms-message": "Допустиме мінімальне значення часу очікування — 0 мс.", + "handshake-timeout-ms": "Час очікування на з'єднання (мс)", + "min-handshake-timeout-ms-message": "Допустиме мінімальне значення часу очікування на з'єднання — 0 мс.", + "client-properties": "Властивості клієнта", + "queue-url-pattern": "Шаблон URL черги", + "queue-url-pattern-required": "Шаблон URL черги є обов'язковим", + "delay-seconds": "Затримка (секунди)", + "min-delay-seconds-message": "Допустиме мінімальне значення затримки — 0 секунд.", + "max-delay-seconds-message": "Допустиме максимальне значення затримки — 900 секунд.", + "name": "Назва", + "name-required": "Назва є обов'язковою", + "queue-type": "Тип черги", + "sqs-queue-standard": "Стандартна", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "ID проекту GCP", + "gcp-project-id-required": "ID проекту GCP є обов'язковим", + "gcp-service-account-key": "Файл ключа облікового запису сервісу GCP", + "gcp-service-account-key-required": "Файл ключа облікового запису сервісу GCP є обов'язковим", + "pubsub-topic-name": "Назва топіка", + "pubsub-topic-name-required": "Назва топіка є обов'язковою", + "message-attributes": "Атрибути повідомлення", + "message-attributes-hint": "Використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення в полях name/value", + "connect-timeout": "Час очікування підключення (сек)", + "connect-timeout-required": "Час очікування підключення є обов'язковим.", + "connect-timeout-range": "Час очікування підключення повинен бути в діапазоні від 1 до 200.", + "client-id": "Client ID", + "client-id-hint": "Необов'язково. Залиште порожнім для автоматично згенерованого Client ID. Будьте обережні при вказуванні Client ID. Більшість MQTT брокерів не дозволяють кілька з'єднань з однаковим Client ID. Для підключення до таких брокерів ваш MQTT Client ID повинен бути унікальним. Коли платформа працює в режимі мікросервісів, копія правила вузла запускається в кожному мікросервісі. Це автоматично призведе до множинних MQTT клієнтів з однаковим ID і може спричинити помилки правила вузла. Для уникнення таких помилок увімкніть опцію \"Додати ID сервісу як суфікс до Client ID\" нижче.", + "append-client-id-suffix": "Додати ID сервісу як суфікс до Client ID", + "client-id-suffix-hint": "Необов'язково. Застосовується, коли \"Client ID\" явно вказано. Якщо вибрано, то ID сервісу буде додано до Client ID як суфікс. Це допомагає уникнути помилок, коли платформа працює в режимі мікросервісів.", + "device-id": "ID пристрою", + "device-id-required": "ID пристрою є обов'язковим.", + "clean-session": "Чиста сесія", + "enable-ssl": "Увімкнути SSL", + "credentials": "Облікові дані", + "credentials-type": "Тип облікових даних", + "credentials-type-required": "Тип облікових даних є обов'язковим.", + "credentials-anonymous": "Анонімні", + "credentials-basic": "Базові", + "credentials-pem": "PEM", + "credentials-pem-hint": "Необхідно хоча б сертифікат CA сервера або пара сертифіката клієнта та приватного ключа клієнта", + "credentials-sas": "Shared Access Signature", + "sas-key": "SAS ключ", + "sas-key-required": "SAS ключ є обов'язковим.", + "hostname": "Хост", + "hostname-required": "Хост є обов'язковим.", + "azure-ca-cert": "Файл сертифіката CA", + "username-required": "Ім'я користувача є обов'язковим.", + "password-required": "Пароль є обов'язковим.", + "ca-cert": "Сертифікат CA сервера", + "private-key": "Файл приватного ключа клієнта", + "cert": "Файл сертифіката клієнта", + "no-file": "Файл не вибрано.", + "drop-file": "Перетягніть файл або натисніть, щоб вибрати файл для завантаження.", + "private-key-password": "Пароль приватного ключа", + "use-system-smtp-settings": "Використовувати системні налаштування SMTP", + "use-metadata-dynamic-interval": "Використовувати динамічний інтервал", + "metadata-dynamic-interval-hint": "Поля введення початку та кінця інтервалу підтримують темплейтизацію. Зверніть увагу, що підставлене значення шаблону має бути задане в мілісекундах. Використовуйте $[messageKey] для витягнення значення з повідомлення та ${metadataKey} для витягнення значення з метаданих.", + "use-metadata-interval-patterns-hint": "Якщо вибрано, вузол правила використовує шаблони інтервалів початку та кінця з метаданих повідомлення або даних, припускаючи, що інтервали в мілісекундах.", + "use-message-alarm-data": "Використовувати дані тривоги з повідомлення", + "overwrite-alarm-details": "Перезаписати деталі тривоги", + "use-alarm-severity-pattern": "Використовувати шаблон тяжкості тривоги", + "check-all-keys": "Перевірити, що всі зазначені поля наявні", + "check-all-keys-hint": "Якщо вибрано, перевіряє, що всі зазначені ключі наявні в даних повідомлення та метаданих.", + "check-relation-to-specific-entity": "Перевірити відношення до конкретної сутності", + "check-relation-to-specific-entity-tooltip": "Якщо увімкнено, перевіряє наявність відношення з конкретною сутністю; інакше перевіряє наявність відношення з будь-якою сутністю. В обох випадках пошук відношення базується на налаштованих напрямку та типу.", + "check-relation-hint": "Перевіряє наявність відношення до конкретної сутності або до будь-якої сутності на основі напрямку та типу відношення.", + "delete-relation-with-specific-entity": "Видалити відношення з конкретною сутністю", + "delete-relation-with-specific-entity-hint": "Якщо увімкнено, видалить відношення лише з однією конкретною сутністю. Інакше відношення буде видалено з усіма сутностями, що відповідають умовам.", + "delete-relation-hint": "Видаляє відношення від ініціатора вхідного повідомлення до зазначеної сутності або списку сутностей на основі напрямку та типу.", + "remove-current-relations": "Видалити поточні відношення", + "remove-current-relations-hint": "Видаляє поточні відношення від ініціатора вхідного повідомлення на основі напрямку та типу.", + "change-originator-to-related-entity": "Змінити ініціатора на пов'язану сутність", + "change-originator-to-related-entity-hint": "Використовується для обробки надісланого повідомлення як повідомлення від іншої сутності.", + "start-interval": "Початок інтервалу", + "end-interval": "Кінець інтервалу", + "start-interval-required": "Початок інтервалу є обов'язковим.", + "end-interval-required": "Кінець інтервалу є обов'язковим.", + "smtp-protocol": "Протокол", + "smtp-host": "Хост SMTP", + "smtp-host-required": "Хост SMTP є обов'язковим.", + "smtp-port": "Порт SMTP", + "smtp-port-required": "Необхідно вказати порт SMTP.", + "smtp-port-range": "Порт SMTP повинен бути в діапазоні від 1 до 65535.", + "timeout-msec": "Час очікування, мс", + "min-timeout-msec-message": "Допустиме мінімальне значення — 0 мс.", + "enter-username": "Ввести ім'я користувача", + "enter-password": "Ввести пароль", + "enable-tls": "Увімкнути TLS", + "tls-version": "Версія TLS", + "enable-proxy": "Увімкнути проксі", + "use-system-proxy-properties": "Використовувати системні властивості проксі", + "proxy-host": "Хост проксі", + "proxy-host-required": "Хост проксі є обов'язковим.", + "proxy-port": "Порт проксі", + "proxy-port-required": "Порт проксі є обов'язковим.", + "proxy-port-range": "Порт проксі повинен бути в діапазоні від 1 до 65535.", + "proxy-user": "Користувач проксі", + "proxy-password": "Пароль проксі", + "proxy-scheme": "Схема проксі", + "numbers-to-template": "Номери телефону для шаблону", + "numbers-to-template-required": "Номери телефону для шаблону є обов'язковими", + "numbers-to-template-hint": "Номери телефону через кому, використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення", + "sms-message-template": "Шаблон SMS повідомлення", + "sms-message-template-required": "Шаблон SMS повідомлення є обов'язковим", + "use-system-sms-settings": "Використовувати системні налаштування провайдера SMS", + "min-period-0-seconds-message": "Допустимий мінімальний період — 0 секунд.", + "max-pending-messages": "Максимальна кількість повідомлень в очікуванні", + "max-pending-messages-required": "Максимальна кількість повідомлень в очікуванні є обов'язковою.", + "max-pending-messages-range": "Максимальна кількість повідомлень в очікуванні повинна бути в діапазоні від 1 до 100000.", + "originator-types-filter": "Фільтр типів ініціатора", + "interval-seconds": "Інтервал у секундах", + "interval-seconds-required": "Інтервал є обов'язковим.", + "int-range": "Значення не повинно перевищувати максимальну межу цілого числа (2147483648)", + "min-interval-seconds-message": "Допустимий мінімальний інтервал — 1 секунда.", + "output-timeseries-key-prefix": "Префікс ключа ряду часу виходу", + "output-timeseries-key-prefix-required": "Префікс ключа ряду часу виходу є обов'язковим.", + "separator-hint": "Натисніть \"Enter\", щоб завершити введення поля.", + "select-details": "Вибрати деталі", + "entity-details-id": "Id", + "entity-details-title": "Назва", + "entity-details-country": "Країна", + "entity-details-state": "Область", + "entity-details-city": "Місто", + "entity-details-zip": "Індекс", + "entity-details-address": "Адреса", + "entity-details-address2": "Адреса2", + "entity-details-additional_info": "Додаткова інформація", + "entity-details-phone": "Телефон", + "entity-details-email": "Email", + "email-sender": "Відправник Email", + "fields-to-check": "Поля для перевірки", + "add-detail": "Додати деталь", + "check-all-keys-tooltip": "Якщо увімкнено, перевіряє наявність усіх полів зі списку в полях повідомлення та назвах полів метаданих у вхідному повідомленні та його метаданих.", + "fields-to-check-hint": "Натисніть \"Enter\", щоб завершити введення назви поля. Підтримуються кілька назв полів.", + "entity-details-list-empty": "Потрібно вибрати принаймні одну деталь.", + "alarm-status": "Статус тривоги", + "alarm-required": "Потрібно вибрати принаймні один статус тривоги.", + "no-entity-details-matching": "Відповідних деталей сутності не знайдено.", + "custom-table-name": "Власна назва таблиці", + "custom-table-name-required": "Назва таблиці є обов'язковою", + "custom-table-hint": "Таблиця має бути створена у Вашому кластері Cassandra, а її назва повинна починатися з префікса 'cs_tb_', щоб уникнути вставки даних у спільні таблиці TB. Введіть тут назву таблиці без префікса 'cs_tb_'.", + "message-field": "Поле повідомлення", + "message-field-required": "Поле повідомлення є обов'язковим.", + "table-col": "Стовпець таблиці", + "table-col-required": "Стовпець таблиці є обов'язковим.", + "latitude-field-name": "Назва поля широти", + "longitude-field-name": "Назва поля довготи", + "latitude-field-name-required": "Назва поля широти є обов'язковою.", + "longitude-field-name-required": "Назва поля довготи є обов'язковою.", + "fetch-perimeter-info-from-metadata": "Отримати інформацію про периметр з метаданих", + "fetch-perimeter-info-from-metadata-tooltip": "Якщо тип периметра встановлено як 'Polygon', значення поля метаданих '{{perimeterKeyName}}' буде задано як визначення периметра без додаткового парсингу значення. Інакше, якщо тип периметра встановлено як 'Circle', значення поля метаданих '{{perimeterKeyName}}' буде розібрано для витягнення полів 'latitude', 'longitude', 'radius', 'radiusUnit', які використовуються для визначення периметра кола.", + "perimeter-key-name": "Назва ключа периметра", + "perimeter-key-name-hint": "Назва поля метаданих, що містить інформацію про периметр.", + "perimeter-key-name-required": "Назва ключа периметра є обов'язковою.", + "perimeter-circle": "Коло", + "perimeter-polygon": "Полігон", + "perimeter-type": "Тип периметра", + "circle-center-latitude": "Широта центру", + "circle-center-latitude-required": "Широта центру є обов'язковою.", + "circle-center-longitude": "Довгота центру", + "circle-center-longitude-required": "Довгота центру є обов'язковою.", + "range-unit-meter": "Метр", + "range-unit-kilometer": "Кілометр", + "range-unit-foot": "Фут", + "range-unit-mile": "Миля", + "range-unit-nautical-mile": "Морська миля", + "range-units": "Одиниці діапазону", + "range-units-required": "Одиниці діапазону є обов'язковими.", + "range": "Діапазон", + "range-required": "Діапазон є обов'язковим.", + "polygon-definition": "Визначення полігону", + "polygon-definition-required": "Визначення полігону є обов'язковим.", + "polygon-definition-hint": "Використовуйте такий формат для ручного визначення полігону: [[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]].", + "min-inside-duration": "Мінімальна тривалість перебування всередині", + "min-inside-duration-value-required": "Мінімальна тривалість перебування всередині є обов'язковою", + "min-inside-duration-time-unit": "Одиниця часу мінімальної тривалості перебування всередині", + "min-outside-duration": "Мінімальна тривалість перебування зовні", + "min-outside-duration-value-required": "Мінімальна тривалість перебування зовні є обов'язковою", + "min-outside-duration-time-unit": "Одиниця часу мінімальної тривалості перебування зовні", + "tell-failure-if-absent": "Повідомляти \"Failure\"", + "tell-failure-if-absent-hint": "Якщо принаймні один вибраний ключ не існує, вихідне повідомлення повідомить \"Failure\".", + "get-latest-value-with-ts": "Отримувати мітку часу для останніх значень телеметрії", + "get-latest-value-with-ts-hint": "Якщо вибрано, останні значення телеметрії також включатимуть мітку часу, напр.: \"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "Ігнорувати порожні рядки", + "ignore-null-strings-hint": "Якщо вибрано, вузол правила ігноруватиме поля сутності з порожнім значенням.", + "add-metadata-key-values-as-kafka-headers": "Додати пари ключ-значення метаданих повідомлення до заголовків запису Kafka", + "add-metadata-key-values-as-kafka-headers-hint": "Якщо вибрано, пари ключ-значення з метаданих повідомлення буде додано до заголовків вихідних записів як масиви байтів із попередньо визначеним кодуванням набору символів.", + "charset-encoding": "Кодування набору символів", + "charset-encoding-required": "Кодування набору символів є обов'язковим.", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "Назву черги можна вибрати зі спадного списку або додати власну назву.", + "device-profile-node-hint": "Корисно, якщо у Вас є умови тривалості або повторювані умови, щоб забезпечити безперервність оцінювання стану тривоги.", + "persist-alarm-rules": "Зберігати стан правил тривог", + "persist-alarm-rules-hint": "Якщо увімкнено, вузол правила зберігатиме стан обробки в базі даних.", + "fetch-alarm-rules": "Отримувати стан правил тривог", + "fetch-alarm-rules-hint": "Якщо увімкнено, вузол правила відновлюватиме стан обробки під час ініціалізації та гарантуватиме створення тривог навіть після перезапусків сервера. Інакше стан буде відновлено, коли надійде перше повідомлення від пристрою.", + "input-value-key": "Ключ вхідного значення", + "input-value-key-required": "Ключ вхідного значення є обов'язковим.", + "output-value-key": "Ключ вихідного значення", + "output-value-key-required": "Ключ вихідного значення є обов'язковим.", + "number-of-digits-after-floating-point": "Кількість цифр після десяткової крапки", + "number-of-digits-after-floating-point-range": "Кількість цифр після десяткової крапки повинна бути в діапазоні від 0 до 15.", + "failure-if-delta-negative": "Повідомляти \"Failure\", якщо дельта від'ємна", + "failure-if-delta-negative-tooltip": "Вузол правила примусово встановлює помилку обробки повідомлення, якщо значення дельти від'ємне.", + "use-caching": "Використовувати кешування", + "use-caching-tooltip": "Вузол правила кешуватиме значення \"{{inputValueKey}}\", що надходить із вхідного повідомлення, для підвищення продуктивності. Зверніть увагу, що кеш не буде оновлено, якщо Ви зміните значення \"{{inputValueKey}}\" в іншому місці.", + "add-time-difference-between-readings": "Додати різницю часу між вимірюваннями \"{{inputValueKey}}\"", + "add-time-difference-between-readings-tooltip": "Якщо увімкнено, вузол правила додасть \"{{periodValueKey}}\" до вихідного повідомлення.", + "period-value-key": "Ключ значення періоду", + "period-value-key-required": "Ключ значення періоду є обов'язковим.", + "general-pattern-hint": "Використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення.", + "alarm-severity-pattern-hint": "Використовуйте ${metadataKey} для значення з метаданих, $[messageKey] для значення з тіла повідомлення. Рівень тривоги має бути системним (CRITICAL, MAJOR тощо.)", + "output-node-name-hint": "Назва вузла правила відповідає типу зв'язку вихідного повідомлення та використовується для пересилання повідомлень до інших вузлів правил у ланцюгу правил виклику.", + "use-server-ts": "Використовувати мітку часу сервера", + "use-server-ts-hint": "Використовувати поточну мітку часу сервера для даних часових рядів, у яких немає явної мітки часу. Це допомагає зберігати коректний порядок під час обробки повідомлень із кількох джерел або коли повідомлення надходять не за порядком.", + "kv-map-pattern-hint": "Усі вхідні поля підтримують шаблонізацію. Використовуйте $[messageKey], щоб витягнути значення з повідомлення, і ${metadataKey}, щоб витягнути значення з метаданих.", + "kv-map-single-pattern-hint": "Вхідне поле підтримує шаблонізацію. Використовуйте $[messageKey], щоб витягнути значення з повідомлення, і ${metadataKey}, щоб витягнути значення з метаданих.", + "shared-scope": "Спільна область", + "server-scope": "Серверна область", + "client-scope": "Клієнтська область", + "attribute-type": "Атрибут", + "attribute-type-description": "Отримати значення атрибута з бази даних", + "attribute-type-result-description": "Зберегти результат як атрибут сутності в базі даних", + "constant-type": "Константа", + "constant-type-description": "Визначити константне значення", + "time-series-type": "Часовий ряд", + "time-series-type-description": "Отримати останнє значення часового ряду з бази даних", + "time-series-type-result-description": "Зберегти результат як часовий ряд сутності в базі даних", + "message-body-type": "Повідомлення", + "message-body-type-description": "Отримати значення аргументу з вхідного повідомлення", + "message-body-type-result-description": "Додати результат до вихідного повідомлення", + "message-metadata-type": "Метадані", + "message-metadata-type-description": "Отримати значення аргументу з метаданих вхідного повідомлення", + "message-metadata-result-description": "Додати результат до вихідних метаданих повідомлення", + "argument-tile": "Аргументи", + "no-arguments-prompt": "Аргументи не налаштовано", + "result-title": "Результат", + "functions-field-input": "Функції", + "no-option-found": "Параметр не знайдено", + "argument-source-field-input": "Джерело", + "argument-source-field-input-required": "Джерело аргументу є обов'язковим.", + "argument-key-field-input": "Ключ", + "argument-key-field-input-required": "Ключ аргументу є обов'язковим.", + "constant-value-field-input": "Константне значення", + "constant-value-field-input-required": "Константне значення є обов'язковим.", + "attribute-scope-field-input": "Область атрибутів", + "attribute-scope-field-input-required": "Область атрибутів є обов'язковою.", + "default-value-field-input": "Значення за замовчуванням", + "type-field-input": "Тип", + "type-field-input-required": "Тип є обов'язковим.", + "key-field-input": "Ключ", + "add-entity-type": "Додати тип сутності", + "add-device-profile": "Додати профіль пристрою", + "key-field-input-required": "Ключ є обов'язковим.", + "number-floating-point-field-input": "Кількість цифр після десяткової крапки", + "number-floating-point-field-input-hint": "Використовуйте 0, щоб перетворити результат на ціле число", + "add-to-message-field-input": "Додати до повідомлення", + "add-to-metadata-field-input": "Додати до метаданих", + "custom-expression-field-input": "Математичний вираз", + "custom-expression-field-input-required": "Математичний вираз є обов'язковим", + "custom-expression-field-input-hint": "Вкажіть математичний вираз для обчислення. Вираз за замовчуванням демонструє, як перетворити Фаренгейт у Цельсій", + "retained-message": "Збережене", + "attributes-mapping": "Зіставлення атрибутів", + "latest-telemetry-mapping": "Зіставлення останньої телеметрії", + "add-mapped-attribute-to": "Додати зіставлені атрибути до", + "add-mapped-latest-telemetry-to": "Додати зіставлені значення останньої телеметрії до", + "add-mapped-fields-to": "Додати зіставлені поля до", + "add-selected-details-to": "Додати вибрані деталі до", + "clear-selected-types": "Очистити вибрані типи", + "clear-selected-details": "Очистити вибрані деталі", + "clear-selected-fields": "Очистити вибрані поля", + "clear-selected-keys": "Очистити вибрані ключі", + "geofence-configuration": "Налаштування геозони", + "coordinate-field-names": "Назви полів координат", + "coordinate-field-hint": "Вузол правила намагається отримати вказані поля з повідомлення. Якщо їх немає, він шукатиме їх у метаданих.", + "presence-monitoring-strategy": "Стратегія моніторингу присутності", + "presence-monitoring-strategy-on-first-message": "За першим повідомленням", + "presence-monitoring-strategy-on-each-message": "За кожним повідомленням", + "presence-monitoring-strategy-on-first-message-hint": "Повідомляє статус присутності 'Всередині' або 'Зовні' у першому повідомленні після того, як мине налаштована мінімальна тривалість від попереднього оновлення статусу присутності 'Увійшов' або 'Вийшов'.", + "presence-monitoring-strategy-on-each-message-hint": "Повідомляє статус присутності 'Всередині' або 'Зовні' у кожному повідомленні після оновлення статусу присутності 'Увійшов' або 'Вийшов'.", + "fetch-credentials-to": "Отримати облікові дані до", + "add-originator-attributes-to": "Додати атрибути ініціатора до", + "originator-attributes": "Атрибути ініціатора", + "fetch-latest-telemetry-with-timestamp": "Отримати останні телеметричні дані з міткою часу", + "fetch-latest-telemetry-with-timestamp-tooltip": "Якщо вибрано, останні телеметричні значення будуть додані до вихідних метаданих з міткою часу, наприклад: \"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "Повідомити про помилку, якщо відсутні будь-які атрибути", + "tell-failure-tooltip": "Якщо хоча б один вибраний ключ не існує, вихідне повідомлення буде позначено як \"Failure\".", + "created-time": "Час створення", + "chip-help": "Натисніть 'Enter' для завершення введення {{inputName}}. \nНатисніть 'Backspace' для видалення {{inputName}}. \nПідтримуються кілька значень.", + "detail": "деталь", + "field-name": "ім'я поля", + "device-profile": "профіль пристрою", + "entity-type": "тип сутності", + "message-type": "тип повідомлення", + "timeseries-key": "ключ часових рядів", + "type": "Тип", + "first-name": "Ім'я", + "last-name": "Прізвище", + "label": "Маркування", + "originator-fields-mapping": "Зіставлення полів ініціатора", + "add-mapped-originator-fields-to": "Додати зіставлені поля ініціатора до", + "fields": "Поля", + "skip-empty-fields": "Пропустити порожні поля", + "skip-empty-fields-tooltip": "Поля з порожніми значеннями не будуть додані до вихідного повідомлення/метаданих.", + "fetch-interval": "Інтервал вибірки", + "fetch-strategy": "Стратегія вибірки", + "fetch-timeseries-from-to": "Отримати часові ряди з {{startInterval}} {{startIntervalTimeUnit}} тому до {{endInterval}} {{endIntervalTimeUnit}} тому.", + "fetch-timeseries-from-to-invalid": "Недійсна вибірка часових рядів (\"Інтервал початку\" повинен бути меншим за \"Інтервал завершення\").", + "use-metadata-dynamic-interval-tooltip": "Якщо вибрано, вузол правила використовуватиме динамічні інтервали початку та кінця на основі шаблонів повідомлення та метаданих.", + "all-mode-hint": "Якщо вибрано режим вибірки \"Усі\", вузол правила отримає телеметрію з вибраного інтервалу з налаштовуваними параметрами запиту.", + "first-mode-hint": "Якщо вибрано режим вибірки \"Перший\", вузол правила отримає найближчі телеметричні дані до початку вибраного інтервалу.", + "last-mode-hint": "Якщо вибрано режим вибірки \"Останній\", вузол правила отримає найближчі телеметричні дані до кінця вибраного інтервалу.", + "ascending": "За зростанням", + "descending": "За спаданням", + "min": "Мінімум", + "max": "Максимум", + "average": "Середнє", + "sum": "Сума", + "count": "Кількість", + "none": "Немає", + "last-level-relation-tooltip": "Якщо вибрано, вузол правила шукатиме пов'язані сутності тільки на рівні, визначеному в максимальному рівні зв'язку.", + "last-level-device-relation-tooltip": "Якщо вибрано, вузол правила шукатиме пов'язані пристрої тільки на рівні, визначеному в максимальному рівні зв'язку.", + "data-to-fetch": "Дані для вибірки", + "mapping-of-customers": "Зіставлення клієнтів", + "map-fields-required": "Усі поля зіставлення є обов'язковими.", + "attributes": "Атрибути", + "related-device-attributes": "Атрибути пов'язаних пристроїв", + "add-selected-attributes-to": "Додати вибрані атрибути до", + "device-profiles": "Профілі пристроїв", + "mapping-of-tenant": "Зіставлення орендатора", + "add-attribute-key": "Додати ключ атрибута", + "message-template": "Шаблон повідомлення", + "message-template-required": "Шаблон повідомлення є обов'язковим", + "use-system-slack-settings": "Використовувати системні налаштування Slack", + "slack-api-token": "Slack API токен", + "slack-api-token-required": "Slack API токен є обов'язковим", + "keys-mapping": "Зіставлення ключів", + "add-key": "Додати ключ", + "recipients": "Отримувачі", + "message-subject-and-content": "Тема і вміст повідомлення", + "template-rules-hint": "Обидва поля вводу підтримують шаблони. Використовуйте $[messageKey] для отримання значення з повідомлення і ${metadataKey} для отримання значення з метаданих повідомлення.", + "originator-customer-desc": "Використовувати клієнта ініціатора вхідного повідомлення як нового ініціатора.", + "originator-tenant-desc": "Використовувати поточного орендатора як нового ініціатора.", + "originator-related-entity-desc": "Використовувати пов'язану сутність як нового ініціатора. Пошук на основі налаштованого типу зв'язку та напрямку.", + "originator-alarm-originator-desc": "Використовувати ініціатора тривоги як нового ініціатора. Тільки якщо ініціатор вхідного повідомлення є сутністю тривоги.", + "originator-entity-by-name-pattern-desc": "Використовувати сутність, що отримана з БД, як нового ініціатора. Пошук на основі типу сутності та заданого шаблону імені.", + "email-from-template-hint": "Використовуйте $[messageKey] для отримання значення з повідомлення та ${metadataKey} для отримання значення з метаданих.", + "recipients-block-main-hint": "Список адрес, розділений комами. Всі поля вводу підтримують шаблони. Використовуйте $[messageKey] для отримання значення з повідомлення та ${metadataKey} для отримання значення з метаданих.", + "forward-msg-default-rule-chain": "Переслати повідомлення до стандартного ланцюга правил ініціатора", + "forward-msg-default-rule-chain-tooltip": "Якщо увімкнено, повідомлення буде переслано до стандартного ланцюга правил ініціатора або до ланцюга правил за замовчуванням, якщо ініціатор не має стандартного ланцюга правил у профілі сутності.", + "exclude-zero-deltas": "Виключити нульові зміни з вихідного повідомлення", + "exclude-zero-deltas-hint": "Якщо увімкнено, вихідний ключ \"{{outputValueKey}}\" буде доданий до вихідного повідомлення, якщо його значення не нульове.", + "exclude-zero-deltas-time-difference-hint": "Якщо увімкнено, вихідні ключі \"{{outputValueKey}}\" та \"{{periodValueKey}}\" будуть додані до вихідного повідомлення тільки якщо значення \"{{outputValueKey}}\" не нульове.", + "search-direction-from": "Від ініціатора до цільової сутності", + "search-direction-to": "Від цільової сутності до ініціатора", + "del-relation-direction-from": "Від ініціатора", + "del-relation-direction-to": "До ініціатора", + "target-entity": "Цільова сутність", + "function-configuration": "Налаштування функції", + "function-name": "Назва функції", + "function-name-required": "Назва функції є обов'язковою.", + "qualifier": "Кваліфікатор", + "qualifier-hint": "Якщо кваліфікатор не вказано, буде використано кваліфікатор за замовчуванням \"$LATEST\".", + "aws-credentials": "Облікові дані AWS", + "connection-timeout": "Тайм-аут підключення", + "connection-timeout-required": "Тайм-аут підключення є обов'язковим.", + "connection-timeout-min": "Мінімальний тайм-аут підключення — 0.", + "connection-timeout-hint": "Час очікування в секундах під час початкового встановлення з'єднання перед відмовою та завершенням за тайм-аутом. Значення 0 означає нескінченність і не рекомендується.", + "request-timeout": "Тайм-аут запиту", + "request-timeout-required": "Тайм-аут запиту є обов'язковим", + "request-timeout-min": "Мінімальний тайм-аут запиту — 0", + "request-timeout-hint": "Час очікування в секундах на завершення запиту перед відмовою та завершенням за тайм-аутом. Значення 0 означає нескінченність і не рекомендується.", + "units": "Одиниці", + "tell-failure-aws-lambda": "Повідомляти про збій, якщо виконання функції AWS Lambda викликає виняток", + "tell-failure-aws-lambda-hint": "Вузол правила примусово позначає обробку повідомлення як невдалу, якщо виконання функції AWS Lambda викликає виняток.", + "basic-mode": "Базовий", + "advanced-mode": "Розширений", + "save-time-series": { + "processing-settings": "Налаштування обробки", + "processing-settings-hint": "Визначте, як обробляються вхідні повідомлення. Базові налаштування обробки дозволяють вибрати попередньо налаштовані стратегії, тоді як розширені налаштування дозволяють вибрати окремі стратегії обробки для кожної дії.", + "advanced-settings-hint": "Будьте обережні під час налаштування стратегій обробки. Певні комбінації можуть призвести до неочікуваної поведінки.", + "strategy": "Стратегія", + "deduplication-interval": "Інтервал дедуплікації", + "deduplication-interval-required": "Інтервал дедуплікації є обов'язковим", + "deduplication-interval-min-max-range": "Інтервал дедуплікації має бути щонайменше 1 секунда і щонайбільше 1 день", + "strategy-type": { + "every-message": "На кожне повідомлення", + "skip": "Пропустити", + "deduplicate": "Дедуплікувати", + "web-sockets-only": "Лише WebSockets" + }, + "time-series": "Часові ряди", + "latest": "Останні значення", + "web-sockets": "WebSockets", + "calculated-fields-and-alarm-rules": "Розрахункові поля та правила тривог" + }, + "save-attribute": { + "processing-settings": "Налаштування обробки", + "processing-settings-hint": "Визначте, як обробляються вхідні повідомлення. Базові налаштування обробки дозволяють вибрати попередньо налаштовані стратегії, тоді як розширені налаштування дозволяють вибрати окремі стратегії обробки для кожної дії.", + "advanced-settings-hint": "Будьте обережні під час налаштування стратегій обробки. Певні комбінації можуть призвести до неочікуваної поведінки.", + "strategy": "Стратегія", + "deduplication-interval": "Інтервал дедуплікації", + "deduplication-interval-required": "Інтервал дедуплікації є обов'язковим", + "deduplication-interval-min-max-range": "Інтервал дедуплікації має бути щонайменше 1 секунда і щонайбільше 1 день", + "scope": "Область", + "strategy-type": { + "every-message": "На кожне повідомлення", + "skip": "Пропустити", + "deduplicate": "Дедуплікувати", + "web-sockets-only": "Лише WebSockets" + }, + "attributes": "Атрибути" + }, + "key-val": { + "key": "Ключ", + "value": "Значення", + "see-examples": "Переглянути приклади.", + "remove-entry": "Видалити запис", + "remove-mapping-entry": "Видалити елемент зіставлення", + "add-mapping-entry": "Додати зіставлення", + "add-entry": "Додати запис", + "copy-key-values-from": "Копіювати пари ключ-значення з", + "delete-key-values": "Видалити пари ключ-значення", + "delete-key-values-from": "Видалити пари ключ-значення з", + "at-least-one-key-error": "Має бути вибрано щонайменше один ключ.", + "unique-key-value-pair-error": "'{{keyText}}' має відрізнятися від '{{valText}}'!" + }, + "mail-body-types": { + "plain-text": "Звичайний текст", + "html": "HTML", + "dynamic": "Динамічний", + "use-body-type-template": "Використовувати шаблон типу тіла листа", + "plain-text-description": "Простий неформатований текст без спеціального стилю або форматування.", + "html-text-description": "Дозволяє використовувати HTML-теги для форматування, посилань і зображень у тілі листа.", + "dynamic-text-description": "Дозволяє динамічно використовувати звичайний текст або HTML на основі функції шаблонізації.", + "after-template-evaluation-hint": "Після обчислення шаблону значення має бути true для HTML і false для Plain text." + }, + "ai": { + "ai-model": "Модель ШІ", + "model": "Модель", + "ai-model-hint": "Виберіть попередньо налаштовану модель ШІ для обробки запитів, надісланих цим правилом, або використовуйте \"Створити нову\", щоб налаштувати нову.", + "prompt-settings": "Налаштування підказок", + "prompt-settings-hint": "Опційна система підказок визначає загальну роль і обмеження для ШІ, в той час як підказка користувача визначає конкретне завдання для виконання. Обидва поля також підтримують шаблонізацію.", + "system-prompt": "Система підказок", + "system-prompt-max-length": "Система підказок повинна містити не більше 500000 символів.", + "system-prompt-blank": "Система підказок не повинна бути порожньою.", + "user-prompt": "Підказка користувача", + "user-prompt-required": "Підказка користувача обов'язкова.", + "user-prompt-max-length": "Підказка користувача повинна містити не більше 500000 символів.", + "user-prompt-blank": "Підказка користувача не повинна бути порожньою.", + "response-format": "Формат відповіді", + "response-text": "Текст", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "Дозволяє моделі генерувати довільний текст, який може або не може бути допустимим JSON-об'єктом. Якщо вихідні дані не є допустимим JSON-об'єктом, вони будуть автоматично обгорнуті в JSON-об'єкт під ключем \"response\".", + "response-format-hint-JSON": "Модель повинна згенерувати відповідь, яка є допустимим JSON. Якщо вихідні дані не є допустимим JSON-об'єктом, вони будуть автоматично обгорнуті в JSON-об'єкт під ключем \"response\".", + "response-format-hint-JSON_SCHEMA": "Модель повинна згенерувати JSON, що відповідає конкретній структурі та типам даних, визначеним у наданій схемі. Якщо вихідні дані не є допустимим JSON-об'єктом, вони будуть автоматично обгорнуті в JSON-об'єкт під ключем \"response\".", + "response-json-schema-hint": "Хоча можна ввести будь-яку допустиму JSON-схему, цей правило вузла підтримує лише обмежену підмножину її функцій. Деталі див. у документації до вузла.", + "response-json-schema-required": "JSON Schema обов'язкова", + "advanced-settings": "Розширені налаштування", + "timeout": "Тайм-аут", + "timeout-hint": "Максимальний час очікування відповіді \nвід моделі ШІ перед завершенням запиту.", + "timeout-required": "Тайм-аут обов'язковий", + "timeout-validation": "Повинен бути від 1 секунди до 10 хвилин.", + "force-acknowledgement": "Примусове підтвердження", + "force-acknowledgement-hint": "Якщо увімкнено, вхідне повідомлення негайно підтверджується. Відповідь моделі потім ставиться в чергу як окреме нове повідомлення.", + "ai-resources": "Ресурси ШІ" + } + }, + "timezone": { + "timezone": "Часовий пояс", + "select-timezone": "Вибрати часовий пояс", + "no-timezones-matching": "Не знайдено часових поясів, що відповідають '{{timezone}}'.", + "timezone-required": "Часовий пояс обов'язковий.", + "browser-time": "Час браузера" + }, + "queue": { + "queue-name": "Черга", + "no-queues-found": "Черги не знайдено.", + "no-queues-matching": "Не знайдено черг, що відповідають '{{queue}}'.", + "select-name": "Вибрати назву черги", + "name": "Назва", + "name-required": "Назва черги є обов'язкова!", + "name-unique": "Назва черги не унікальна!", + "name-pattern": "Назва черги містить символ, відмінний від ASCII літер і цифр, '.', '_' та '-'!", + "queue-required": "Черга є обов'язкова!", + "topic-required": "Топік черги є обов'язковий!", + "poll-interval-required": "Інтервал опитування є обов'язковий!", + "poll-interval-min-value": "Значення інтервалу опитування не може бути меншим за 1", + "partitions-required": "Розділи є обов'язкові!", + "partitions-min-value": "Значення розділів не може бути меншим за 1", + "pack-processing-timeout-required": "Тайм-аут обробки є обов'язковий", + "pack-processing-timeout-min-value": "Значення тайм-ауту обробки не може бути меншим за 1", + "batch-size-required": "Розмір пакета є обов'язковий!", + "batch-size-min-value": "Значення розміру пакета не може бути меншим за 1", + "retries-required": "Повторні спроби є обов'язкові!", + "retries-min-value": "Значення повторних спроб не може бути від'ємним", + "failure-percentage-required": "Відсоток збоїв є обов'язковий!", + "failure-percentage-min-value": "Значення відсотка збоїв не може бути меншим за 0", + "failure-percentage-max-value": "Значення відсотка збоїв не може бути більшим за 100", + "pause-between-retries-required": "Пауза між повторними спробами є обов'язкова!", + "pause-between-retries-min-value": "Значення паузи між повторними спробами не може бути меншим за 1", + "max-pause-between-retries-required": "Максимальна пауза між повторними спробами є обов'язкова!", + "max-pause-between-retries-min-value": "Значення максимальної паузи між повторними спробами не може бути меншим за 1", + "submit-strategy-type-required": "Тип стратегії відправлення є обов'язковий!", + "processing-strategy-type-required": "Тип стратегії обробки є обов'язковий!", + "queues": "Черги", + "selected-queues": "{ count, plural, =1 {1 черга} other {# черг} } вибрано", + "delete-queue-title": "Ви впевнені, що хочете видалити чергу '{{queueName}}'?", + "delete-queues-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 чергу} other {# черг} }?", + "delete-queue-text": "Будьте обережні, після підтвердження черга і всі пов'язані дані будуть безповоротно видалені.", + "delete-queues-text": "Після підтвердження всі вибрані черги буде видалено, і вони будуть недоступні.", + "search": "Пошук черги", + "add": "Додати чергу", + "details": "Деталі черги", + "topic": "Топік", + "submit-settings": "Налаштування відправлення", + "submit-strategy": "Тип стратегії *", + "grouping-parameter": "Параметр групування", + "processing-settings": "Налаштування обробки повторних спроб", + "processing-strategy": "Тип обробки *", + "retries-settings": "Налаштування повторних спроб", + "polling-settings": "Налаштування опитування", + "batch-processing": "Пакетна обробка", + "poll-interval": "Інтервал опитування", + "partitions": "Розділи", + "immediate-processing": "Негайна обробка", + "consumer-per-partition": "Надсилати повідомлення для кожного споживача", + "consumer-per-partition-hint": "Увімкнути окремих споживачів для кожного розділу", + "duplicate-msg-to-all-partitions": "Дублювати повідомлення для всіх розділів", + "processing-timeout": "Тайм-аут обробки, мс", + "batch-size": "Розмір пакета", + "retries": "Кількість повторних спроб (0 — необмежено)", + "failure-percentage": "Відсоток збоїв для пропуску повторних спроб, %", + "pause-between-retries": "Пауза між повторними спробами, сек", + "max-pause-between-retries": "Максимальна пауза між повторними спробами, сек", + "delete": "Видалити чергу", + "copyId": "Копіювати ID черги", + "idCopiedMessage": "ID черги скопійовано в буфер обміну", + "description": "Опис", + "description-hint": "Цей текст буде відображатися в описі черги замість вибраної стратегії", + "alt-description": "Стратегія відправлення: {{submitStrategy}}, Стратегія обробки: {{processingStrategy}}", + "custom-properties": "Користувацькі властивості", + "custom-properties-hint": "Властивості для створення користувацької черги (топіка), наприклад 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "Послідовно за ініціатором", + "sequential-by-originator-hint": "Нове повідомлення для, наприклад, пристрою A не відправляється, поки попереднє повідомлення для пристрою A не буде підтверджене", + "sequential-by-tenant-label": "Послідовно за тенантом", + "sequential-by-tenant-hint": "Нове повідомлення для, наприклад, тенанта A не відправляється, поки попереднє повідомлення для тенанта A не буде підтверджене", + "sequential-label": "Послідовно", + "sequential-hint": "Нове повідомлення не відправляється, поки попереднє повідомлення не буде підтверджене", + "burst-label": "Сплеск", + "burst-hint": "Усі повідомлення відправляються в ланцюги правил у порядку їх надходження", + "batch-label": "Пакет", + "batch-hint": "Новий пакет не відправляється, поки попередній пакет не буде підтверджений", + "skip-all-failures-label": "Пропустити всі збої", + "skip-all-failures-hint": "Ігнорувати всі збої", + "skip-all-failures-and-timeouts-label": "Пропустити всі збої та тайм-аути", + "skip-all-failures-and-timeouts-hint": "Ігнорувати всі збої та тайм-аути", + "retry-all-label": "Повторити всі", + "retry-all-hint": "Повторити всі повідомлення з пакету обробки", + "retry-failed-label": "Повторити збої", + "retry-failed-hint": "Повторити всі неуспішні повідомлення з пакету обробки", + "retry-timeout-label": "Повторити тайм-аут", + "retry-timeout-hint": "Повторити всі повідомлення з тайм-аутом з пакету обробки", + "retry-failed-and-timeout-label": "Повторити збої та тайм-аути", + "retry-failed-and-timeout-hint": "Повторити всі неуспішні та ті, що мають тайм-аут, повідомлення з пакету обробки" + } + }, + "queue-statistics": { + "queue-statistics": "Статистика черги", + "no-queue-statistics-matching": "Не знайдено статистики черги, що відповідають '{{entity}}'.", + "queue-statistics-required": "Статистика черги обов'язкова.", + "list-of-queue-statistics": "{ count, plural, =1 {Одна статистика черги} other {Список з # статистик черги} }", + "selected-queue-statistics": "{ count, plural, =1 {1 статистика черги} other {# статистик черги} } вибрано", + "no-queue-statistics-text": "Статистика черги не знайдена", + "queue-statistics-starts-with": "Статистика черг, назви яких починаються з '{{prefix}}'" + }, + "server-error": { + "general": "Загальна помилка сервера", + "authentication": "Помилка аутентифікації", + "jwt-token-expired": "Термін дії JWT токена закінчився", + "tenant-trial-expired": "Термін пробного періоду тенанта закінчився", + "credentials-expired": "Термін дії облікових даних закінчився", + "permission-denied": "Доступ заборонено", + "invalid-arguments": "Невірні аргументи", + "bad-request-params": "Невірні параметри запиту", + "item-not-found": "Елемент не знайдений", + "too-many-requests": "Занадто багато запитів", + "too-many-updates": "Занадто багато оновлень", + "entities-limit-exceeded": "Перевищено ліміт сутностей" + }, + "tenant": { + "tenant": "Тенант", + "tenants": "Тенанти", + "management": "Управління тенантами", + "add": "Додати тенанта", + "admins": "Адміністратори", + "manage-tenant-admins": "Управління адміністраторами тенанта", + "delete": "Видалити тенанта", + "add-tenant-text": "Додати нового тенанта", + "no-tenants-text": "Тенанти не знайдені", + "tenant-details": "Деталі тенанта", + "title-max-length": "Назва повинна бути меншою за 256 символів", + "delete-tenant-title": "Ви впевнені, що хочете видалити тенанта '{{tenantTitle}}'?", + "delete-tenant-text": "Будьте обережні, після підтвердження тенант і всі пов'язані дані будуть безповоротно видалені.", + "delete-tenants-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 тенанта} other {# тенантів} }?", + "delete-tenants-action-title": "Видалити { count, plural, =1 {1 тенанта} other {# тенантів} }", + "delete-tenants-text": "Будьте обережні, після підтвердження всі вибрані тенанти будуть видалені, а всі пов'язані дані будуть безповоротно видалені.", + "title": "Назва", + "title-required": "Назва обов'язкова.", + "description": "Опис", + "details": "Деталі", + "events": "Події", + "copyId": "Копіювати ID тенанта", + "idCopiedMessage": "ID тенанта скопійовано в буфер обміну", + "select-tenant": "Вибрати тенанта", + "no-tenants-matching": "Не знайдено тенантів, що відповідають '{{entity}}'.", + "tenant-required": "Тенант обов'язковий", + "search": "Пошук тенантів", + "selected-tenants": "{ count, plural, =1 {1 тенант} other {# тенантів} } вибрано", + "isolated-tb-rule-engine": "Використовувати ізольовані черги Rule Engine ThingsBoard", + "isolated-tb-rule-engine-details": "Кожен тенант матиме окремі черги Rule Engine" + }, + "tenant-profile": { + "tenant-profile": "Профіль тенанта", + "tenant-profiles": "Профілі тенантів", + "add": "Додати профіль тенанта", + "add-profile": "Додати профіль", + "debug": "Налагодження", + "edit": "Редагувати профіль тенанта", + "tenant-profile-details": "Деталі профілю тенанта", + "no-tenant-profiles-text": "Профілі тенантів не знайдені", + "name-max-length": "Назва повинна бути меншою за 256 символів", + "search": "Пошук профілів тенантів", + "selected-tenant-profiles": "{ count, plural, =1 {1 профіль тенанта} other {# профілів тенантів} } вибрано", + "no-tenant-profiles-matching": "Не знайдено профілів тенантів, що відповідають '{{entity}}'.", + "tenant-profile-required": "Профіль тенанта обов'язковий", + "idCopiedMessage": "ID профілю тенанта скопійовано в буфер обміну", + "set-default": "Зробити профіль тенанта за замовчуванням", + "delete": "Видалити профіль тенанта", + "copyId": "Копіювати ID профілю тенанта", + "name": "Назва", + "name-required": "Назва обов'язкова.", + "data": "Дані профілю", + "profile-configuration": "Конфігурація профілю", + "description": "Опис", + "default": "За замовчуванням", + "delete-tenant-profile-title": "Ви впевнені, що хочете видалити профіль тенанта '{{tenantProfileName}}'?", + "delete-tenant-profile-text": "Будьте обережні, після підтвердження профіль тенанта і всі пов'язані дані будуть безповоротно видалені.", + "delete-tenant-profiles-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 профіль тенанта} other {# профілів тенантів} }?", + "delete-tenant-profiles-text": "Будьте обережні, після підтвердження всі вибрані профілі тенантів будуть видалені, а всі пов'язані дані будуть безповоротно видалені.", + "set-default-tenant-profile-title": "Ви впевнені, що хочете зробити профіль тенанта '{{tenantProfileName}}' профілем за замовчуванням?", + "set-default-tenant-profile-text": "Після підтвердження профіль тенанта буде позначений як профіль за замовчуванням і використовуватиметься для нових тенантів, для яких не вказаний профіль.", + "no-tenant-profiles-found": "Профілі тенантів не знайдені.", + "create-new-tenant-profile": "Створити новий!", + "create-tenant-profile": "Створити новий профіль тенанта", + "import": "Імпортувати профіль тенанта", + "export": "Експортувати профіль тенанта", + "export-failed-error": "Не вдалося експортувати профіль тенанта: {{error}}", + "tenant-profile-file": "Файл профілю тенанта", + "invalid-tenant-profile-file-error": "Не вдалося імпортувати профіль тенанта: Невірна структура даних профілю тенанта.", + "advanced-settings": "Розширені налаштування", + "entities": "Сутності", + "rule-engine": "Механізм правил", + "time-to-live": "Час життя", + "calculated-fields": "Розрахункові поля", + "alarms-and-notifications": "Тривоги та сповіщення", + "ota-files-in-bytes": "Файли", + "ws-title": "WS", + "unlimited": "(0 - необмежено)", + "maximum-devices": "Максимальна кількість пристроїв", + "maximum-devices-required": "Максимальна кількість пристроїв обов'язкова.", + "maximum-devices-range": "Максимальна кількість пристроїв не може бути від'ємною", + "maximum-assets": "Максимальна кількість активів", + "maximum-assets-required": "Максимальна кількість активів обов'язкова.", + "maximum-assets-range": "Максимальна кількість активів не може бути від'ємною", + "maximum-customers": "Максимальна кількість клієнтів", + "maximum-customers-required": "Максимальна кількість клієнтів обов'язкова.", + "maximum-customers-range": "Максимальна кількість клієнтів не може бути від'ємною", + "maximum-users": "Максимальна кількість користувачів", + "maximum-users-required": "Максимальна кількість користувачів обов'язкова.", + "maximum-users-range": "Максимальна кількість користувачів не може бути від'ємною", + "maximum-dashboards": "Максимальна кількість дашбордів", + "maximum-dashboards-required": "Максимальна кількість дашбордів обов'язкова.", + "maximum-dashboards-range": "Максимальна кількість дашбордів не може бути від'ємною", + "maximum-edges": "Максимальна кількість Edge", + "maximum-edges-required": "Максимальна кількість Edge обов'язкова.", + "maximum-edges-range": "Максимальна кількість Edge не може бути від'ємною", + "maximum-rule-chains": "Максимальна кількість ланцюгів правил", + "maximum-rule-chains-required": "Максимальна кількість ланцюгів правил обов'язкова.", + "maximum-rule-chains-range": "Максимальна кількість ланцюгів правил не може бути від'ємною", + "maximum-resources-sum-data-size": "Максимальний загальний розмір файлів ресурсів (байти)", + "maximum-resources-sum-data-size-required": "Максимальний загальний розмір файлів ресурсів обов'язковий.", + "maximum-resources-sum-data-size-range": "Максимальний загальний розмір файлів ресурсів не може бути від'ємним", + "maximum-resource-size": "Максимальний розмір файлу ресурсу (байти)", + "maximum-resource-size-required": "Максимальний розмір файлу ресурсу обов'язковий", + "maximum-resource-size-range": "Максимальний розмір файлу ресурсу не може бути від'ємним", + "maximum-ota-packages-sum-data-size": "Максимальний загальний розмір файлів OTA пакета (байти)", + "maximum-ota-package-sum-data-size-required": "Максимальний загальний розмір файлів OTA пакета обов'язковий.", + "maximum-ota-package-sum-data-size-range": "Максимальний загальний розмір файлів OTA пакета не може бути від'ємним", + "maximum-debug-duration-min": "Максимальна тривалість налагодження (хв)", + "maximum-debug-duration-min-range": "Максимальна тривалість налагодження не може бути від'ємною", + "rest-requests-for-tenant": "REST запити для тенанта", + "transport-tenant-telemetry-msg-rate-limit": "Обмеження на кількість повідомлень телеметрії тенанта", + "transport-tenant-telemetry-data-points-rate-limit": "Обмеження на кількість точок даних телеметрії тенанта", + "transport-device-msg-rate-limit": "Обмеження на кількість повідомлень пристрою", + "transport-device-telemetry-msg-rate-limit": "Обмеження на кількість повідомлень телеметрії пристрою", + "transport-device-telemetry-data-points-rate-limit": "Обмеження на кількість точок даних телеметрії пристрою", + "transport-gateway-msg-rate-limit": "Обмеження на кількість повідомлень шлюзу", + "transport-gateway-telemetry-msg-rate-limit": "Обмеження на кількість повідомлень телеметрії шлюзу", + "transport-gateway-telemetry-data-points-rate-limit": "Обмеження на кількість точок даних телеметрії шлюзу", + "transport-gateway-device-msg-rate-limit": "Обмеження на кількість повідомлень пристрою шлюзу", + "transport-gateway-device-telemetry-msg-rate-limit": "Обмеження на кількість повідомлень телеметрії пристрою шлюзу", + "transport-gateway-device-telemetry-data-points-rate-limit": "Обмеження на кількість точок даних телеметрії пристрою шлюзу", + "tenant-entity-export-rate-limit": "Обмеження на створення версії сутності", + "tenant-entity-import-rate-limit": "Обмеження на завантаження версії сутності", + "tenant-notification-request-rate-limit": "Обмеження на запити сповіщень", + "tenant-notification-requests-per-rule-rate-limit": "Обмеження на запити сповіщень на правило сповіщення", + "max-calculated-fields": "Максимальна кількість розрахункових полів на сутність", + "max-calculated-fields-range": "Максимальна кількість розрахункових полів на сутність не може бути від'ємною", + "max-calculated-fields-required": "Максимальна кількість розрахункових полів на сутність обов'язкова", + "max-data-points-per-rolling-arg": "Максимальна кількість точок даних у змінних аргументах", + "max-data-points-per-rolling-arg-range": "Максимальна кількість точок даних у змінних аргументах не може бути від'ємною", + "max-data-points-per-rolling-arg-required": "Максимальна кількість точок даних у змінних аргументах обов'язкова", + "max-arguments-per-cf": "Максимальна кількість аргументів для розрахункового поля", + "max-arguments-per-cf-range": "Максимальна кількість аргументів для розрахункового поля не може бути від'ємною", + "max-arguments-per-cf-required": "Максимальна кількість аргументів для розрахункового поля обов'язкова", + "max-related-level-per-argument": "Максимальний рівень зв'язку для аргументу 'Пов'язані сутності'", + "max-related-level-per-argument-range": "Максимальна кількість рівнів зв'язку для аргументу 'Пов'язані сутності' не може бути меншою за '1'", + "max-related-level-per-argument-required": "Максимальна кількість рівнів зв'язку для аргументу 'Пов'язані сутності' обов'язкова", + "min-allowed-scheduled-update-interval": "Мінімальний дозволений інтервал оновлення для аргументів 'Пов'язані сутності' (секунди)", + "min-allowed-scheduled-update-interval-range": "Мінімальний дозволений інтервал оновлення не може бути від'ємним", + "min-allowed-deduplication-interval": "Мінімальний дозволений інтервал дедуплікації (секунди)", + "min-allowed-deduplication-interval-range": "Мінімальний дозволений інтервал дедуплікації не може бути від'ємним", + "min-allowed-deduplication-interval-required": "Мінімальний дозволений інтервал дедуплікації обов'язковий", + "intermediate-aggregation-interval": "Інтервал проміжної агрегації (секунди)", + "intermediate-aggregation-interval-range": "Значення інтервалу проміжної агрегації не може бути меншим за '1'", + "intermediate-aggregation-interval-required": "Інтервал проміжної агрегації обов'язковий", + "reevaluation-check-interval": "Інтервал перевірки повторної оцінки (секунди)", + "reevaluation-check-interval-range": "Значення інтервалу перевірки повторної оцінки не може бути меншим за '1'", + "reevaluation-check-interval-required": "Інтервал перевірки повторної оцінки обов'язковий", + "alarms-reevaluation-interval": "Інтервал повторної оцінки тривог (секунди)", + "alarms-reevaluation-interval-range": "Значення інтервалу повторної оцінки тривог не може бути меншим за '1'", + "alarms-reevaluation-interval-required": "Інтервал повторної оцінки тривог обов'язковий", + "min-allowed-aggregation-interval": "Мінімальний дозволений інтервал агрегації (секунди)", + "min-allowed-aggregation-interval-range": "Мінімальний дозволений інтервал агрегації не може бути від'ємним", + "min-allowed-aggregation-interval-required": "Мінімальний дозволений інтервал агрегації обов'язковий", + "min-allowed-scheduled-update-interval-required": "Мінімальний дозволений інтервал оновлення обов'язковий", + "max-state-size": "Максимальний розмір стану в КБ", + "max-state-size-range": "Максимальний розмір стану в КБ не може бути від'ємним", + "max-state-size-required": "Максимальний розмір стану в КБ обов'язковий", + "max-value-argument-size": "Максимальний розмір аргументу одиничного значення в КБ", + "max-value-argument-size-range": "Максимальний розмір аргументу одиничного значення в КБ не може бути від'ємним", + "max-value-argument-size-required": "Максимальний розмір аргументу одиничного значення в КБ обов'язковий", + "max-transport-messages": "Максимальна кількість транспортних повідомлень", + "max-transport-messages-required": "Максимальна кількість транспортних повідомлень обов'язкова.", + "max-transport-messages-range": "Максимальна кількість транспортних повідомлень не може бути від'ємною", + "max-transport-data-points": "Максимальна кількість точок даних транспорту", + "max-transport-data-points-required": "Максимальна кількість точок даних транспорту обов'язкова.", + "max-transport-data-points-range": "Максимальна кількість точок даних транспорту не може бути від'ємною", + "max-r-e-executions": "Максимальна кількість виконань Механізму правил", + "max-r-e-executions-required": "Максимальна кількість виконань Механізму правил обов'язкова.", + "max-r-e-executions-range": "Максимальна кількість виконань Механізму правил не може бути від'ємною", + "max-j-s-executions": "Максимальна кількість виконань JavaScript", + "max-j-s-executions-required": "Максимальна кількість виконань JavaScript обов'язкова.", + "max-j-s-executions-range": "Максимальна кількість виконань JavaScript не може бути від'ємною", + "max-tbel-executions": "Максимальна кількість виконань TBEL", + "max-tbel-executions-required": "Максимальна кількість виконань TBEL обов'язкова.", + "max-tbel-executions-range": "Максимальна кількість виконань TBEL не може бути від'ємною", + "max-d-p-storage-days": "Максимальна кількість днів зберігання точок даних", + "max-d-p-storage-days-required": "Максимальна кількість днів зберігання точок даних обов'язкова.", + "max-d-p-storage-days-range": "Максимальна кількість днів зберігання точок даних не може бути від'ємною", + "default-storage-ttl-days": "TTL дні зберігання за замовчуванням", + "default-storage-ttl-days-required": "TTL дні зберігання за замовчуванням обов'язкові.", + "default-storage-ttl-days-range": "TTL дні зберігання за замовчуванням не можуть бути від'ємними", + "alarms-ttl-days": "TTL дні тривог", + "alarms-ttl-days-required": "TTL дні тривог обов'язкові", + "alarms-ttl-days-days-range": "TTL дні тривог не можуть бути від'ємними", + "rpc-ttl-days": "TTL дні RPC", + "rpc-ttl-days-required": "TTL дні RPC обов'язкові", + "rpc-ttl-days-days-range": "TTL дні RPC не можуть бути від'ємними", + "queue-stats-ttl-days": "TTL дні статистики черги", + "queue-stats-ttl-days-required": "TTL дні статистики черги обов'язкові", + "queue-stats-ttl-days-range": "TTL дні статистики черги не можуть бути від'ємними", + "rule-engine-exceptions-ttl-days": "TTL дні виключень Rule Engine", + "rule-engine-exceptions-ttl-days-required": "TTL дні виключень Rule Engine обов'язкові", + "rule-engine-exceptions-ttl-days-range": "TTL дні виключень Rule Engine не можуть бути від'ємними", + "max-rule-node-executions-per-message": "Максимальна кількість виконань вузла правила на повідомлення", + "max-rule-node-executions-per-message-required": "Максимальна кількість виконань вузла правила на повідомлення обов'язкова.", + "max-rule-node-executions-per-message-range": "Максимальна кількість виконань вузла правила на повідомлення не може бути від'ємною", + "max-emails": "Максимальна кількість відправлених електронних листів", + "max-emails-required": "Максимальна кількість відправлених електронних листів обов'язкова.", + "max-emails-range": "Максимальна кількість відправлених електронних листів не може бути від'ємною", + "sms-enabled": "SMS увімкнено", + "max-sms": "Максимальна кількість відправлених SMS", + "max-sms-required": "Максимальна кількість відправлених SMS обов'язкова.", + "max-sms-range": "Максимальна кількість відправлених SMS не може бути від'ємною", + "max-created-alarms": "Максимальна кількість створених тривог", + "max-created-alarms-required": "Максимальна кількість створених тривог обов'язкова.", + "max-created-alarms-range": "Максимальна кількість створених тривог не може бути від'ємною", + "no-queue": "Черга не налаштована", + "add-queue": "Додати чергу", + "queues-with-count": "Черги ({{count}})", + "tenant-rest-limits": "REST запити для тенанта", + "customer-rest-limits": "REST запити для клієнта", + "incorrect-pattern-for-rate-limits": "Формат повинен бути у вигляді кома-розділених пар ємності та періоду (в секундах) з двокрапкою між ними, наприклад 100:1,2000:60", + "too-small-value-zero": "Значення повинно бути більшим за 0", + "too-small-value-one": "Значення повинно бути більшим за 1", + "queue-size-is-limited-by-system-configuration": "Розмір черги також обмежений конфігурацією системи.", + "cassandra-write-tenant-core-limits-configuration": "Запити на запис в Cassandra через Rest API для тенанта", + "cassandra-read-tenant-core-limits-configuration": "Запити на читання телеметрії в Cassandra через Rest API та WS для тенанта", + "cassandra-write-tenant-rule-engine-limits-configuration": "Запити на запис в Cassandra для телеметрії Rule Engine", + "cassandra-read-tenant-rule-engine-limits-configuration": "Запити на читання телеметрії в Cassandra для Rule Engine", + "ws-limit-max-sessions-per-tenant": "Максимальна кількість сесій на тенанта", + "ws-limit-max-sessions-per-customer": "Максимальна кількість сесій на клієнта", + "ws-limit-max-sessions-per-regular-user": "Максимальна кількість сесій на звичайного користувача", + "ws-limit-max-sessions-per-public-user": "Максимальна кількість сесій на публічного користувача", + "ws-limit-queue-per-session": "Максимальний розмір черги повідомлень на сесію", + "ws-limit-max-subscriptions-per-tenant": "Максимальна кількість підписок на тенанта", + "ws-limit-max-subscriptions-per-customer": "Максимальна кількість підписок на клієнта", + "ws-limit-max-subscriptions-per-regular-user": "Максимальна кількість підписок на звичайного користувача", + "ws-limit-max-subscriptions-per-public-user": "Максимальна кількість підписок на публічного користувача", + "ws-limit-updates-per-session": "Кількість оновлень WS на сесію", + "relation-search-entity-limit": "Обмеження на пошук сутностей у зв'язках", + "relation-search-entity-limit-hint": "Обмежує кількість сутностей, що вирішуються на останньому рівні шляху зв'язку. Застосовується до аргументів 'Пов'язані сутності' та полів пропаганди.", + "relation-search-entity-limit-required": "Обмеження на пошук сутностей у зв'язках обов'язкове", + "relation-search-entity-limit-range": "Обмеження на пошук сутностей у зв'язках не може бути меншим за '1'", + "rate-limits": { + "add-limit": "Додати ліміт", + "and-also-less-than": "а також менше ніж", + "advanced-settings": "Розширені налаштування", + "edit-limit": "Редагувати ліміт", + "calculated-field-debug-event-rate-limit": "Події налагодження розрахункового поля", + "edit-calculated-field-debug-event-rate-limit": "Редагувати ліміти подій налагодження розрахункового поля", + "edit-transport-tenant-msg-title": "Редагувати ліміти транспортних повідомлень тенанта", + "edit-transport-tenant-telemetry-msg-title": "Редагувати ліміти транспортних повідомлень телеметрії тенанта", + "edit-transport-tenant-telemetry-data-points-title": "Редагувати ліміти транспортних точок даних телеметрії тенанта", + "edit-transport-device-msg-title": "Редагувати ліміти транспортних повідомлень пристрою", + "edit-transport-device-telemetry-msg-title": "Редагувати ліміти транспортних повідомлень телеметрії пристрою", + "edit-transport-device-telemetry-data-points-title": "Редагувати ліміти транспортних точок даних телеметрії пристрою", + "edit-transport-gateway-msg-title": "Редагувати ліміти транспортних повідомлень Gateway", + "edit-transport-gateway-telemetry-msg-title": "Редагувати ліміти транспортних повідомлень телеметрії Gateway", + "edit-transport-gateway-telemetry-data-points-title": "Редагувати ліміти транспортних точок даних телеметрії Gateway", + "edit-transport-gateway-device-msg-title": "Редагувати ліміти транспортних повідомлень пристрою Gateway", + "edit-transport-gateway-device-telemetry-msg-title": "Редагувати ліміти транспортних повідомлень телеметрії пристрою Gateway", + "edit-transport-gateway-device-telemetry-data-points-title": "Редагувати ліміти транспортних точок даних телеметрії пристрою Gateway", + "edit-tenant-rest-limits-title": "Редагувати ліміти запитів REST для тенанта", + "edit-customer-rest-limits-title": "Редагувати ліміти запитів REST для клієнта", + "edit-ws-limit-updates-per-session-title": "Редагувати ліміти оновлень WS на сеанс", + "edit-cassandra-write-tenant-core-limits-configuration": "Редагувати запити запису Cassandra для REST API", + "edit-cassandra-read-tenant-core-limits-configuration": "Редагувати запити читання Cassandra телеметрії для REST API та WS", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "Редагувати запити запису Cassandra телеметрії Механізму правил", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "Редагувати запити читання Cassandra телеметрії Механізму правил", + "edit-tenant-entity-export-rate-limit-title": "Редагувати ліміти створення версії сутності", + "edit-tenant-entity-import-rate-limit-title": "Редагувати ліміти завантаження версії сутності", + "edit-tenant-notification-request-rate-limit-title": "Редагувати ліміти запитів сповіщень", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "Редагувати ліміти запитів сповіщень на правило сповіщення", + "edit-edge-events-rate-limit": "Редагувати ліміти швидкості подій Edge", + "edit-edge-events-per-edge-rate-limit": "Редагувати ліміти швидкості подій Edge для кожного Edge", + "edge-events-rate-limit": "Edge події", + "edge-events-per-edge-rate-limit": "Edge події для кожного Edge", + "edit-edge-uplink-messages-rate-limit": "Редагувати ліміти висхідних повідомлень Edge", + "edit-edge-uplink-messages-per-edge-rate-limit": "Редагувати ліміти висхідних повідомлень Edge для кожного Edge", + "edge-uplink-messages-rate-limit": "Висхідні повідомлення Edge", + "edge-uplink-messages-per-edge-rate-limit": "Висхідні повідомлення Edge для кожного Edge", + "messages-per": "повідомлення на", + "not-set": "Не задано", + "number-of-messages": "Кількість повідомлень", + "number-of-messages-required": "Кількість повідомлень обов'язкова.", + "number-of-messages-min": "Мінімальне значення — 1.", + "preview": "Попередній перегляд", + "per-seconds": "На секунди", + "per-seconds-required": "Часовий ліміт обов'язковий.", + "per-seconds-min": "Мінімальне значення — 1.", + "per-seconds-duplicate": "Дубльований інтервал часу. Кожен інтервал часу має бути унікальним.", + "rate-limits": "Ліміти швидкості", + "remove-limit": "Видалити ліміт", + "transport-tenant-msg": "Транспортні повідомлення тенанта", + "transport-tenant-telemetry-msg": "Транспортні повідомлення телеметрії тенанта", + "transport-tenant-telemetry-data-points": "Транспортні точки даних телеметрії тенанта", + "transport-device-msg": "Транспортні повідомлення пристрою", + "transport-device-telemetry-msg": "Транспортні повідомлення телеметрії пристрою", + "transport-device-telemetry-data-points": "Транспортні точки даних телеметрії пристрою", + "transport-gateway-msg": "Транспортні повідомлення Gateway", + "transport-gateway-telemetry-msg": "Транспортні повідомлення телеметрії Gateway", + "transport-gateway-telemetry-data-points": "Транспортні точки даних телеметрії Gateway", + "transport-gateway-device-msg": "Транспортні повідомлення пристрою Gateway", + "transport-gateway-device-telemetry-msg": "Транспортні повідомлення телеметрії пристрою Gateway", + "transport-gateway-device-telemetry-data-points": "Транспортні точки даних телеметрії пристрою Gateway", + "sec": "сек" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, =1 {1 секунда} other {# секунд} }", + "minutes-interval": "{ minutes, plural, =1 {1 хвилина} other {# хвилин} }", + "hours-interval": "{ hours, plural, =1 {1 година} other {# годин} }", + "days-interval": "{ days, plural, =1 {1 день} other {# днів} }", + "days": "Дні", + "hours": "Години", + "minutes": "Хвилини", + "seconds": "Секунди", + "advanced": "Розширені", + "custom": "Користувацький", + "predefined": { + "yesterday": "Вчора", + "day-before-yesterday": "Позавчора", + "this-day-last-week": "Цей день минулого тижня", + "previous-week": "Попередній тиждень (Нд - Сб)", + "previous-week-iso": "Попередній тиждень (Пн - Нд)", + "previous-month": "Попередній місяць", + "previous-quarter": "Попередній квартал", + "previous-half-year": "Попереднє півріччя", + "previous-year": "Попередній рік", + "current-hour": "Поточна година", + "current-day": "Поточний день", + "current-day-so-far": "Поточний день (дотепер)", + "current-week": "Поточний тиждень (Нд - Сб)", + "current-week-iso": "Поточний тиждень (Пн - Нд)", + "current-week-so-far": "Поточний тиждень (дотепер) (Нд - Сб)", + "current-week-iso-so-far": "Поточний тиждень (дотепер) (Пн - Нд)", + "current-month": "Поточний місяць", + "current-month-so-far": "Поточний місяць (дотепер)", + "current-quarter": "Поточний квартал", + "current-quarter-so-far": "Поточний квартал (дотепер)", + "current-half-year": "Поточне півріччя", + "current-half-year-so-far": "Поточне півріччя (дотепер)", + "current-year": "Поточний рік", + "current-year-so-far": "Поточний рік (дотепер)" + }, + "type": { + "week": "Тиждень (Нд - Сб)", + "week-iso": "Тиждень (Пн - Нд)", + "month": "Місяць", + "quarter": "Квартал" + } + }, + "timeunit": { + "milliseconds": "Мілісекунди", + "seconds": "Секунди", + "minutes": "Хвилини", + "hours": "Години", + "days": "Дні" + }, + "timewindow": { + "timewindow": "Часовий інтервал", + "timewindow-settings": "Налаштування часового інтервалу", + "years": "{ years, plural, =1 {1 рік } other {# років } }", + "years-short": "{{ years }}р", + "months": "{ months, plural, =1 {1 місяць } other {# місяців } }", + "months-short": "{{ months }}м", + "weeks": "{ weeks, plural, =1 {1 тиждень } other {# тижнів } }", + "weeks-short": "{{ weeks }}т", + "days": "{ days, plural, =1 {1 день } other {# днів } }", + "days-short": "{{ days }}д", + "hours": "{ hours, plural, =0 { годин } =1 {1 година } other {# годин } }", + "hr": "{{ hr }} год", + "hr-short": "{{ hr }}г", + "minutes": "{ minutes, plural, =0 { хвилин } =1 {1 хвилина } other {# хвилин } }", + "min": "{{ min }} хв", + "min-short": "{{ min }}хв", + "seconds": "{ seconds, plural, =0 { секунд } =1 {1 секунда } other {# секунд } }", + "sec": "{{ sec }} сек", + "sec-short": "{{ sec }}с", + "short": { + "years": "{ years, plural, =1 {1 рік } other {# років } }", + "days": "{ days, plural, =1 {1 день } other {# днів } }", + "hours": "{ hours, plural, =1 {1 година } other {# годин } }", + "minutes": "{{minutes}} хв ", + "seconds": "{{seconds}} сек " + }, + "realtime": "В реальному часі", + "history": "Історія", + "last-prefix": "останні", + "period": "з {{ startTime }} по {{ endTime }}", + "edit": "Редагувати часовий інтервал", + "date-range": "Діапазон дат", + "for-all-time": "На весь час", + "last": "Останні", + "time-period": "Часовий період", + "hide": "Приховати", + "interval": "Інтервал", + "just-now": "Щойно", + "just-now-lower": "щойно", + "ago": "тому", + "style": "Стиль часового інтервалу", + "icon": "Іконка", + "icon-position": "Позиція іконки", + "icon-position-left": "Зліва", + "icon-position-right": "Справа", + "font": "Шрифт", + "color": "Колір", + "displayTypePrefix": "Відображати префікс для В реальному часі/Історія", + "preview": "Попередній перегляд", + "relative": "Відносний", + "range": "Діапазон", + "hide-timewindow-section": "Приховати секцію часового інтервалу для кінцевих користувачів", + "hide-last-interval": "Приховати останній інтервал для кінцевих користувачів", + "hide-relative-interval": "Приховати відносний інтервал для кінцевих користувачів", + "hide-fixed-interval": "Приховати фіксований інтервал для кінцевих користувачів", + "hide-aggregation": "Приховати агрегацію для кінцевих користувачів", + "hide-group-interval": "Приховати інтервал групування для кінцевих користувачів", + "hide-max-values": "Приховати максимальні значення для кінцевих користувачів", + "hide-timezone": "Приховати часовий пояс для кінцевих користувачів", + "disable-custom-interval": "Вимкнути вибір кастомного інтервалу", + "edit-aggregation-functions-list": "Редагувати список агрегаційних функцій", + "edit-aggregation-functions-list-hint": "Можна вказати список доступних опцій.", + "allowed-aggregation-functions": "Дозволені агрегаційні функції", + "edit-intervals-list": "Редагувати список інтервалів", + "allowed-agg-intervals": "Інтервали групування", + "default-agg-interval": "За замовчуванням інтервал групування", + "edit-intervals-list-hint": "Можна вказати список доступних опцій інтервалів.", + "edit-grouping-intervals-list-hint": "Можна налаштувати список інтервалів групування та інтервал групування за замовчуванням.", + "all": "Усі", + "save-current-settings-as-default": "Зберегти поточні налаштування як стандартний часовий інтервал", + "hide-option-from-end-users": "Приховати опцію від кінцевих користувачів" + }, + "tooltip": { + "trigger": "Тригер", + "trigger-point": "Точка", + "trigger-axis": "Вісь", + "label": "Маркування", + "value": "Значення", + "date": "Дата", + "show-date-time-interval": "Показати інтервал дати та часу", + "show-date-time-interval-hint": "Показати інтервал дати та часу відповідно до агрегації даних.", + "hide-zero-tooltip-values": "Приховати значення нуля", + "show-stack-total": "Показати загальну суму в режимі стеку", + "background-color": "Колір фону", + "background-blur": "Розмиття фону" + }, + "unit": { + "set-unit-conversion": "Встановити перетворення одиниць", + "unit-settings": { + "unit-settings": "Налаштування одиниць", + "source-unit": "Одиниця вимірювання джерела", + "source-unit-hint": "Це одиниця збереженого значення. Одиниця, з якої ви конвертуєте. Введіть символ, який використовує ваші вхідні дані (наприклад, м, км, фут, дюйм).", + "target-metric-unit": "Цільова метрична одиниця", + "target-metric-unit-hint": "Виберіть, в яку метричну одиницю (SI) ви хочете перетворити ваше значення (наприклад, см, мм, км).", + "target-imperial-unit": "Цільова імперська одиниця", + "target-imperial-unit-hint": "Виберіть, в яку імперську одиницю ви хочете перетворити ваше значення (наприклад, дюйм, фут, ярд).", + "target-hybrid-unit": "Цільова гібридна одиниця", + "target-hybrid-unit-hint": "Виберіть, в яку гібридну одиницю ви хочете перетворити ваше значення (наприклад, см, дюйм, км). Гібридні одиниці поєднують метричні або імперські одиниці.", + "enable-unit-conversion": "Увімкнути перетворення одиниць", + "enable-unit-conversion-hint": "Увімкніть для активації перетворення. Коли вимкнено, ваше значення передається без змін. Вимкнено, якщо в групі вимірювання є тільки одна одиниця (наприклад, світловий потік, AQI)." + }, + "unit-system": "Система одиниць", + "unit-system-type": { + "AUTO": "Авто", + "METRIC": "Метрична", + "IMPERIAL": "Імперська", + "HYBRID": "Гібридна" + }, + "measures": { + "absorbed-dose-rate": "Швидкість поглиненої дози", + "acceleration": "Прискорення", + "acidity": "Кислотність", + "air-quality-index": "Індекс якості повітря", + "amount-of-substance": "Кількість речовини", + "angle": "Кут", + "angular-acceleration": "Кутове прискорення", + "area": "Площа", + "area-density": "Щільність площі", + "capacitance": "Ємність", + "catalytic-activity": "Каталітична активність", + "catalytic-concentration": "Каталітична концентрація", + "charge": "Заряд", + "current-density": "Щільність струму", + "data-transfer-rate": "Швидкість передачі даних", + "density": "Щільність", + "digital": "Цифровий", + "dimension-ratio": "Відношення розмірів", + "dynamic-viscosity": "Динамічна в'язкість", + "earthquake-magnitude": "Магнітуда землетрусу", + "electric-charge-density": "Щільність електричного заряду", + "electric-current": "Електричний струм", + "electric-dipole-moment": "Електричний дипольний момент", + "electric-field-strength": "Сила електричного поля", + "electric-flux": "Електричний потік", + "electric-permittivity": "Електрична проникність", + "electric-polarizability": "Електрична поляризованість", + "electrical-conductance": "Електрична провідність", + "electrical-conductivity": "Електрична провідність", + "energy": "Енергія", + "energy-density": "Щільність енергії", + "force": "Сила", + "frequency": "Частота", + "fuel-efficiency": "Ефективність пального", + "heat-capacity": "Теплова ємність", + "illuminance": "Освітленість", + "inductance": "Індуктивність", + "kinematic-viscosity": "Кінематична в'язкість", + "length": "Довжина", + "light-exposure": "Освітлення", + "linear-charge-density": "Лінійна щільність заряду", + "logarithmic-ratio": "Логарифмічне відношення", + "luminous-efficacy": "Світлова ефективність", + "luminous-flux": "Світловий потік", + "luminous-intensity": "Світлова інтенсивність", + "magnetic-field-gradient": "Градієнт магнітного поля", + "magnetic-flux": "Магнітний потік", + "magnetic-flux-density": "Щільність магнітного потоку", + "magnetic-moment": "Магнітний момент", + "magnetic-permeability": "Магнітна проникність", + "mass": "Маса", + "mass-fraction": "Масова частка", + "molar-concentration": "Молярна концентрація", + "molar-energy": "Молярна енергія", + "molar-heat-capacity": "Молярна теплова ємність", + "molar-mass": "Молярна маса", + "number-concentration": "Числова концентрація", + "parts-per-million": "Частки на мільйон", + "power": "Потужність", + "power-density": "Щільність потужності", + "pressure": "Тиск", + "radiance": "Радіація", + "radiant-intensity": "Світлова інтенсивність", + "radiation-dose": "Доза радіації", + "radioactive-decay": "Радіоактивний розпад", + "radioactivity": "Радіоактивність", + "radioactivity-concentration": "Концентрація радіоактивності", + "reciprocal-length": "Обернена довжина", + "resistance": "Опір", + "reynolds-number": "Число Рейнольдса", + "signal-level": "Рівень сигналу", + "solid-angle": "Твердий кут", + "specific-energy": "Специфічна енергія", + "specific-heat-capacity": "Специфічна теплова ємність", + "specific-humidity": "Специфічна вологість", + "specific-volume": "Специфічний об'єм", + "speed": "Швидкість", + "surface-charge-density": "Щільність поверхневого заряду", + "surface-tension": "Поверхневий натяг", + "temperature": "Температура", + "thermal-conductivity": "Теплопровідність", + "time": "Час", + "torque": "Момент сили", + "turbidity": "Помутніння", + "voltage": "Напруга", + "volume": "Об'єм", + "volume-flow": "Об'ємний потік" + }, + "millimeter": "Міліметр", + "centimeter": "Сантиметр", + "decimeter": "Дециметр", + "angstrom": "Анґстром", + "nanometer": "Нанометр", + "micrometer": "Мікрометр", + "meter": "Метр", + "kilometer": "Кілометр", + "inch": "Дюйм", + "foot": "Фут", + "foot-us": "Фут (американський)", + "yard": "Ярд", + "mile": "Миля", + "nautical-mile": "Морська миля", + "astronomical-unit": "Астрономічна одиниця", + "reciprocal-metre": "Обернений метр", + "meter-per-meter": "Метр на метр", + "steradian": "Стеріадіан", + "thou": "Тисяча частка дюйма", + "barleycorn": "Ячмінне зерно", + "hand": "Долоня", + "chain": "Ланка", + "furlong": "Ферлонг", + "league": "Ліга", + "fathom": "Фатом", + "cable": "Кабель", + "link": "Ланка", + "rod": "Сталевий стрижень", + "nanogram": "Нанограм", + "microgram": "Мікрограм", + "milligram": "Міліграм", + "gram": "Грам", + "kilogram": "Кілограм", + "tonne": "Тонна", + "ounce": "Унція", + "pound": "Фунт", + "stone": "Стоун", + "hundredweight-count": "Стоунове навантаження", + "short-tons": "Коротка тонна", + "dalton": "Дальтон", + "grain": "Зерно", + "drachm": "Драхма", + "quarter": "Четвертина", + "slug": "Слідження", + "carat": "Карат", + "cubic-millimeter": "Кубічний міліметр", + "cubic-centimeter": "Кубічний сантиметр", + "cubic-meter": "Кубічний метр", + "cubic-kilometer": "Кубічний кілометр", + "microliter": "Мікролітр", + "milliliter": "Мілілітр", + "liter": "Літр", + "hectoliter": "Гектолітр", + "cubic-inch": "Кубічний дюйм", + "cubic-foot": "Кубічний фут", + "cubic-yard": "Кубічний ярд", + "fluid-ounce": "Рідка унція", + "fluid-ounce-per-second": "Рідка унція на секунду", + "pint": "Пінта", + "quart": "Кварт", + "gallon": "Галон", + "oil-barrels": "Насосна бочка", + "cubic-meter-per-kilogram": "Кубічний метр на кілограм", + "gill": "Жил", + "hogshead": "Гоксхед", + "teaspoon": "Чайна ложка", + "tablespoon": "Столова ложка", + "cup": "Чашка", + "celsius": "Цельсій", + "kelvin": "Кельвін", + "rankine": "Ранкін", + "fahrenheit": "Фаренгейт", + "percent": "Відсоток", + "meter-per-second": "Метр на секунду", + "kilometer-per-hour": "Кілометр на годину", + "foot-per-second": "Фут на секунду", + "foot-per-minute": "Фут на хвилину", + "mile-per-hour": "Миля на годину", + "knot": "Вузол", + "inch-per-second": "Дюйм на секунду", + "inch-per-hour": "Дюйм на годину", + "millimeters-per-minute": "Міліметри на хвилину", + "meter-per-minute": "Метр на хвилину", + "kilometer-per-hour-squared": "Кілометр на годину в квадраті", + "foot-per-second-squared": "Фут на секунду в квадраті", + "pascal": "Паскаль", + "kilopascal": "Кілопаскаль", + "megapascal": "Мегапаскаль", + "gigapascal": "Гігапаскаль", + "millibar": "Міллібар", + "bar": "Бар", + "kilobar": "Кілобар", + "newton": "Ньютон", + "newton-meter": "Ньютон-метр", + "foot-pounds": "Фут-фунти", + "inch-pounds": "Дюйм-фунти", + "newton-per-meter": "Ньютон на метр", + "atmospheres": "Атмосфери", + "pounds-per-square-inch": "Фунти на квадратний дюйм", + "kilopound-per-square-inch": "Кілопаунд на квадратний дюйм", + "torr": "Торр", + "inches-of-mercury": "Дюйми ртуті", + "pascal-per-square-meter": "Паскаль на квадратний метр", + "pound-per-square-inch": "Фунт на квадратний дюйм", + "newton-per-square-meter": "Ньютон на квадратний метр", + "kilogram-force-per-square-meter": "Кілограм-сила на квадратний метр", + "pascal-per-square-centimeter": "Паскаль на квадратний сантиметр", + "ton-force-per-square-inch": "Тонна-сила на квадратний дюйм", + "kilonewton-per-square-meter": "КілоНьютон на квадратний метр", + "newton-per-square-millimeter": "Ньютон на квадратний міліметр", + "microjoule": "Мікроджоуль", + "millijoule": "Міліджоуль", + "joule": "Джоуль", + "kilojoule": "Кілоджоуль", + "megajoule": "Мегаджоуль", + "gigajoule": "Гігаджоуль", + "watt-hour": "Ват-година", + "watt-minute": "Ват-кілька хвилин", + "kilowatt-hour": "Кіловат-година", + "milliwatt-hour": "Міліват-година", + "megawatt-hour": "Мегават-година", + "gigawatt-hour": "ГігаВат-година", + "electron-volts": "Електронвольти", + "joules-per-coulomb": "Джоулі на кулон", + "british-thermal-unit": "Британська теплова одиниця", + "thousand-british-thermal-unit": "Тисяча британських теплових одиниць", + "million-british-thermal-unit": "Мільйон британських теплових одиниць", + "foot-pound": "Фут-фунт", + "calorie": "Калорія", + "small-calorie": "Мала калорія", + "kilocalorie": "Кілокалорія", + "joule-per-kelvin": "Джоуль на кельвін", + "joule-per-kilogram-kelvin": "Джоуль на кілограм-кельвін", + "joule-per-kilogram": "Джоуль на кілограм", + "watt-per-meter-kelvin": "Ват на метр-кельвін", + "joule-per-cubic-meter": "Джоуль на кубічний метр", + "therm": "Терм", + "electric-dipole-moment": "Електричний дипольний момент", + "magnetic-dipole-moment": "Магнітний дипольний момент", + "debye": "Дебай", + "coulomb-per-square-meter-per-volt": "Кулон на квадратний метр на вольт", + "milliwatt": "Міліват", + "microwatt": "Мікроват", + "watt": "Ват", + "kilowatt": "Кіловат", + "megawatt": "Мегават", + "gigawatt": "ГігаВат", + "metric-horsepower": "Метична кінська сила", + "milliwatt-per-square-centimeter": "Мілівати на квадратний сантиметр", + "watt-per-square-centimeter": "Вати на квадратний сантиметр", + "kilowatt-per-square-centimeter": "Кіловати на квадратний сантиметр", + "milliwatt-per-square-meter": "Мілівати на квадратний метр", + "watt-per-square-meter": "Вати на квадратний метр", + "kilowatt-per-square-meter": "Кіловати на квадратний метр", + "watt-per-square-inch": "Вати на квадратний дюйм", + "kilowatt-per-square-inch": "Кіловати на квадратний дюйм", + "horsepower": "Кінська сила", + "btu-per-hour": "Британські теплові одиниці на годину", + "btu-per-second": "Британські теплові одиниці на секунду", + "btu-per-day": "Британські теплові одиниці на день", + "mbtu-per-hour": "Тисяча британських теплових одиниць на годину", + "mbtu-per-second": "Тисяча британських теплових одиниць на секунду", + "mbtu-per-day": "Тисяча британських теплових одиниць на день", + "mmbtu-per-hour": "Мільйон британських теплових одиниць на годину", + "mmbtu-per-second": "Мільйон британських теплових одиниць на секунду", + "mmbtu-per-day": "Мільйон британських теплових одиниць на день", + "foot-pound-per-second": "Фут-фунт на секунду", + "coulomb": "Кулон", + "millicoulomb": "Мілікулон", + "microcoulomb": "Мікрокулон", + "nanocoulomb": "Нанокулон", + "picocoulomb": "Пікохулон", + "coulomb-per-meter": "Кулон на метр", + "coulomb-per-cubic-meter": "Кулон на кубічний метр", + "coulomb-per-square-meter": "Кулон на квадратний метр", + "square-millimeter": "Квадратний міліметр", + "square-centimeter": "Квадратний сантиметр", + "square-meter": "Квадратний метр", + "hectare": "Гектар", + "square-kilometer": "Квадратний кілометр", + "square-inch": "Квадратний дюйм", + "square-foot": "Квадратний фут", + "square-yard": "Квадратний ярд", + "acre": "Акр", + "square-mile": "Квадратна миля", + "are": "Ар", + "barn": "Барн", + "circular-inch": "Круглий дюйм", + "milliampere-hour": "Міліампер-година", + "ampere-hours": "Ампер-години", + "kiloampere-hours": "Кілоампер-години", + "nanoampere": "Наноампер", + "picoampere": "Пікоампер", + "microampere": "Мікроампер", + "milliampere": "Міліампер", + "ampere": "Ампер", + "kiloampere": "Кілоампер", + "megaampere": "Мегаампер", + "gigaampere": "Гігаампер", + "microampere-per-square-centimeter": "Мікроампер на квадратний сантиметр", + "ampere-per-square-meter": "Ампер на квадратний метр", + "ampere-per-meter": "Ампер на метр", + "oersted": "Ерстед", + "bohr-magneton": "Магнетон Бора", + "ampere-meter-squared": "Ампер-метр в квадраті", + "nanovolt": "Нановольт", + "picovolt": "Піковольт", + "millivolt": "Мілівольт", + "microvolt": "Мікровольт", + "volt": "Вольт", + "kilovolt": "Кіловольт", + "megavolt": "Мегавольт", + "dbmV": "Децибел вольт", + "dbm": "Децибел-міліват", + "volt-meter": "Вольт-метр", + "kilovolt-meter": "Кіловольт-метр", + "megavolt-meter": "Мегавольт-метр", + "microvolt-meter": "Мікровольт-метр", + "millivolt-meter": "Мілівольт-метр", + "nanovolt-meter": "Нановольт-метр", + "ohm": "Ом", + "microohm": "Мікроом", + "milliohm": "Міліом", + "kilohm": "Кілоом", + "megohm": "Мегаом", + "gigohm": "Гігаом", + "millihertz": "Мілігерц", + "hertz": "Герц", + "kilohertz": "Кілогерц", + "megahertz": "Мегагерц", + "gigahertz": "Гігагерц", + "terahertz": "Терагерц", + "rpm": "Обертів на хвилину", + "candela-per-square-meter": "Кандела на квадратний метр", + "candela": "Кандела", + "lumen": "Люмен", + "lux": "Люкси", + "foot-candle": "Фут-кандела", + "lumen-per-square-meter": "Люмен на квадратний метр", + "lux-second": "Люкс-секунда", + "lumen-second": "Люмен-секунда", + "lumens-per-watt": "Люмен на ват", + "mole": "Моль", + "nanomole": "Наномоль", + "micromole": "Мікромоль", + "millimole": "Мілімоль", + "kilomole": "Кіломоль", + "mole-per-cubic-meter": "Моль на кубічний метр", + "rssi": "Індикатор сили отриманого сигналу", + "ppm": "Частки на мільйон", + "ppb": "Частки на мільярд", + "micrograms-per-cubic-meter": "Мікрограми на кубічний метр", + "aqi": "Індекс якості повітря", + "gram-per-cubic-meter": "Грам на кубічний метр", + "gram-per-kilogram": "Грам на кілограм", + "millimeters-per-second": "Міліметри на секунду", + "neper": "Непер", + "bel": "Бел", + "decibel": "Децибел", + "meters-per-second-squared": "Метри на секунду в квадраті", + "becquerel": "Беккерель", + "curie": "Кюрі", + "gray": "Грей", + "sievert": "Зіверт", + "roentgen": "Рентген", + "cps": "Лічильник на секунду", + "rad": "Рад", + "rem": "Рем", + "dps": "Розпади на секунду", + "rutherford": "Резерфорд", + "coulombs-per-kilogram": "Кулони на кілограм", + "becquerels-per-cubic-meter": "Беккерелі на кубічний метр", + "curies-per-liter": "Кюрі на літр", + "becquerels-per-second": "Беккерелі на секунду", + "curies-per-second": "Кюрі на секунду", + "gy-per-second": "Грей на секунду", + "watt-per-steradian": "Ват на стерадіан", + "watt-per-square-metre-steradian": "Ват на квадратний метр-стерадіан", + "ph-level": "Рівень pH", + "turbidity": "Помутніння", + "mg-per-liter": "Міліграми на літр", + "microsiemens-per-centimeter": "Мікросіменс на сантиметр", + "millisiemens-per-meter": "Мілісіменс на метр", + "siemens-per-meter": "Сіменс на метр", + "kilogram-per-cubic-meter": "Кілограм на кубічний метр", + "gram-per-cubic-centimeter": "Грам на кубічний сантиметр", + "kilogram-per-square-meter": "Кілограм на квадратний метр", + "milligram-per-milliliter": "Міліграм на мілілітр", + "milligram-per-cubic-meter": "Міліграм на кубічний метр", + "pound-per-cubic-foot": "Фунт на кубічний фут", + "ounces-per-cubic-inch": "Унції на кубічний дюйм", + "tons-per-cubic-yard": "Тонни на кубічний ярд", + "particle-density": "Щільність частинок", + "kilometers-per-liter": "Кілометри на літр", + "miles-per-gallon": "Милі на галон", + "liters-per-100-km": "Літри на 100 км", + "gallons-per-mile": "Галони на милю", + "liters-per-hour": "Літри на годину", + "gallons-per-hour": "Галони на годину", + "beats-per-minute": "Удари на хвилину", + "millimeters-of-mercury": "Міліметри ртуті", + "milligrams-per-deciliter": "Міліграми на декалітр", + "g-force": "Сила тяжіння", + "kilonewton": "Кілоньютон", + "kilogram-force": "Кілограм-сила", + "pound-force": "Фунт-сила", + "kilopound-force": "Кілопаунд-сила", + "dyne": "Дина", + "poundal": "Паундал", + "kip": "Кіп", + "gal": "Гал", + "gravity": "Гравітація", + "hectopascal": "Гектопаскаль", + "atmosphere": "Атмосфера", + "millibars": "Мілібари", + "inch-of-mercury": "Один дюйм ртуті", + "richter-scale": "Шкала Ріхтера", + "nanosecond": "Наносекунда", + "microsecond": "Мікросекунда", + "millisecond": "Мілісекунда", + "second": "Секунда", + "minute": "Хвилина", + "hour": "Година", + "day": "День", + "week": "Тиждень", + "month": "Місяць", + "year": "Рік", + "cubic-foot-per-minute": "Кубічний фут на хвилину", + "cubic-meters-per-hour": "Кубічні метри на годину", + "cubic-meters-per-second": "Кубічні метри на секунду", + "liter-per-second": "Літр на секунду", + "liter-per-minute": "Літр на хвилину", + "gallons-per-minute": "Галони на хвилину", + "cubic-foot-per-second": "Кубічний фут на секунду", + "milliliters-per-minute": "Мілілітри на хвилину", + "cubic-decimeter-per-second": "Кубічний дециметр на секунду", + "bit": "Біт", + "byte": "Байт", + "kilobyte": "Кілобайт", + "megabyte": "Мегабайт", + "gigabyte": "Гігабайт", + "terabyte": "Терабайт", + "petabyte": "Петабайт", + "exabyte": "Екзабайт", + "zettabyte": "Зетабайт", + "yottabyte": "Йоттабайт", + "bit-per-second": "Біт на секунду", + "kilobit-per-second": "Кілобіт на секунду", + "megabit-per-second": "Мегабіт на секунду", + "gigabit-per-second": "Гігабіт на секунду", + "terabit-per-second": "Терабіт на секунду", + "byte-per-second": "Байт на секунду", + "kilobyte-per-second": "Кілобайт на секунду", + "megabyte-per-second": "Мегабайт на секунду", + "gigabyte-per-second": "Гігабайт на секунду", + "degree": "Градус", + "radian": "Радіан", + "gradian": "Градіан", + "arcminute": "Кутова хвилина", + "arcsecond": "Кутова секунда", + "milliradian": "Мілірадіан", + "revolution": "Оберт", + "siemens": "Сіменс", + "millisiemens": "Мілісіменс", + "microsiemens": "Мікросіменс", + "kilosiemens": "Кілосіменс", + "megasiemens": "Мегасіменс", + "gigasiemens": "Гігасіменс", + "farad": "Фарад", + "millifarad": "Міліфарад", + "microfarad": "Мікрофарад", + "nanofarad": "Нанофарад", + "picofarad": "Пікофарад", + "kilofarad": "Кілофарад", + "megafarad": "Мегафарад", + "gigafarad": "Гігафарад", + "terfarad": "Терафарад", + "farad-per-meter": "Фарад на метр", + "tesla": "Тесла", + "gauss": "Гаусс", + "kilogauss": "Кілогаусс", + "millitesla": "Мілітесла", + "microtesla": "Мікротесла", + "nanotesla": "Нанотесла", + "kilotesla": "Кілотесла", + "megatesla": "Мегатесла", + "millitesla-square-meters": "Мілітесла на квадратний метр", + "gamma": "Гамма", + "lambda": "Лямбда", + "square-meter-per-second": "Квадратний метр на секунду", + "square-centimeter-per-second": "Квадратний сантиметр на секунду", + "stoke": "Сток", + "centistokes": "Центісток", + "square-foot-per-second": "Квадратний фут на секунду", + "square-inch-per-second": "Квадратний дюйм на секунду", + "pascal-second": "Паскаль-секунда", + "centipoise": "Центіпуаз", + "poise": "Пуаз", + "reynolds": "Рейнольдс", + "pound-per-foot-hour": "Фунт на фут-годину", + "newton-second-per-square-meter": "Ньютон-секунда на квадратний метр", + "dyne-second-per-square-centimeter": "Дина-секунда на квадратний сантиметр", + "kilogram-per-meter-second": "Кілограм на метр-секунду", + "tesla-square-meters": "Тесла на квадратний метр", + "maxwell": "Максвелл", + "tesla-per-meter": "Тесла на метр", + "gauss-per-centimeter": "Гаусс на сантиметр", + "weber": "Вебер", + "microweber": "Мікровебер", + "milliweber": "Мілівебер", + "gauss-square-centimeter": "Гаусс на квадратний сантиметр", + "kilogauss-square-centimeter": "Кілогаусс на квадратний сантиметр", + "henry": "Генрі", + "millihenry": "Мілігенрі", + "microhenry": "Мікрогенрі", + "nanohenry": "Наногенрі", + "henry-per-meter": "Генрі на метр", + "tesla-meter-per-ampere": "Тесла на метр на ампер", + "gauss-per-oersted": "Гаусс на ерстед", + "kilogram-per-mole": "Кілограм на моль", + "gram-per-mole": "Грам на моль", + "milligram-per-mole": "Міліграм на моль", + "joule-per-mole": "Джоуль на моль", + "joule-per-mole-kelvin": "Джоуль на моль-кельвін", + "millivolts-per-meter": "Мілівольти на метр", + "volts-per-meter": "Вольти на метр", + "kilovolts-per-meter": "Кіловольти на метр", + "radian-per-second": "Радіан на секунду", + "radian-per-second-squared": "Радіан на секунду в квадраті", + "revolutions-per-minute-per-second": "Кутове прискорення", + "deg-per-second": "Градуси на секунду", + "rotation-per-minute": "Обертів на хвилину", + "degrees-brix": "Градуси Брикса", + "katal": "Катал", + "katal-per-cubic-metre": "Катал на кубічний метр", + "paris-inch": "Паризький дюйм" + }, + "user": { + "user": "Користувач", + "users": "Користувачі", + "customer-users": "Користувачі клієнтів", + "tenant-admins": "Адміни тенантів", + "sys-admin": "Системний адміністратор", + "tenant-admin": "Адміністратор тенанта", + "customer": "Клієнт", + "anonymous": "Анонім", + "add": "Додати користувача", + "delete": "Видалити користувача", + "add-user-text": "Додати нового користувача", + "no-users-text": "Користувачі не знайдені", + "user-details": "Деталі користувача", + "delete-user-title": "Ви впевнені, що хочете видалити користувача '{{userEmail}}'?", + "delete-user-text": "Будьте обережні, після підтвердження користувач і всі пов'язані з ним дані стануть невідновлювальними.", + "delete-users-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 користувача} other {# користувачів} }?", + "delete-users-action-title": "Видалити { count, plural, =1 {1 користувача} other {# користувачів} }", + "delete-users-text": "Будьте обережні, після підтвердження всі вибрані користувачі будуть видалені, і всі пов'язані з ними дані стануть невідновлювальними.", + "activation-email-sent-message": "Лист активації було успішно надіслано!", + "resend-activation": "Переслати активацію", + "email": "Електронна пошта", + "email-required": "Електронна пошта обов'язкова.", + "invalid-email-format": "Невірний формат електронної пошти.", + "first-name": "Ім'я", + "last-name": "Прізвище", + "description": "Опис", + "default-dashboard": "Дефолтний дашборд", + "always-fullscreen": "Завжди на повний екран", + "select-user": "Вибрати користувача", + "no-users-matching": "Користувачі, що відповідають '{{entity}}', не знайдені.", + "user-required": "Користувач обов'язковий", + "activation-method": "Метод активації", + "display-activation-link": "Показати посилання для активації", + "send-activation-mail": "Надіслати лист активації", + "activation-link": "Посилання для активації користувача", + "activation-link-text": "Для активації користувача використовуйте наступне посилання для активації (термін дії закінчується через {{activationLinkTtl}}) :", + "copy-activation-link": "Копіювати посилання для активації", + "activation-link-copied-message": "Посилання для активації користувача було скопійовано в буфер обміну", + "details": "Деталі", + "login-as-tenant-admin": "Увійти як адміністратор тенанта", + "login-as-customer-user": "Увійти як користувач клієнта", + "search": "Шукати користувачів", + "selected-users": "{ count, plural, =1 {1 користувач} other {# користувачів} } вибрано", + "disable-account": "Вимкнути обліковий запис користувача", + "enable-account": "Увімкнути обліковий запис користувача", + "enable-account-message": "Обліковий запис користувача було успішно увімкнено!", + "disable-account-message": "Обліковий запис користувача було успішно вимкнено!", + "copyId": "Копіювати Id користувача", + "idCopiedMessage": "Id користувача було скопійовано в буфер обміну", + "user-list": "Список користувачів", + "user-list-required": "Список користувачів обов'язковий" + }, + "value": { + "type": "Тип значення", + "string": "Рядок", + "string-value": "Значення рядка", + "string-value-required": "Значення рядка обов'язкове", + "integer": "Ціле число", + "integer-value": "Значення цілого числа", + "integer-value-required": "Значення цілого числа обов'язкове", + "invalid-integer-value": "Невірне значення цілого числа", + "double": "Число з плаваючою комою", + "double-value": "Значення числа з плаваючою комою", + "double-value-required": "Значення числа з плаваючою комою обов'язкове", + "boolean": "Булевий тип", + "boolean-value": "Значення булевого типу", + "false": "Хибне", + "true": "Істинне", + "long": "Довге ціле число", + "json": "JSON", + "json-value": "Значення JSON", + "json-value-invalid": "Невірний формат значення JSON", + "json-value-required": "Значення JSON обов'язкове." + }, + "version-control": { + "version-control": "Керування версіями", + "management": "Управління керуванням версіями", + "search": "Шукати версії", + "branch": "Гілка", + "default": "За замовчуванням", + "select-branch": "Вибрати гілку", + "branch-required": "Гілка обов'язкова", + "create-entity-version": "Створити версію сутності", + "version-name": "Назва версії", + "version-name-required": "Назва версії обов'язкова", + "author": "Автор", + "export-relations": "Експортувати зв'язки", + "export-attributes": "Експортувати атрибути", + "export-credentials": "Експортувати облікові дані", + "export-calculated-fields": "Експортувати розрахункові поля \nта правила тривог", + "export-alarm-rules": "Експортувати правила тривог", + "entity-versions": "Версії сутностей", + "versions": "Версії", + "created-time": "Час створення", + "version-id": "ID версії", + "no-entity-versions-text": "Версії сутностей не знайдено", + "no-versions-text": "Версії не знайдено", + "copy-full-version-id": "Копіювати повний ID версії", + "create-version": "Створити версію", + "creating-version": "Створення версії... Будь ласка, зачекайте", + "nothing-to-commit": "Немає змін для коміту", + "restore-version": "Відновити версію", + "restore-entity-from-version": "Відновити сутність з версії '{{versionName}}'", + "restoring-entity-version": "Відновлення версії сутності... Будь ласка, зачекайте", + "load-relations": "Завантажити зв'язки", + "load-attributes": "Завантажити атрибути", + "load-credentials": "Завантажити облікові дані", + "load-calculated-fields": "Завантажити розрахункові поля та правила тривог", + "load-alarm-rules": "Завантажити правила тривог", + "compare-with-current": "Порівняти з поточною", + "diff-entity-with-version": "Порівняти з версією сутності '{{versionName}}'", + "previous-difference": "Попередня різниця", + "next-difference": "Наступна різниця", + "current": "Поточний", + "differences": "{ count, plural, =1 {1 різниця} other {# різниць} }", + "create-entities-version": "Створити версію сутностей", + "default-sync-strategy": "Стратегії синхронізації за замовчуванням", + "sync-strategy-merge": "Злиття", + "sync-strategy-overwrite": "Перезаписати", + "entities-to-export": "Сутності для експорту", + "entities-to-restore": "Сутності для відновлення", + "sync-strategy": "Стратегія синхронізації", + "all-entities": "Усі сутності", + "no-entities-to-export-prompt": "Будь ласка, вкажіть сутності для експорту", + "no-entities-to-restore-prompt": "Будь ласка, вкажіть сутності для відновлення", + "add-entity-type": "Додати тип сутності", + "remove-all": "Видалити все", + "version-create-result": "{ added, plural, =0 {Сутності не додано} =1 {1 сутність} other {# сутностей} } додано.
{ modified, plural, =0 {Сутності не змінено} =1 {1 сутність} other {# сутностей} } змінено.
{ removed, plural, =0 {Сутності не видалено} =1 {1 сутність} other {# сутностей} } видалено.", + "remove-other-entities": "Видалити інші сутності", + "find-existing-entity-by-name": "Знайти існуючу сутність за назвою", + "restore-entities-from-version": "Відновити сутності з версії '{{versionName}}'", + "restoring-entities-from-version": "Відновлення сутностей... Будь ласка, зачекайте", + "no-entities-restored": "Сутності не відновлено", + "created": "{{created}} створено", + "updated": "{{updated}} оновлено", + "deleted": "{{deleted}} видалено", + "remove-other-entities-confirm-text": "Будьте обережні! Це постійно видалить всі поточні сутності,
яких немає у версії, яку ви хочете відновити.

Будь ласка, введіть \"remove other entities\" для підтвердження.", + "auto-commit-to-branch": "автоматичний коміт в гілку {{ branch }}", + "default-create-entity-version-name": "{{entityName}} оновлення", + "sync-strategy-merge-hint": "Створює або оновлює вибрані сутності в репозиторії. Інші сутності в репозиторії не змінюються.", + "sync-strategy-overwrite-hint": "Створює або оновлює вибрані сутності в репозиторії. Інші сутності в репозиторії видаляються.", + "device-credentials-conflict": "Не вдалося завантажити пристрій з зовнішнім ID {{entityId}}
через те, що такі ж облікові дані вже є в базі даних для іншого пристрою.
Будь ласка, розгляньте можливість вимкнення параметра завантажити облікові дані у формі відновлення.", + "missing-referenced-entity": "Не вдалося завантажити {{sourceEntityTypeName}} з зовнішнім ID {{sourceEntityId}}
тому що вона посилається на відсутню {{targetEntityTypeName}} з ID {{targetEntityId}}.", + "runtime-failed": "Не вдалося: {{message}}", + "auto-commit-settings-read-only-hint": "Функція автоматичного коміту не працює з увімкненою опцією лише для читання в налаштуваннях репозиторію.", + "rollback-on-error": "Откат при помилці", + "rollback-on-error-hint": "Якщо вам потрібно відновити велику кількість сутностей, рекомендується вимкнути цю опцію для підвищення продуктивності.\n Зверніть увагу, якщо виникне помилка під час завантаження версії, вже збережені сутності (з відносинами, атрибутами тощо) залишаться без змін." + }, + "widget": { + "widget-library": "Бібліотека віджетів", + "widget-bundle": "Набір віджетів", + "all-bundles": "Всі набори", + "select-widgets-bundle": "Вибрати набір віджетів", + "widgets": "Віджети", + "all-widgets": "Усі віджети", + "widget": "Віджет", + "select-widget": "Вибрати віджет", + "no-widgets-matching": "Віджети, що відповідають '{{entity}}', не знайдено.", + "no-widgets": "Віджетів поки що немає", + "no-widgets-text": "Віджетів не знайдено", + "management": "Управління віджетами", + "editor": "Редактор віджетів", + "confirm-to-exit-editor-html": "У вас є незбережені налаштування віджета.
Ви впевнені, що хочете залишити цю сторінку?", + "widget-type-not-found": "Проблема з завантаженням конфігурації віджета.
Ймовірно, асоційований\n тип віджета був видалений.", + "widget-type-load-error": "Віджет не було завантажено через наступні помилки:", + "remove": "Видалити віджет", + "delete": "Видалити віджет", + "edit": "Редагувати віджет", + "remove-widget-title": "Ви впевнені, що хочете видалити віджет '{{widgetTitle}}'?", + "remove-widget-text": "Після підтвердження віджет і всі пов'язані з ним дані стануть невідновлювальними.", + "replace-reference-with-widget-copy": "Замінити посилання на копію віджета", + "timeseries": "Часовий ряд", + "search-data": "Шукати дані", + "no-data-found": "Дані не знайдені", + "latest": "Останні значення", + "rpc": "Керувати віджетом", + "alarm": "Віджет тривоги", + "static": "Статичний віджет", + "timeseries-short": "ряд", + "latest-short": "останні", + "rpc-short": "керування", + "alarm-short": "тривога", + "static-short": "статичний", + "select-widget-type": "Вибрати тип віджета", + "missing-widget-title-error": "Назва віджета повинна бути вказана!", + "widget-saved": "Віджет збережено", + "unable-to-save-widget-error": "Не вдалося зберегти віджет! Віджет містить помилки!", + "save": "Зберегти віджет", + "saveAs": "Зберегти віджет як", + "move": "Перемістити віджет", + "save-widget-as": "Зберегти віджет як", + "save-widget-as-text": "Будь ласка, введіть нову назву віджета", + "toggle-fullscreen": "Перемкнути на повний екран", + "run": "Запустити віджет", + "widget-title": "Назва віджета", + "title": "Назва", + "title-required": "Назва віджета обов'язкова.", + "title-max-length": "Назва повинна бути менше 256 символів", + "system": "Система", + "type": "Тип віджета", + "resources": "Ресурси", + "resource-url": "URL JavaScript/CSS", + "resource-is-extension": "Є розширенням", + "remove-resource": "Видалити ресурс", + "add-resource": "Додати ресурс", + "html": "HTML", + "tidy": "Упорядкувати", + "css": "CSS", + "settings-form": "Форма налаштувань", + "data-key-settings-form": "Форма налаштувань ключа даних", + "latest-data-key-settings-form": "Форма налаштувань ключа останніх даних", + "widget-settings": "Налаштування віджета", + "description": "Опис", + "tags": "Теги", + "image-preview": "Попередній перегляд зображення", + "settings-form-selector": "Вибір форми налаштувань", + "data-key-settings-form-selector": "Вибір форми налаштувань ключа даних", + "latest-data-key-settings-form-selector": "Вибір форми налаштувань ключа останніх даних", + "all": "Усі", + "actual": "Актуальний", + "scada": "SCADA символ", + "deprecated": "Застарілий", + "has-basic-mode": "Має базовий режим", + "basic-mode-form-selector": "Вибір форми базового режиму", + "basic-mode": "Базовий", + "advanced-mode": "Розширений", + "javascript": "Javascript", + "js": "JS", + "delete-widget-title": "Ви впевнені, що хочете видалити віджет '{{widgetName}}'?", + "delete-widget-text": "Після підтвердження віджет і всі пов'язані з ним дані стануть невідновлювальними.", + "delete-widgets-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 віджет} other {# віджетів} }?", + "delete-widgets-text": "Будьте обережні, після підтвердження всі вибрані віджети будуть видалені, і всі пов'язані з ними дані стануть невідновлювальними.", + "delete-widget": "Видалити віджет", + "widget-template-load-failed-error": "Не вдалося завантажити шаблон віджета!", + "details": "Деталі", + "widget-details": "Деталі віджета", + "add": "Додати віджет", + "add-existing-widget": "Додати існуючий віджет", + "add-new-widget": "Додати новий віджет", + "search-widgets": "Шукати віджети", + "selected-widgets": "{ count, plural, =1 {1 віджет} other {# віджетів} } вибрано", + "undo": "Скасувати зміни віджета", + "export": "Експортувати віджет", + "export-prompt": "Вбудувати зображення віджета та ресурси", + "export-widgets": "Експортувати віджети", + "export-widgets-prompt": "Вбудувати зображення віджетів та ресурси", + "import": "Імпортувати віджет", + "no-data": "Немає даних для відображення на віджеті", + "data-overflow": "Віджет показує {{count}} з {{total}} сутностей", + "alarm-data-overflow": "Віджет показує тривоги для {{allowedEntities}} (максимум дозволених) сутностей з {{totalEntities}} сутностей", + "search": "Шукати віджет", + "filter": "Тип фільтра віджета", + "loading-widgets": "Завантаження віджетів...", + "widget-template-error": "Невірний HTML шаблон віджета.", + "reference": "Посилання" + }, + "widget-action": { + "header-button": "Кнопка заголовка віджета", + "do-nothing": "Не робити нічого", + "open-dashboard-state": "Перейти до нового стану дашборду", + "update-dashboard-state": "Оновити поточний стан дашборду", + "open-dashboard": "Перейти до іншого дашборду", + "custom": "Користувацька дія", + "custom-pretty": "Користувацька дія (з HTML шаблоном)", + "custom-pretty-error-title": "Помилка користувацького діалогу", + "custom-pretty-template-error": "Невірний шаблон користувацького діалогу.", + "custom-pretty-controller-error": "Сталася помилка при оцінці функції користувацького діалогу.", + "mobile-action": "Мобільна дія", + "target-dashboard-state": "Цільовий стан дашборду", + "target-dashboard-state-required": "Цільовий стан дашборду обов'язковий", + "set-entity-from-widget": "Встановити сутність з віджета", + "target-dashboard": "Цільовий дашборд", + "select-target-dashboard": "Вибрати цільовий дашборд", + "target-dashboard-required": "Цільовий дашборд обов'язковий.", + "open-right-layout": "Відкрити правий макет дашборду (мобільний перегляд)", + "state-display-type": "Опція відображення стану дашборду", + "open-normal": "Нормальний", + "open-in-separate-dialog": "Відкрити в окремому діалозі", + "open-in-popover": "Відкрити в поповер", + "dialog-title": "Заголовок діалогу", + "dialog-hide-dashboard-toolbar": "Сховати панель інструментів дашборду в діалозі", + "dialog-width": "Ширина діалогу у відсотках від ширини вікна перегляду", + "dialog-height": "Висота діалогу у відсотках від висоти вікна перегляду", + "dialog-size-range-error": "Відсоткове значення розміру діалогу повинно бути в діапазоні від 1 до 100.", + "popover-preferred-placement": "Бажане розташування поповера", + "popover-placement-top": "Зверху", + "popover-placement-topLeft": "Зверху ліворуч", + "popover-placement-topRight": "Зверху праворуч", + "popover-placement-right": "Праворуч", + "popover-placement-rightTop": "Праворуч зверху", + "popover-placement-rightBottom": "Праворуч знизу", + "popover-placement-bottom": "Знизу", + "popover-placement-bottomLeft": "Знизу ліворуч", + "popover-placement-bottomRight": "Знизу праворуч", + "popover-placement-left": "Ліворуч", + "popover-placement-leftTop": "Ліворуч зверху", + "popover-placement-leftBottom": "Ліворуч знизу", + "popover-hide-on-click-outside": "Сховати поповер при кліку поза ним", + "popover-hide-dashboard-toolbar": "Сховати панель інструментів дашборду в поповері", + "popover-width": "Ширина поповера", + "popover-height": "Висота поповера", + "popover-style": "Стиль поповера", + "open-new-browser-tab": "Відкрити в новій вкладці браузера", + "open-URL": "Відкрити URL", + "URL": "URL", + "url-required": "URL обов'язковий.", + "mobile": { + "device-provision": "Налаштування пристрою", + "action-type": "Тип мобільної дії", + "select-action-type": "Вибрати тип мобільної дії", + "action-type-required": "Тип мобільної дії обов'язковий", + "take-picture-from-gallery": "Зробити фото з галереї", + "take-photo": "Зробити фото", + "map-direction": "Відкрити напрямок на карті", + "map-location": "Відкрити місцезнаходження на карті", + "scan-qr-code": "Сканувати QR код", + "make-phone-call": "Зробити телефонний дзвінок", + "get-location": "Отримати місцезнаходження телефону", + "take-screenshot": "Зробити скріншот", + "handle-provision-success-function": "Обробка функції успішного налаштування", + "get-location-function": "Функція отримання місцезнаходження", + "process-launch-result-function": "Функція обробки результату запуску", + "get-phone-number-function": "Функція отримання номера телефону", + "process-image-function": "Функція обробки зображення", + "process-qr-code-function": "Функція обробки QR коду", + "process-location-function": "Функція обробки місцезнаходження", + "handle-empty-result-function": "Функція обробки порожнього результату", + "handle-error-function": "Функція обробки помилки", + "handle-non-mobile-fallback-function": "Функція обробки резервної ситуації для не мобільних пристроїв", + "save-to-gallery": "Зберегти в 'Галерею зображень'", + "provision-type": "Тип налаштування", + "auto": "Авто", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" + }, + "custom-action-function": "Функція користувацької дії", + "custom-pretty-function": "Користувацька дія (з HTML шаблоном) функція", + "map-item-type": "Тип елемента карти", + "map-item": { + "marker": "Маркер", + "polygon": "Полігон", + "rectangle": "Прямокутник", + "circle": "Коло", + "polyline": "Лінія" + }, + "place-map-item": "Розмістити елемент карти", + "map-item-tooltip": { + "customize-map-item-tooltips": "Налаштувати підказки для елементів карти", + "place-marker": "Розмістити маркер", + "start-draw-rectangle": "Почати малювати прямокутник", + "finish-draw-rectangle": "Завершити малювання прямокутника", + "start-draw-polygon": "Почати малювати полігон", + "continue-draw-polygon": "Продовжити малювати полігон", + "finish-draw-polygon": "Завершити малювання полігону", + "start-draw-circle": "Почати малювати коло", + "finish-draw-circle": "Завершити малювання кола", + "start-draw-polyline": "Почати малювати лінію", + "finish-draw-polyline": "Завершити малювання лінії" + } + }, + "widgets-bundle": { + "current": "Поточний набір", + "widgets-bundles": "Набори віджетів", + "widgets-bundle-widgets": "Віджети набору віджетів", + "add": "Додати набір віджетів", + "delete": "Видалити набір віджетів", + "title": "Назва", + "title-required": "Назва обов'язкова.", + "title-max-length": "Назва повинна бути меншою за 256 символів", + "description": "Опис", + "image-preview": "Попередній перегляд зображення", + "scada": "Набір віджетів SCADA", + "order": "Порядок", + "add-widgets-bundle-text": "Додати новий набір віджетів", + "no-widgets-bundles-text": "Набори віджетів не знайдені", + "empty": "Набір віджетів порожній", + "details": "Деталі", + "widgets-bundle-details": "Деталі набору віджетів", + "delete-widgets-bundle-title": "Ви впевнені, що хочете видалити набір віджетів '{{widgetsBundleTitle}}'?", + "delete-widgets-bundle-text": "Будьте обережні, після підтвердження набір віджетів і всі пов'язані з ним дані стануть невідновлювальними.", + "delete-widgets-bundles-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 набір віджетів} other {# набори віджетів} }?", + "delete-widgets-bundles-action-title": "Видалити { count, plural, =1 {1 набір віджетів} other {# набори віджетів} }", + "delete-widgets-bundles-text": "Будьте обережні, після підтвердження всі вибрані набори віджетів будуть видалені, і всі пов'язані з ними дані стануть невідновлювальними.", + "no-widgets-bundles-matching": "Набори віджетів, що відповідають '{{widgetsBundle}}', не знайдено.", + "widgets-bundle-required": "Набір віджетів обов'язковий.", + "system": "Система", + "import": "Імпортувати набір віджетів", + "export": "Експортувати набір віджетів", + "export-widgets-bundle-widgets-prompt": "Включити віджети набору в експортовані дані (в іншому випадку будуть експортовані тільки посилання на віджети FQN)", + "export-failed-error": "Не вдалося експортувати набір віджетів: {{error}}", + "create-new-widgets-bundle": "Створити новий набір віджетів", + "widgets-bundle-file": "Файл набору віджетів", + "invalid-widgets-bundle-file-error": "Не вдалося імпортувати набір віджетів: Невірна структура даних набору віджетів.", + "search": "Шукати набори віджетів", + "selected-widgets-bundles": "{ count, plural, =1 {1 набір віджетів} other {# набори віджетів} } вибрано", + "open-widgets-bundle": "Відкрити набір віджетів", + "loading-widgets-bundles": "Завантаження наборів віджетів...", + "create-new": "Створити новий набір віджетів" + }, + "widget-config": { + "data": "Дані", + "settings": "Налаштування", + "advanced": "Розширені", + "appearance": "Зовнішній вигляд", + "widget-card": "Картка віджета", + "mobile": "Мобільний", + "title": "Назва", + "title-tooltip": "Підказка для назви", + "general-settings": "Загальні налаштування", + "display-title": "Відображати назву віджета", + "card-title": "Назва картки", + "drop-shadow": "Тінь", + "enable-fullscreen": "Увімкнути повний екран", + "background-color": "Колір фону", + "text-color": "Колір тексту", + "border-radius": "Радіус обведення", + "padding": "Відступи", + "margin": "Поля", + "widget-style": "Стиль віджета", + "widget-css": "CSS віджета", + "title-style": "Стиль назви", + "mobile-mode-settings": "Налаштування мобільного режиму", + "order": "Порядок", + "height": "Висота", + "mobile-hide": "Сховати віджет у мобільному режимі", + "desktop-hide": "Сховати віджет на десктопі", + "units": "Спеціальний символ для відображення поруч з значенням", + "units-by-default": "Одиниці за замовчуванням", + "decimals": "Кількість знаків після коми", + "decimals-by-default": "Десяткові розряди за замовчуванням", + "default-data-key-parameter-hint": "Цей параметр застосовується до всіх значень віджета, якщо не буде переозначено налаштуванням ключа даних", + "units-short": "Одиниці", + "decimals-short": "Десяткові", + "decimals-suffix": "десяткових", + "digits-suffix": "цифр", + "timewindow": "Часовий інтервал", + "use-dashboard-timewindow": "Використовувати часовий інтервал дашборду", + "use-widget-timewindow": "Використовувати часовий інтервал віджета", + "display-timewindow": "Відображати часовий інтервал", + "legend": "Легенда", + "display-legend": "Відображати легенду", + "datasources": "Джерела даних", + "datasource": "Джерело даних", + "maximum-datasources": "Максимум { count, plural, =1 {дозволено 1 джерело даних.} other {дозволено # джерел даних} }", + "timeseries-key-error": "Повинно бути вказано хоча б одне джерело даних для часового ряду", + "datasource-type": "Тип", + "datasource-parameters": "Параметри", + "remove-datasource": "Видалити джерело даних", + "add-datasource": "Додати джерело даних", + "target-device": "Цільовий пристрій", + "alarm-source": "Джерело тривоги", + "actions": "Дії", + "action": "Дія", + "add-action": "Додати дію", + "search-actions": "Шукати дії", + "no-actions-text": "Дії не знайдені", + "action-source": "Джерело дії", + "select-action-source": "Вибрати джерело дії", + "action-source-required": "Джерело дії обов'язкове.", + "column-index": "Індекс стовпця", + "select-column-index": "Вибрати індекс стовпця", + "column-index-required": "Індекс стовпця обов'язковий.", + "not-set": "Не встановлено", + "action-name": "Назва дії", + "action-name-required": "Назва дії обов'язкова.", + "action-name-not-unique": "Інша дія з такою ж назвою вже існує.\nНазва дії повинна бути унікальною в межах одного джерела дії.", + "action-icon": "Іконка", + "header-button": { + "button-settings": "Налаштування кнопки", + "button-type": "Тип кнопки", + "button-type-basic": "Базова", + "button-type-raised": "Піднята", + "button-type-stroked": "Окреслена", + "button-type-flat": "Плоска", + "button-type-icon": "Іконка", + "button-type-mini-fab": "FAB", + "colors": "Кольори", + "color": "Колір", + "background": "Фон", + "border": "Окреслення", + "advanced-button-style": "Розширений стиль кнопки", + "button-style": "Стиль кнопки" + }, + "show-hide-action-using-function": "Показати/сховати дію за допомогою функції", + "show-action-function": "Функція показу дії", + "action-type": "Тип дії", + "action-type-required": "Тип дії обов'язковий.", + "edit-action": "Редагувати дію", + "delete-action": "Видалити дію", + "delete-action-title": "Видалити дію віджета", + "delete-action-text": "Ви впевнені, що хочете видалити дію віджета з назвою '{{actionName}}'?", + "title-icon": "Іконка заголовка", + "display-icon": "Відображати іконку заголовка", + "card-icon": "Іконка картки", + "icon": "Іконка", + "icon-color": "Колір іконки", + "icon-size": "Розмір іконки", + "advanced-settings": "Розширені налаштування", + "data-settings": "Налаштування даних", + "limits": "Обмеження", + "no-data-display-message": "Альтернативне повідомлення \"Немає даних для відображення\"", + "data-page-size": "Максимальна кількість сутностей на джерело даних", + "settings-component-not-found": "Компонент форми налаштувань не знайдено для селектора '{{selector}}'", + "preview": "Попередній перегляд", + "set": "Встановити", + "set-message": "Встановити повідомлення", + "advanced-title-style": "Розширений стиль заголовка", + "card-style": "Стиль картки", + "text": "Текст", + "background": "Фон", + "advanced-widget-style": "Розширений стиль віджета", + "card-buttons": "Кнопки картки", + "show-card-buttons": "Показати кнопки картки", + "card-border-radius": "Радіус обведення картки", + "card-padding": "Відступи картки", + "card-appearance": "Зовнішній вигляд картки", + "color": "Колір", + "tooltip": "Підказка", + "units-required": "Одиниці обов'язкові.", + "list-layout": "Список макетів", + "layout": "Макет", + "resize-options": "Опції зміни розміру", + "resizable": "Можна змінювати розмір", + "preserve-aspect-ratio": "Зберігати співвідношення сторін" + }, + "widget-type": { + "import": "Імпортувати тип віджета", + "export": "Експортувати тип віджета", + "export-failed-error": "Не вдалося експортувати віджет: {{error}}", + "widget-file": "Файл віджета", + "invalid-widget-file-error": "Не вдалося імпортувати віджет: Невірна структура даних віджета." + }, + "markdown": { + "edit": "Редагувати", + "preview": "Попередній перегляд", + "copy-code": "Натисніть для копіювання", + "copied": "Скопійовано!" + }, + "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "Налаштування залежить від віджета QR-коду мобільного додатку в основних налаштуваннях платформи", + "get-it-on-google-play": "Отримати в Google Play", + "download-on-the-app-store": "Завантажити в App Store" + }, + "action-button": { + "behavior": "Поводження", + "on-click": "При натисканні", + "on-click-hint": "Дія, що виконується при натисканні на кнопку", + "first-button-click": "Натискання першої кнопки", + "first-button-click-hint": "Дія при натисканні на першу кнопку.", + "second-button-click": "Натискання другої кнопки", + "second-button-click-hint": "Дія при натисканні на другу кнопку.", + "button-click-hint": "Дія при натисканні на віджет." + }, + "command-button": { + "behavior": "Поводження", + "on-click": "При натисканні", + "on-click-hint": "Дія, що виконується при натисканні на кнопку." + }, + "power-button": { + "behavior": "Поводження", + "power-on": "Живлення 'Вкл.'", + "power-on-hint": "Дія, що виконується для ввімкнення компонента.", + "power-off": "Живлення 'Викл.'", + "power-off-hint": "Дія, що виконується для вимкнення компонента.", + "on-label": "Вкл.", + "off-label": "Викл.", + "layout": "Макет", + "layout-default": "За замовчуванням", + "layout-simplified": "Спрощений", + "layout-outlined": "Обведений", + "layout-default-volume": "За замовчуванням.Об'єм", + "layout-simplified-volume": "Спрощений.Об'єм", + "layout-outlined-volume": "Обведений.Об'єм", + "layout-default-icon": "За замовчуванням.Іконка", + "layout-simplified-icon": "Спрощений.Іконка", + "layout-outlined-icon": "Обведений.Іконка", + "main": "Основний", + "background": "Фон", + "button-icon-on": "Іконка кнопки 'Вкл.'", + "button-icon-off": "Іконка кнопки 'Викл.'", + "power-on-colors": "Кольори 'Вкл.'", + "power-off-colors": "Кольори 'Викл.'", + "disabled-colors": "Кольори для вимкнених станів", + "button": "Кнопка" + }, + "toggle-button": { + "behavior": "Поводження", + "checked": "Відмічено", + "unchecked": "Не відмічено", + "check": "Відмітити", + "check-hint": "Дія, що виконується для позначення компонента.", + "uncheck": "Зняти відмітку", + "uncheck-hint": "Дія, що виконується для зняття відмітки з компонента.", + "auto-scale": "Автоматичне масштабування", + "horizontal-fill": "Горизонтальне заповнення", + "vertical-fill": "Вертикальне заповнення", + "button-appearance": "Зовнішній вигляд кнопки" + }, + "segmented-button": { + "layout": "Макет", + "layout-squared": "Квадратний", + "layout-rounded": "Закруглений", + "card-border": "Кордон картки", + "button-appearance": "Зовнішній вигляд кнопки", + "first": "Перша", + "second": "Друга", + "color-styles": "Стилі кольорів", + "selected": "Вибрано", + "unselected": "Не вибрано" + }, + "button": { + "layout": "Макет", + "outlined": "Обведений", + "filled": "Заповнений", + "underlined": "Підкреслений", + "basic": "Базовий", + "auto-scale": "Автоматичне масштабування", + "label": "Маркування", + "icon": "Іконка", + "border-radius": "Радіус кордону", + "color-palette": "Палітра кольорів", + "main": "Основний", + "background": "Фон", + "border": "Кордон", + "custom-styles": "Користувацькі стилі", + "clear-style": "Очистити стиль", + "shadow": "Тінь", + "enabled": "Увімкнено", + "disabled": "Вимкнено", + "preview": "Попередній перегляд", + "copy-style-from": "Копіювати стиль з" + }, + "value-stepper": { + "behavior": "Поводження", + "simplified": "Спрощений", + "filled": "Заповнений", + "outlined": "Обведений", + "volume": "Об'єм", + "initial-state": "Початковий стан", + "initial-state-hint": "Дія для отримання початкового значення.", + "disabled-state": "Вимкнений стан", + "disabled-state-hint": "Налаштуйте умови, за яких компонент буде вимкнений.", + "right-button-click": "Натискання правої кнопки", + "right-button-click-hint": "Дія при натисканні правою кнопкою.", + "left-button-click": "Натискання лівої кнопки", + "left-button-click-hint": "Дія при натисканні лівою кнопкою.", + "auto-scale": "Автоматичне масштабування", + "value-range": "Діапазон значень", + "min-range": "Мінімум", + "max-range": "Максимум", + "value-increment-decrement-step": "Крок збільшення/зменшення значення", + "value": "Значення", + "value-box-background": "Фон поля значення", + "border": "Кордон", + "button-appearance": "Зовнішній вигляд кнопки", + "left": "Ліворуч", + "right": "Праворуч", + "left-button": "Ліва кнопка", + "right-button": "Права кнопка", + "icon": "Іконка", + "color-palette": "Палітра кольорів", + "main": "Основний", + "background": "Фон", + "button-icon-on": "Іконка кнопки 'Вкл.'", + "button-on-colors": "Кольори 'Вкл.'", + "disabled-colors": "Кольори для вимкнених станів" + }, + "button-state": { + "activated-state": "Активований стан", + "activated-state-hint": "Налаштуйте умови, за яких кнопка буде активною.", + "disabled-state": "Вимкнений стан", + "disabled-state-hint": "Налаштуйте умови, за яких кнопка буде вимкнена.", + "selected-state": "Вибрана кнопка", + "selected-state-hint": "Налаштуйте умови, за яких кнопка буде вибрана.", + "enabled": "Увімкнено", + "hovered": "Над панеллю", + "pressed": "Натиснута", + "activated": "Активована", + "disabled": "Вимкнено", + "initial": "Перша кнопка", + "first": "Перша", + "second": "Друга" + }, + "background": { + "background": "Фон", + "background-settings": "Налаштування фону", + "background-type-image": "Зображення", + "background-type-color": "Колір", + "image-url": "URL зображення", + "overlay": "Накладання", + "enable-overlay": "Увімкнути накладання", + "blur": "Розмиття", + "preview": "Попередній перегляд" + }, + "bar-chart": { + "bar-appearance": "Зовнішній вигляд стовпця", + "label-on-bar": "Маркування на стовпці", + "value-on-bar": "Значення на стовпці", + "bar-chart-style": "Стиль стовпцевої діаграми", + "bar-axis": "Ось стовпців" + }, + "polar-area-chart": { + "polar-axis": "Полярна вісь", + "start-angle": "Початковий кут", + "polar-area-chart-style": "Стиль діаграми полярної області" + }, + "battery-level": { + "layout": "Макет", + "layout-vertical-solid": "Вертикальний. Твердий", + "layout-horizontal-solid": "Горизонтальний. Твердий", + "layout-vertical-divided": "Вертикальний. Поділений", + "layout-horizontal-divided": "Горизонтальний. Поділений", + "icon": "Іконка", + "value": "Значення", + "auto-scale": "Автоматичне масштабування", + "battery-level-color": "Колір рівня заряду батареї", + "battery-shape-color": "Колір форми батареї", + "battery-level-card-style": "Стиль картки рівня заряду батареї", + "sections-count": "Кількість секцій" + }, + "signal-strength": { + "value": "Значення", + "last-update": "Останнє оновлення", + "no-signal": "Немає сигналу", + "layout": "Макет", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "Стовпчик мобільного сигналу", + "icon": "Іконка", + "date": "Дата", + "active-bars-color": "Колір активних сигналів", + "inactive-bars-color": "Колір неактивних сигналів", + "signal-strength-card-style": "Стиль картки сили сигналу", + "no-signal-rssi-value": "\"Немає сигналу\" значення rssi" + }, + "status-widget": { + "behavior": "Поводження", + "layout": "Макет", + "layout-default": "За замовчуванням", + "layout-center": "Центр", + "layout-icon": "Іконка", + "on": "Увімкнено", + "off": "Вимкнено", + "label": "Маркування", + "status": "Статус", + "icon": "Іконка", + "color-palette": "Палітра кольорів", + "disabled-color-palette": "Палітра кольорів для вимкнених станів", + "primary": "Основний", + "primary-color-hint": "Колір іконки та маркування", + "secondary": "Додатковий", + "secondary-color-hint": "Колір статусу", + "background": "Фон" + }, + "chart": { + "common-settings": "Загальні налаштування", + "enable-stacking-mode": "Увімкнути режим стекування", + "selection": "Вибір діапазону часу", + "enable-selection-mode": "Увімкнути режим вибору", + "line-shadow-size": "Розмір тіні лінії", + "display-smooth-lines": "Відображати гладкі (криві) лінії", + "default-bar-width": "Стандартна ширина стовпця для неагрегованих даних (мілісекунди)", + "bar-alignment": "Вирівнювання стовпця", + "bar-alignment-left": "Зліва", + "bar-alignment-right": "Праворуч", + "bar-alignment-center": "По центру", + "default-font": "Стандартний шрифт", + "default-font-size": "Розмір стандартного шрифту", + "default-font-color": "Колір стандартного шрифту", + "thresholds-line-width": "Стандартна товщина лінії для всіх порогів", + "tooltip-settings": "Налаштування підказки", + "tooltip": "Підказка", + "show-tooltip": "Показати підказку", + "hover-individual-points": "Навести на окремі точки", + "show-cumulative-values": "Показати кумулятивні значення в режимі стекування", + "hide-zero-false-values": "Сховати нульові/хибні значення з підказки", + "tooltip-value-format-function": "Функція формату значення підказки", + "grid-settings": "Налаштування сітки", + "show-vertical-lines": "Показати вертикальні лінії", + "show-horizontal-lines": "Показати горизонтальні лінії", + "grid-outline-border-width": "Товщина лінії обведення/кордону сітки (px)", + "primary-color": "Основний колір", + "background-color": "Колір фону", + "ticks-color": "Колір поділок", + "xaxis-settings": "Налаштування осі X", + "axis-title": "Назва осі", + "xaxis-tick-labels-settings": "Налаштування маркування поділок осі X", + "show-tick-labels": "Показати маркування поділок осі", + "yaxis-settings": "Налаштування осі Y", + "min-scale-value": "Мінімальне значення на шкалі", + "max-scale-value": "Максимальне значення на шкалі", + "yaxis-tick-labels-settings": "Налаштування маркування поділок осі Y", + "tick-step-size": "Розмір кроку між поділками", + "number-of-decimals": "Кількість десяткових знаків для відображення", + "ticks-formatter-function": "Функція форматування поділок", + "comparison-settings": "Налаштування порівняння", + "enable-comparison": "Увімкнути порівняння", + "time-for-comparison": "Період порівняння", + "time-for-comparison-previous-interval": "Попередній інтервал (за замовчуванням)", + "time-for-comparison-days": "День тому", + "time-for-comparison-weeks": "Тиждень тому", + "time-for-comparison-months": "Місяць тому", + "time-for-comparison-years": "Рік тому", + "time-for-comparison-custom-interval": "Користувацький інтервал", + "custom-interval-value": "Значення користувацького інтервалу (мс)", + "comparison-x-axis-settings": "Налаштування осі X порівняння", + "axis-position": "Позиція осі", + "axis-position-top": "Верх (за замовчуванням)", + "axis-position-bottom": "Низ", + "custom-legend-settings": "Налаштування кастомної легенди", + "enable-custom-legend": "Увімкнути користувацьку легенду (це дозволить використовувати значення атрибутів/часових рядів в ключах маркування)", + "key-name": "Назва ключа", + "key-name-required": "Назва ключа обов'язкова", + "key-type": "Тип ключа", + "key-type-attribute": "Атрибут", + "key-type-timeseries": "Часовий ряд", + "label-keys-list": "Список ключів для використання в мітках", + "no-label-keys": "Ключі не налаштовані", + "add-label-key": "Додати новий ключ", + "line-width": "Товщина лінії", + "color": "Колір", + "data-is-hidden-by-default": "Дані приховані за замовчуванням", + "disable-data-hiding": "Вимкнути приховування даних", + "remove-from-legend": "Видалити datakey з легенди", + "exclude-from-stacking": "Виключити з режиму стекування (доступно в режимі \"Stacking\")", + "line-settings": "Налаштування лінії", + "show-line": "Показати лінію", + "fill-line": "Заповнити лінію", + "fill-line-opacity": "Непрозорість заповнення лінії", + "points-settings": "Налаштування точок", + "show-points": "Показати точки", + "points-line-width": "Товщина лінії точок", + "points-radius": "Радіус точок", + "point-shape": "Форма точки", + "point-shape-circle": "Коло", + "point-shape-cross": "Хрест", + "point-shape-diamond": "Діамант", + "point-shape-square": "Квадрат", + "point-shape-triangle": "Трикутник", + "point-shape-custom": "Користувацька функція", + "point-shape-draw-function": "Функція малювання форми точки", + "show-separate-axis": "Показати окрему вісь", + "axis-position-left": "Зліва", + "axis-position-right": "Праворуч", + "thresholds": "Пороги", + "no-thresholds": "Пороги не налаштовані", + "add-threshold": "Додати поріг", + "show-values-for-comparison": "Показати історичні значення для порівняння", + "comparison-values-label": "Маркування історичних значень", + "comparison-line-color": "Колір лінії порівняння", + "threshold-settings": "Налаштування порогів", + "use-as-threshold": "Використовувати значення ключа як поріг", + "threshold-line-width": "Товщина лінії порогу", + "threshold-color": "Колір порогу", + "common-pie-settings": "Загальні налаштування кругової діаграми", + "radius": "Радіус", + "inner-radius": "Внутрішній радіус", + "tilt": "Нахил", + "common-pie-settings-range-error": "Значення повинно бути в діапазоні від 0 до 1", + "stroke-settings": "Налаштування обводки", + "width-pixels": "Ширина (пікселі)", + "show-labels": "Показати маркування", + "animation-settings": "Налаштування анімації", + "animated-pie": "Увімкнути анімацію кругової діаграми (експериментальна)", + "border-settings": "Налаштування кордону", + "border-width": "Товщина кордону", + "border-color": "Колір кордону", + "legend-settings": "Налаштування легенди", + "display-legend": "Показати легенду", + "labels-font-color": "Колір шрифту міток", + "series": "Часовий ряд", + "add-series": "Додати часовий ряд", + "series-settings": "Налаштування часового ряду", + "remove-series": "Видалити часовий ряд", + "no-series": "Часові ряди не налаштовані", + "no-series-error": "Має бути вказано хоча б один часовий ряд", + "chart-appearance": "Зовнішній вигляд діаграми", + "vertical-grid-lines": "Вертикальні лінії сітки", + "horizontal-grid-lines": "Горизонтальні лінії сітки", + "chart-background": "Фон діаграми", + "grid-lines-color": "Колір ліній сітки", + "border": "Кордон", + "axis": "Ось", + "vertical-axis": "Вертикальна вісь", + "ticks": "Поділки", + "horizontal-axis": "Горизонтальна вісь", + "shape-empty-circle": "Порожнє коло", + "shape-circle": "Коло", + "shape-rect": "Прямокутник", + "shape-round-rect": "Закруглений прямокутник", + "shape-triangle": "Трикутник", + "shape-diamond": "Діамант", + "shape-pin": "Штифт", + "shape-arrow": "Стрілка", + "shape-none": "Без форми", + "line-type-solid": "Тверда лінія", + "line-type-dashed": "Пунктирна лінія", + "line-type-dotted": "Точкова лінія", + "label-position-top": "Верх", + "label-position-bottom": "Низ", + "label-position-outside": "Зовні", + "label-position-inside": "Всередині", + "fill": "Заповнення", + "fill-type-none": "Без заповнення", + "fill-type-solid": "Твердий колір", + "fill-type-opacity": "Непрозорість", + "fill-type-gradient": "Градієнт", + "background": "Фон", + "opacity": "Непрозорість", + "gradient-stops": "Точки градієнта", + "gradient-start": "початок", + "gradient-end": "кінець", + "animation": { + "animation": "Анімація", + "animation-threshold": "Поріг анімації", + "animation-duration": "Тривалість анімації", + "animation-easing": "Затримка анімації", + "animation-delay": "Затримка анімації", + "update-animation-duration": "Оновити тривалість анімації", + "update-animation-easing": "Оновити затримку анімації", + "update-animation-delay": "Оновити затримку анімації" + }, + "chart-axis": { + "limit": "Ліміт", + "source": "Джерело", + "key-value": "Ключ / Значення", + "value-required": "Значення обов'язкове.", + "entity-key-required": "Ключ сутності обов'язковий.", + "key-required": "Ключ обов'язковий.", + "scale-limits": "Ліміти шкали", + "scale-appearance": "Зовнішній вигляд шкали", + "scale": "Шкала", + "scale-min": "мін", + "scale-max": "макс", + "scale-auto": "Авто" + }, + "bar": { + "show-border": "Показати кордон", + "border-width": "Товщина кордону", + "border-radius": "Радіус кордону", + "bar-width": "Ширина стовпця", + "label": "Маркування", + "label-hint": "Відображати маркування над стовпцем.", + "series-label-hint": "Відображати маркування зі значенням над стовпцем.", + "label-background": "Фон маркування" + } + }, + "color": { + "color-settings": "Налаштування кольору", + "color-type-constant": "Постійний", + "color-type-gradient": "Градієнт", + "color-type-range": "Діапазон", + "color-type-function": "Функція", + "color": "Колір", + "value-range": "Діапазон значень", + "from": "Від", + "to": "До", + "color-function": "Функція кольору", + "copy-color-settings-from": "Копіювати налаштування кольору з", + "copy-from": "Копіювати з", + "settings-type": "Тип налаштувань", + "basic-mode": "Базовий", + "advanced-mode": "Розширений", + "entity-alias": "Псевдонім сутності", + "entity-attribute": "Атрибут сутності", + "gradient-color": "Колір градієнта", + "gradient-color-min": "Колір", + "gradient-start": "Колір початку градієнта", + "gradient-start-min": "Початок", + "gradient-end": "Колір кінця градієнта", + "gradient-end-min": "Кінець", + "start-value": "Початкове значення", + "end-value": "Кінцеве значення", + "gradient-type": "Тип градієнта" + }, + "dashboard-state": { + "dashboard-state-settings": "Налаштування стану дашборду", + "dashboard-state": "ID стану дашборду", + "autofill-state-layout": "Автозаповнення висоти макету стану за замовчуванням", + "default-margin": "Стандартний відступ віджетів", + "default-background-color": "Стандартний колір фону", + "sync-parent-state-params": "Синхронізувати параметри стану з батьківським дашбордом" + }, + "date-range-navigator": { + "date-range-picker-settings": "Налаштування вибору діапазону дат", + "hide-date-range-picker": "Приховати вибір діапазону дат", + "picker-one-panel": "Вибір діапазону дат в одному панелі", + "picker-auto-confirm": "Автоматичне підтвердження вибору діапазону дат", + "picker-show-template": "Показати шаблон вибору діапазону дат", + "first-day-of-week": "Перший день тижня", + "interval-settings": "Налаштування інтервалу", + "hide-interval": "Приховати інтервал", + "initial-interval": "Початковий інтервал", + "interval-hour": "Година", + "interval-day": "День", + "interval-week": "Тиждень", + "interval-two-weeks": "2 тижні", + "interval-month": "Місяць", + "interval-three-months": "3 місяці", + "interval-six-months": "6 місяців", + "step-settings": "Налаштування кроку", + "hide-step-size": "Приховати розмір кроку", + "initial-step-size": "Початковий розмір кроку", + "hide-labels": "Приховати маркування", + "use-session-storage": "Використовувати сесійне сховище", + "localizationMap": { + "Sun": "Нд", + "Mon": "Пн", + "Tue": "Вт", + "Wed": "Ср", + "Thu": "Чт", + "Fri": "Пт", + "Sat": "Сб", + "Jan": "Січ", + "Feb": "Лют", + "Mar": "Бер", + "Apr": "Кві", + "May": "Тра", + "Jun": "Чер", + "Jul": "Лип", + "Aug": "Сер", + "Sep": "Вер", + "Oct": "Жов", + "Nov": "Лис", + "Dec": "Гру", + "January": "Січень", + "February": "Лютий", + "March": "Березень", + "April": "Квітень", + "June": "Червень", + "July": "Липень", + "August": "Серпень", + "September": "Вересень", + "October": "Жовтень", + "November": "Листопад", + "December": "Грудень", + "Custom Date Range": "Користувацький діапазон дат", + "Date Range Template": "Шаблон діапазону дат", + "Today": "Сьогодні", + "Yesterday": "Вчора", + "This Week": "Цього тижня", + "Last Week": "Минулого тижня", + "This Month": "Цього місяця", + "Last Month": "Минулого місяця", + "Year": "Рік", + "This Year": "Цього року", + "Last Year": "Минулого року", + "Date picker": "Вибір дати", + "Hour": "Година", + "Day": "День", + "Week": "Тиждень", + "2 weeks": "2 тижні", + "Month": "Місяць", + "3 months": "3 місяці", + "6 months": "6 місяців", + "Custom interval": "Користувацький інтервал", + "Interval": "Інтервал", + "Step size": "Розмір кроку", + "Ok": "Ок" + } + }, + "doughnut": { + "doughnut-appearance": "Зовнішній вигляд пончикової діаграми", + "layout": "Макет", + "layout-default": "За замовчуванням", + "layout-with-total": "Із підсумком", + "central-total-value": "Центральне підсумкове значення", + "doughnut-card-style": "Стиль картки пончикової діаграми" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "Налаштування даних ієрархії", + "relations-query-function": "Функція запиту зв'язків вузла", + "has-children-function": "Функція наявності дочірніх елементів у вузла", + "node-state-settings": "Налаштування стану вузла", + "node-opened-function": "Функція відкритого вузла за замовчуванням", + "node-disabled-function": "Функція вимкненого вузла", + "display-settings": "Налаштування відображення", + "node-icon-function": "Функція іконки вузла", + "node-text-function": "Функція тексту вузла", + "sort-settings": "Налаштування сортування", + "nodes-sort-function": "Функція сортування вузлів" + }, + "edge": { + "display-default-title": "Відображати заголовок за замовчуванням" + }, + "gateway": { + "general-settings": "Загальні налаштування", + "widget-title": "Заголовок віджета", + "default-archive-file-name": "Назва файлу архіву за замовчуванням", + "device-type-for-new-gateway": "Тип пристрою для нового Gateway", + "messages-settings": "Налаштування повідомлень", + "save-config-success-message": "Текстове повідомлення про успішне збереження конфігурації Gateway", + "device-name-exists-message": "Текстове повідомлення, коли пристрій із введеною назвою вже існує", + "gateway-title": "Форма Gateway", + "read-only": "Лише читання", + "events-title": "Заголовок форми подій Gateway", + "events-filter": "Фільтр подій", + "event-key-contains": "Ключ події містить...", + "show-connector": "Показувати для конектора", + "connector-state-param-key": "Ключ параметра стану конектора", + "message": "Повідомлення", + "level": "Рівень", + "created-time": "Час створення" + }, + "gauge": { + "default-color": "Колір по замовчуванням", + "radial-gauge-settings": "Налаштування радіального індикатора", + "ticks-settings": "Налаштування поділок", + "min-value": "Мінімальне значення", + "max-value": "Максимальне значення", + "min-value-short": "мін", + "max-value-short": "макс", + "start-ticks-angle": "Початковий кут поділок", + "ticks-angle": "Кут поділок", + "major-ticks": "Основні поділки", + "major-ticks-count": "Кількість основних поділок", + "major-ticks-color": "Колір основних поділок", + "minor-ticks": "Додаткові поділки", + "minor-ticks-count": "Кількість додаткових поділок", + "minor-ticks-color": "Колір додаткових поділок", + "tick-numbers-font": "Шрифт чисел поділок", + "unit-title-settings": "Налаштування заголовка одиниць", + "show-unit-title": "Заголовок одиниць", + "unit-title": "Заголовок одиниць", + "title-font": "Шрифт тексту заголовка", + "units-settings": "Налаштування одиниць", + "units-font": "Шрифт тексту одиниць", + "value-box-settings": "Налаштування поля значення", + "show-value-box": "Показувати поле значення", + "value-box": "Поле значення", + "value-int": "Кількість цифр цілої частини значення", + "value-text": "Текст значення", + "value-text-shadow": "Тінь тексту значення", + "value-font": "Шрифт тексту значення", + "rect-stroke-color-start": "Колір обведення прямокутника — початок градієнта", + "rect-stroke-color-end": "Колір обведення прямокутника — кінець градієнта", + "background-color": "Колір фону", + "shadow-color": "Колір тіні", + "value-box-rect-stroke-color": "Колір обведення прямокутника поля значення", + "value-box-rect-stroke-color-end": "Колір обведення прямокутника поля значення — кінець градієнта", + "value-box-background-color": "Колір фону поля значення", + "value-box-shadow-color": "Колір тіні поля значення", + "plate-settings": "Налаштування панелі", + "show-plate-border": "Рамка панелі", + "plate-color": "Колір панелі", + "needle-settings": "Налаштування стрілки", + "needle-circle-size": "Розмір кола стрілки", + "needle-color": "Колір стрілки", + "needle-color-start": "Колір стрілки — початок градієнта", + "needle-color-end": "Колір стрілки — кінець градієнта", + "needle-color-shadow-up": "Колір тіні верхньої половини стрілки", + "needle-color-shadow-down": "Тінь", + "highlights-settings": "Налаштування виділень", + "highlights-width": "Ширина виділень", + "highlights": "Виділення", + "highlight-from": "Від", + "highlight-to": "До", + "highlight-color": "Колір", + "no-highlights": "Виділення не налаштовані", + "add-highlight": "Додати виділення", + "animation-settings": "Налаштування анімації", + "enable-animation": "Анімація", + "animation-duration-rule": "Тривалість анімації та правило", + "animation-duration": "Тривалість анімації", + "animation-rule": "Правило анімації", + "animation-linear": "Лінійна", + "animation-quad": "Квадратична", + "animation-quint": "Квінтова", + "animation-cycle": "Цикл", + "animation-bounce": "Пружинна", + "animation-elastic": "Еластична", + "animation-dequad": "Зворотна квадратична", + "animation-dequint": "Зворотна квінтова", + "animation-decycle": "Зворотний цикл", + "animation-debounce": "Зворотна пружинна", + "animation-delastic": "Зворотна еластична", + "linear-gauge-settings": "Налаштування лінійного індикатора", + "bar-stroke": "Обведення смуги", + "bar-stroke-width": "Товщина обведення смуги", + "bar-stroke-color": "Колір обведення смуги", + "bar-background-color": "Колір фону смуги — початок градієнта", + "bar-background-color-end": "Колір фону смуги — кінець градієнта", + "progress-bar-color": "Колір смуги прогресу", + "progress-bar": "Смуга прогресу", + "progress-bar-color-start": "Колір смуги прогресу — початок градієнта", + "progress-bar-color-end": "Колір смуги прогресу — кінець градієнта", + "major-ticks-names": "Назви основних поділок", + "show-stroke-ticks": "Показувати обведення поділок", + "major-ticks-font": "Шрифт основних поділок", + "border-color": "Колір кордону", + "border-width": "Товщина кордону", + "needle-circle": "Коло стрілки", + "needle-circle-color": "Колір кола стрілки", + "animation-target": "Ціль анімації", + "animation-target-needle": "Стрілка", + "animation-target-plate": "Панель", + "common-settings": "Загальні налаштування індикатора", + "gauge-type": "Тип індикатора", + "gauge-type-arc": "Дуга", + "gauge-type-donut": "Пончиковий індикатор", + "gauge-type-horizontal-bar": "Горизонтальна смуга", + "gauge-type-vertical-bar": "Вертикальна смуга", + "donut-start-angle": "Кут початку (градуси)", + "bar-settings": "Налаштування смуги індикатора", + "relative-bar-width": "Відносна ширина смуги", + "neon-glow-brightness": "Яскравість неонового ефекту (0-100)", + "neon-glow-brightness-hint": "0 - вимкнути ефект", + "stripes-thickness": "Товщина смуг", + "stripes-thickness-hint": "0 - без смуг", + "rounded-line-cap": "Округлий кінець лінії", + "bar-color-settings": "Налаштування кольору смуги", + "use-precise-level-color-values": "Використовувати точні значення кольору рівнів", + "bar-colors": "Кольори смуги, від нижчого до вищого", + "color": "Колір", + "no-bar-colors": "Кольори смуги не налаштовані", + "add-bar-color": "Додати колір смуги", + "from": "Від", + "to": "До", + "fixed-level-colors": "Кольори смуги з використанням граничних значень", + "gauge-title-settings": "Налаштування заголовка індикатора", + "show-gauge-title": "Показати заголовок індикатора", + "gauge-title": "Заголовок індикатора", + "gauge-title-font": "Шрифт заголовка індикатора", + "unit-title-and-timestamp-settings": "Налаштування заголовка одиниць та мітки часу", + "show-timestamp": "Мітка часу", + "timestamp-format": "Формат мітки часу", + "label-font": "Шрифт маркування, що показується під значенням", + "value-settings": "Налаштування значення", + "show-value": "Показати текст значення", + "min-max-settings": "Налаштування міток мінімуму/максимуму", + "show-min-max": "Показати мінімальні та максимальні значення", + "min-max-font": "Шрифт міток мінімуму та максимуму", + "show-ticks": "Показати поділки", + "tick-width": "Ширина поділок", + "tick-color": "Колір поділок", + "tick-values": "Значення поділок", + "no-tick-values": "Значення поділок не налаштовані", + "add-tick-value": "Додати значення поділки", + "gauge-appearance": "Зовнішній вигляд індикатора", + "units-title": "Заголовок одиниць", + "value": "Значення", + "ticks": "Поділки", + "arrow-and-scale-color": "Колір стрілки та шкали за замовчуванням", + "scale-settings": "Налаштування шкали", + "scale": "Шкала", + "scale-color": "Кольори шкали", + "compass-appearance": "Зовнішній вигляд компаса", + "label": "Маркування", + "labels": "Маркування", + "label-style": "Стиль маркування", + "simple-gauge-type": "Тип", + "gauge-bar-background": "Фон смуги індикатора", + "bar-color": "Колір смуги", + "min-and-max-value": "Мінімальне та максимальне значення", + "min-and-max-label": "Маркування мінімуму та максимуму", + "font": "Шрифт", + "tick-width-and-color": "Ширина та колір поділок", + "min-max-validation-text": "Максимальне значення повинно бути більше мінімального" + }, + "gpio": { + "pin": "Пін", + "label": "Маркування", + "row": "Рядок", + "column": "Стовпець", + "color": "Колір", + "panel-settings": "Налаштування панелі", + "background-color": "Колір фону", + "gpio-switches": "GPIO перемикачі", + "no-gpio-switches": "GPIO перемикачі не налаштовані", + "add-gpio-switch": "Додати GPIO перемикач", + "gpio-status-request": "Запит статусу GPIO", + "method-name": "Назва методу", + "method-body": "Тіло методу", + "gpio-status-change-request": "Запит зміни статусу GPIO", + "parse-gpio-status-function": "Функція парсингу статусу GPIO", + "gpio-leds": "GPIO світлодіоди", + "no-gpio-leds": "GPIO світлодіоди не налаштовані", + "add-gpio-led": "Додати GPIO світлодіод" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "Параметр атрибута не може бути використаний у цьому віджеті", + "blocked-location": "Геолокація заблокована у вашому браузері", + "claim-device": "Претендувати на пристрій", + "claim-failed": "Не вдалося претендувати на пристрій!", + "claim-not-found": "Пристрій не знайдено!", + "claim-successful": "Пристрій успішно отримано!", + "date": "Дата", + "device-name": "Назва пристрою", + "device-name-required": "Назва пристрою обов'язкова", + "discard-changes": "Відкинути зміни", + "entity-attribute-required": "Атрибут сутності обов'язковий", + "entity-coordinate-required": "Обидва поля, широта та довгота, обов'язкові", + "entity-timeseries-required": "Часовий ряд сутності обов'язковий", + "get-location": "Отримати поточне місцезнаходження", + "invalid-date": "Невірна дата", + "latitude": "Широта", + "longitude": "Довгота", + "min-value-error": "Мінімальне значення {{value}}", + "max-value-error": "Максимальне значення {{value}}", + "not-allowed-entity": "Вибрана сутність не може мати спільні атрибути", + "no-attribute-selected": "Не вибрано атрибут", + "no-datakey-selected": "Не вибрано datakey", + "no-coordinate-specified": "Datakey для широти/довготи не вказано", + "no-entity-selected": "Не вибрано сутність", + "no-image": "Немає зображення", + "no-support-geolocation": "Ваш браузер не підтримує геолокацію", + "no-support-web-camera": "Ваш браузер не підтримує камери", + "enable-https-use-widget": "Будь ласка, увімкніть HTTPS для використання цього віджету", + "no-found-your-camera": "Не вдалося знайти вашу камеру", + "no-permission-camera": "Дозвіл було відмовлено користувачем / цей сайт не має дозволу на використання камери", + "no-timeseries-selected": "Не вибрано часовий ряд", + "secret-key": "Секретний ключ", + "secret-key-required": "Секретний ключ обов'язковий", + "switch-attribute-value": "Перемикання значення атрибута сутності", + "switch-camera": "Перемикання камери", + "switch-timeseries-value": "Перемикання значення часового ряду сутності", + "take-photo": "Зробити фото", + "time": "Час", + "timeseries-not-allowed": "Параметр часового ряду не може бути використаний у цьому віджеті", + "update-failed": "Оновлення не вдалося", + "update-successful": "Оновлення успішне", + "update-attribute": "Оновити атрибут", + "update-timeseries": "Оновити часовий ряд", + "value": "Значення", + "general-settings": "Загальні налаштування", + "widget-title": "Заголовок віджету", + "claim-button-label": "Маркування кнопки претендування", + "show-secret-key-field": "Показати поле вводу 'Секретний ключ'", + "labels-settings": "Налаштування міток", + "show-labels": "Показати маркування", + "device-name-label": "Маркування для поля вводу назви пристрою", + "secret-key-label": "Маркування для поля вводу секретного ключа", + "messages-settings": "Налаштування повідомлень", + "claim-device-success-message": "Текстове повідомлення про успішне претендування на пристрій", + "claim-device-not-found-message": "Текстове повідомлення, коли пристрій не знайдено", + "claim-device-failed-message": "Текстове повідомлення про неуспішне претендування на пристрій", + "claim-device-name-required-message": "Повідомлення про помилку 'Назва пристрою обов'язкова'", + "claim-device-secret-key-required-message": "Повідомлення про помилку 'Секретний ключ обов'язковий'", + "show-label": "Показати маркування", + "label": "Маркування", + "required": "Обов'язково", + "required-error-message": "Повідомлення про помилку 'Обов'язково'", + "show-result-message": "Показати повідомлення результату", + "integer-field-settings": "Налаштування поля для цілих чисел", + "min-value": "Мінімальне значення", + "max-value": "Максимальне значення", + "double-field-settings": "Налаштування поля для чисел з плаваючою комою", + "text-field-settings": "Налаштування текстового поля", + "min-length": "Мінімальна довжина", + "max-length": "Максимальна довжина", + "checkbox-settings": "Налаштування чекбоксу", + "true-label": "Маркування для вибраного", + "false-label": "Маркування для не вибраного", + "image-input-settings": "Налаштування вводу зображення", + "display-preview": "Показати прев'ю", + "display-clear-button": "Показати кнопку очищення", + "display-apply-button": "Показати кнопку застосування", + "display-discard-button": "Показати кнопку скасування", + "datetime-field-settings": "Налаштування поля дати/часу", + "display-time-input": "Показати ввід часу", + "latitude-key-name": "Назва ключа широти", + "longitude-key-name": "Назва ключа довготи", + "show-get-location-button": "Показати кнопку 'Отримати поточне місцезнаходження'", + "use-high-accuracy": "Використовувати високу точність", + "location-fields-settings": "Налаштування полів місцезнаходження", + "latitude-label": "Маркування для широти", + "longitude-label": "Маркування для довготи", + "input-fields-alignment": "Вирівнювання полів вводу", + "input-fields-alignment-column": "Стовпець (за замовчуванням)", + "input-fields-alignment-row": "Рядок", + "layout": "Макет", + "row-gap": "Проміжок між рядками в пікселях", + "column-gap": "Проміжок між стовпцями в пікселях", + "latitude-field-required": "Поле широти обов'язкове", + "longitude-field-required": "Поле довготи обов'язкове", + "attribute-settings": "Налаштування атрибута", + "widget-mode": "Режим віджету", + "widget-mode-update-attribute": "Оновити атрибут", + "widget-mode-update-timeseries": "Оновити часовий ряд", + "attribute-scope": "Обсяг атрибута", + "attribute-scope-server": "Атрибут сервера", + "attribute-scope-shared": "Спільний атрибут", + "value-required": "Значення обов'язкове", + "image-settings": "Налаштування зображення", + "image-format": "Формат зображення", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "Якість зображення", + "max-image-width": "Максимальна ширина", + "max-image-height": "Максимальна висота", + "action-buttons": "Кнопки дій", + "show-action-buttons": "Показати кнопки дій", + "update-all-values": "Оновити всі значення, не тільки змінені", + "save-button-label": "Маркування кнопки 'ЗБЕРЕГТИ'", + "reset-button-label": "Маркування кнопки 'Скасувати'", + "group-settings": "Налаштування групи", + "show-group-title": "Показати заголовок для групи полів, пов'язаних з різними сутностями", + "group-title": "Заголовок групи", + "fields-alignment": "Вирівнювання полів", + "fields-alignment-row": "Рядок (за замовчуванням)", + "fields-alignment-column": "Стовпець", + "fields-in-row": "Кількість полів у рядку", + "option-value": "Значення (напишіть 'null' для створення порожньої опції)", + "option-label": "Маркування", + "hide-input-field": "Сховати поле вводу", + "datakey-type": "Тип datakey", + "datakey-type-server": "Атрибут сервера (за замовчуванням)", + "datakey-type-shared": "Спільний атрибут", + "datakey-type-timeseries": "Часовий ряд", + "datakey-value-type": "Тип значення datakey", + "datakey-value-type-string": "Рядок", + "datakey-value-type-double": "Число з плаваючою комою", + "datakey-value-type-integer": "Ціле число", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "Булевий (чекбокс)", + "datakey-value-type-boolean-switch": "Булевий (перемикач)", + "datakey-value-type-date-time": "Дата та час", + "datakey-value-type-date": "Дата", + "datakey-value-type-time": "Час", + "datakey-value-type-select": "Вибір", + "datakey-value-type-radio": "Радіо", + "datakey-value-type-color": "Колір", + "value-is-required": "Значення обов'язкове", + "ability-to-edit-attribute": "Можливість редагувати атрибут", + "ability-to-edit-attribute-editable": "Редагований (за замовчуванням)", + "ability-to-edit-attribute-disabled": "Вимкнено", + "ability-to-edit-attribute-readonly": "Тільки для читання", + "disable-on-datakey-name": "Вимкнути при неправильному значенні іншого datakey (вкажіть назву datakey)", + "field-appearance": "Зовнішній вигляд поля", + "appearance-fill": "Заповнення", + "appearance-outline": "Контур", + "subscript-sizing": "Розмір підрядка", + "subscript-sizing-fixed": "Фіксований", + "subscript-sizing-dynamic": "Динамічний", + "slide-toggle-settings": "Налаштування слайд-перемикача", + "slide-toggle-label-position": "Позиція мітки слайд-перемикача", + "slide-toggle-label-position-after": "Після", + "slide-toggle-label-position-before": "До", + "select-options": "Опції вибору", + "no-select-options": "Опції вибору не налаштовані", + "add-select-option": "Додати опцію вибору", + "numeric-field-settings": "Налаштування числового поля", + "step-interval": "Інтервал кроку між значеннями", + "error-messages": "Повідомлення про помилки", + "min-value-error-message": "Повідомлення про помилку 'Мінімальне значення'", + "max-value-error-message": "Повідомлення про помилку 'Максимальне значення'", + "invalid-date-error-message": "Повідомлення про помилку 'Невірна дата'", + "invalid-JSON-error-message": "Повідомлення про помилку 'Невірний JSON'", + "icon-settings": "Налаштування іконки", + "dialog-editor-settings": "Налаштування редактора діалогу", + "use-custom-icon": "Використовувати кастомну іконку", + "input-cell-icon": "Іконка для відображення перед клітинкою вводу", + "value-conversion-settings": "Налаштування перетворення значень", + "get-value-settings": "Налаштування отримання значення", + "use-get-value-function": "Використовувати функцію getValue", + "get-value-function": "Функція getValue", + "set-value-settings": "Налаштування встановлення значення", + "use-set-value-function": "Використовувати функцію setValue", + "set-value-function": "Функція setValue", + "json-invalid": "Формат значення JSON неправильний", + "title": "Заголовок", + "cancel-button-label": "Мітка кнопки 'Скасувати'", + "radio-button-settings": "Налаштування радіокнопки", + "color": "Колір", + "columns": "Стовпці", + "radio-options": "Опції радіокнопок", + "no-radio-options": "Опції радіокнопок не налаштовані", + "add-radio-option": "Додати опцію радіокнопки", + "radio-label-position": "Позиція мітки", + "radio-label-position-before": "До", + "radio-label-position-after": "Після", + "save-image": "Зберегти зображення", + "save-to-gallery": "Автоматично зберігати захоплені зображення в Галереї зображень", + "public-image": "Зробити зображення доступним для будь-якого неавторизованого користувача" + }, + "invalid-qr-code-text": "Невірний текст для QR-коду. Вхідні дані повинні мати тип рядка", + "qr-code": { + "use-qr-code-text-function": "Використовувати функцію тексту QR-коду", + "qr-code-text-pattern": "Шаблон тексту для QR-коду (наприклад, '${entityName} | ${keyName} - деякий текст.')", + "qr-code-text-pattern-hint": "Шаблон тексту QR-коду використовує значення першого знайденого ключа в сутностях за псевдонімом сутності.", + "qr-code-text-pattern-required": "Шаблон тексту QR-коду обов'язковий.", + "qr-code-text-function": "Функція тексту QR-коду" + }, + "label-widget": { + "label-pattern": "Шаблон", + "label-pattern-hint": "Підказка: наприклад, 'Текст ${keyName} одиниць.' або ${#<key index>} одиниць'", + "label-pattern-required": "Шаблон обов'язковий", + "label-position": "Позиція (відсоток відносно фону)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "Колір фону", + "font-settings": "Налаштування шрифта", + "background-image": "Фонове зображення", + "labels": "Маркування", + "no-labels": "Маркування не налаштовані", + "add-label": "Додати маркування" + }, + "navigation": { + "title": "Заголовок", + "navigation-path": "Шлях навігації", + "filter-type": "Тип фільтра", + "filter-type-all": "Усі елементи", + "filter-type-include": "Включити елементи", + "filter-type-exclude": "Виключити елементи", + "items": "Елементи", + "enter-urls-to-filter": "Введіть URL-адреси для фільтрації..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "Тип повідомлення", + "method": "Метод", + "params": "Параметри", + "created-time": "Час створення", + "expiration-time": "Час закінчення", + "retries": "Повторні спроби", + "status": "Статус", + "filter": "Фільтр", + "refresh": "Оновити", + "add": "Додати RPC запит", + "details": "Деталі", + "delete": "Видалити", + "delete-request-title": "Видалити постійний RPC запит", + "delete-request-text": "Ви впевнені, що хочете видалити запит?", + "details-title": "Деталі RPC ID: ", + "additional-info": "Додаткова інформація", + "response": "Відповідь", + "any-status": "Будь-який статус", + "rpc-status-list": "Список статусів RPC", + "no-request-prompt": "Немає запитів для відображення", + "send-request": "Відправити запит", + "add-title": "Створити постійний RPC запит", + "method-error": "Метод обов'язковий.", + "timeout-error": "Мінімальне значення часу очікування — 5000 (5 секунд).", + "white-space-error": "Пробіли не дозволяються.", + "rpc-status": { + "QUEUED": "В ЧЕРЗІ", + "SENT": "ВІДПРАВЛЕНО", + "DELIVERED": "ДОСТАВЛЕНО", + "SUCCESSFUL": "УСПІШНО", + "TIMEOUT": "ТАЙМАУТ", + "EXPIRED": "ПРОСТРОЧЕНИЙ", + "FAILED": "НЕВДАЧА" + }, + "rpc-search-status-all": "ВСІ", + "message-types": { + "false": "Двосторонній", + "true": "Односторонній" + }, + "general-settings": "Загальні налаштування", + "enable-filter": "Увімкнути фільтр", + "enable-sticky-header": "Показувати заголовок під час прокручування", + "enable-sticky-action": "Показувати стовпець дій під час прокручування", + "display-request-details": "Показати деталі запиту", + "allow-send-request": "Дозволити відправку RPC запиту", + "allow-delete-request": "Дозволити видалення запиту", + "columns-settings": "Налаштування стовпців", + "display-columns": "Стовпці для відображення", + "column": "Стовпець", + "no-columns-found": "Стовпці не знайдені", + "no-columns-matching": "'{{column}}' не знайдений." + }, + "range-chart": { + "chart": "Графік", + "data-zoom": "Масштабування даних", + "range-chart-appearance": "Зовнішній вигляд графіка діапазону", + "range-colors": "Кольори діапазону", + "out-of-range-color": "Колір поза діапазоном", + "show-range-thresholds": "Показати пороги діапазону", + "range-thresholds-settings": "Налаштування порогів діапазону", + "fill-area": "Заповнення області", + "fill-area-opacity": "Непрозорість заповненої області", + "range-chart-style": "Стиль графіка діапазону" + }, + "knob": { + "behavior": "Поводження", + "initial-value": "Початкове значення", + "initial-value-hint": "Дія для отримання початкового значення ручки.", + "on-value-change": "При зміні значення", + "on-value-change-hint": "Дія, що виконується при зміні значення ручки.", + "range": "Діапазон", + "min": "мін", + "max": "макс", + "value": "Значення", + "fallback-initial-value": "Резервне початкове значення" + }, + "rpc": { + "value-settings": "Налаштування значення", + "initial-value": "Початкове значення", + "retrieve-value-settings": "Налаштування отримання значення вкл/викл", + "retrieve-value-method": "Метод отримання значення", + "retrieve-value-method-none": "Не отримувати", + "retrieve-value-method-rpc": "Викликати RPC метод отримання значення", + "retrieve-value-method-attribute": "Підписатися на атрибут", + "retrieve-value-method-timeseries": "Підписатися на часовогу ряду", + "attribute-value-key": "Ключ атрибута", + "timeseries-value-key": "Ключ часового ряду", + "get-value-method": "Метод RPC для отримання значення", + "parse-value-function": "Функція розбору значення", + "update-value-settings": "Оновлення налаштувань значення", + "set-value-method": "Метод RPC для встановлення значення", + "convert-value-function": "Функція перетворення значення", + "rpc-settings": "Налаштування RPC", + "request-timeout": "Таймаут запиту RPC (мс)", + "persistent-rpc-settings": "Постійні налаштування RPC", + "request-persistent": "Постійний запит RPC", + "persistent-polling-interval": "Інтервал опитування (мс) для отримання відповіді на постійний RPC запит", + "common-settings": "Загальні налаштування", + "switch-title": "Назва перемикача", + "show-on-off-labels": "Показувати маркування вкл/викл", + "slide-toggle-label": "Маркування перемикача", + "label-position": "Позиція маркування", + "label-position-before": "Перед", + "label-position-after": "Після", + "slider-color": "Колір слайдера", + "slider-color-primary": "Основний", + "slider-color-accent": "Акцентний", + "slider-color-warn": "Попередження", + "button-style": "Стиль кнопки", + "button-raised": "Піднята кнопка", + "button-primary": "Основний колір", + "button-background-color": "Колір фону кнопки", + "button-text-color": "Колір тексту кнопки", + "widget-title": "Заголовок віджета", + "button-label": "Маркування кнопки", + "device-attribute-scope": "Область атрибутів пристрою", + "server-attribute": "Атрибут сервера", + "shared-attribute": "Спільний атрибут", + "device-attribute-parameters": "Параметри атрибутів пристрою", + "is-one-way-command": "Є односторонньою командою", + "rpc-method": "RPC метод", + "rpc-method-params": "Параметри RPC методу", + "show-rpc-error": "Показати помилку виконання RPC команди", + "led-title": "Заголовок LED", + "led-color": "Колір LED", + "check-status-settings": "Налаштування перевірки статусу", + "perform-rpc-status-check": "Виконати перевірку статусу пристрою через RPC", + "retrieve-led-status-value-method": "Метод отримання значення статусу LED", + "led-status-value-attribute": "Атрибут пристрою, що містить значення статусу LED", + "led-status-value-timeseries": "Часовий ряд пристрою, що містить значення статусу LED", + "check-status-method": "RPC метод перевірки статусу пристрою", + "parse-led-status-value-function": "Функція розбору значення статусу LED", + "knob-title": "Заголовок кнопки" + }, + "maps": { + "map-type": { + "type": "Тип карти", + "map": "Карта", + "image": "Зображення" + }, + "image": { + "image-source": "Джерело зображення", + "image-source-image": "Зображення", + "image-source-entity-key": "Ключ сутності", + "source-entity-alias": "Псевдонім джерела сутності", + "image-url-key": "Ключ URL зображення", + "image-url-key-required": "Ключ URL зображення є обов'язковим" + }, + "control": { + "map-controls": "Управління картою", + "position": "Позиція", + "position-topleft": "Верхній лівий кут", + "position-topright": "Верхній правий кут", + "position-bottomleft": "Нижній лівий кут", + "position-bottomright": "Нижній правий кут", + "zoom-actions": "Дії масштабування", + "zoom-scroll": "Прокрутка", + "zoom-double-click": "Подвійне натискання", + "zoom-control-buttons": "Кнопки управління масштабом", + "scale": "Масштаб", + "scale-metric": "Метрика", + "scale-imperial": "Імперіал", + "switch-to-drag-mode-using-button": "Перемкнути в режим перетягування за допомогою кнопки" + }, + "timeline": { + "control-panel": "Панель управління часовою шкалою", + "time-step": "Крок часу", + "speed-options": "Опції швидкості", + "timestamp": "Мітка часу", + "snap-to-real-location": "Прив'язати до реального місця", + "location-snap-filter-function": "Функція фільтрації прив'язки до місця", + "no-trips-data-available": "Дані по поїздках не доступні" + }, + "map-action": { + "map-action-buttons": "Кнопки дій карти", + "label": "Маркування", + "icon": "Іконка", + "color": "Колір", + "action": "Дія", + "add-button": "Додати кнопку", + "no-action-buttons-configured": "Кнопки дій не налаштовані", + "remove-action-button": "Видалити кнопку дії", + "map-action-button": "Кнопка дії карти", + "button-requires": "Кнопка потребує маркування або іконки" + }, + "common": { + "common-map-settings": "Загальні налаштування карти", + "fit-map-bounds": "Пристосувати межі карти, щоб покрити всі маркери", + "default-map-center-position": "Позиція центру карти за замовчуванням", + "default-map-zoom-level": "Рівень масштабування карти за замовчуванням", + "entities-limit": "Обмеження на кількість сутностей для завантаження" + }, + "layer": { + "label": "Маркування", + "layer": "Шар", + "layers": "Шари", + "map-layers": "Шари карти", + "add-layer": "Додати шар", + "layer-settings": "Налаштування шару", + "remove-layer": "Видалити шар", + "no-layers": "Шарів не налаштовано", + "roadmap": "Дорожня карта", + "satellite": "Супутник", + "hybrid": "Гібридний", + "reference": { + "reference-layer": "Референсний шар", + "no-layer": "Без шару", + "openstreetmap-hybrid": "OpenStreetMap Гібрид", + "world-edition-hybrid": "Гібрид світової редакції", + "enhanced-contrast-hybrid": "Гібрид з підвищеним контрастом" }, - "column-type-required": "Необхідно вказати тип стовпця.", - "entity-field": { - "created-time": "Час створення", - "name": "Назва", - "type": "Тип", - "assigned_customer": "Призначений клієнт", - "authority": "Авторитет", - "first_name": "Ім'я", - "last_name": "Прізвище", - "email": "Електронна пошта", - "title": "Назва", - "country": "Країна", - "state": "Штат", - "city": "Місто", - "address": "Адреса", - "address2": "Адреса 2", - "zip": "Zip", - "phone": "Телефон" + "provider": { + "provider": "Постачальник", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "Дорожня карта", + "satellite": "Супутникова карта", + "hybrid": "Гібридна", + "terrain": "Рельєф" + }, + "here": { + "title": "HERE", + "normal-day": "Звичайний день", + "normal-night": "Звичайна ніч", + "hybrid-day": "Гібридний день", + "terrain-day": "Рельєфний день" + }, + "tencent": { + "title": "Tencent", + "normal": "Звичайний", + "satellite": "Супутниковий", + "terrain": "Рельєф" + }, + "custom": { + "title": "Користувацький", + "tile-url": "URL плитки" + } }, - "sort-order": { - "asc": "У порядку зростання", - "desc": "У порядку спадання", - "none": "Немає" + "credentials": { + "credentials": "Облікові дані", + "api-key": "API ключ" + } + }, + "overlays": { + "overlays": "Накладки", + "overlays-hint": "Налаштуйте джерела даних, зовнішній вигляд, поведінку, параметри редагування та групування для сутностей карти", + "trips": "Поїздки", + "markers": "Маркери", + "polygons": "Многоугольники", + "circles": "Коло", + "polylines": "Полілінії" + }, + "data-layer": { + "source": "Джерело", + "filter": "Фільтр", + "additional-data-keys": "Додаткові ключі даних", + "additional-datasources": "Додаткові джерела даних", + "additional-datasources-hint": "Джерело даних для доступу до атрибутів або телеметрії з сутностей, які не відображаються на карті, використовується в функціях накладок карти.", + "more-datasources": "Більше джерел даних", + "data-keys": "Ключі даних", + "add-datasource": "Додати джерело даних", + "no-datasources": "Джерела даних не налаштовані", + "remove-datasource": "Видалити джерело даних", + "behavior": "Поведінка", + "on-click": "По кліку", + "on-click-hint": "Дія, що виконується при кліку користувача на елемент карти.", + "groups": "Групи", + "groups-hint": "Список імен груп, призначених для накладки, використовуваний для перемикання її видимості на карті.", + "color": "Колір", + "color-settings": "Налаштування кольору", + "color-type-constant": "Постійний", + "color-type-range": "Діапазон", + "color-type-function": "Функція", + "color-range-source-key": "Ключ джерела діапазону кольорів", + "color-range-source-key-required": "Ключ джерела діапазону кольорів обов'язковий", + "color-range": "Діапазон кольорів", + "color-function": "Функція кольору", + "label": "Маркування", + "tooltip": "Підказка", + "pattern-type-pattern": "Шаблон", + "pattern-type-function": "Функція", + "label-pattern": "Маркування (приклади шаблонів: '${entityName}', '${entityName}: (Текст ${keyName} одиниці.)' )", + "label-function": "Функція маркування", + "tooltip-pattern": "Підказка (наприклад, 'Текст ${keyName} одиниці.' або Текст посилання)", + "tooltip-function": "Функція підказки", + "tooltip-trigger": "Тригер підказки", + "tooltip-trigger-click": "Показати підказку при кліку", + "tooltip-trigger-hover": "Показати підказку при наведенні", + "auto-close-tooltips": "Автоматичне закриття підказок", + "tooltip-offset": "Зміщення підказки", + "tooltip-offset-horizontal": "Горизонтальне", + "tooltip-offset-vertical": "Вертикальне", + "tooltip-tag-actions": "Дії з тегами", + "add-tooltip-tag-action": "Додати дію тегу", + "edit-tooltip-tag-action": "Редагувати дію тегу", + "remove-tooltip-tag-action": "Видалити дію тегу", + "action-add": "Додати", + "action-edit": "Редагувати", + "action-move": "Перемістити", + "action-remove": "Видалити", + "edit-instruments": "Інструменти", + "persist-location-attribute-scope": "Область атрибуту для збереження місця", + "enable-snapping": "Увімкнути прив'язку до інших вершин для точного малювання", + "enable-snapping-hint": "Автоматично вирівнює нові точки з існуючими формами, щоб полегшити і зробити малювання точнішим.", + "drag-drop-mode": "Режим перетягування", + "trip": { + "no-trips": "Поїздки не налаштовані", + "add-trip": "Додати поїздку", + "trip-configuration": "Налаштування поїздки", + "remove-trip": "Видалити поїздку" }, - "details-mode": { - "on-row-click": "Клацніть на рядок", - "on-action-button-click": "Клацніть на кнопку детелі", - "disabled": "Вимкнено" + "marker": { + "marker": "Маркер", + "latitude-key": "Ключ широти", + "longitude-key": "Ключ довготи", + "x-pos-key": "Ключ X позиції", + "y-pos-key": "Ключ Y позиції", + "latitude-key-required": "Ключ широти обов'язковий", + "longitude-key-required": "Ключ довготи обов'язковий", + "x-pos-key-required": "Ключ X позиції обов'язковий", + "y-pos-key-required": "Ключ Y позиції обов'язковий", + "no-markers": "Маркерів не налаштовано", + "add-marker": "Додати маркер", + "marker-configuration": "Налаштування маркера", + "remove-marker": "Видалити маркер", + "marker-type": "Тип маркера", + "marker-type-shape": "Форма", + "marker-type-icon": "Іконка", + "marker-type-image": "Зображення", + "shape": "Форма", + "icon": "Іконка", + "image": "Зображення", + "marker-shapes": "Форми маркерів", + "marker-icon": "Іконка маркера", + "marker-appearance": "Зовнішній вигляд маркера", + "marker-image": "Зображення маркера", + "marker-image-type-image": "Зображення", + "marker-image-type-function": "Функція", + "custom-marker-image-size": "Користувацький розмір зображення маркера", + "marker-image-function": "Функція зображення маркера", + "marker-images": "Зображення маркерів", + "marker-offset": "Зміщення маркера", + "offset-horizontal": "Горизонтальне", + "offset-vertical": "Вертикальне", + "rotate-marker": "Обертати маркер", + "offset-angle": "Кут зміщення", + "position-conversion": "Конверсія позиції", + "position-conversion-function": "Функція конверсії позиції, повинна повертати координати x, y як числа з плаваючою точкою від 0 до 1", + "clustering": { + "use-map-markers-clustering": "Використовувати кластеризацію маркерів на карті", + "zoom-on-cluster-click": "Масштабування при натисканні на кластер", + "max-zoom": "Максимальний рівень масштабування, коли маркер може бути частиною кластера (0 - 18)", + "max-radius": "Максимальний радіус, який покриває кластер", + "zoom-animation": "Анімація маркерів при масштабуванні", + "bounds-on-cluster-mouse-over": "Межі маркерів при наведенні миші на кластер", + "spiderfy-max-zoom-level": "Spiderfy на максимальному рівні масштабування (щоб побачити всі маркери кластера)", + "load-optimization": "Оптимізація завантаження", + "chunked-load": "Використовувати чанки для додавання маркерів, щоб сторінка не зависала", + "lazy-load": "Використовувати відкладене завантаження для додавання маркерів", + "use-cluster-marker-color-function": "Використовувати функцію кольору для маркерів кластера", + "marker-color-function": "Функція кольору маркера" + }, + "edit": "Редагувати маркер", + "remove-marker-for": "Видалити маркер для '{{entityName}}'", + "place-marker": "Розмістити маркер", + "place-marker-hint": "Натисніть, щоб розмістити маркер", + "place-marker-hint-with-entity": "Натисніть, щоб розмістити сутність '{{entityName}}'" }, - "add-to-group": "Додати до групи", - "move-to-group": "Перемістити до групи", - "select-entity-group": "Виберіть групу сутностей", - "no-entity-groups-matching": "Не знайдено жодних груп сутностей, що відповідають '{{entityGroup}}'.", - "target-entity-group-required": "Необхідно вказати цільову групу сутності.", - "remove-from-group": "Видалити з групи", - "group-table-title": "Group table title", - "enable-search": "Увімкнути пошук сутностей", - "enable-add": "Увімкнути додавання сутностей", - "enable-delete": "Увімкнути видалення сутностей", - "enable-selection": "Увімкнути вибір сутностей", - "enable-group-transfer": "Увімкнути дії групового перенесення", - "display-pagination": "Відображення сторінок", - "default-page-size": "Розмір сторінки за замовчуванням", - "enable-assignment-actions": "Увімкнути дії призначення", - "enable-credentials-management": "Увімкнути керування авторизаційними даними", - "enable-users-management": "Увімкнути керування користувачами", - "enable-assets-management": "Увімкнути керування активами", - "enable-devices-management": "Увімкнути керування пристроями", - "enable-dashboards-management": "Увімкнути керування панелями візуалізації", - "open-details-on": "Відкрити деталі сутності по", - "select-existing": "Виберіть існуючу групу сутностей", - "create-new": "Створити нову групу сутностей", - "new-entity-group-name": "Нова назва групи сутностей", - "entity-group-list": "Список групи сутностей", - "entity-group-list-empty": "Не вибрано жодної групи сутностей.", - "name-starts-with": "Назва групи сутностей починається з", - "entity-group-name-filter-required": "Необхідно задати назву групи сутностей." - }, - "entity-view": { - "entity-view": "Представлення сутності", - "entity-view-required": "Необхідно вказати представлення сутності.", - "entity-views": "Представлення сутностей", - "management": "Керування представленням сутностей", - "view-entity-views": "Переглянути представлення сутностей", - "entity-view-alias": "Псевдонім представлення сутності", - "aliases": "Псевдоніми представлення сутності", - "no-alias-matching": "Псевдонім '{{alias}}' не знайдено.", - "no-aliases-found": "Псевдоніми не знайдено.", - "no-key-matching": "Ключ '{{key}}' не знайдено.", - "no-keys-found": "Ключі не знайдено.", - "create-new-alias": "Створити новий!", - "create-new-key": "Створити новий!", - "duplicate-alias-error": "Псевдонім з такою назвою вже існує '{{alias}}'.
Псевдоніми представлення повинні бути унікальними на панелі візуалізації.", - "configure-alias": "Налаштувати псевдонім '{{alias}}'", - "no-entity-views-matching": "Сутності, які відповідають '{{entity}}' не знайдені.", - "alias": "Псевдонім", - "alias-required": "Необхідно вказати псевдонім представлення сутності.", - "remove-alias": "Видалити псевдонім представлення сутності", - "add-alias": "Додати псевдонім представлення сутності", - "name-starts-with": "Ім'я представлення сутності починається з", - "entity-view-list": "Список представлень сутностей", - "use-entity-view-name-filter": "Використати фільтр", - "entity-view-list-empty": "Не вибрано жодного представлення сутності.", - "entity-view-name-filter-required": "Необхідно вказати фільтр назв представлення сутності.", - "entity-view-name-filter-no-entity-view-matched": "Представлення сутностей, назви яких починаються з '{{entityView}}' не знайдено.", - "add": "Додати представлення сутності", - "assign-to-customer": "Призначити клієнту", - "assign-entity-view-to-customer": "Призначити представлення сутності(-ей) клієнту", - "assign-entity-view-to-customer-text": "Будь ласка, виберіть представлення сутності для призначення клієнту", - "no-entity-views-text": "Представлення сутності не знайдено", - "assign-to-customer-text": "Будь ласка, виберіть клієнта, для призначення представлення(-ь) сутності(-ей)", - "entity-view-details": "Деталі представлення сутності", - "add-entity-view-text": "Додати нове представлення сутності", - "delete": "Видалити представлення сутності", - "assign-entity-views": "Призначити представлення сутності", - "assign-entity-views-text": "Призначити { count, plural, =1 {1 представлення сутності} other {# представлення сутностей } } клієнту", - "delete-entity-views": "Видалити представлення сутностей", - "unassign-from-customer": "Відкликати у клієнта", - "unassign-entity-views": "Відкликати представлення сутностей", - "unassign-entity-views-action-title": "Відкликати { count, plural, =1 {1 представлення сутності} other {# представлень сутностей} } у клієнта", - "assign-new-entity-view": "Призначити нове представлення сутності", - "delete-entity-view-title": "Ви впевнені, що хочете видалити представлення сутності '{{entityViewName}}'?", - "delete-entity-view-text": "Будьте обережні, після підтвердження, представлення сутності та всі пов'язані з ним дані стануть недоступними.", - "delete-entity-views-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 представлення сутності } other {# представлення сутностей } }?", - "delete-entity-views-action-title": "Видалити { count, plural, =1 {1 представлення сутності } other {# представлення сутностей } }", - "delete-entity-views-text": "Будьте обережні, після підтвердження, всі виділені представлення сутностей та дні, пов'язані з ними стануть недоступними.", - "unassign-entity-view-title": "Ви впевнені, що хочете відкликати представлення сутності '{{entityViewName}}'?", - "unassign-entity-view-text": "Після підтвердження представлення сутності буде відкликане у клієнта. Дані представлення сутності не будуть доступні клієнту.", - "unassign-entity-view": "Відкликати представлення сутності", - "unassign-entity-views-title": "Ви впевнені, що хочете відкликати { count, plural, =1 {1 представлення сутності} other {# представлень сутностей} }?", - "unassign-entity-views-text": "Після підтвердження, клієнта буде позбавлено всіх виділених представлень сутностей. Дані представлень сутностей не будуть доступні клієнту.", - "entity-view-type": "Тип представлення сутності", - "entity-view-type-required": "Необхідно вказати тип представлення сутності.", - "select-entity-view-type": "Виберіть тип представлення сутності", - "enter-entity-view-type": "Введіть тип представлення сутності", - "any-entity-view": "Будь-яке представлення сутності", - "no-entity-view-types-matching": "Не знайдено жодних типів представлення сутності, що відповідають '{{entitySubtype}}'.", - "entity-view-type-list-empty": "Не вибрано тип представлення сутності.", - "entity-view-types": "Типи представлення сутності", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "events": "Події", - "details": "Деталі", - "copyId": "Скопіювати Id представлення сутності", - "assignedToCustomer": "Призначений клієнту", - "unable-entity-view-device-alias-title": "Неможливо видалити псевдонім представлення сутності", - "unable-entity-view-device-alias-text": "Не вдалося видалити псевдонім пристрою '{{entityViewAlias}}', так як він використовується наступним(-и) віджетом(-ами):
{{widgetsList}}", - "select-entity-view": "Вибрати представлення сутності", - "make-public": "Зробити представлення сутності публічним", - "make-private": "Зробити представлення сутності приватним", - "start-date": "Дата початку", - "start-ts": "Час початку", - "end-date": "Дата закінчення", - "end-ts": "Час завершення", - "date-limits": "Обмеження дати", - "client-attributes": "Атрибути клієнта", - "shared-attributes": "Спільні атрибути", - "server-attributes": "Атрибути сервера", - "timeseries": "Телеметрія", - "client-attributes-placeholder": "Атрибути клієнта", - "shared-attributes-placeholder": "Спільні атрибути", - "server-attributes-placeholder": "Атрибути сервера", - "timeseries-placeholder": "Телеметрія", - "target-entity": "Цільова сутність", - "attributes-propagation": "Поширення атрибутів", - "attributes-propagation-hint": "Представлення сутності автоматично копіюватиме вказані атрибути з цільової сутності кожного разу, коли ви зберігаєте або оновлюєте його. В цілях продуктивності, атрибути цільової сутності не поширюються на представлення сутності при кожній зміні її атрибутів. Можна ввімкнути автоматичне поширення, налаштувавши правило \"copy to view\" у вашому ланцюжку правил і пов'язуючи його з повідомленнями типу \"Post attributes\" і \"Attributes Updated\"..", - "timeseries-data": "Дані телеметрії", - "timeseries-data-hint": "Налаштуйте ключі даних телеметрії цільової сутності, які будуть доступні представленню сутності. Ці дані доступні лише для читання.", - "make-public-entity-view-title": "Ви впевнені, що бажаєте зробити представлення сутності '{{entityViewName}}' публічним?", - "make-public-entity-view-text": "Після підтвердження представлення сутності і всі пов'язані з ним дані стануть публічними і будуть доступні для інших користувачів.", - "make-private-entity-view-title": "Ви впевнені, що бажаєте зробити представлення сутності '{{entityViewName}}' приватним?", - "make-private-entity-view-text": "Після підтвердження представлення сутності і всі пов'язані з ним дані стануть приватними і не будуть доступні для інших користувачів." - }, - "event": { - "events": "Події", - "event-type": "Тип події", - "type-error": "Помилка", - "type-lc-event": "Подія життєвого циклу", - "type-stats": "Статистика", - "type-debug-converter": "Налагоджувати", - "type-debug-integration": "Налагоджувати", - "type-debug-rule-node": "Налагоджувати", - "type-debug-rule-chain": "Налагоджувати", - "no-events-prompt": "Не знайдено жодних подій", - "error": "Помилка", - "alarm": "Сигнал тривоги", - "event-time": "Час події", - "server": "Сервер", - "body": "Тіло", - "method": "Метод", - "type": "Тип", - "in": "In", - "out": "Out", - "metadata": "Метадані", - "message": "Повідомлення", - "message-id": "Id повідомлення", - "message-type": "Тип повідомлення", - "data-type": "Тип даних", - "relation-type": "Тип зв'язку", - "data": "Дані", - "event": "Подія", - "status": "Статус", - "success": "Успіх", - "failed": "Невдача", - "messages-processed": "Повідомлення опрацьовані", - "errors-occurred": "Виникли помилки", - "all-events": "Всі", - "entity-type": "Тип сутності", - "clear-request-title": "Видалити всі події", - "clear-request-text": "Ви впевнені, що хочете видалити всі події?" - }, - "extension": { - "extensions": "Розширення", - "selected-extensions": " вибрано { count, plural, =1 {1 розширення} other {# розширення} }", - "type": "Тип", - "key": "Ключ", - "value": "Значення", - "id": "Id", - "extension-id": "Id розширення", - "extension-type": "Тип розширення", - "transformer-json": "JSON *", - "unique-id-required": "Таке Id розширення вже існує.", - "delete": "Видалити розширення", - "add": "Додати розширення", - "edit": "Редагувати розширення", - "delete-extension-title": "Ви дійсно бажаєте видалити розширення '{{extensionId}}'?", - "delete-extension-text": "Будьте обережні, після підтвердження, розширення та всі пов'язані з ним дані стануть недоступними.", - "delete-extensions-title": "Ви дійсно бажаєте видалити { count, plural, =1 {1 розширення} other {# розширення} }?", - "delete-extensions-text": "Будьте обережні, після підтвердження, всі вибрані розширення будуть видалені.", - "converters": "Перетворювачі", - "converter-id": "Id перетворювача", - "configuration": "Конфігурація", - "converter-configurations": "Конфігурації перетворювача", - "token": "Маркер безпеки", - "add-converter": "Додати конвертер", - "add-config": "Додати конфігурацію конвертера", - "device-name-expression": "Маска назви пристрою", - "device-type-expression": "Маска типу пристрою", - "custom": "Користувач", - "to-double": "Подвоїти", - "transformer": "Трансформатор", - "json-required": "Необхідно вказати json трансформатора.", - "json-parse": "Неможливо проаналізувати json трансформатора.", - "attributes": "Атрибути", - "add-attribute": "Додати атрибут", - "add-map": "Додати елемент відображення", - "timeseries": "Телеметрія", - "add-timeseries": "Додати параметри телеметрії", - "field-required": "Поле обовʼязкове для заповнення", - "brokers": "Брокери", - "add-broker": "Додати брокера", - "host": "Хост", - "port": "Порт", - "port-range": "Значення порту має бути в діапазоні від 1 до 65535.", - "ssl": "Ssl", - "credentials": "Авторизаційні дані", - "username": "Ім'я користувача", - "password": "Пароль", - "retry-interval": "Інтервал повтору в мілісекундах", - "anonymous": "Анонімний", - "basic": "Основний", - "pem": "PEM", - "ca-cert": "Файл CA сертифіката *", - "private-key": "Файл приватного ключа *", - "cert": "Файл сертифіката *", - "no-file": "Не вибрано жодного файлу.", - "drop-file": "Перетягніть файл, або клацніть, щоб вибрати файл для завантаження.", - "mapping": "Зіставлення", - "topic-filter": "Фільтр тем", - "converter-type": "Тип конвертера", - "converter-json": "Json", - "json-name-expression": " Json вираз для назви пристрою", - "topic-name-expression": "Вираз для назви пристрою в назві теми", - "json-type-expression": "Json вираз для типу пристрою", - "topic-type-expression": "Вираз для типу пристрою в назві теми", - "attribute-key-expression": "Вираз для ключа атрибута", - "attr-json-key-expression": " Json вираз для ключа атрибута", - "attr-topic-key-expression": "Вираз для ключа атрибута в назві теми", - "request-id-expression": "Вираз для id запиту", - "request-id-json-expression": "Json вираз для id запиту", - "request-id-topic-expression": "Вираз для id запиту в назві теми", - "response-topic-expression": "Вираз для теми відповідей", - "value-expression": "Вираз для значення", - "topic": "Тема", - "timeout": "Час очікування в мілісекундах", - "converter-json-required": "Необхідно вказати json конвертер.", - "converter-json-parse": "Неможливо проаналізувати json конвертера.", - "filter-expression": "Вираз для фільтра", - "connect-requests": "Запити на підключення", - "add-connect-request": "Додати запит на підключення", - "disconnect-requests": "Відключення запитів", - "add-disconnect-request": "Додати запит на відключення", - "attribute-requests": "Запити атрибутів", - "add-attribute-request": "Додати запит атрибута", - "attribute-updates": "Оновлення атрибутів", - "add-attribute-update": "Додати оновлення атрибутів", - "server-side-rpc": "Серверна сторона RPC", - "add-server-side-rpc-request": "Додати RPC-запит на стороні сервера", - "device-name-filter": "Фільтр назви пристрою", - "attribute-filter": "Фільтр атрибутів", - "method-filter": "Фільтр методів", - "request-topic-expression": "Вираз для теми запитів", - "response-timeout": "Час очікування відповіді в мілісекундах", - "topic-expression": "Вираз для назви теми", - "client-scope": "Обсяг клієнта", - "add-device": "Додати пристрій", - "opc-server": "Сервери", - "opc-add-server": "Додати сервер", - "opc-add-server-prompt": "Будь ласка, додайте сервер", - "opc-application-name": "Назва програми", - "opc-application-uri": "URI програми", - "opc-scan-period-in-seconds": "Період сканування в секундах", - "opc-security": "Безпека", - "opc-identity": "Ідентифікація", - "opc-keystore": "Сховище ключів", - "opc-type": "Тип", - "opc-keystore-type": "Тип", - "opc-keystore-location": "Розташування *", - "opc-keystore-password": "Пароль", - "opc-keystore-alias": "Псевдонім", - "opc-keystore-key-password": "Пароль для ключа", - "opc-device-node-pattern": "Патерн OPC вузла пристрою", - "opc-device-name-pattern": "Патерн назви пристрою", - "modbus-server": "Сервери/ведені пристрої", - "modbus-add-server": "Додати сервер/ведений пристрій", - "modbus-add-server-prompt": "Будь ласка, додайте сервер/ведений пристрій", - "modbus-transport": "Транспорт", - "modbus-tcp-reconnect": "Перепідключатися автоматично", - "modbus-port-name": "Ім'я послідовного порту", - "modbus-encoding": "Кодування", - "modbus-parity": "Паритет", - "modbus-baudrate": "Швидкість передачі даних", - "modbus-databits": "Біти даних", - "modbus-stopbits": "Стоп-біти", - "modbus-databits-range": "Біти даних повинні знаходитися в діапазоні від 7 до 8.", - "modbus-stopbits-range": "Стоп-біти повинні знаходитися в діапазоні від 1 до 2.", - "modbus-unit-id": "Unit ID", - "modbus-unit-id-range": "Unit ID should be in a range from 1 to 247.", - "modbus-device-name": "Назва пристрою", - "modbus-poll-period": "Період опитування (мс)", - "modbus-attributes-poll-period": "Період опитування атрибутів (мс)", - "modbus-timeseries-poll-period": "Період опитування телеметрії (мс)", - "modbus-poll-period-range": "Період опитування повинен бути більше 0.", - "modbus-tag": "Тег", - "modbus-function": "Modbus функція", - "modbus-register-address": "Адреса регістру ", - "modbus-register-address-range": "Адреса регістру повинна бути в діапазоні від 0 до 65535.", - "modbus-register-bit-index": "Номер бітів", - "modbus-register-bit-index-range": "Номер бітів повинен знаходитися в діапазоні від 0 до 15.", - "modbus-register-count": "Рахунок регістру", - "modbus-register-count-range": "Рахунок регістру повинен бути більше 0.", - "modbus-byte-order": "Порядок байтів", - "sync": { - "status": "Статус", - "sync": "Синхронізований", - "not-sync": "Не синхронізований", - "last-sync-time": "Час останньої синхронізації", - "not-available": "Недоступний" + "path": { + "path": "Шлях", + "path-decorator": "Декоратор шляху", + "decorator-symbol": "Символ декоратора", + "decorator-symbol-arrow-head": "Стрілка", + "decorator-symbol-dash": "Риска", + "decorator-arrangement": "Розташування декоратора", + "decorator-offset": "Початок", + "decorator-end-offset": "Кінець", + "decorator-repeat": "Повторювати" }, - "export-extensions-configuration": "Експортувати конфігурацію розширень", - "import-extensions-configuration": "Імпортувати конфігурацію розширень", - "import-extensions": "Імпортувати розширення", - "import-extension": "Імпортувати розширення", - "export-extension": "Експортувати розширення", - "file": "Файл розширень", - "invalid-file-error": "Неправильний формат файлу" - }, - "feature": { - "advanced-features": "Додаткові можливості" - }, - "fullscreen": { - "expand": "Відкрити у повноекранному режимі", - "exit": "Вийти з повноекранного режиму", - "toggle": "Перемкнути повноекранний режим", - "fullscreen": "Повноекранний режим" - }, - "function": { - "function": "Функція" - }, - "grid": { - "delete-item-title": "Ви впевнені, що хочете видалити цей елемент?", - "delete-item-text": "Будьте обережні, після підтвердження, цей елемент і всі пов'язані з ним дані, стануть недоступними.", - "delete-items-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 елемент} other {# елементи} }?", - "delete-items-action-title": "Видалити{ count, plural, =1 {1 елемент} other {# елементи} }", - "delete-items-text": "Будьте обережні, після підтвердження, всі виділені елементи і пов'язані з ними дані, стануть недоступними.", - "add-item-text": "Додати новий елемент", - "no-items-text": "Не знайдено жодного елемента", - "item-details": "Деталі елемента", - "delete-item": "Видалити елемент", - "delete-items": "Видалити елементи", - "scroll-to-top": "Перейти угору" - }, - "help": { - "goto-help-page": "Перейти на сторінку довідки" - }, - "home": { - "home": "Домашня сторінка", - "profile": "Профіль", - "logout": "Вийти", - "menu": "Меню", - "avatar": "Аватар", - "open-user-menu": "Відкрити меню користувача" - }, - "file-input": { - "browse-file": "Виберіть файл", - "browse-files": "Виберіть файли" - }, - "image": { - "gallery": "Галерея зображень", - "search": "Пошук зображень", - "selected-images": "{ count, plural, =1 {1 зображення} many {# зображень} other {# зображення} } вибрано", - "created-time": "Час створення", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "resolution": "Розд. здатність", - "size": "Розмір", - "system": "Системне", - "download-image": "Завантажити зображення", - "export-image": "Експортувати зображення до JSON", - "import-image": "Імпортувати зображення з JSON", - "upload-image": "Надіслати зображення", - "edit-image": "Редагувати зображення", - "image-details": "Деталі зображення", - "no-images": "Не знайдено жодного зображення", - "delete-image": "Видалити зображення", - "delete-images-text": "Будьте обережні, після підтвердження, всі вибрані зображення будуть видалені, а всі пов'язані з ним дані стануть недоступними для відновлення.", - "delete-image-title": "Ви впевнені, що хочете видалити зображення '{{imageTitle}}'?", - "list-mode": "Перегляд списком", - "grid-mode": "Перегляд ґраткою", - "image-preview": "Попередній перегляд зображення", - "update-image": "Оновити зображення", - "export-failed-error": "Не вдалося експортувати зображення: {{error}}", - "image-json-file": "Файл JSON із зображення", - "invalid-image-json-file-error": "Не вдалося імпортувати з JSON: Неправильна структура даних зображення в JSON.", - "image-is-in-use": "Зображення вже використовується в інших сутностях", - "images-are-in-use": "Зображення вже використовуються в інших сутностях", - "image-is-in-use-text": "Зображення '{{title}}' не було видалено, тому що воно вже використовується в таких сутностях:", - "images-are-in-use-text": "Не всі зображення були видалені, оскільки вони використовуються в інших сутностях.
Ви можете переглянути вищезгадані сутності, натиснувши на кнопку Посилання у відповідному рядку зображення.
Якщо ви все ж бажаєте видалити ці зображення, виберіть їх у таблиці нижче та натисніть кнопку Видалити вибране.", - "delete-image-in-use-text": "Якщо ви все ж бажаєте видалити ці зображення, натисніть кнопку Все одно видалити.", - "system-entities": "Системні сутності:", - "entities": "сутності:", - "references": "Посилання", - "include-system-images": "Включаючи системні зображення", - "clear-image": "Прибрати зображення", - "no-image": "Відсутні зображення", - "no-image-selected": "Не вибрано жодного зображення", - "browse-from-gallery": "Переглянути в галереї", - "set-link": "Створити посилання", - "image-link": "Посилання на зображення", - "link": "Посилання", - "copy-image-link": "Скопіювати посилання на зображення", - "embed-image": "Вбудувати зображення", - "embed-to-html": "Вбудувати в HTML", - "embed-to-html-hint": "Ця функція зробить посилання доступним для будь-якого неавторизованого користувача.", - "embed-to-html-text": "Використовуючи цей фрагмент коду, ви можете вбудовувати зображення в компоненти на базі звичайного HTML.
До таких компонентів належать: HTML картки віджетів, функції вмісту комірок, тощо.", - "embed-to-angular-template": "Вбудувати в HTML-шаблон Angular", - "embed-to-angular-template-text": "Використовуючи цей фрагмент коду, ви можете вбудувати зображення в HTML-шаблон Angular для використання в компонентах.
До таких компонентів належать: віджет Markdown, HTML-секція в редакторі віджетів, користувацькі дії тощо." - }, - "image-input": { - "drop-images-or": "Перетягування зображень чи", - "drag-and-drop": "Перетягування", - "or": "чи", - "browse": "Огляд", - "no-images": "Не вибрано жодного зображення", - "images": "зображення" - }, - "import": { - "no-file": "Не вибрано жодного файлу", - "drop-file": "Перетягніть JSON файл, або клацніть, щоб вибрати файл для завантаження.", - "drop-csv-file": "Перетягніть CSV файл, або клацніть, щоб вибрати файл для завантаження.", - "column-value": "Значення", - "column-title": "Назва", - "column-example": "Приклад значень даних", - "column-key": "Ключ атрибута/телеметрії", - "csv-delimiter": "Розділювач в CSV файлі", - "csv-first-line-header": "Перший рядок містить назви колонок", - "csv-update-data": "Оновити атрибути/телеметрію", - "import-csv-number-columns-error": "Файл має містити як мінімум дві колонки", - "import-csv-invalid-format-error": "Невірний формат даних. Рядок: '{{line}}'", - "column-type": { - "name": "Назва", - "type": "Тип", - "label": "Мітка", - "column-type": "Тип колонки", - "client-attribute": "Атрибут клієнта", - "shared-attribute": "Спільний атрибут", - "server-attribute": "Атрибут сервера", - "timeseries": "Телеметрія", - "entity-field": "Entity field", - "access-token": "Токен" + "points": { + "points": "Точки", + "point-tooltip": "Підказка для точки" }, - "stepper-text": { - "select-file": "Виберіть файл", - "configuration": "Конфігурація імпорту", - "column-type": "Виберіть тип колонок", - "creat-entities": "Створення нових сутностей" + "shape": { + "fill": "Заповнення", + "fill-type-color": "Колір", + "fill-type-stripe": "Смуга", + "fill-type-image": "Зображення", + "color": "Колір", + "stripe": "Смуга", + "image": "Зображення", + "stroke": "Контур", + "fill-image": "Заповнення зображенням", + "fill-image-type-image": "Зображення", + "fill-image-type-function": "Функція", + "preserve-aspect-ratio": "Зберігати співвідношення сторін", + "opacity": "Прозорість", + "angle": "Кут обертання", + "scale": "Масштаб", + "fill-image-function": "Функція заповнення зображенням для форми", + "fill-images": "Зображення для заповнення форми", + "stripe-pattern": "Візерунок смуги", + "first-stripe": "Перша смуга", + "second-stripe": "Друга смуга" }, - "message": { - "create-entities": "{{count}} нову(-их) сутність(-ей) успішно створено.", - "update-entities": "{{count}} сутність(-ей) успішно оновлено.", - "error-entities": "Виникла помилка при створенні {{count}} сутності(-ей)." - } + "polygon": { + "polygon-key": "Ключ полігону", + "polygon-key-required": "Ключ полігону необхідний", + "no-polygons": "Полігони не налаштовані", + "add-polygon": "Додати полігон", + "polygon-configuration": "Налаштування полігону", + "remove-polygon": "Видалити полігон", + "edit": "Редагувати полігон", + "remove-polygon-for": "Видалити полігон для '{{entityName}}'", + "cut": "Обрізати область полігону", + "rotate": "Поворот полігону", + "draw-rectangle": "Малювати прямокутник", + "draw-polygon": "Малювати полігон", + "polygon-place-first-point-cut-hint": "Натисніть, щоб розмістити першу точку", + "continue-polygon-cut-hint": "Натисніть, щоб продовжити малювати", + "finish-polygon-cut-hint": "Натисніть на перший маркер, щоб завершити та зберегти", + "polygon-place-first-point-hint": "Полігон: натисніть, щоб розмістити першу точку", + "polygon-place-first-point-hint-with-entity": "Полігон для '{{entityName}}': натисніть, щоб розмістити першу точку", + "continue-polygon-hint": "Полігон: натисніть, щоб продовжити малювати", + "continue-polygon-hint-with-entity": "Полігон для '{{entityName}}': натисніть, щоб продовжити малювати", + "finish-polygon-hint": "Полігон: натисніть на перший маркер, щоб завершити малювання", + "finish-polygon-hint-with-entity": "Полігон для '{{entityName}}': натисніть на перший маркер, щоб завершити та зберегти", + "rectangle-place-first-point-hint": "Прямокутник: натисніть, щоб розмістити першу точку", + "rectangle-place-first-point-hint-with-entity": "Прямокутник для '{{entityName}}': натисніть, щоб розмістити першу точку", + "finish-rectangle-hint": "Прямокутник: натисніть, щоб завершити малювання", + "finish-rectangle-hint-with-entity": "Прямокутник для '{{entityName}}': натисніть, щоб завершити та зберегти" + }, + "circle": { + "circle-key": "Ключ кола", + "circle-key-required": "Ключ кола необхідний", + "no-circles": "Кола не налаштовані", + "add-circle": "Додати коло", + "circle-configuration": "Налаштування кола", + "remove-circle": "Видалити коло", + "edit": "Редагувати коло", + "remove-circle-for": "Видалити коло для '{{entityName}}'", + "draw-circle": "Намалювати коло", + "place-circle-center-hint-with-entity": "Коло для '{{entityName}}': натисніть, щоб розмістити центр кола", + "place-circle-center-hint": "Коло: натисніть, щоб розмістити центр кола", + "finish-circle-hint-with-entity": "Коло для '{{entityName}}': натисніть, щоб завершити та зберегти коло", + "finish-circle-hint": "Коло: натисніть, щоб завершити малювання" + }, + "polyline": { + "polyline-key": "Ключ полілінії", + "polyline-key-required": "Ключ полілінії необхідний", + "no-polylines": "Полілінії не налаштовані", + "add-polylines": "Додати полілінію", + "polyline-configuration": "Налаштування полілінії", + "remove-polyline": "Видалити полілінію", + "edit": "Редагувати полілінію", + "cut": "Обрізати область полілінії", + "rotate": "Поворот полілінії", + "remove-polyline-for": "Видалити полілінію для '{{entityName}}'", + "draw-polyline": "Малювати полілінію", + "polyline-place-first-point-hint-with-entity": "Полілінія для '{{entityName}}': натисніть, щоб розмістити першу точку", + "polyline-place-first-point-hint": "Полілінія: натисніть, щоб розмістити першу точку", + "finish-polyline-hint-with-entity": "Полілінія для '{{entityName}}': натисніть, щоб завершити малювання", + "finish-polyline-hint": "Полілінія: натисніть, щоб завершити малювання", + "polyline-place-first-point-cut-hint": "Натисніть, щоб розмістити першу точку", + "finish-polyline-cut-hint": "Натисніть на перший маркер, щоб завершити та зберегти" + }, + "select-entity": "Вибрати сутність", + "select-entity-hint": "Підказка: після вибору натисніть на карті, щоб встановити позицію" + }, + "select-entity": "Вибрати сутність", + "select-entity-hint": "Підказка: після вибору натисніть на карті, щоб встановити позицію", + "tooltips": { + "placeMarker": "Натисніть, щоб розмістити сутність '{{entityName}}'", + "firstVertex": "Полілінія для '{{entityName}}': натисніть, щоб розмістити першу точку", + "firstVertex-cut": "Натисніть, щоб розмістити першу точку", + "continueLine": "Полілінія для '{{entityName}}': натисніть, щоб продовжити малювання", + "continueLine-cut": "Натисніть, щоб продовжити малювання", + "finishLine": "Натисніть на будь-який існуючий маркер, щоб завершити", + "finishPoly": "Полілінія для '{{entityName}}': натисніть на перший маркер, щоб завершити і зберегти", + "finishPoly-cut": "Натисніть на перший маркер, щоб завершити і зберегти", + "finishRect": "Полілінія для '{{entityName}}': натисніть, щоб завершити і зберегти", + "startCircle": "Коло для '{{entityName}}': натисніть, щоб розмістити центр кола", + "finishCircle": "Коло для '{{entityName}}': натисніть, щоб завершити коло", + "placeCircleMarker": "Натисніть, щоб розмістити маркер кола" + }, + "actions": { + "finish": "Завершити", + "cancel": "Скасувати", + "removeLastVertex": "Видалити останню точку" + }, + "buttonTitles": { + "drawMarkerButton": "Розмістити сутність", + "drawPolyButton": "Створити полілінію", + "drawLineButton": "Створити полілінію", + "drawCircleButton": "Створити коло", + "drawRectButton": "Створити прямокутник", + "editButton": "Режим редагування", + "dragButton": "Режим перетягування", + "cutButton": "Обрізати площу полілінії", + "deleteButton": "Видалити", + "drawCircleMarkerButton": "Створити маркер кола", + "rotateButton": "Поворот полілінії" + }, + "map-provider-settings": "Налаштування постачальника карт", + "map-provider": "Постачальник карт", + "map-provider-google": "Google Maps", + "map-provider-openstreet": "OpenStreet Maps", + "map-provider-here": "HERE Maps", + "map-provider-image": "Зображення карти", + "map-provider-tencent": "Tencent Maps", + "openstreet-provider": "Постачальник карт OpenStreet", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik (За замовчуванням)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "Використовувати власного постачальника", + "custom-provider-tile-url": "URL плитки власного постачальника", + "google-maps-api-key": "Google Maps API ключ", + "default-map-type": "Тип карти за замовчуванням", + "google-map-type-roadmap": "Дорожня карта", + "google-map-type-satelite": "Супутникова карта", + "google-map-type-hybrid": "Гібридна карта", + "google-map-type-terrain": "Рельєфна карта", + "map-layer": "Шар карти", + "here-map-normal-day": "HERE.normalDay (За замовчуванням)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "Облікові дані", + "here-app-id": "ID програми HERE", + "here-app-code": "Код програми HERE", + "here-api-key": "API ключ HERE", + "here-use-new-version-api-3": "Використовувати версію API 3", + "tencent-maps-api-key": "API ключ Tencent Maps", + "tencent-map-type-roadmap": "Дорожня карта", + "tencent-map-type-satelite": "Супутникова карта", + "tencent-map-type-hybrid": "Гібридна карта", + "image-map-background": "Фон карти з зображенням", + "image-map-background-from-entity-attribute": "Взяти фон карти з атрибута сутності", + "image-url-source-entity-alias": "Аліас сутності джерела URL зображення", + "image-url-source-entity-attribute": "Атрибут сутності джерела URL зображення", + "common-map-settings": "Загальні налаштування карти", + "x-pos-key-name": "Назва ключа X позиції", + "y-pos-key-name": "Назва ключа Y позиції", + "latitude-key-name": "Назва ключа широти", + "longitude-key-name": "Назва ключа довготи", + "default-map-zoom-level": "Рівень масштабування карти за замовчуванням (0 - 20)", + "default-map-center-position": "Позиція центру карти за замовчуванням (0,0)", + "disable-scroll-zooming": "Вимкнути масштабування за допомогою прокручування", + "disable-double-click-zooming": "Вимкнути масштабування подвійним клацанням", + "disable-zoom-control-buttons": "Вимкнути кнопки управління масштабуванням", + "fit-map-bounds": "Підлаштувати межі карти, щоб покрити всі маркери", + "use-default-map-center-position": "Використовувати позицію центру карти за замовчуванням", + "entities-limit": "Обмеження кількості сутностей для завантаження", + "markers-settings": "Налаштування маркерів", + "marker-offset-x": "Зсув маркера по осі X відносно позиції, помножений на ширину маркера", + "marker-offset-y": "Зсув маркера по осі Y відносно позиції, помножений на висоту маркера", + "position-function": "Функція перетворення позиції, повинна повертати координати x, y як числа з плаваючою комою від 0 до 1", + "draggable-marker": "Перетасовуваний маркер", + "label": "Маркування", + "show-label": "Показати маркування", + "use-label-function": "Використовувати функцію маркування", + "label-pattern": "Маркування (приклади шаблонів: '${entityName}', '${entityName}: (Текст ${keyName} одиниць.)')", + "label-function": "Функція маркування", + "tooltip": "Підказка", + "show-tooltip": "Показати підказку", + "show-tooltip-action": "Дія для відображення підказки", + "show-tooltip-action-click": "Показати підказку при кліку (за замовчуванням)", + "show-tooltip-action-hover": "Показати підказку при наведенні", + "auto-close-tooltips": "Автоматично закривати підказки", + "use-tooltip-function": "Використовувати функцію підказки", + "tooltip-pattern": "Підказка (наприклад, 'Текст ${keyName} одиниць.' або Текст посилання)", + "tooltip-function": "Функція підказки", + "tooltip-offset-x": "Зсув підказки по осі X відносно якоря маркера, помножений на ширину маркера", + "tooltip-offset-y": "Зсув підказки по осі Y відносно якоря маркера, помножений на висоту маркера", + "color": "Колір", + "use-color-function": "Використовувати функцію кольору", + "color-function": "Функція кольору", + "marker-image": "Зображення маркера", + "use-marker-image-function": "Використовувати функцію зображення маркера", + "custom-marker-image": "Користувацьке зображення маркера", + "custom-marker-image-size": "Розмір користувацького зображення маркера (px)", + "marker-image-function": "Функція зображення маркера", + "marker-images": "Зображення маркерів", + "polygon-settings": "Налаштування полігону", + "show-polygon": "Показати полігон", + "polygon-key-name": "Назва ключа полігону", + "enable-polygon-edit": "Дозволити редагування полігону", + "polygon-label": "Маркування полігону", + "show-polygon-label": "Показати маркування полігону", + "use-polygon-label-function": "Використовувати функцію маркування полігону", + "polygon-label-pattern": "Маркування полігону (приклади шаблонів: '${entityName}', '${entityName}: (Текст ${keyName} одиниць.)')", + "polygon-label-function": "Функція маркування полігону", + "polygon-tooltip": "Підказка для полігону", + "show-polygon-tooltip": "Показати підказку для полігону", + "auto-close-polygon-tooltips": "Автоматично закривати підказки для полігону", + "use-polygon-tooltip-function": "Використовувати функцію підказки для полігону", + "polygon-tooltip-pattern": "Підказка для полігону (наприклад, 'Текст ${keyName} одиниць.' або Текст посилання)", + "polygon-tooltip-function": "Функція підказки для полігону", + "polygon-color": "Колір полігону", + "polygon-opacity": "Прозорість полігону", + "use-polygon-color-function": "Використовувати функцію кольору полігону", + "polygon-color-function": "Функція кольору полігону", + "polygon-stroke": "Контур полігону", + "stroke-color": "Колір обводки", + "stroke-opacity": "Прозорість обводки", + "stroke-weight": "Товщина обводки", + "use-polygon-stroke-color-function": "Використовувати функцію кольору обводки полігону", + "polygon-stroke-color-function": "Функція кольору обводки полігону", + "circle-settings": "Налаштування кола", + "show-circle": "Показати коло", + "circle-key-name": "Назва ключа кола", + "enable-circle-edit": "Дозволити редагування кола", + "circle-label": "Маркування кола", + "show-circle-label": "Показати маркування кола", + "use-circle-label-function": "Використовувати функцію маркування кола", + "circle-label-pattern": "Маркування кола (приклади шаблонів: '${entityName}', '${entityName}: (Текст ${keyName} одиниць.)')", + "circle-label-function": "Функція маркування кола", + "circle-tooltip": "Підказка для кола", + "show-circle-tooltip": "Показати підказку для кола", + "auto-close-circle-tooltips": "Автоматично закривати підказки для кола", + "use-circle-tooltip-function": "Використовувати функцію підказки для кола", + "circle-tooltip-pattern": "Підказка для кола (наприклад, 'Текст ${keyName} одиниць.' або Текст посилання)", + "circle-tooltip-function": "Функція підказки для кола", + "circle-fill-color": "Колір заливки кола", + "circle-fill-color-opacity": "Прозорість кольору заливки кола", + "use-circle-fill-color-function": "Використовувати функцію кольору заливки кола", + "circle-fill-color-function": "Функція кольору заливки кола", + "circle-stroke": "Обводка кола", + "use-circle-stroke-color-function": "Використовувати функцію кольору обводки кола", + "circle-stroke-color-function": "Функція кольору обводки кола", + "markers-clustering-settings": "Налаштування кластеризації маркерів", + "use-map-markers-clustering": "Використовувати кластеризацію маркерів на карті", + "zoom-on-cluster-click": "Масштабувати при кліку на кластер", + "max-cluster-zoom": "Максимальний рівень масштабування для маркерів, що можуть бути частиною кластера (0 - 18)", + "max-cluster-radius-pixels": "Максимальний радіус, який може покривати кластер в пікселях", + "cluster-zoom-animation": "Показати анімацію на маркерах при масштабуванні", + "show-markers-bounds-on-cluster-mouse-over": "Показати межі маркерів при наведенні на кластер", + "spiderfy-max-zoom-level": "Spiderfy на максимальному рівні масштабування (щоб побачити всі маркери кластера)", + "load-optimization": "Оптимізація завантаження", + "cluster-chunked-loading": "Використовувати чанки для додавання маркерів, щоб не заморожувати сторінку", + "cluster-markers-lazy-load": "Використовувати лінійне завантаження для додавання маркерів", + "editor-settings": "Налаштування редактора", + "enable-snapping": "Включити прив'язку до інших вершин для точного малювання", + "init-draggable-mode": "Ініціалізувати карту в режимі перетягування", + "hide-all-edit-buttons": "Приховати всі кнопки керування редагуванням", + "hide-draw-buttons": "Приховати кнопки малювання", + "hide-edit-buttons": "Приховати кнопки редагування", + "hide-remove-button": "Приховати кнопку видалення", + "route-map-settings": "Налаштування карти маршруту", + "trip-animation-settings": "Налаштування анімації поїздки", + "normalization-step": "Крок нормалізації даних (мс)", + "tooltip-background-color": "Колір фону підказки", + "tooltip-font-color": "Колір шрифта підказки", + "tooltip-opacity": "Прозорість підказки (0-1)", + "auto-close-tooltip": "Автоматично закривати підказку", + "rotation-angle": "Встановити додатковий кут повороту для маркера (градуси)", + "path-settings": "Налаштування шляху", + "path-color": "Колір шляху", + "use-path-color-function": "Використовувати функцію кольору шляху", + "path-color-function": "Функція кольору шляху", + "path-decorator": "Декоратор шляху", + "use-path-decorator": "Використовувати декоратор шляху", + "decorator-symbol": "Символ декоратора", + "decorator-symbol-arrow-head": "Стрілка", + "decorator-symbol-dash": "Рамка", + "decorator-symbol-size": "Розмір символу декоратора (пікселі)", + "use-path-decorator-custom-color": "Використовувати власний колір декоратора шляху", + "decorator-custom-color": "Власний колір декоратора", + "decorator-offset": "Зсув декоратора", + "end-decorator-offset": "Зсув кінцевого декоратора", + "decorator-repeat": "Повтор декоратора", + "points-settings": "Налаштування точок", + "show-points": "Показати точки", + "point-color": "Колір точки", + "point-size": "Розмір точки (пікселі)", + "use-point-color-function": "Використовувати функцію кольору точки", + "point-color-function": "Функція кольору точки", + "use-point-as-anchor": "Використовувати точку як якір", + "point-as-anchor-function": "Функція точки як якоря", + "independent-point-tooltip": "Незалежна підказка для точки", + "clustering-markers": "Кластеризація маркерів", + "use-icon-create-function": "Використовувати функцію кольору маркерів", + "marker-color-function": "Функція кольору маркерів" }, - "integration": { - "integration": "Інтеграція", - "integrations": "Інтеграції", - "select-integration": "Виберіть інтеграцію", - "no-integrations-matching": "Не знайдено жодних інтеграцій, які відповідають '{{entity}}'.", - "integration-required": "Необхідно вказати інтеграцію", - "delete": "Видалити інтеграцію", - "management": "Управління інтеграціями", - "add-integration-text": "Додати нову інтеграцію", - "no-integrations-text": "Не знайдено жодної інтеграції", - "selected-integrations": "{ count, plural, =1 {1 інтеграція} other {# інтеграції} } вибрано", - "delete-integration-title": "Ви впевнені, що хочете видалити інтеграцію '{{integrationName}}'?", - "delete-integration-text": "Будьте обережні, після підтвердження інтеграція та всі пов'язані з нею дані стануть недоступними.", - "delete-integrations-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 інтеграцію} other {# інтеграції} }?", - "delete-integrations-action-title": "Видалити { count, plural, =1 {1 інтеграцію} other {# інтеграції} }", - "delete-integrations-text": "Будьте обережні, після підтвердження всі вибрані інтеграції будуть видалені, і всі пов'язані з ними дані стануть недоступними.", - "events": "Події", - "add": "Додати інтеграцію", - "integration-details": "Деталі інтеграції", - "details": "Деталі", - "copyId": "Копіювати Id інтеграції", - "idCopiedMessage": "Id інтеграції скопійовано в буфер обміну", - "debug-mode": "Режим налагодження", - "enable-security": "Увімкнути безпеку", - "headers-filter": "Заголовки фільтра", - "header": "Заголовок", - "no-headers-filter": "Немає фільтрів заголовків", - "downlink-url": "Downlink URL", - "application-uri": "URI програми", - "as-id": "AS ID", - "as-id-required": "Необхідно вказати AS ID.", - "as-key": "AS ключ", - "as-key-required": "Необхідно вказати AS ключ.", - "max-time-diff-in-seconds": "Максимальна різниця в часі (секунди)", - "max-time-diff-in-seconds-required": "Необхідно вказати максимальну різницю в часі.", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "base-url": "Базова URL-адреса", - "base-url-required": "Необхідно вказати базову URL-адресу", - "security-key": "Ключ захисту", - "http-endpoint": "URL кінцевої точки HTTP", - "copy-http-endpoint-url": "Скопіювати URL-адресу кінцевої точки HTTP", - "http-endpoint-url-copied-message": "URL кінцевої точки HTTP скопійовано в буфер обміну", - "host": "Хост", - "host-required": "Необхідно вказати хост.", - "host-type": "Тип хоста", - "host-type-required": "Необхідно вказати тип хоста.", - "custom-host": "Хост користувача", - "custom-host-required": "Необхідний спеціальний хост.", - "port": "Порт", - "port-required": "Необхідно вказати порт.", - "port-range": "Порт має бути в діапазоні від 1 до 65535.", - "connect-timeout": "Час очікування з'єднання (сек)", - "connect-timeout-required": " Необхідно вказати час з'єднання підключення.", - "connect-timeout-range": "Час очікування з'єднання має бути в діапазоні від 1 до 200.", - "client-id": "ID клієнта", - "clean-session": "Очистити сеанс", - "enable-ssl": "Увімкнути SSL", - "credentials": "Авторизаційні дані", - "credentials-type": "Тип авторизаційних даних", - "credentials-type-required": "Необхідно вказати тип авторизаційних даних.", - "username": "Ім'я користувача", - "username-required": "Необхідно вказати ім'я користувача.", - "password": "Пароль", - "password-required": "Необхідно вказати пароль.", - "ca-cert": "Файл сертифіката CA *", - "private-key": "Файл приватного ключа *", - "private-key-password": "Пароль приватного ключа", - "cert": "Файл сертифіката*", - "no-file": "Не вибрано жодного файлу.", - "drop-file": "Перетягніть файл, або клацніть, щоб вибрати файл для завантаження.", - "topic-filters": "Тематичні фільтри", - "remove-topic-filter": "Видалити фільтр тем", - "add-topic-filter": "Додати фільтр тем", - "add-topic-filter-prompt": "Будь ласка, додайте фільтр тем", - "topic": "Тема", - "mqtt-qos": "QoS", - "mqtt-qos-at-most-once": "Не більше одного разу", - "mqtt-qos-at-least-once": "Принаймні, один раз", - "mqtt-qos-exactly-once": "Точно один раз", - "downlink-topic-pattern": "Downlink topic pattern", - "downlink-topic-pattern-required": "Downlink topic pattern is required.", - "aws-iot-endpoint": "AWS IoT Endpoint", - "aws-iot-endpoint-required": "AWS IoT Endpoint is required.", - "aws-iot-credentials": "Авторизаційні дані AWS IoT", - "application-credentials": "Авторизаційні дані додатків", - "api-key": "API ключ", - "api-key-required": "Необхідно вказати API ключ.", - "auth-token": "Маркер аутентифікації", - "auth-token-required": "Необхідно вказати маркер аутентифікації.", - "region": "Регіон", - "region-required": "Необхідно вказати регіон.", - "application-id": "ID програми", - "application-id-required": "Необхідно вказати ID програми.", - "access-key": "Ключ доступу", - "access-key-required": "Необхідно вказати ключ доступу.", - "connection-parameters": "Параметри підключення", - "service-bus-namespace-name": "Service Bus Namespace Name", - "service-bus-namespace-name-required": "Необхідно вказати Service Bus Namespace Name is required.", - "event-hub-name": "Event Hub Name", - "event-hub-name-required": "Необхідно вказати назву Event Hub.", - "sas-key-name": "Назва ключа SAS", - "sas-key-name-required": "Необхідно вказати назву ключа SAS.", - "sas-key": "Ключ SAS", - "sas-key-required": "Потрібен ключ SAS.", - "iot-hub-name": "Назва IoT Hub (необхідна для downlink)", - "metadata": "Метадані", - "type": "Тип", - "type-required": "Необхідно вказати тип.", - "uplink-converter": "Конвертер передачі даних", - "uplink-converter-required": "Необхідно вказати конвертер передачі даних.", - "downlink-converter": "Downlink data converter", - "type-http": "HTTP", - "type-ocean-connect": "OceanConnect", - "type-sigfox": "SigFox", - "type-thingpark": "ThingPark", - "type-tmobile-iot-cdp": "T-Mobile – IoT CDP", - "type-mqtt": "MQTT", - "type-aws-iot": "AWS IoT", - "type-ibm-watson-iot": "IBM Watson IoT", - "type-ttn": "TheThingsNetwork", - "type-azure-event-hub": "Azure Event Hub", - "type-opc-ua": "OPC-UA", - "type-ffb": "FFB", - "opc-ua-application-name": "Назва програми", - "opc-ua-application-uri": "Application uri", - "opc-ua-scan-period-in-seconds": "Період сканування в секундах", - "opc-ua-scan-period-in-seconds-required": "Необхідно вказати період сканування в секундах", - "opc-ua-timeout": "Час очікування в мілісекундах", - "opc-ua-timeout-required": "Необхідно вказати час очікування в мілісекундах", - "opc-ua-security": "Безпека", - "opc-ua-security-required": "Необхідно задати безпеку", - "opc-ua-identity": "Ідентифікація", - "opc-ua-identity-required": "Необхідно вказати ідентифікацію", - "opc-ua-keystore": "Сховище ключів", - "add-opc-ua-keystore-prompt": "Будь ласка, додайте файл сховища ключів", - "opc-ua-keystore-required": "Необхідно вказати сховище ключів", - "opc-ua-type": "Тип", - "opc-ua-keystore-type": "Тип сховища ключів", - "opc-ua-keystore-type-required": "Необхідно вказати тип", - "opc-ua-keystore-location": "Розташування *", - "opc-ua-keystore-password": "Пароль", - "opc-ua-keystore-password-required": "Необхідно вказати пароль", - "opc-ua-keystore-alias": "Псевдонім", - "opc-ua-keystore-alias-required": "Необхідно вказати псевдонім", - "opc-ua-keystore-key-password": "Пароль ключа", - "opc-ua-keystore-key-password-required": "Необхідно вказати пароль ключа", - "opc-ua-mapping": "Зіставлення", - "add-opc-ua-mapping-prompt": "Будь ласка, додайте зіставлення", - "opc-ua-mapping-type": "Тип зіставлення", - "opc-ua-mapping-type-required": "Необхідно вказати тип зіставлення", - "opc-ua-device-node-pattern": "Шаблон вузла пристрою", - "opc-ua-device-node-pattern-required": "Необхідно вказати шаблон вузла пристрою", - "opc-ua-add-map": "Додати елемент зіставлення", - "subscription-tags": "Теги передплати Теги підписки", - "remove-subscription-tag": "Видалити тег підписки", - "add-subscription-tag": "Додати тег підписки", - "add-subscription-tag-prompt": "Будь ласка, додайте тег підписки", - "key": "Ключ", - "path": "Шлях", - "required": "Необхідно" + "markdown": { + "use-markdown-text-function": "Використовувати функцію значення markdown/HTML", + "markdown-text-function": "Функція значення markdown/HTML", + "markdown-text-pattern": "Шаблон markdown/HTML (markdown або HTML з змінними, наприклад, '${entityName} або ${keyName} - деякий текст.')", + "apply-default-markdown-style": "Застосувати стандартний стиль markdown", + "markdown-css": "CSS для markdown/HTML" }, - "item": { - "selected": "Вибрані" + "simple-card": { + "label": "Маркування", + "label-position": "Позиція маркування", + "label-position-left": "Зліва", + "label-position-top": "Зверху" }, - "js-func": { - "no-return-error": "Функція повинна повертати значення!", - "return-type-mismatch": "Функція повинна повернути значення типу '{{type}}'!" + "single-switch": { + "behavior": "Поведінка", + "layout": "Розташування", + "layout-right": "Праворуч", + "layout-left": "Ліворуч", + "layout-centered": "По центру", + "auto-scale": "Автомасштабування", + "label": "Маркування", + "icon": "Піктограма", + "switch-color": "Колір перемикача", + "on": "Увімкнено", + "off": "Вимкнено", + "disabled": "Вимкнено", + "tumbler-color": "Колір тумблера", + "on-label": "Маркування для увімкненого стану", + "off-label": "Маркування для вимкненого стану", + "switch": "Перемикач" }, - "key-val": { - "key": "Ключ", - "value": "Значення", - "remove-entry": "Видалити елемент", - "add-entry": "Додати елемент", - "no-data": "Елементи відсутні" - }, - "layout": { - "layout": "Макет", - "manage": "Керування макетами", - "settings": "Налаштування макета", - "color": "Колір", - "main": "Основний", - "right": "Правий", - "select": "Вибрати макет" - }, - "legend": { - "direction": "Розташування елементів легенди", - "position": "Розташування легенди", - "show-max": "Показати максимальне значення", - "show-min": "Показати мінімальне значення ", - "show-avg": "Показати середнє значення", - "show-total": "Показати суму", - "settings": "Налаштування легенди", - "min": "мін", - "max": "макс", - "avg": "середнє", - "total": "Сума", - "comparison-time-ago": { - "days": "(день тому)", - "weeks": "(тиждень тому)", - "months": "(місяць тому)", - "years": "(рік тому)" - } + "slider": { + "behavior": "Поведінка", + "initial-value": "Початкове значення", + "initial-value-hint": "Дія для отримання початкового значення повзунка.", + "on-value-change": "При зміні значення", + "on-value-change-hint": "Дія, що запускається, коли змінюється значення повзунка.", + "layout": "Розташування", + "layout-default": "За замовчуванням", + "layout-extended": "Розширений", + "layout-simplified": "Спрощений", + "auto-scale": "Автомасштабування", + "icon": "Піктограма", + "value": "Значення", + "range": "Діапазон", + "min": "мін", + "max": "макс", + "range-ticks": "Поділки діапазону", + "tick-marks": "Позначки поділок", + "colors": "Кольори", + "main": "Основний", + "background": "Фон", + "left-icon": "Ліва піктограма", + "right-icon": "Права піктограма", + "slider": "Повзунок" + }, + "value-card": { + "layout": "Розташування", + "layout-square": "Квадратне", + "layout-vertical": "Вертикальне", + "layout-centered": "По центру", + "layout-simplified": "Спрощене", + "layout-horizontal": "Горизонтальне", + "layout-horizontal-reversed": "Горизонтальне у зворотному порядку", + "label": "Маркування", + "icon": "Піктограма", + "value": "Значення", + "date": "Дата", + "value-card-style": "Стиль картки значення", + "auto-scale": "Автомасштабування" + }, + "label-card": { + "auto-scale": "Автомасштабування", + "label": "Маркування", + "icon": "Піктограма", + "label-card-style": "Стиль картки маркування" + }, + "label-value-card": { + "value": "Значення", + "label-value-card-style": "Стиль картки маркування й значення" + }, + "liquid-level-card": { + "layout-simple": "Простий", + "layout-percentage": "Відсотки", + "layout-absolute": "Абсолютний", + "layout": "Розташування", + "background-overlay": "Накладка фону значення", + "total-volume": "Загальний об'єм", + "total-volume-units": "Одиниці загального об'єму", + "tank": "Резервуар", + "shape": "Форма", + "datasource-units": "Одиниці джерела", + "widget-units": "Одиниці віджета", + "decimals": "Десяткові знаки", + "liquid": "Рідина", + "liquid-color": "Колір рідини", + "value": "Значення", + "value-font": "Шрифт значення", + "level": "Рівень", + "last-update": "Останнє оновлення", + "shape-by-attribute": "Задати форму резервуара за назвою атрибута", + "tooltip-background": "Колір фону", + "background-blur": "Розмиття фону", + "tank-color": "Колір резервуара", + "static": "Статичний", + "see-examples": "Переглянути приклади", + "attribute": "Атрибут", + "shape-type": "Тип", + "v-oval": "Вертикальний овал", + "v-cylinder": "Вертикальний циліндр", + "v-capsule": "Вертикальна капсула", + "rectangle": "Прямокутник", + "h-oval": "Горизонтальний овал", + "h-ellipse": "Горизонтальний еліпс", + "h-dish-ends": "Горизонтальні торці-тарілки", + "h-cylinder": "Горизонтальний циліндр", + "h-capsule": "Горизонтальна капсула", + "h-elliptical_2_1": "Горизонтальний еліптичний 2:1", + "icon": "Піктограма картки", + "title": "Заголовок картки", + "units": "Одиниці", + "color-and-font": "Колір і шрифт", + "shape-attribute-name": "Назва атрибута", + "total-volume-required": "Загальний об'єм обов'язковий.", + "attribute-name-required": "Назва атрибута обов'язкова.", + "attribute-key-not-set": "Ключ атрибута '{{attributeName}}' не задано", + "attribute-key-invalid": "Ключ атрибута '{{attributeName}}' недійсний" + }, + "aggregated-value-card": { + "subtitle": "Підзаголовок", + "chart": "Графік", + "values": "Значення", + "value-appearance": "Вигляд значення", + "position": "Розташування", + "position-center": "По центру", + "position-right-top": "Праворуч зверху", + "position-right-bottom": "Праворуч знизу", + "position-left-top": "Ліворуч зверху", + "position-left-bottom": "Ліворуч знизу", + "font": "Шрифт", + "color": "Колір", + "arrow": "Стрілка", + "display-up-down-arrow": "Показувати стрілку вгору/вниз", + "add-value": "Додати значення", + "remove-value": "Видалити значення", + "no-values": "Немає налаштованих значень", + "aggregation": "Агрегація", + "aggregated-value-card-style": "Стиль картки агрегованого значення", + "auto-scale": "Автомасштабування" }, - "login": { - "login": "Увійти", - "request-password-reset": "Запит скидання пароля", - "reset-password": "Скинути пароль", - "create-password": "Створити пароль", - "passwords-mismatch-error": "Введені паролі повинні бути однаковими!", - "password-again": "Введіть пароль ще раз", - "username": "Ім'я користувача (ел. пошта)", - "remember-me": "Запам'ятати мене", - "forgot-password": "Забули пароль?", - "password-reset": "Скидання пароля", - "expired-password-reset-message": "Термін дії Вашого паролю закінчився! Будь ласка, створіть новий пароль.", - "new-password": "Новий пароль", - "new-password-again": "Повторіть новий пароль", - "password-link-sent-message": "Посилання для скидання пароля було успішно надіслано!", - "email": "Електронна пошта", - "login-with": "Увійти через {{name}}", - "or": "або" + "value-chart-card": { + "layout": "Розташування", + "layout-left": "Ліворуч", + "layout-right": "Праворуч", + "auto-scale": "Автомасштабування", + "icon": "Іконка", + "value": "Значення", + "chart": "Графік", + "value-chart-card-style": "Стиль картки значення з графіком" + }, + "progress-bar": { + "layout": "Розташування", + "layout-default": "За замовчуванням", + "layout-simplified": "Спрощений", + "auto-scale": "Автомасштабування", + "icon": "Іконка", + "value": "Значення", + "range": "Діапазон", + "min": "мін", + "max": "макс", + "range-ticks": "Поділки діапазону", + "bar": "Смуга", + "bar-color": "Колір смуги", + "bar-background": "Фон смуги", + "progress-bar-card-style": "Стиль картки індикатора прогресу" }, "notification": { - "notification-center": "Центр сповіщень" - }, - "ota-update": { - "ota-updates": "Оновлення OTA" - }, - "position": { - "top": "Угорі", - "bottom": "Знизу", - "left": "Ліворуч", - "right": "Праворуч" - }, - "profile": { - "profile": "Профіль", - "last-login-time": "Час останнього входу", - "change-password": "Змінити пароль", - "current-password": "Поточний пароль", - "copy-jwt-token": "Копіювати JWT токен", - "tokenCopiedMessage": "JWT токен скопійовано в буфер обміну", - "tokenCopiedWarnMessage": "JWT токен не є дійсним! Перезавантажте сторінку." - }, - "profiles": { - "profiles": "Профілі" - }, - "security": { - "security": "Безпека" - }, - "relation": { - "relations": "Відношення", - "direction": "Напрямок", - "search-direction": { - "FROM": "З", - "TO": "До" - }, - "direction-type": { - "FROM": "з", - "TO": "до" - }, - "from-relations": "Вихідні відношення", - "to-relations": "Вхідні відношення", - "selected-relations": "Вибрано { count, plural, =1 {1 відношення} other {# відношення} }", - "type": "Тип", - "to-entity-type": "До типу сутності", - "to-entity-name": "До назви сутності", - "from-entity-type": "Від типу сутності", - "from-entity-name": "Від назви сутності", - "to-entity": "До сутності", - "from-entity": "Від сутності", - "delete": "Видалити відношення", - "relation-type": "Тип відношення", - "relation-type-required": "Необхідно вказати тип відношення.", - "any-relation-type": "Будь-який тип", - "add": "Додати відношення", - "edit": "Редагувати відношення", - "delete-to-relation-title": "Ви впевнені, що хочете видалити відношення до сутності '{{entityName}}'?", - "delete-to-relation-text": "Будьте обережні, після підтвердження, сутність '{{entityName}}' не буде пов'язана з поточним об'єктом.", - "delete-to-relations-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 відношення} other {# відношення} }?", - "delete-to-relations-text": "Будьте обережні, після підтвердження, всі вибрані відношення стануть не пов'язані з поточною сутністю.", - "delete-from-relation-title": "Ви впевнені, що хочете видалити зв'язок, який йде від сутності '{{entityName}}'?", - "delete-from-relation-text": "Будьте обережні, після підтвердження поточна сутність буде відв'язана від сутності '{{entityName}}'.", - "delete-from-relations-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 відношення} other {# відношення} }?", - "delete-from-relations-text": "Будьте обережні, після підтвердження, всі вибрані відношення будуть видалені, а поточна сутність стане не зв'язаною з відповідними сутностями.", - "remove-relation-filter": "Видалити фільтр відношення", - "add-relation-filter": "Додати фільтр відношення", - "any-relation": "Будь-яке відношення", - "relation-filters": "Фільтри відношення", - "additional-info": "Додаткова інформація (JSON)", - "invalid-additional-info": "Не вдалося розібрати JSON з додатковою інформацією ." - }, - "resource": { - "resources-library": "Бібліотека ресурсів" - }, - "rulechain": { - "rulechain": "Ланцюжок правил", - "rulechains": "Ланцюжки правил", - "root": "Основний", - "delete": "Видалити ланцюжок правил", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "description": "Опис", - "add": "Додати ланцюжок правил", - "set-root": "Зробити ланцюжок правил основним", - "set-root-rulechain-title": "Ви впевнені, що хочете зробити ланцюжок правил '{{ruleChainName}}' основним?", - "set-root-rulechain-text": "Після підтвердження ланцюжок правил стане основним і буде обробляти всі вхідні транспортні повідомлення.", - "delete-rulechain-title": "Ви впевнені, що хочете видалити ланцюжок правил '{{ruleChainName}}'?", - "delete-rulechain-text": "Будьте обережні, після підтвердження ланцюжок правил і всі пов'язані з ним дані стануть недоступними.", - "delete-rulechains-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 ланцюг правил} other {# ланцюги правил} }?", - "delete-rulechains-action-title": "Видалити{ count, plural, =1 {1 ланцюг правил} other {# ланцюги правил} }", - "delete-rulechains-text": "Будьте обережні, після підтвердження, вибрані ланцюжки правил і всі пов'язані з ними дані стануть недоступними.", - "add-rulechain-text": "Додати новий ланцюжок правил", - "no-rulechains-text": "Ланцюжок правил не знайдено", - "rulechain-details": "Деталі ланцюжка правил", - "details": "Деталі", - "events": "Події", - "system": "Система", - "import": "Імпортувати ланцюжок правил", - "export": "Експортувати ланцюжок правил", - "export-failed-error": "Не вдалося експортувати ланцюжок правил: {{error}}", - "create-new-rulechain": "Створити новий ланцюжок правил", - "rulechain-file": "Файл ланцюжка правил", - "invalid-rulechain-file-error": "Неможливо імпортувати ланцюжок правил: недійсна структуру даних ланцюжка правил.", - "copyId": "Копіювати Id ланцюжка правил", - "idCopiedMessage": "Id ланцюжка правил скопійовано в буфер обміну", - "select-rulechain": "Вибрати ланцюжок правил", - "no-rulechains-matching": "Не знайдено жодних ланцюжків правил, які відповідають '{{entity}}'.", - "rulechain-required": "Необхідно вказати ланцюжок правил", - "management": "Управління ланцюжками правил", - "debug-mode": "Режим налагодження" - }, - "rulenode": { - "details": "Деталі", - "events": "Події", - "search": "Пошук вузлів", - "open-node-library": "Відкрити бібліотеку вузлів", - "add": "Додати вузол правил", - "name": "Назва", - "name-required": "Необхідно вказати назву.", - "type": "Тип", - "rule-node-description": "Опис вузла правил", - "delete": "Видалити вузол правил", - "select-all-objects": "Вибрати усі вузли та з'єднання", - "deselect-all-objects": "Зняти виділення з усіх вузлів і з'єднань", - "delete-selected-objects": "Видалити вибрані вузли та з'єднання", - "delete-selected": "Видалити вибране", - "select-all": "Вибрати все", - "copy-selected": "Копіювати вибране", - "deselect-all": "Відмінити вибране", - "rulenode-details": "Деталі вузла правил", - "debug-mode": "Режим налагодження", - "configuration": "Конфігурація", - "link": "Посилання", - "link-details": "Деталі посилання про вузол правил", - "add-link": "Додати посилання", - "link-label": "Мітка посилання", - "link-label-required": "Необхідно вказати мітку посилання.", - "custom-link-label": "Мітка посилання користувача", - "custom-link-label-required": "Необхідно вказати мітку посилання користувача.", - "link-labels": "Мітки посилання", - "link-labels-required": "Необхідно вказати мітки посилання.", - "no-link-labels-found": "Не знайдено жодних міток посилання", - "no-link-label-matching": "Мітку '{{label}}' не знайдено.", - "create-new-link-label": "Створити нову!", - "type-filter": "Фільтр", - "type-filter-details": "Фільтрувати вхідні повідомлення з заданими умовами", - "type-enrichment": "Насичення", - "type-enrichment-details": "Додати додаткову інформацію до метаданих повідомлень", - "type-transformation": "Трансформація", - "type-transformation-details": "Змінити склад повідомлення та його метадані", - "type-action": "Дія", - "type-action-details": "Виконати задану дію", - "type-analytics": "Аналітика", - "type-analytics-details": "Виконує аналіз потокових або збережених даних", - "type-external": "Зовнішній", - "type-external-details": "Взаємодіє з зовнішньою системою", - "type-rule-chain": "Ланцюжок правил", - "type-rule-chain-details": "Перенаправити вхідне повідомлення на вказаний ланцюжок правил", - "type-input": "Вхід", - "type-input-details": "Логічний вхід ланцюжка правил, перенаправляє вхідні повідомлення на наступний пов'язаний вузол правил", - "type-unknown": "Невідомий", - "type-unknown-details": "Невизначений вузол правил", - "directive-is-not-loaded": "Вказана директива конфігурації '{{directiveName}}' недоступна.", - "ui-resources-load-error": "Не вдалося завантажити UI ресурси.", - "invalid-target-rulechain": "Не вдається визначити цільовий ланцюжок правил!", - "test-script-function": "Протестувати скрипт", - "message": "Повідомлення", - "message-type": "Тип повідомлення", - "select-message-type": "Вибрати тип повідомлення", - "message-type-required": "Необхідно вказати тип повідомлення", - "metadata": "Метадані", - "metadata-required": "Записи метаданих не можуть бути порожніми.", - "output": "Вихід", - "test": "Тест", - "help": "Допомога", - "reset-debug-settings": "Вимкнути налаштування налагодження у всіх правилах" - }, - "scheduler": { - "scheduler": "Планувальник", - "scheduler-event": "Подія планувальника", - "select-scheduler-event": "Вибрати подію", - "no-scheduler-events-matching": "Не знайдено жодних подій, які відповідають '{{entity}}'.", - "scheduler-event-required": "Необхідно вказати заплановану подію", - "management": "Управління планувальником", - "scheduler-events": "Планування подій", - "add-scheduler-event": "Додати подію", - "search-scheduler-events": "Пошук події", - "created-time": "Час створення", - "name": "Назва", - "type": "Тип", - "created_customer": "Створено клієнтом", - "edit-scheduler-event": "Редагувати подію", - "delete-scheduler-event": "Видалити подію", - "no-scheduler-events": "Не знайдено жодних запланованих подій", - "selected-scheduler-events": "{ count, plural, =1 {1 запланована подія} other {# заплановані події} } вибрано", - "delete-scheduler-event-title": "Ви впевнені, що хочете видалити подію '{{schedulerEventName}}'?", - "delete-scheduler-event-text": "Будьте обережні, після підтвердження подія і всі пов'язані з нею дані стануть недоступними.", - "delete-scheduler-events-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 запланована подія} other {# заплановані події} }?", - "delete-scheduler-events-text": "Будьте обережні, після підтвердження всі вибрані події будуть видалені, і всі пов'язані з ними дані стануть недоступними.", - "create": "Створити подію планувальника", - "edit": "Змінити подію планувальника", - "name-required": "Необхідно вказати назву", - "configuration": "Конфігурація", - "schedule": "Розклад", - "start-time": "Початок", - "repeat": "Повтор", - "repeats": "Повтори", - "daily": "Щодня", - "weekly": "Щотижня", - "repeats-required": "Необхідно вказати повторення.", - "repeat-on": "Повторювати по", - "repeat-every": "Повторювати кожний(у)", - "ends-on": "Завершення", - "sunday-label": "Нд", - "monday-label": "Пн", - "tuesday-label": "Вт", - "wednesday-label": "Ср", - "thursday-label": "Чт", - "friday-label": "Пт", - "saturday-label": "Сб", - "repeat-on-sunday": "Повторити у неділю", - "repeat-on-monday": "Повторити в понеділок", - "repeat-on-tuesday": "Повторити у вівторок", - "repeat-on-wednesday": "Повторити в середу", - "repeat-on-thursday": "Повторити в четвер", - "repeat-on-friday": "Повторити в п'ятницю", - "repeat-on-saturday": "Повторити в суботу", - "event-type": "Тип події", - "select-event-type": "Вибрати тип події", - "event-type-required": "Необхідно вказати типи події.", - "list-mode": "Перегляд списку", - "calendar-mode": "Перегляд календаря", - "calendar-view-type": "Тип перегляду календаря", - "month": "Місяць", - "week": "Тиждень", - "day": "День", - "agenda-week": "Порядок тижня", - "agenda-day": "Порядок дня", - "list-year": "Список року", - "list-month": "Список місяця", - "list-week": "Список тижня", - "list-day": "Список дня", - "today": "Сьогодні", - "navigate-before": "Перейти до", - "navigate-next": "Перейти далі", - "starting-from": "Починаючи з", - "until": "до", - "on": "в", - "sunday": "Неділя", - "monday": "Понеділок", - "tuesday": "Вівторок", - "wednesday": "Середа", - "thursday": "Четвер", - "friday": "П'ятниця", - "saturday": "Субота", - "originator": "Ініціатор", - "single-entity": "Єдина сутність", - "group-of-entities": "Група сутностей", - "single-device": "Один пристрій", - "group-of-devices": "Група пристроїв", - "message-body": "Текст повідомлення", - "target": "Ціль", - "rpc-method": "Метод", - "rpc-method-required": "Необхідно вказати метод", - "rpc-params": "Параметри", - "select-dashboard-state": "Виберіть стан панелі візуалізації", - "hours": "Години", - "minutes": "Хвилини", - "seconds": "Секунди", - "time-interval-required": "Необхідно вказати часовий інтервал", - "time-unit-required": "Необхідно вказати одиниці часу" - }, - "report": { - "report-config": "Конфігурація звіту", - "email-config": "Конфігурація електронної пошти", - "dashboard-state-param": "Значення параметра стану панелі візуалізації", - "base-url": "Базова URL-адреса", - "base-url-required": "Необхідно вказати базову URL-адресу.", - "use-dashboard-timewindow": "Використовуйте вікно часу на панелі інструментів", - "timewindow": "Вікно часу", - "name-pattern": "Шаблон назви звіту", - "name-pattern-required": "Необхідно задати шаблон назви звіту", - "type": "Report type", - "use-current-user-credentials": "Використовувати поточні авторизаційні дані користувача", - "customer-user-credentials": "Авторизаційні дані користувачів", - "customer-user-credentials-required": "Необхідно задати авторизаційні дані користувачів", - "generate-test-report": "Створити звіт про перевірку", - "send-email": "Відправити лист", + "max-notification-display": "Максимальна кількість сповіщень для відображення", + "counter": "Лічильник", + "counter-hint": "Лічильник буде відображатися, якщо ввімкнено «Назва віджета»", + "icon": "Іконка", + "counter-value": "Значення", + "counter-color": "Колір", + "notification-button": "Кнопки сповіщень", + "button-view-all": "Переглянути всі", + "button-filter": "Фільтр", + "type-filter": "Фільтр типу", + "button-mark-read": "Позначити все як прочитане", + "notification-types": "Типи сповіщень", + "notification-type": "Тип сповіщення", + "search-type": "Пошук за типом", + "any-type": "Будь-який тип" + }, + "alarm-count": { + "alarm-count-card-style": "Стиль картки кількості тривог" + }, + "entity-count": { + "entity-count-card-style": "Стиль картки кількості сутностей" + }, + "count": { + "layout": "Розташування", + "layout-column": "Стовпцем", + "layout-row": "Рядком", + "label": "Маркування", + "icon": "Іконка", + "icon-background": "Фон іконки", + "value": "Значення", + "chevron": "Шеврон", + "auto-scale": "Автомасштабування" + }, + "table": { + "common-table-settings": "Загальні налаштування таблиці", + "enable-search": "Увімкнути пошук", + "enable-sticky-header": "Завжди показувати заголовок", + "enable-sticky-action": "Завжди показувати стовпець дій", + "hidden-cell-button-display-mode": "Режим відображення дій кнопок у прихованій комірці", + "show-empty-space-hidden-action": "Показувати порожнє місце замість прихованої дії кнопки комірки", + "dont-reserve-space-hidden-action": "Не резервувати місце для прихованих кнопок дій", + "display-timestamp": "Мітка часу", + "timestamp-column-name": "Назва стовпця мітки часу", + "display-pagination": "Показувати пагінацію", + "default-page-size": "Розмір сторінки за замовчуванням", + "page-step-settings": "Налаштування кроку пагінації", + "page-step-count": "Кількість кроків", + "page-step-increment": "Приріст кроку", + "page-step-count-format-message": "Має бути цілим числом у діапазоні від 1 до 100.", + "page-step-increment-format-message": "Має бути цілим числом, більшим або рівним 1.", + "use-entity-label-tab-name": "Використовувати маркування сутності в назві вкладки", + "hide-empty-lines": "Приховувати порожні рядки", + "row-style": "Стиль рядка", + "use-row-style-function": "Використовувати функцію стилю рядка", + "row-style-function": "Функція стилю рядка", + "cell-style": "Стиль комірки", + "use-cell-style-function": "Використовувати функцію стилю комірки", + "cell-style-function": "Функція стилю комірки", + "cell-content": "Вміст комірки", + "use-cell-content-function": "Використовувати функцію вмісту комірки", + "cell-content-function": "Функція вмісту комірки", + "show-latest-data-column": "Показувати стовпець останніх даних", + "latest-data-column-order": "Порядок стовпця останніх даних", + "entities-table-title": "Заголовок таблиці сутностей", + "enable-select-column-display": "Дозволити вибір стовпців для відображення", + "display-entity-name": "Показувати стовпець назви сутності", + "entity-name-column-title": "Назва стовпця назви сутності", + "display-entity-label": "Показувати стовпець маркування сутності", + "entity-label-column-title": "Назва стовпця маркування сутності", + "display-entity-type": "Показувати стовпець типу сутності", + "default-sort-order": "Порядок сортування за замовчуванням", + "custom-title": "Власний заголовок шапки", + "column-width": "Ширина стовпця (px або %)", + "default-column-visibility": "Видимість стовпців за замовчуванням", + "column-visibility-visible": "Видимо", + "column-visibility-hidden": "Приховано", + "column-visibility-hidden-mobile": "Приховано в мобільному режимі", + "column-selection-to-display": "Вибір стовпців у 'Стовпці для відображення'", + "column-selection-to-display-enabled": "Увімкнено", + "column-selection-to-display-disabled": "Вимкнено", + "alarms-table-title": "Заголовок таблиці тривог", + "enable-alarms-selection": "Увімкнути вибір тривог", + "enable-alarms-search": "Увімкнути пошук тривог", + "enable-alarm-filter": "Увімкнути фільтр тривог", + "display-alarm-details": "Показувати деталі тривоги", + "allow-alarms-ack": "Дозволити підтвердження тривог", + "allow-alarms-clear": "Дозволити очищення тривог", + "display-alarm-activity": "Показувати активність тривоги", + "allow-alarms-assign": "Дозволити призначення тривог", + "columns": "Стовпці", + "column-settings": "Налаштування стовпців", + "remove-column": "Видалити стовпець", + "add-column": "Додати стовпець", + "no-columns": "Стовпці не налаштовані", + "columns-to-display": "Стовпці для відображення", + "table-header": "Шапка таблиці", + "header-buttons": "Кнопки шапки", + "table-buttons": "Кнопки таблиці", + "pagination": "Пагінація", + "rows": "Рядки", + "timeseries-column-error": "Потрібно вказати хоча б один стовпець часового ряду", + "alarm-column-error": "Потрібно вказати хоча б один стовпець тривоги", + "table-tabs": "Вкладки таблиці", + "show-cell-actions-menu-mobile": "Показувати випадаюче меню дій комірки в мобільному режимі", + "disable-sorting": "Вимкнути сортування", + "sort-by": "Сортувати вкладки за", + "sort-timestamp-option": "Час створення" + }, + "latest-chart": { + "total": "Загалом", + "auto-scale": "Автомасштаб", + "clockwise-layout": "Розташування за годинниковою стрілкою", + "sort-series": "Сортувати ряди за маркуванням", + "tooltip-value-type-absolute": "Абсолютне значення", + "tooltip-value-type-percentage": "Відсоток" + }, + "pie-chart": { + "pie-chart-appearance": "Зовнішній вигляд кругової діаграми", + "label": "Маркування", + "border": "Межа", + "radius": "Радіус", + "pie-chart-card-style": "Стиль картки кругової діаграми" + }, + "radar-chart": { + "radar-appearance": "Зовнішній вигляд радарної діаграми", + "shape": "Форма", + "shape-polygon": "Багатокутник", + "shape-circle": "Коло", + "color": "Колір", + "line": "Лінія", + "points": "Точки", + "points-label": "Маркування точок", + "radar-axis": "Вісь радарної діаграми", + "axis-label": "Маркування осі", + "ticks-label": "Маркування поділок", + "radar-chart-style": "Стиль радарної діаграми", + "max-axes-scaling": "Максимальне масштабування осей", + "max-axes-scaling-hint": "Виберіть, чи матиме кожна вісь радарної діаграми власне максимальне значення (Окремо), чи всі осі використовуватимуть найбільше значення серед усіх осей на основі набору даних віджета (Спільне).", + "separate": "Окремо", + "common": "Спільне" + }, + "time-series-chart": { + "chart": "Діаграма", + "chart-style": "Стиль діаграми", + "data-zoom": "Масштабування даних", + "stack-mode": "Режим накладання", + "stack-mode-hint": "Накладає ряди на діаграмі. Ряди з однаковою одиницею вимірювання розміщуються один над одним.", + "axes": "Осі", + "y-axes": "Осі Y", + "line-type": "Тип лінії", + "line-width": "Товщина лінії", + "type-line": "Лінія", + "type-bar": "Стовпчик", + "type-point": "Точка", + "no-aggregation-bar-width-strategy": "Стратегія ширини стовпчиків для неагрегованих даних", + "no-aggregation-bar-width-strategy-group": "Групувати", + "no-aggregation-bar-width-strategy-separate": "Окремо", + "bar-group-width": "Ширина групи стовпчиків", + "bar-width": "Ширина стовпчика", + "bar-width-relative": "Відсоток часового вікна", + "bar-width-absolute": "Абсолютна (мс)", + "comparison": { + "comparison": "Порівняння", + "comparison-hint": "Порівняння працює лише з історичними даними!", + "show": "Показати", + "settings": "Налаштування порівняння", + "show-values-for-comparison": "Показувати історичні дані для порівняння", + "comparison-values-label": "Маркування ключа порівняння", + "comparison-values-label-auto": "Авто", + "comparison-data-color": "Колір даних порівняння" + }, + "threshold": { + "thresholds": "Пороги", + "source": "Джерело", + "key-value": "Ключ / Значення", + "no-thresholds": "Пороги не налаштовані", + "add-threshold": "Додати поріг", + "type-constant": "Константа", + "type-latest-key": "Ключ", + "type-entity": "Сутність", + "threshold-settings": "Налаштування порогу", + "remove-threshold": "Видалити поріг", + "threshold-value-required": "Потрібне значення порогу.", + "key-required": "Потрібен ключ.", + "entity-key-required": "Потрібен ключ сутності.", + "line-appearance": "Зовнішній вигляд лінії", + "line-color": "Колір лінії", + "start-symbol": "Початковий символ", + "end-symbol": "Кінцевий символ", + "symbol-size": "розмір", + "label": "Маркування", + "label-position-start": "Початок", + "label-position-middle": "Середина", + "label-position-end": "Кінець", + "label-position-inside-start": "Всередині на початку", + "label-position-inside-start-top": "Всередині на початку зверху", + "label-position-inside-start-bottom": "Всередині на початку знизу", + "label-position-inside-middle": "Всередині посередині", + "label-position-inside-middle-top": "Всередині посередині зверху", + "label-position-inside-middle-bottom": "Всередині посередині знизу", + "label-position-inside-end": "Всередині в кінці", + "label-position-inside-end-top": "Всередині в кінці зверху", + "label-position-inside-end-bottom": "Всередині в кінці знизу", + "label-background": "Фон маркування" + }, + "state": { + "states": "Стани", + "label": "Маркування", + "ticks-value": "Значення поділок", + "source": "Джерело", + "value-range": "Значення / Діапазон", + "no-states": "Стани не налаштовані", + "add-state": "Додати стан", + "type-constant": "Константа", + "type-range": "Діапазон", "from": "Від", - "from-required": "Необхідно вказати від кого.", "to": "До", - "to-required": "Необхідно вказати до кого.", - "cc": "Cc", - "bcc": "Bcc", - "subject": "Тема", - "subject-required": "Необхідно вказати тему.", - "body": "Текст", - "body-required": "Лист не може бути пустим." - }, - "blob-entity": { - "blob-entity": "Blob сутності", - "select-blob-entity": "Вибрати blob сутності", - "no-blob-entities-matching": "Не знайдено жодних сутностей blob, які відповідають '{{entity}}'.", - "blob-entity-required": "Необхідно вказати blob сутності", - "files": "Файли", - "search": "Пошук файлів", - "clear-search": "Очистити пошук", - "no-blob-entities-prompt": "Файлів не знайдено", - "report": "Звіт", - "created-time": "Час створення", - "name": "Назва", + "remove-state": "Видалити стан" + }, + "grid": { + "grid": "Сітка", + "background-color": "Колір фону", + "border": "Межа" + }, + "axis": { + "axes": "Осі", + "x-axis": "Вісь X", + "y-axis": "Вісь Y", + "y-axis-settings": "Налаштування осі Y", + "comparison-x-axis-settings": "Налаштування осі X для порівняння", + "remove-y-axis": "Видалити вісь Y", + "id": "Id", + "label": "Маркування", + "position": "Позиція", + "position-left": "Ліворуч", + "position-right": "Праворуч", + "position-top": "Вгорі", + "position-bottom": "Внизу", + "tick-labels": "Маркування поділок", + "ticks-formatter-function": "Функція форматування поділок", + "ticks-generator-function": "Функція генерації поділок", + "show-ticks": "Показувати поділки", + "show-line": "Показувати лінію", + "show-split-lines": "Показувати лінії поділу", + "show-split-lines-x-axis-hint": "Якщо увімкнено, на діаграмі будуть показані вертикальні лінії.", + "show-split-lines-y-axis-hint": "Якщо увімкнено, на діаграмі будуть показані горизонтальні лінії.", + "ticks-interval": "Інтервал поділок", + "ticks-interval-hint": "Примусово встановити інтервал сегментації для осі.", + "split-number": "Кількість сегментів", + "split-number-hint": "Кількість сегментів, на які поділена вісь.", + "min": "Мін", + "max": "Макс", + "show": "Показати", + "add-y-axis": "Додати вісь Y" + }, + "series": { + "legend-settings": "Налаштування легенди", + "show-in-legend": "Показувати в легенді", + "show-in-legend-hint": "Показувати назву ряду та дані в легенді.", + "hidden-by-default": "Приховано за замовчуванням", + "hidden-by-default-hint": "Зробити ряд прихованим у легенді за замовчуванням.", + "series-type": "Тип ряду", "type": "Тип", - "created_customer": "Створено клієнтом", - "download-blob-entity": "Завантажити файл", - "delete-blob-entity": "Видалити файл", - "delete-blob-entity-title": "Ви впевнені, що хочете видалити файл '{{blobEntityName}}'?", - "delete-blob-entity-text": "Будьте обережні, після підтвердження, дані з файлу стануть недоступними для відновлення." - }, - "timezone": { - "timezone": "Часовий пояс", - "select-timezone": "Вибрати часовий пояс ", - "no-timezones-matching": "Не знайдено жодних часових поясів, які відповідають '{{timezone}}'.", - "timezone-required": "Необхідно вказати часовий пояс." - }, - "queue": { - "select_name": "Виберіть назву для Queue", - "name": "Назва для Queue", - "name_required": "Поле 'Назва для Queue' необхідно заповнити!" - }, - "tenant": { - "tenant": "Власник", - "tenants": "Власники", - "management": "Управління власниками", - "add": "Додати власника", - "admins": "Адміністратори", - "manage-tenant-admins": "Керування адміністраторами власника", - "delete": "Видалити власника", - "add-tenant-text": "Додати нового власника", - "no-tenants-text": "Не знайдено жодного власника", - "tenant-details": "Подробиці про власника", - "delete-tenant-title": "Ви впевнені, що хочете видалити власника '{{tenantTitle}}'?", - "delete-tenant-text": "Будьте обережні, після підтвердження власник і всі пов'язані з ним дані стануть недоступними.", - "delete-tenants-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 власник} other {# власники} }?", - "delete-tenants-action-title": "Видалити { count, plural, =1 {1 власник} other {# власники} }", - "delete-tenants-text": "Будьте обережні, після підтвердження, усі вибрані власники будуть видалені, і всі пов'язані з ними дані стануть недоступними.", - "title": "Назва", - "title-required": "Необхідно вказати назву.", - "description": "Опис", - "details": "Деталі", - "events": "Події", - "copyId": "Копіювати Id власника", - "idCopiedMessage": "Id власника скопійовано в буфер обміну", - "select-tenant": "Вибрати власника", - "no-tenants-matching": "Не знайдено жодних власників, які відповідають '{{entity}}'.", - "tenant-required": "Необхідно вказати власника", - "selected-tenants": "{ count, plural, =1 {1 власник} other {# власники} } вибрано", - "search": "Пошук власників" - }, - "timeinterval": { - "seconds-interval": "{ seconds, plural, =1 {1 секунда} other {# секунди} }", - "minutes-interval": "{ minutes, plural, =1 {1 хвилина} other {# хвилини} }", - "hours-interval": "{ hours, plural, =1 {1 година} other {# години} }", - "days-interval": "{ days, plural, =1 {1 день} other {# дні} }", - "days": "Дні", - "hours": "Години", - "minutes": "Хвилини", - "seconds": "Секунди", - "advanced": "Додатково" - }, - "timewindow": { - "days": "{ days, plural, =1 { день } other {# дні } }", - "hours": "{ hours, plural, =0 { годин } =1 {1 година } other {# години } }", - "minutes": "{ minutes, plural, =0 { хвилин } =1 {1 хвилина } other {# хвилини } }", - "seconds": "{ seconds, plural, =0 { секунд } =1 {1 секунда } other {# секунди } }", - "realtime": "Реальний час", - "history": "Історія", - "last-prefix": "Останнє", - "period": "з {{ startTime }} до {{ endTime }}", - "edit": "Редагувати вікно часу", - "date-range": "Проміжок часу", - "last": "Останнє", - "time-period": "Період часу", - "hide": "Приховати" - }, - "user": { - "user": "Користувач", - "users": "Користувачі", - "customer-users": "Користувачі клієнта", - "tenant-admins": "Адміністратори власників", - "sys-admin": "Системний адміністратор", - "tenant-admin": "Адміністратор власника", - "customer": "Клієнт", - "anonymous": "Анонім", - "add": "Додати користувача", - "delete": "Видалити користувача", - "add-user-text": "Додати нового користувача", - "no-users-text": "Не знайдено жодного користувача", - "user-details": "Подробиці про користувача", - "delete-user-title": "Ви впевнені, що хочете видалити користувача '{{userEmail}}'?", - "delete-user-text": "Будьте обережні, після підтвердження, користувач і всі пов'язані з ним дані стануть недоступними.", - "delete-users-title": "Ви впевнені, що хочете видалити { count, plural, =1 {1 користувача} other {# користувачів} }?", - "delete-users-action-title": "Видалити { count, plural, =1 {1 користувача} other {# користувачів} }", - "delete-users-text": "Будьте обережні, після підтвердження, усіх виділених користувачів буде видалено, і всі пов'язані з ними дані стануть недоступними.", - "activation-email-sent-message": "Активаційний лист успішно надіслано!", - "resend-activation": "Повторно надіслати активаційного листа", - "email": "Електронна пошта", - "email-required": "Необхідно вказати електронну пошту.", - "invalid-email-format": "Недійсний формат електронної пошти.", - "first-name": "Ім'я", - "last-name": "Прізвище", - "description": "Опис", - "default-dashboard": "Панель візуалізації за замовчуванням", - "always-fullscreen": "Завжди в повноекранному режимі", - "select-user": "Вибрати користувача", - "no-users-matching": "Не знайдено жодного користувача, що відповідає '{{entity}}'.", - "user-required": "Необхідно вказати користувача", - "activation-method": "Спосіб активації", - "display-activation-link": "Показати посилання для активації", - "send-activation-mail": "Надіслати активаційного листа", - "activation-link": "Активаційне посилання для користувача", - "activation-link-text": "Для активації користувача, скористайтеся наступним посиланням :", - "copy-activation-link": "Скопіювати активаційне посилання ", - "activation-link-copied-message": "Посилання на активацію користувача було скопійовано в буфер обміну", - "selected-users": "{ count, plural, =1 {1 користувач} other {# користувачі} } вибрано", - "search": "Пошук користувачів", - "details": "Подробиці", - "login-as-tenant-admin": "Увійти як адміністратор власника", - "login-as-customer-user": "Увійти як користувач клієнта", - "disable-account": "Вимкнути обліковий запис користувача", - "enable-account": "Увімкнути обліковий запис користувача", - "enable-account-message": "Обліковий запис користувача успішно увімкнено!", - "disabled-account-message": "Обліковий запис користувача успішно вимкнено!", - "copyId": "Копіювати Id користувача", - "idCopiedMessage": "Id користувача було скопійовано в буфер обміну" - }, - "value": { - "type": "Тип значення", - "string": "Рядок", - "string-value": "Значення рядка", - "integer": "Ціле", - "integer-value": "Ціле значення", - "invalid-integer-value": "Недійсне ціле значення", - "double": "Подвійне", - "double-value": "Подвійне значення", - "boolean": "Логічне", - "boolean-value": "Логічне значення", - "false": "Помилкове", - "true": "Правдиве", - "long": "Довге" - }, - "version-control": { - "version-control": "Керування версіями" - }, - "widget": { - "widget-library": "Бібліотека віджетів", - "widget-bundle": "Пакет віджетів", - "select-widgets-bundle": "Виберіть пакет віджетів", - "management": "Керування віджетами", - "editor": "Редактор віджетів", - "widget-type-not-found": "Помилка завантаження конфігурації віджетів.
Можливо, пов'язаний з нею\n тип віджета було видалено.", - "widget-type-load-error": "Віджет не вдалося завантажити з наступних причин:", - "remove": "Видалити віджет", - "edit": "Відредагувати віджет", - "remove-widget-title": "Ви впевнені, що хочете видалити віджет '{{widgetTitle}}'?", - "remove-widget-text": "Після підтвердження віджет і всі пов'язані з ним дані стануть недоступними.", - "timeseries": "Телеметрія", - "search-data": "Пошук даних", - "no-data-found": "Даних не знайдено", - "latest": "Останні значення", - "rpc": "Керуючий віджет", - "alarm": "Віджет сигналу тривоги", - "static": "Статичний віджет", - "select-widget-type": "Вибрати тип віджета", - "missing-widget-title-error": "Необхідно вказати назву віджета!", - "widget-saved": "Віджет збережено", - "unable-to-save-widget-error": "Неможливо зберегти віджет! Віджет має помилки!", - "save": "Зберегти віджет", - "saveAs": "Зберегти віджет як", - "save-widget-type-as": "Зберегти тип віджета як", - "save-widget-type-as-text": "Введіть новий заголовок віджета та / або виберіть цільові віджети", - "toggle-fullscreen": "Перейти в повноекранний режим", - "run": "Запустити віджет", - "title": "Назва віджета", - "title-required": "Необхідно вказати назву віджета.", - "type": "Тип віджета", - "resources": "Ресурси", - "resource-url": "JavaScript/CSS URL", - "remove-resource": "Видалити ресурс", - "add-resource": "Додати ресурс", - "html": "HTML", - "tidy": "Форматувати", - "css": "CSS", - "settings-schema": "Схема налаштувань", - "datakey-settings-schema": "Схема налаштувань ключів даних", - "javascript": "Javascript", - "add-widget-type": "Додати новий тип віджета", - "widget-template-load-failed-error": "Не вдалося завантажити шаблон віджета!", - "add": "Додати віджет", - "undo": "Скасувати зміни віджета", - "export": "Експортувати віджет", - "export-data": "Експортувати дані віджетів", - "export-to-csv": "Експортувати дані в CSV...", - "export-to-excel": "Експортувати дані в XLS..." + "type-line": "Лінія", + "type-bar": "Стовпчик", + "line": { + "line": "Лінія", + "show-line": "Показувати лінію", + "step-line": "Східчаста лінія", + "step-type-start": "Початок", + "step-type-middle": "Середина", + "step-type-end": "Кінець", + "smooth-line": "Згладжена лінія" + }, + "point": { + "points": "Точки", + "show-points": "Показувати точки", + "point-label": "Маркування точки", + "point-label-hint": "Показувати маркування зі значенням над точкою ряду.", + "point-label-background": "Фон маркування точки", + "point-shape": "Форма точки", + "point-size": "Розмір точки" + } + } }, - "widget-action": { - "header-button": "Кнопка заголовка віджета", - "open-dashboard-state": "Перейти до нового стану панелі візуалізації", - "update-dashboard-state": "Оновити поточний стан панелі візуалізації", - "open-dashboard": "Перейти до іншої панелі візуалізації", - "custom": "Дії користувачів", - "custom-pretty": "Дії користувачів (з HTML шаблоном)", - "target-dashboard-state": "Цільовий стан панелі візуалізації", - "target-dashboard-state-required": "Необхідно вказати цільовий стан панелі візуалізації", - "set-entity-from-widget": "Встановити сутність із віджета", - "target-dashboard": "Цільова панель візуалізації", - "open-right-layout": "Відкрити мобільний режим панелі візуалізації" - }, - "widgets-bundle": { - "current": "Поточний зв'язок", - "widgets-bundles": "Пакети віджетів", - "add": "Додати пакет віджетів", - "delete": "Видалити пакет віджетів", - "title": "Назва", - "title-required": "Необхідно вказати назву віджета.", - "add-widgets-bundle-text": "Додати новий пакет віджетів", - "no-widgets-bundles-text": "Не знайдено жодних пакетів віджетів", - "empty": "Пакет віджетів порожній", - "details": "Подробиці", - "widgets-bundle-details": "Деталі пакетів віджетів", - "delete-widgets-bundle-title": "Ви впевнені, що хочете видалити пакет віджетів '{{widgetsBundleTitle}}'?", - "delete-widgets-bundle-text": "Будьте обережні, після підтвердження, пакети віджетів і всі пов'язані з ними дані стануть недоступними.", - "delete-widgets-bundles-title": "Ви впевнені, що хочете видалити { count, plural, =1 {пакет віджетів} other {# пакети віджетів} }?", - "delete-widgets-bundles-action-title": "Видалити { count, plural, =1 {1 пакет віджетів} other {# пакет віджетів} }", - "delete-widgets-bundles-text": "Будьте обережні, після підтвердження, всі виділені пакети віджетів і всі пов'язані з ними дані стануть недоступними.", - "no-widgets-bundles-matching": "Не знайдено жодних пакетів віджетів, які відповідають '{{widgetsBundle}}'.", - "widgets-bundle-required": "Необхідно вказати пакет віджетів.", - "system": "Системний", - "import": "Імпортувати пакет віджетів", - "export": "Експортувати пакет віджетів", - "export-failed-error": "Неможливо експортувати пакет віджетів: {{error}}", - "create-new-widgets-bundle": "Створити новий пакет віджетів", - "widgets-bundle-file": "Файл набору віджетів", - "invalid-widgets-bundle-file-error": "Неможливо імпортувати пакет віджетів: недійсна структура даних пакету віджетів." - }, - "widget-config": { - "data": "Дані", - "settings": "Налаштування", - "advanced": "Додатково", - "title": "Назва", - "general-settings": "Загальні налаштування", - "display-title": "Відобразити назву у віджеті", - "drop-shadow": "Тінь", - "enable-fullscreen": "Увімкнути повноекранний режим", - "enable-data-export": "Увімкнути експорт даних", - "background-color": "Колір фону", - "text-color": "Колір тексту", - "padding": "Відступ", - "margin": "Границі", - "widget-style": "Стиль віджетів", - "title-style": "Стиль заголовка", - "mobile-mode-settings": "мобільний режим", - "order": "Порядок", - "height": "Висота", - "units": "Спеціальний символ після значення", - "decimals": "Кількість цифр після коми", - "timewindow": "Вікно часу", - "use-dashboard-timewindow": "Використати вікно часу на панелі візуалізації", - "display-timewindow": "Показувати вікно часу", - "legend": "Легенда", - "display-legend": "Показати легенду", - "datasources": "Джерела даних", - "maximum-datasources": "Максимально { count, plural, =1 {1 дозволене джерело даних.} other {# дозволені джерела даних } }", - "datasource-type": "Тип", - "datasource-parameters": "Параметри", - "remove-datasource": "Видалити джерело даних", - "add-datasource": "Додати джерело даних", - "target-device": "Цільовий пристрій", - "alarm-source": "Джерело сигналу тривоги", - "actions": "Дії", - "action": "Дія", - "add-action": "Додати дію", - "search-actions": "Пошук дії", - "action-source": "Джерело дії", - "action-source-required": "Необхідно вказати джерело дії.", - "action-name": "Назва дії", - "action-name-required": "Необхідно вказати назву дії.", - "action-name-not-unique": "Дія з такою назвою вже існує.\nНазва дії має бути унікальною в межах одного джерела дії.", - "action-icon": "Іконка", - "action-type": "Тип", - "action-type-required": "Необхідно вказати тип дії.", - "edit-action": "Редагувати дію", - "delete-action": "Видалити дію", - "delete-action-title": "Видалити дію віджета", - "delete-action-text": "Ви впевнені, що хочете видалити дію віджета '{{actionName}}'?", - "title-icon": "Іконка у назві віджету", - "display-icon": "Показувати іконку у назві віджету", - "icon-color": "Колір іконки", - "icon-size": "Розмір іконки", - "advanced-settings": "Розширені налаштування", - "data-settings": "Налаштування даних", - "no-data-display-message": "\"Немає данних для відображення\" альтернативний текст" - }, - "widget-type": { - "import": "Імпортувати тип віджета", - "export": "Експортувати тип віджета", - "export-failed-error": "Неможливо експортувати тип віджета: {{error}}", - "create-new-widget-type": "Створити новий тип віджета", - "widget-type-file": "Файл типу віджета", - "invalid-widget-type-file-error": "Неможливо імпортувати тип віджету: неправильна структура даних типу віджета." - }, - "widgets": { - "date-range-navigator": { - "localizationMap": { - "Sun": "Нд", - "Mon": "Пн", - "Tue": "Вт", - "Wed": "Ср", - "Thu": "Чт", - "Fri": "Пт", - "Sat": "Сб", - "Jan": "Січ.", - "Feb": "Лют.", - "Mar": "Берез.", - "Apr": "Квіт.", - "May": "Трав.", - "Jun": "Черв.", - "Jul": "Лип.", - "Aug": "Серп.", - "Sep": "Верес.", - "Oct": "Жовт.", - "Nov": "Листоп.", - "Dec": "Груд.", - "January": "Січень", - "February": "Лютий", - "March": "Березень", - "April": "Квітень", - "June": "Червень", - "July": "Липень", - "August": "Серпень", - "September": "Вересень", - "October": "Жовтень", - "November": "Листопад", - "December": "Грудень", - "Custom Date Range": "Користувацький діапазон дат", - "Date Range Template": "Шаблон діапазону дат", - "Today": "Сьогодні", - "Yesterday": "Вчора", - "This Week": "Цього тижня", - "Last Week": "Минулий тиждень", - "This Month": "Цей місяць", - "Last Month": "Минулий місяць", - "Year": "Рік", - "This Year": "Цього року", - "Last Year": "Минулий рік", - "Date picker": "Вибір дати", - "Hour": "Година", - "Day": "День", - "Week": "Тиждень", - "2 weeks": "2 Тижні", - "Month": "Місяць", - "3 months": "3 Місяці", - "6 months": "6 Місяців", - "Custom interval": "Користувацький інтервал", - "Interval": "Інтервал", - "Step size": "Розмір кроку", - "Ok": "Ok" - } + "wind-speed-direction": { + "layout": "Макет", + "layout-default": "За замовчуванням", + "layout-advanced": "Розширений", + "layout-simplified": "Спрощений", + "values": "Значення", + "wind-direction": "Напрямок вітру", + "center-value": "Центральне значення", + "icon": "Піктограма", + "arrow": "Стрілка", + "ticks": "Поділки", + "labels-type": "Тип маркування", + "directional-names": "Назви напрямків", + "degrees": "Градуси", + "major-ticks": "Основні поділки", + "minor-ticks": "Проміжні поділки", + "wind-speed-direction-card-style": "Стиль картки швидкості та напрямку вітру", + "ticks-color": "Колір поділок", + "ticks-labels-type": "Тип маркування поділок", + "arrow-color": "Колір стрілки" + }, + "value-source": { + "value-source": "Джерело значення", + "predefined-value": "Константа", + "entity-attribute": "Атрибут сутності", + "value": "Значення", + "value-required": "Потрібне значення.", + "key-required": "Потрібен ключ.", + "entity-key-required": "Потрібен ключ сутності.", + "source-entity-alias": "Псевдонім вихідної сутності", + "source-entity-attribute": "Атрибут вихідної сутності", + "type-constant": "Константа", + "type-latest-key": "Ключ", + "type-entity": "Сутність" + }, + "rpc-state": { + "initial-state": "Початковий стан", + "initial-state-hint": "Дія для отримання початкового стану (Увімкнено/Вимкнено) компонента.", + "disabled-state": "Вимкнений стан", + "disabled-state-hint": "Налаштуйте умову, за якої компонент вимкнено.", + "turn-on": "Увімкнути 'On'", + "turn-on-hint": "Дія, що запускається, коли повзунок перемикається на 'On'", + "turn-off": "Вимкнути 'Off'", + "turn-off-hint": "Дія, що запускається, коли повзунок перемикається на 'Off'", + "on": "On", + "off": "Off", + "disabled": "Вимкнено" + }, + "value-action": { + "do-nothing": "Нічого не робити", + "execute-rpc": "Виконати RPC", + "get-attribute": "Отримати атрибут", + "set-attribute": "Встановити атрибут", + "get-time-series": "Отримати часовий ряд", + "get-alarm-status": "Отримати стан тривоги", + "get-dashboard-state": "Отримати id стану дашборда", + "get-dashboard-state-object": "Отримати об'єкт стану дашборда", + "add-time-series": "Додати часовий ряд", + "execute-rpc-text": "Виконати RPC-метод '{{methodName}}'", + "get-time-series-text": "Використовувати часовий ряд '{{key}}'", + "get-attribute-text": "Використовувати атрибут '{{key}}'", + "get-alarm-status-text": "Використовувати стан тривоги", + "get-dashboard-state-text": "Використовувати стан дашборда", + "get-dashboard-state-object-text": "Використовувати об'єкт стану дашборда", + "when-dashboard-state-is-text": "Коли id стану дашборда дорівнює '{{state}}'", + "when-dashboard-state-function-is-text": "Коли f(id стану дашборда) дорівнює '{{state}}'", + "when-dashboard-state-object-function-is-text": "Коли f(об'єкта стану дашборда) дорівнює '{{state}}'", + "set-attribute-to-value-text": "Встановити атрибут '{{key}}' у: {{value}}", + "add-time-series-value-text": "Додати значення часового ряду '{{key}}': {{value}}", + "set-attribute-text": "Встановити атрибут '{{key}}'", + "add-time-series-text": "Додати часовий ряд '{{key}}'", + "action": "Дія", + "value": "Значення", + "init-value-hint": "Значення, яке буде встановлено, доки пристрій не надішле дані.", + "method": "Метод", + "method-name-required": "Потрібна назва методу.", + "request-timeout-ms": "Тайм-аут запиту RPC (мс)", + "request-timeout-required": "Потрібен тайм-аут запиту.", + "min-request-timeout-error": "Значення тайм-ауту запиту має бути не менше 5000 мс (5 секунд).", + "request-persistent": "Постійний запит RPC", + "persistent-polling-interval": "Інтервал опитування постійного запиту (мс)", + "persistent-polling-interval-hint": "Інтервал опитування (мс) для отримання відповіді на команду постійного RPC", + "persistent-polling-interval-required": "Потрібен інтервал опитування постійного запиту.", + "min-persistent-polling-interval-error": "Значення інтервалу опитування постійного запиту має бути не менше 1000 мс (1 секунда).", + "attribute-scope": "Область атрибутів", + "attribute-key": "Ключ атрибута", + "attribute-key-required": "Потрібен ключ атрибута.", + "time-series-key": "Ключ часового ряду", + "time-series-key-required": "Потрібен ключ часового ряду.", + "action-result-converter": "Конвертер результату дії", + "converter-none": "Немає", + "converter-function": "Функція", + "converter-constant": "Константа", + "converter-value": "Значення", + "parse-value-function": "Функція розбору значення", + "state-when-result-is": "'{{state}}' коли результат", + "parameters": "Параметри", + "convert-value-function": "Функція перетворення значення", + "error": { + "target-entity-is-not-set": "Цільову сутність не задано!", + "failed-to-perform-action": "Не вдалося виконати дію {{ actionLabel }}.", + "invalid-attribute-scope": "Область атрибутів {{scope}} не підтримується сутністю {{entityType}}." + } + }, + "widget-font": { + "font-settings": "Налаштування шрифту", + "font-family": "Сімейство шрифтів", + "size": "Розмір", + "relative-font-size": "Відносний розмір шрифту (відсотки)", + "font-style": "Стиль", + "font-style-normal": "Звичайний", + "font-style-italic": "Курсив", + "font-style-oblique": "Похилий", + "font-weight": "Насиченість", + "font-weight-normal": "Звичайна", + "font-weight-bold": "Жирна", + "font-weight-bolder": "Жирніша", + "font-weight-lighter": "Тонша", + "color": "Колір", + "shadow-color": "Колір тіні", + "preview": "Попередній перегляд", + "line-height": "Висота рядка", + "auto": "Авто" + }, + "home": { + "no-data-available": "Дані недоступні" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "Диск", + "cpu-warning-text": "Високе навантаження на CPU. Щоб уникнути збою системи, оптимізуйте продуктивність системи.", + "cpu-critical-text": "Критично високе навантаження на CPU. Щоб уникнути збою системи, оптимізуйте продуктивність системи.", + "ram-warning-text": "Низький запас RAM. Щоб уникнути збою системи, оптимізуйте продуктивність системи або збільште обсяг RAM.", + "ram-critical-text": "Критично низький запас RAM. Щоб уникнути збою системи, оптимізуйте продуктивність системи або збільште обсяг RAM.", + "disk-warning-text": "Мало місця на диску. Щоб уникнути втрати даних, звільніть або розширте дисковий простір.", + "disk-critical-text": "Критично мало місця на диску. Щоб уникнути втрати даних, звільніть або розширте дисковий простір." + }, + "cluster-info": { + "service-id": "Id сервісу", + "service-type": "Тип сервісу", + "no-data": "Немає даних" + }, + "transport-messages": { + "title": "Транспортні повідомлення", + "info": "Усі повідомлення, що надійшли від пристроїв" + }, + "activity": { + "title": "Активність" + }, + "documentation": { + "title": "Документація", + "add-link": "Додати посилання", + "add-link-title": "Додати посилання на документацію", + "name": "Назва", + "name-required": "Потрібна назва.", + "link": "Посилання", + "link-required": "Потрібне посилання.", + "columns": "Стовпці" + }, + "quick-links": { + "title": "Швидкі посилання", + "add-link": "Додати посилання", + "add-link-title": "Додати швидке посилання", + "quick-link": "Швидке посилання", + "quick-link-required": "Потрібне швидке посилання.", + "no-links-matching": "Не знайдено посилань, що відповідають '{{name}}'.", + "columns": "Стовпці" + }, + "recent-dashboards": { + "title": "Дашборди", + "last": "Нещодавні", + "starred": "Обрані", + "name": "Назва", + "last-viewed": "Проглянуто", + "no-last-viewed-dashboards": "Поки немає нещодавно переглянутих дашбордів" + }, + "configured-features": { + "title": "Налаштовані можливості", + "info": "Статус можливостей, що потребують налаштування", + "email-feature": "Email", + "sms-feature": "SMS", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "2FA", + "feature-configured": "Функцію налаштовано.\nНатисніть, щоб налаштувати", + "feature-not-configured": "Функцію не налаштовано.\nНатисніть, щоб налаштувати" + }, + "version-info": { + "title": "Версія", + "contact-us": "Зв'язатися з нами", + "current-version": "Поточна версія", + "current": "Поточна", + "available-version": "Доступна версія", + "available": "Доступна", + "upgrade": "Оновити", + "version-is-up-to-date": "Версія актуальна" + }, + "usage-info": { + "title": "Ліміти", + "entities": "Сутності", + "api-calls": "Виклики API" + }, + "functions": { + "title": "Функції", + "pe-feature-tooltip": "Лише у ThingsBoard\nProfessional Edition", + "switch-to-pe": "Перемкнутися на PE", + "alarms": "Тривоги", + "dashboards": "Дашборди", + "entities-and-relations": "Сутності та зв'язки", + "profiles": "Профілі", + "advanced-features": "Розширені можливості", + "notification-center": "Центр сповіщень", + "api-usage": "Використання API", + "customers": "Клієнти", + "customers-hierarchy": "Ієрархія клієнтів", + "roles-and-permissions": "Ролі та дозволи", + "groups": "Групи", + "integrations": "Інтеграції", + "solution-templates": "Шаблони рішень", + "scheduler": "Планувальник", + "white-labeling": "Брендування" + }, + "devices": { + "view-docs": "Переглянути документацію", + "inactive": "Неактивні", + "active": "Активні", + "total": "Усі" + }, + "alarms": { + "critical": "Критичні", + "assigned-to-me": "Призначені мені", + "total": "Усі" + }, + "getting-started": { + "get-started": "Розпочати", + "finish": "Завершити", + "done-welcome-title": "Ласкаво просимо", + "done-welcome-text": "Ви чудово впоралися!", + "sys-admin": { + "step1": { + "title": "Створити тенанта та адміністратора тенанта", + "content": "

Тенант — це окрема особа або організація, що володіє або виробляє пристрої та активи. Тенант може мати кількох користувачів-адміністраторів тенанта, клієнтів, пристрої та активи.

Адміністратор тенанта може створювати та керувати пристроями, активами, клієнтами та дашбордами в обліковому записі тенанта.

Перегляньте документацію, як це зробити:

", + "how-to-create-tenant": "Як створити тенанта та адміністратора тенанта" + }, + "step2": { + "title": "Налаштувати функцію: Поштовий сервер", + "content": "

Налаштування поштового сервера необхідне для активації користувачів, відновлення пароля та доставки сповіщень про тривоги.

Перегляньте документацію, як це зробити:

", + "how-to-configure-mail-server": "Як налаштувати поштовий сервер" + }, + "step3": { + "title": "Налаштувати функцію: SMS-провайдер", + "content": "

Налаштуйте SMS-провайдерів, щоб сповіщати клієнтів про тривоги через SMS.

Перегляньте документацію, як це зробити:

", + "how-to-configure-sms-provider": "Як налаштувати SMS-провайдера" }, - "input-widgets": { - "attribute-not-allowed": "Атрибут не може бути вибраний в цьому віджеті", - "date": "Дата", - "blocked-location": "Геолокація заблокована у вашому браузері", - "claim-device": "Підтвердити пристрій", - "claim-failed": "Не вдалося підтвердити пристрій!", - "claim-not-found": "Пристрій не знайдено!", - "claim-successful": "Пристрій успішно підтверджено!", - "discard-changes": "Скасувати зміни", - "device-name": "Назва пристрою", - "device-name-required": "Необхідно вказати назву пристрою", - "entity-attribute-required": "Необхідно вказати значення атрибута", - "entity-coordinate-required": "Необхідно вказати широту та довготу", - "entity-timeseries-required": "Необхідно вказати значення телеметрії", - "get-location": "Отримати поточне місцезнаходження", - "latitude": "Широта", - "longitude": "Довгота", - "not-allowed-entity": "Обрана сутність не має спільних атрибутів", - "no-attribute-selected": "Атрибут не вибрано", - "no-datakey-selected": "Ні один datakey не обраний", - "no-entity-selected": "Сутність не вибрано", - "no-coordinate-specified": "Ключ для широти/довготи не вказаний", - "no-support-geolocation": "Ваш браузер не підтримує геолокацію", - "no-image": "Немає зображення", - "no-support-web-camera": "Немає підтримуваної веб-камери", - "no-timeseries-selected": "Параметр телеметрії не вибрано", - "secret-key": "Секретний ключ", - "secret-key-required": "Необхідно вказати секретний ключ", - "switch-attribute-value": "Змінити значення атрибута", - "switch-camera": "Змінити камеру", - "switch-timeseries-value": "Змінити значення телеметрії", - "take-photo": "Зробити фото", - "time": "Час", - "timeseries-not-allowed": "Телеметрія не може бути вибрана в цьому віджеті", - "update-failed": "Не вдалося оновити", - "update-successful": "Успішно оновлено", - "update-attribute": "Оновити атрибут", - "update-timeseries": "Оновити телеметрію", - "value": "Значення" + "step4": { + "title": "Налаштувати функцію: Брендування", + "content": "

Легко налаштуйте логотип і колірну схему Вашої компанії або продукту без кодування та без перезапуску сервісу.

Перегляньте документацію, як це зробити:

" }, - "persistent-table": { - "rpc-id": "RPC ID", - "message-type": "Тип повідомлення", - "method": "Метод", - "params": "Параметри", - "created-time": "Час створення", - "expiration-time": "Час життя", - "retries": "Повторні спроби", - "status": "Статус", - "filter": "Фільтр", - "refresh": "Оновити", - "add": "Додати RPC запит", - "details": "Деталі", - "delete": "Видалити", - "delete-request-title": "Видалити RPC запит", - "delete-request-text": "Ви впевнені, що хочете видалити RPC запит?", - "details-title": "Деталі RPC ID: ", - "additional-info": "Додаткова інформація", - "response": "Відповідь", - "any-status": "Будь-який статус", - "rpc-status-list": "Список RPC статусів", - "no-request-prompt": "Запитів не знайдено", - "send-request": "Відправити запит", - "add-title": "Додати новий RPC запит", - "method-error": "Необхідно вказати метод.", - "white-space-error": "Пробіли не допускаються.", - "rpc-status": { - "QUEUED": "В ЧЕРЗІ", - "SENT": "ВІДПРАВЛЕНО", - "DELIVERED": "ДОСТАВЛЕНО", - "SUCCESSFUL": "УСПІШНО", - "TIMEOUT": "ЧАС МИНУВ", - "EXPIRED": "ПРОТЕРМІНОВАНО", - "FAILED": "НЕ ВДАЛО" - }, - "rpc-search-status-all": "ВСІ", - "message-types": { - "false": "Двосторонній", - "true": "Односторонній" - } + "step5": { + "title": "Налаштувати функцію: 2FA", + "content": "

Підвищте безпеку облікових записів платформи за допомогою двофакторної автентифікації.

Перегляньте документацію, як це зробити:

" + }, + "step6": { + "title": "Налаштувати функцію: OAuth 2", + "content": "

Спростіть вхід для користувачів тенанта та клієнтів за допомогою функції єдиного входу (SSO) через OAuth 2.0.

Перегляньте документацію, як це зробити:

" } - }, - "white-labeling": { - "white-labeling": "Брендування", - "login-white-labeling": "Login White Labeling", - "preview": "Попередній перегляд", - "app-title": "Назва програми", - "favicon": "Іконка веб-сайту", - "favicon-description": "*.ico, *.gif or *.png image with maximum size {{kbSize}} KBytes.", - "favicon-size-error": "Зображення веб-сайту завелике. Максимально дозволений розмір зображення веб-сайту {{kbSize}} KBytes.", - "favicon-type-error": "Недійсний формат файлу зображення веб-сайту. Приймаються лише зображення ICO, GIF або PNG.", - "drop-favicon-image": "Зніміть зображення піктограми веб-сайту або клацніть, щоб вибрати файл для завантаження.", - "no-favicon-image": "Не вибрано жодної іконки", - "logo": "Логотип", - "logo-description": "Будь-яке зображення з максимальним розміром {{kbSize}} KBytes.", - "logo-size-error": "Зображення логотипу занадто велике. Максимально дозволений розмір зображення логотипу{{kbSize}} KBytes.", - "logo-type-error": "Недійсний формат файлу логотипу. Приймаються тільки зображення.", - "drop-logo-image": "Зніміть зображення логотипу або клацніть, щоб вибрати файл для завантаження.", - "no-logo-image": "Не вибрано жодного логотипу", - "logo-height": "Висота логотипу, px", - "primary-palette": "Основна палітра", - "accent-palette": "Палітра акцент", - "customize-palette": "Налаштування", - "edit-palette": "Редагувати палітру", - "save-palette": "Зберегти палітру", - "primary-background": "Первинний фон", - "secondary-background": "Вторинний фон", - "hue1": "HUE 1", - "hue2": "HUE 2", - "hue3": "HUE 3", - "page-background-color": "Колір фону сторінки", - "dark-foreground": "Темний передній план", - "domain-name": "Доменне ім'я" - }, - "icon": { - "icon": "Іконка", - "select-icon": "Виберіть Іконку", - "material-icons": "Іконки в стилі Material", - "show-all": "Показати всі іконки" - }, - "custom": { - "widget-action": { - "action-cell-button": "Кнопка дії в комірці таблиці", - "row-click": "Клацніть на рядок", - "marker-click": "Клацніть на маркер", - "polygon-click": "Дія при натисканні на полігон", - "tooltip-tag-action": "Дія при натисканні на посилання в підказці", - "node-selected": "Дії при виборі ноди", - "element-click": "Дії при натисканні на HTML елементі", - "pie-slice-click": "Дії при натисканні на секції кругової діаграми", - "row-double-click": "Дії при подвійному натисканні на рядок" + }, + "tenant-admin": { + "step1": { + "title": "Створити пристрій", + "content": "

Давайте підключимо Ваш перший пристрій до платформи через UI. Перегляньте документацію, як це зробити:

", + "how-to-create-device": "Як створити пристрій" + }, + "step2": { + "title": "Підключити пристрій", + "content-before": "

Щоб підключити пристрій, Вам потрібно отримати облікові дані пристрою. Для цього посібника ми рекомендуємо використовувати типові автоматично згенеровані облікові дані — токен доступу.

  • Перейдіть до таблиці пристроїв
  • Натисніть на рядок пристрою, щоб відкрити деталі пристрою
  • Натисніть кнопку \"Копіювати токен доступу\"

Використайте прості команди, щоб опублікувати дані через HTTP. Не забудьте замінити $ACCESS_TOKEN на токен доступу Вашого пристрою:

", + "ubuntu": { + "install-curl": "Встановіть cURL для Ubuntu:" + }, + "macos": { + "install-curl": "Встановіть cURL для MacOS:" + }, + "windows": { + "install-curl": "Починаючи з Windows 10 b17063, cURL доступний за замовчуванням." + }, + "replace-access-token": "Замініть $ACCESS_TOKEN на токен Вашого пристрою:", + "content-after": "

Ви також можете використовувати інші протоколи, такі як MQTT, CoAP тощо.

Перегляньте документацію, як це зробити:

", + "how-to-connect-device": "Як підключити пристрій" + }, + "step3": { + "title": "Створити дашборд", + "content": "

Створіть дашборд, щоб візуалізувати дані із сутностей, таких як активи, пристрої тощо.

Перегляньте документацію, як це зробити:

", + "how-to-create-dashboard": "Як створити дашборд" + }, + "step4": { + "title": "Налаштувати правила тривог", + "alarm-rules": "Правила тривог", + "content": "

Давайте створимо тривогу, коли температура досягне 25°C. Перегляньте документацію, як це зробити:

", + "how-to-configure-alarm-rules": "Як налаштувати правила тривог" + }, + "step5": { + "title": "Створити тривогу", + "content-before": "

Щоб спрацювала тривога, надішліть нове значення телеметрії 26°C або вище.

", + "replace-access-token": "Замініть $ACCESS_TOKEN на токен Вашого пристрою:", + "content-after": "

Перегляньте документацію, як це зробити:

", + "how-to-create-alarm": "Як створити тривогу" + }, + "step6": { + "title": "Створити клієнта та надати спільний доступ до дашборда", + "content": "

Після створення дашбордів для кінцевих користувачів користувач клієнта може бачити лише власні пристрої, а дані іншого клієнта буде приховано.

Перегляньте документацію, як це зробити:

" } + } }, - "paginator" : { - "items-per-page": "Елементів на сторінці:", - "first-page-label": "Перша сторінка", - "last-page-label": "Остання сторінка", - "next-page-label": "Наступна сторінка", - "previous-page-label": "Попередня сторінка", - "items-per-page-separator": "з" - }, - "language": { - "language": "Мова" + "api-usage": { + "api-usage": "Використання API", + "label": "Маркування", + "state-name": "Назва стану", + "status": "Статус", + "status-required": "Потрібен статус.", + "limit": "Максимальний ліміт", + "limit-required": "Потрібен максимальний ліміт.", + "current-number": "Поточне значення", + "current-number-required": "Потрібне поточне значення.", + "add-key": "Додати ключ", + "no-key": "Немає ключа", + "delete-key": "Видалити ключ", + "target-dashboard-state": "Цільовий стан дашборда", + "go-to-main-state": "Перейти до перегляду за замовчуванням" + } + }, + "icon": { + "icon": "Піктограма", + "icons": "Піктограми", + "custom": "Користувацька", + "select-icon": "Вибрати піктограму", + "material-icons": "Material icons", + "show-all": "Показати всі піктограми", + "search-icon": "Шукати піктограму", + "no-icons-found": "Не знайдено піктограм для '{{iconSearch}}'" + }, + "phone-input": { + "phone-input-label": "Номер телефону", + "phone-input-required": "Потрібен номер телефону", + "phone-input-validation": "Номер телефону недійсний або неможливий", + "phone-input-pattern": "Недійсний номер телефону. Має бути у форматі E.164, напр. {{phoneNumber}}", + "phone-input-hint": "Номер телефону у форматі E.164, напр. {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "Кнопка дії в комірці", + "row-click": "При натисканні на рядок", + "cell-click": "При натисканні на комірку", + "polygon-click": "При натисканні на полігон", + "marker-click": "При натисканні на маркер", + "circle-click": "При натисканні на коло", + "tooltip-tag-action": "Дія тега підказки", + "node-selected": "При виборі вузла", + "element-click": "При натисканні на HTML-елемент", + "pie-slice-click": "При натисканні на сектор", + "row-double-click": "При подвійному натисканні на рядок", + "cell-double-click": "При подвійному натисканні на комірку", + "card-click": "При натисканні на картку", + "click": "При натисканні" } -} + }, + "paginator": { + "items-per-page": "Елементів на сторінці:", + "first-page-label": "Перша сторінка", + "last-page-label": "Остання сторінка", + "next-page-label": "Наступна сторінка", + "previous-page-label": "Попередня сторінка", + "items-per-page-separator": "з" + }, + "language": { + "auto": "Auto", + "language": "Мова" + } +} \ No newline at end of file diff --git a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json index 2e308570f2..8ed93ba0a4 100644 --- a/ui-ngx/src/assets/locale/locale.constant-zh_CN.json +++ b/ui-ngx/src/assets/locale/locale.constant-zh_CN.json @@ -1,7834 +1,10193 @@ { - "access": { - "unauthorized": "未授权", - "unauthorized-access": "未授权访问", - "unauthorized-access-text": "您需要登陆才能访问这个资源!", - "access-forbidden": "禁止访问", - "access-forbidden-text": "您没有访问此位置的权限
如果您仍希望访问此位置,请尝试使用其他用户登录。", - "refresh-token-expired": "会话已过期", - "refresh-token-failed": "无法刷新会话", - "permission-denied": "权限被拒绝", - "permission-denied-text": "您没有执行此操作的权限!" - }, - "account": { - "account": "账号", - "notification-settings": "通知设置" - }, - "action": { - "activate": "激活", - "suspend": "暂停", - "save": "保存", - "saveAs": "另存为", - "move": "移动", - "cancel": "取消", - "ok": "确定", - "delete": "删除", - "add": "添加", - "yes": "是", - "no": "否", - "update": "更新", - "remove": "移除", - "select": "选择", - "search": "查询", - "clear-search": "清除查询", - "assign": "分配", - "unassign": "取消分配", - "share": "分享", - "make-private": "私有", - "apply": "应用", - "apply-changes": "应用更改", - "edit-mode": "编辑模式", - "enter-edit-mode": "进入编辑模式", - "decline-changes": "撤销更改", - "decline": "撤销", - "close": "关闭", - "back": "后退", - "run": "运行", - "sign-in": "登录!", - "edit": "编辑", - "view": "查看", - "create": "创建", - "drag": "拖拽", - "refresh": "刷新", - "undo": "撤销", - "copy": "复制", - "paste": "粘贴", - "copy-reference": "复制引用", - "paste-reference": "粘贴引用", - "import": "导入", - "export": "导出", - "share-via": "通过{{provider}}分享", - "continue": "继续", - "discard-changes": "放弃更改", - "download": "下载", - "next": "下一个", - "next-with-label": "下一个:{{label}}", - "read-more": "阅读更多", - "hide": "隐藏", - "done": "完成", - "print": "打印", - "restore": "恢复", - "confirm": "确定", - "more": "更多", - "less": "更少", - "skip": "跳过", - "send": "发送", - "reset": "重置", - "show-more": "显示更多", - "dont-show-again": "不再显示", - "see-documentation": "查看文档", - "clear": "清除", - "upload": "上传", - "delete-anyway": "仍要删除", - "delete-selected": "删除所选", - "set": "设置" - }, - "aggregation": { - "aggregation": "聚合", - "function": "聚合功能", - "limit": "限制数", - "group-interval": "间隔", + "access": { + "unauthorized": "未授权", + "unauthorized-access": "未授权访问", + "unauthorized-access-text": "您需要登录才能访问此资源!", + "access-forbidden": "禁止访问", + "access-forbidden-text": "您没有权限访问此位置!
如果您仍希望访问此位置,请尝试使用其他用户登录。", + "refresh-token-expired": "会话已过期", + "refresh-token-failed": "无法刷新会话", + "permission-denied": "权限被拒绝", + "permission-denied-text": "您没有权限执行此操作!" + }, + "account": { + "account": "账户", + "notification-settings": "通知设置" + }, + "action": { + "activate": "激活", + "suspend": "停用", + "save": "保存", + "saveAs": "另存为", + "move": "移动", + "cancel": "取消", + "ok": "确定", + "delete": "删除", + "add": "添加", + "yes": "是", + "no": "否", + "update": "更新", + "remove": "移除", + "search": "搜索", + "clear-search": "清除搜索", + "assign": "分配", + "unassign": "取消分配", + "share": "共享", + "make-private": "设为私有", + "apply": "应用", + "apply-changes": "应用更改", + "edit-mode": "编辑模式", + "enter-edit-mode": "进入编辑模式", + "decline-changes": "放弃更改", + "decline": "拒绝", + "close": "关闭", + "back": "返回", + "run": "运行", + "sign-in": "登录!", + "edit": "编辑", + "view": "查看", + "create": "创建", + "drag": "拖拽", + "refresh": "刷新", + "undo": "撤销", + "copy": "复制", + "paste": "粘贴", + "copy-reference": "复制引用", + "paste-reference": "粘贴引用", + "import": "导入", + "export": "导出", + "share-via": "通过 {{provider}} 共享", + "select": "选择", + "continue": "继续", + "discard-changes": "放弃更改", + "download": "下载", + "next": "下一步", + "next-with-label": "下一步:{{label}}", + "read-more": "阅读更多", + "hide": "隐藏", + "test": "测试", + "done": "完成", + "print": "打印", + "restore": "恢复", + "confirm": "确认", + "more": "更多", + "less": "收起", + "skip": "跳过", + "send": "发送", + "reset": "重置", + "show-more": "显示更多", + "dont-show-again": "不再显示", + "see-documentation": "查看文档", + "see-debug-events": "查看调试事件", + "clear": "清除", + "upload": "上传", + "delete-anyway": "仍然删除", + "delete-selected": "删除所选", + "set": "设置" + }, + "aggregation": { + "aggregation": "聚合", + "function": "数据聚合函数", + "limit": "最大值数量", + "group-interval": "分组间隔", + "min": "最小值", + "max": "最大值", + "avg": "平均值", + "sum": "总和", + "count": "计数", + "none": "无" + }, + "admin": { + "settings": "设置", + "general": "常规", + "general-settings": "常规设置", + "home-settings": "首页设置", + "home": "首页", + "outgoing-mail": "邮件服务器", + "outgoing-mail-settings": "发件邮件服务器设置", + "system-settings": "系统设置", + "test-mail-sent": "测试邮件发送成功!", + "base-url": "基础 URL", + "base-url-required": "基础 URL 为必填项。", + "prohibit-different-url": "禁止使用来自客户端请求头的主机名", + "prohibit-different-url-hint": "此设置应在生产环境中启用。禁用时可能导致安全问题", + "device-connectivity": { + "device-connectivity": "设备连接", + "http-s": "HTTP(s)", + "mqtt-s": "MQTT(s)", + "coap-s": "COAP(s)", + "http": "HTTP", + "https": "HTTPs", + "mqtt": "MQTT", + "mqtts": "MQTTs", + "coap": "COAP", + "coaps": "COAPs", + "hint": "如果主机或端口字段为空,将使用默认协议值。", + "host": "主机", + "port": "端口", + "port-pattern": "端口必须为正整数。", + "port-range": "端口应在 1 到 65535 的范围内。" + }, + "mail-from": "发件人", + "mail-from-required": "发件人为必填项。", + "smtp-protocol": "SMTP 协议", + "smtp-host": "SMTP 主机", + "smtp-host-required": "SMTP 主机为必填项。", + "smtp-port": "SMTP 端口", + "smtp-port-required": "SMTP 端口为必填项。", + "smtp-port-invalid": "SMTP 端口格式无效。", + "timeout-msec": "超时(毫秒)", + "timeout-required": "超时为必填项。", + "timeout-invalid": "超时格式无效。", + "enable-tls": "启用 TLS", + "tls-version": "TLS 版本", + "enable-proxy": "启用代理", + "proxy-host": "代理主机", + "proxy-host-required": "代理主机为必填项。", + "proxy-port": "代理端口", + "proxy-port-required": "代理端口为必填项。", + "proxy-port-range": "代理端口应在 1 到 65535 的范围内。", + "proxy-user": "代理用户", + "proxy-password": "代理密码", + "change-password": "修改密码", + "send-test-mail": "发送测试邮件", + "sms-provider": "短信提供商", + "sms-provider-settings": "短信提供商设置", + "sms-provider-type": "短信提供商类型", + "sms-provider-type-required": "短信提供商类型为必填项。", + "sms-provider-type-aws-sns": "Amazon SNS", + "sms-provider-type-twilio": "Twilio", + "sms-provider-type-smpp": "SMPP", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID 为必填项", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key 为必填项", + "aws-region": "AWS Region", + "aws-region-required": "AWS Region 为必填项", + "number-from": "发送方电话号码", + "number-from-required": "发送方电话号码为必填项。", + "number-to": "接收方电话号码", + "number-to-required": "接收方电话号码为必填项。", + "phone-number-hint": "电话号码需使用 E.164 格式,例如 +19995550123", + "phone-number-hint-twilio": "电话号码需使用 E.164 格式/Phone Number's SID/Messaging Service SID,例如 +19995550123/PNXXX/MGXXX", + "phone-number-pattern": "电话号码无效。应使用 E.164 格式,例如 +19995550123。", + "phone-number-pattern-twilio": "电话号码无效。应使用 E.164 格式/Phone Number's SID/Messaging Service SID,例如 +19995550123/PNXXX/MGXXX。", + "sms-message": "短信消息", + "sms-message-required": "短信消息为必填项。", + "sms-message-max-length": "短信消息不能超过 1600 个字符", + "twilio-account-sid": "Twilio Account SID", + "twilio-account-sid-required": "Twilio Account SID 为必填项", + "twilio-account-token": "Twilio Account Token", + "twilio-account-token-required": "Twilio Account Token 为必填项", + "send-test-sms": "发送测试短信", + "test-sms-sent": "测试短信发送成功!", + "security-settings": "安全设置", + "password-policy": "密码策略", + "minimum-password-length": "最小密码长度", + "minimum-password-length-required": "最小密码长度为必填项", + "minimum-password-length-range": "最小密码长度应在 6 到 50 的范围内", + "maximum-password-length": "最大密码长度", + "maximum-password-length-min": "最大密码长度至少为 6", + "maximum-password-length-less-min": "最大密码长度应大于最小长度", + "minimum-uppercase-letters": "最少大写字母数量", + "minimum-uppercase-letters-range": "最少大写字母数量不能为负数", + "minimum-lowercase-letters": "最少小写字母数量", + "minimum-lowercase-letters-range": "最少小写字母数量不能为负数", + "minimum-digits": "最少数字数量", + "minimum-digits-range": "最少数字数量不能为负数", + "minimum-special-characters": "最少特殊字符数量", + "minimum-special-characters-range": "最少特殊字符数量不能为负数", + "password-expiration-period-days": "密码过期周期(天)", + "password-expiration-period-days-range": "密码过期周期(天)不能为负数", + "password-reuse-frequency-days": "密码重用频率(天)", + "password-reuse-frequency-days-range": "密码重用频率(天)不能为负数", + "allow-whitespace": "允许空格", + "force-reset-password-if-no-valid": "密码无效时强制重置", + "force-reset-password-if-no-valid-hint": "启用此功能时请谨慎:这将要求密码无效的用户通过 email 重置密码。", + "general-policy": "通用策略", + "max-failed-login-attempts": "账户锁定前允许的最大登录失败次数", + "minimum-max-failed-login-attempts-range": "最大登录失败次数不能为负数", + "user-lockout-notification-email": "用户账户锁定时,发送通知到 email", + "user-activation-token-ttl": "用户激活链接 TTL(小时)", + "user-activation-token-ttl-range": "用户激活链接 TTL 必须在 1 到 24 小时范围内", + "password-reset-token-ttl": "密码重置链接 TTL(小时)", + "password-reset-token-ttl-range": "密码重置链接 TTL 必须在 1 到 24 小时范围内", + "mobile-secret-key-length": "移动端密钥长度", + "mobile-secret-key-length-range": "移动端密钥长度必须为正数", + "domain-name": "域名", + "domain-name-unique": "域名和协议需要唯一。", + "domain-name-max-length": "域名应少于 256 个字符", + "error-verification-url": "域名不应包含符号“/”和“:”。例如:thingsboard.io", + "connection-settings": "连接设置", + "oauth2": { + "access-token-uri": "Access token URI", + "access-token-uri-required": "Access token URI 为必填项。", + "activate-user": "激活用户", + "add-domain": "添加域名", + "delete-domain": "删除域名", + "add-provider": "添加提供商", + "delete-provider": "删除提供商", + "allow-user-creation": "允许创建用户", + "always-fullscreen": "始终全屏", + "authorization-uri": "Authorization URI", + "authorization-uri-required": "Authorization URI 为必填项。", + "add-client": "添加 OAuth 2.0 客户端", + "client-details": "OAuth 2.0 客户端详情", + "client": "OAuth 2.0 客户端", + "clients": "OAuth 2.0 客户端", + "no-oauth2-clients": "未找到 OAuth 2.0 客户端", + "search-oauth2-clients": "搜索 OAuth 2.0 客户端", + "delete-client-title": "确定要删除 OAuth 2.0 客户端“{{clientName}}”吗?", + "delete-client-text": "请注意,确认后该客户端及所有相关数据将无法恢复。", + "delete-mobile-app-title": "确定要删除移动应用“{{applicationName}}”吗?", + "delete-mobile-app-text": "请注意,确认后该移动应用及所有相关数据将无法恢复。", + "title": "标题", + "client-title-required": "标题为必填项", + "client-title-max-length": "标题应少于 100 个字符", + "advanced-settings": "高级设置", + "domain-details": "域名详情", + "no-domains": "未找到域名", + "search-domains": "搜索域名", + "mobile-app-details": "移动应用详情", + "add-mobile-app": "添加移动应用", + "no-mobile-apps": "未找到移动应用", + "search-mobile-apps": "搜索移动应用", + "send-token": "发送 Token", + "create-new": "新建", + "client-authentication-method": "客户端认证方法", + "client-id": "Client ID", + "client-id-required": "Client ID 为必填项。", + "client-id-max-length": "Client ID 应少于 256 个字符", + "client-secret": "Client secret", + "client-secret-required": "Client secret 为必填项。", + "client-secret-max-length": "Client secret 应少于 2049 个字符", + "custom-setting": "自定义设置", + "customer-name-pattern": "客户名称模式", + "customer-name-pattern-max-length": "客户名称模式应少于 256 个字符", + "default-dashboard-name": "默认仪表板名称", + "default-dashboard-name-max-length": "默认仪表板名称应少于 256 个字符", + "delete-domain-text": "请注意,确认后该域名及所有提供商数据将不可用。", + "delete-domain-title": "确定要删除域名“{{domainName}}”吗?", + "delete-registration-text": "请注意,确认后提供商数据将不可用。", + "delete-registration-title": "确定要删除提供商“{{name}}”吗?", + "email-attribute-key": "Email 属性键", + "email-attribute-key-required": "Email 属性键为必填项。", + "email-attribute-key-max-length": "Email 属性键应少于 32 个字符", + "first-name-attribute-key": "名字属性键", + "first-name-attribute-key-max-length": "名字属性键应少于 32 个字符", + "general": "常规", + "jwk-set-uri": "JSON Web Key URI", + "last-name-attribute-key": "姓氏属性键", + "last-name-attribute-key-max-length": "姓氏属性键应少于 32 个字符", + "login-button-icon": "登录按钮图标", + "login-button-label": "提供商标签", + "login-button-label-placeholder": "Login with $(Provider label)", + "login-button-label-required": "标签为必填项。", + "login-provider": "登录提供商", + "mapper": "映射器", + "new-domain": "新域名", + "oauth2": "OAuth 2.0", + "password-max-length": "密码应少于 256 个字符", + "redirect-uri-template": "Redirect URI 模板", + "copy-redirect-uri": "复制 Redirect URI", + "registration-id": "注册 ID", + "registration-id-required": "注册 ID 为必填项。", + "registration-id-unique": "注册 ID 在系统中必须唯一。", + "scope": "Scope", + "scope-required": "Scope 为必填项。", + "tenant-name-pattern": "租户名称模式", + "tenant-name-pattern-required": "租户名称模式为必填项。", + "tenant-name-pattern-max-length": "租户名称模式应少于 256 个字符", + "tenant-name-strategy": "租户名称策略", + "type": "映射器类型", + "uri-pattern-error": "URI 格式无效。", + "url": "URL", + "url-pattern": "URL 格式无效。", + "url-required": "URL 为必填项。", + "url-max-length": "URL 应少于 256 个字符", + "user-info-uri": "User info URI", + "user-info-uri-required": "User info URI 为必填项。", + "username-max-length": "用户名应少于 256 个字符", + "user-name-attribute-name": "用户名属性键", + "user-name-attribute-name-required": "用户名属性键为必填项", + "protocol": "协议", + "domain-schema-http": "HTTP", + "domain-schema-https": "HTTPS", + "domain-schema-mixed": "HTTP+HTTPS", + "enable": "启用 OAuth 2.0 设置", + "disable": "禁用 OAuth 2.0 设置", + "edge": "传播到 Edge", + "edge-enable": "启用传播到 Edge", + "edge-disable": "禁用传播到 Edge", + "domains": "域名", + "mobile-apps": "移动应用", + "mobile-package": "应用包", + "mobile-package-placeholder": "例如:my.example.app", + "mobile-package-hint": "Android:您自己的唯一 Application ID。iOS:Product bundle identifier。", + "mobile-package-unique": "应用包必须唯一。", + "mobile-package-required": "应用包为必填项。", + "mobile-package-max-length": "应用包应少于 256 个字符", + "mobile-package-spaces": "应用包不应包含空格", + "mobile-app-secret": "Application secret", + "mobile-app-secret-hint": "Base64 编码的字符串,至少表示 512 位数据。", + "mobile-app-secret-required": "Application secret 为必填项。", + "mobile-app-secret-min-length": "Application secret 必须至少为 512 位数据。", + "mobile-app-secret-base64": "Application secret 必须为 base64 格式。", + "invalid-mobile-app-secret": "Application secret 只能包含字母和数字字符,长度必须在 16 到 2048 个字符之间。", + "copy-mobile-app-secret": "复制 Application secret", + "delete-mobile-app": "删除应用信息", + "providers": "提供商", + "platform-web": "Web", + "platform-android": "Android", + "platform-ios": "iOS", + "all-platforms": "所有平台", + "smtp-provider": "SMTP 提供商", + "allowed-platforms": "允许的平台", + "authentication": "认证", + "basic": "基本", + "provider": "提供商", + "redirect-url": "Redirect URI", + "domain-name": "域名", + "domain-name-required": "域名为必填项", + "redirect-url-template": "Redirect URI 模板", + "microsoft-tenant-id": "Directory (tenant) Id", + "microsoft-tenant-id-required": "Directory (tenant) Id 为必填项", + "token-uri": "Token URI", + "token-uri-required": "Token URI 为必填项", + "redirect-uri": "Redirect URI", + "google-provider": "Google", + "microsoft-provider": "Office 365", + "sendgrid-provider": "Sendgrid", + "custom-provider": "自定义", + "generate-access-token": "生成访问 Token", + "update-access-token": "更新访问 Token", + "access-token-status": "访问 Token 状态:", + "token-status-generated": "已生成", + "token-status-not-generated": "未生成" + }, + "smpp-provider": { + "smpp-version": "SMPP 版本", + "smpp-host": "SMPP 主机", + "smpp-host-required": "SMPP 主机为必填项", + "smpp-port": "SMPP 端口", + "smpp-port-required": "SMPP 端口为必填项", + "system-id": "System ID", + "system-id-required": "System ID 为必填项", + "password": "密码", + "password-required": "密码为必填项", + "type-settings": "类型设置", + "source-settings": "源设置", + "destination-settings": "目标设置", + "additional-settings": "附加设置", + "system-type": "系统类型", + "bind-type": "绑定类型", + "service-type": "服务类型", + "source-address": "源地址", + "source-ton": "源 TON", + "source-npi": "源 NPI", + "destination-ton": "目标 TON(号码类型)", + "destination-npi": "目标 NPI(编号计划标识)", + "address-range": "地址范围", + "coding-scheme": "编码方案", + "bind-type-tx": "发送器", + "bind-type-rx": "接收器", + "bind-type-trx": "收发器", + "ton-unknown": "未知", + "ton-international": "国际", + "ton-national": "国内", + "ton-network-specific": "特定网络", + "ton-subscriber-number": "用户号码", + "ton-alphanumeric": "字母数字", + "ton-abbreviated": "缩写", + "npi-unknown": "0 - 未知", + "npi-isdn": "1 - ISDN/电话编号计划 (E163/E164)", + "npi-data-numbering-plan": "3 - 数据编号计划 (X.121)", + "npi-telex-numbering-plan": "4 - 电传编号计划 (F.69)", + "npi-land-mobile": "6 - 陆地移动 (E.212)", + "npi-national-numbering-plan": "8 - 国家编号计划", + "npi-private-numbering-plan": "9 - 专用编号计划", + "npi-ermes-numbering-plan": "10 - ERMES 编号计划 (ETSI DE/PS 3 01-3)", + "npi-internet": "13 - 互联网 (IP)", + "npi-wap-client-id": "18 - WAP Client Id(由 WAP Forum 定义)", + "scheme-smsc": "0 - SMSC 默认字母表(短代码和长代码为 ASCII,免费号码为 GSM)", + "scheme-ia5": "1 - IA5(短代码和长代码为 ASCII,免费号码为 Latin 9 (ISO-8859-9))", + "scheme-octet-unspecified-2": "2 - 未指定八位字节(8 位二进制)", + "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", + "scheme-octet-unspecified-4": "4 - 未指定八位字节(8 位二进制)", + "scheme-jis": "5 - JIS (X 0208-1990)", + "scheme-cyrillic": "6 - 西里尔文 (ISO-8859-5)", + "scheme-latin-hebrew": "7 - Latin/Hebrew (ISO-8859-8)", + "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", + "scheme-pictogram-encoding": "9 - 象形文字编码", + "scheme-music-codes": "10 - 音乐代码 (ISO-2022-JP)", + "scheme-extended-kanji-jis": "13 - 扩展汉字 JIS (X 0212-1990)", + "scheme-korean-graphic-character-set": "14 - 韩文图形字符集 (KS C 5601/KS X 1001)" + }, + "queue-select-name": "选择队列名称", + "queue-name": "名称", + "queue-name-required": "队列名称为必填项!", + "queues": "队列", + "queue-partitions": "分区", + "queue-submit-strategy": "提交策略", + "queue-processing-strategy": "处理策略", + "queue-configuration": "队列配置", + "repository-settings": "仓库设置", + "repository": "仓库", + "repository-url": "仓库 URL", + "repository-url-required": "仓库 URL 为必填项。", + "default-branch": "默认分支名称", + "repository-read-only": "只读", + "show-merge-commits": "显示合并提交", + "authentication-settings": "认证设置", + "auth-method": "认证方法", + "auth-method-username-password": "密码/访问 Token", + "auth-method-username-password-hint": "GitHub 用户必须使用具有仓库写权限的访问 Token。", + "auth-method-private-key": "私钥", + "password-access-token": "密码/访问 Token", + "change-password-access-token": "修改密码/访问 Token", + "private-key": "私钥", + "drop-private-key-file-or": "拖放私钥文件或", + "passphrase": "密码短语", + "enter-passphrase": "输入密码短语", + "change-passphrase": "修改密码短语", + "check-access": "检查访问权限", + "check-repository-access-success": "仓库访问验证成功!", + "delete-repository-settings-title": "确定要删除仓库设置吗?", + "delete-repository-settings-text": "请注意,确认后仓库设置将被移除,版本控制功能将不可用。", + "auto-commit-settings": "自动提交设置", + "auto-commit": "自动提交", + "auto-commit-entities": "自动提交实体", + "no-auto-commit-entities-prompt": "未配置自动提交的实体", + "delete-auto-commit-settings-title": "确定要删除自动提交设置吗?", + "delete-auto-commit-settings-text": "请注意,确认后自动提交设置将被移除,所有实体的自动提交将被禁用。", + "mobile-app": { + "mobile-app": "移动应用", + "mobile-app-qr-code-widget-settings": "移动应用二维码部件设置", + "applications": "应用", + "default": "默认", + "custom": "自定义", + "android": "Android", + "ios": "iOS", + "appearance": "外观", + "appearance-on-home-page": "首页外观", + "enabled": "已启用", + "disabled": "已禁用", + "badges": "徽章", + "label": "标签", + "label-required": "标签为必填项", + "label-max-length": "标签应不超过 50 个字符", + "right": "右", + "left": "左", + "set": "设置", + "preview": "预览", + "connect-mobile-app": "连接移动应用", + "use-system-settings": "使用系统设置" + }, + "2fa": { + "2fa": "双因素认证", + "available-providers": "可用提供商", + "available-providers-required": "至少需要配置一个双因素认证提供商。", + "issuer-name": "签发者名称", + "issuer-name-required": "签发者名称为必填项。", + "max-verification-failures-before-user-lockout": "用户锁定前最大验证失败次数", + "max-verification-failures-before-user-lockout-pattern": "最大验证失败次数必须为正整数。", + "number-of-checking-attempts": "检查尝试次数", + "number-of-checking-attempts-pattern": "检查尝试次数必须为正整数。", + "number-of-checking-attempts-required": "检查尝试次数为必填项。", + "number-of-codes": "验证码数量", + "number-of-codes-pattern": "验证码数量必须为正整数。", + "number-of-codes-required": "验证码数量为必填项。", + "provider": "提供商", + "retry-verification-code-period": "重试验证码周期", + "retry-verification-code-period-pattern": "最小周期时间为 5 秒", + "retry-verification-code-period-required": "重试验证码周期为必填项。", + "total-allowed-time-for-verification": "验证允许的总时间", + "total-allowed-time-for-verification-pattern": "最小允许总时间为 60 秒", + "total-allowed-time-for-verification-required": "允许的总时间为必填项。", + "use-system-two-factor-auth-settings": "使用系统双因素认证设置", + "verification-code-check-rate-limit": "验证码检查频率限制", + "verification-code-lifetime": "验证码有效期", + "verification-code-lifetime-pattern": "验证码有效期必须为正整数。", + "verification-code-lifetime-required": "验证码有效期为必填项。", + "verification-message-template": "验证消息模板", + "verification-limitations": "验证限制", + "verification-message-template-pattern": "验证消息需要包含模式:${code}", + "verification-message-template-required": "验证消息模板为必填项。", + "within-time": "时间范围", + "within-time-pattern": "时间必须为正整数。", + "within-time-required": "时间为必填项。", + "force-2fa": "强制双因素认证", + "enforce-for": "强制对象" + }, + "jwt": { + "security-settings": "JWT 安全设置", + "issuer-name": "签发者名称", + "issuer-name-required": "签发者名称为必填项。", + "signings-key": "签名密钥", + "signings-key-hint": "Base64 编码的字符串,至少表示 512 位数据。", + "signings-key-required": "签名密钥为必填项。", + "signings-key-min-length": "签名密钥必须至少为 512 位数据。", + "signings-key-base64": "签名密钥必须为 base64 格式。", + "expiration-time": "Token 过期时间(秒)", + "expiration-time-required": "Token 过期时间为必填项。", + "expiration-time-max": "最大允许时间为 2147483647 秒(68 年)。", + "expiration-time-min": "最小时间为 60 秒(1 分钟)。", + "refresh-expiration-time": "刷新 Token 过期时间(秒)", + "refresh-expiration-time-required": "刷新 Token 过期时间为必填项。", + "refresh-expiration-time-max": "最大允许时间为 2147483647 秒(68 年)。", + "refresh-expiration-time-min": "最小时间为 900 秒(15 分钟)。", + "refresh-expiration-time-less-token": "刷新 Token 时间必须大于 Token 时间。", + "generate-key": "生成密钥", + "info-header": "所有用户将需要重新登录", + "info-message": "更改 JWT 签名密钥将导致所有已签发的 Token 失效。所有用户都需要重新登录。这也将影响使用 Rest API/Websockets 的脚本。" + }, + "resources": "资源", + "notifications": "通知", + "notifications-settings": "通知设置", + "slack-api-token": "Slack API Token", + "slack": "Slack", + "slack-settings": "Slack 设置", + "mobile-settings": "移动端设置", + "firebase-service-account-file": "Firebase 服务账户凭证 JSON 文件", + "select-firebase-service-account-file": "拖放您的 Firebase 服务账户凭证文件或" + }, + "alarm": { + "alarm": "告警", + "alarm-list": "告警列表", + "alarms": "告警", + "all-alarms": "所有告警", + "select-alarm": "选择告警", + "no-alarms-matching": "未找到匹配“{{entity}}”的告警。", + "alarm-required": "告警为必填项", + "alarm-filter": "告警过滤器", + "filter": "过滤器", + "alarm-status": "告警状态", + "alarm-status-list": "告警状态列表", + "any-status": "任意状态", + "search-status": { + "ANY": "任意", + "ACTIVE": "活动", + "CLEARED": "已清除", + "ACK": "已确认", + "UNACK": "未确认" + }, + "display-status": { + "ACTIVE_UNACK": "活动未确认", + "ACTIVE_ACK": "活动已确认", + "CLEARED_UNACK": "已清除未确认", + "CLEARED_ACK": "已清除已确认" + }, + "no-alarms-prompt": "未找到告警", + "created-time": "创建时间", + "type": "类型", + "severity": "严重程度", + "originator": "发起者", + "originator-type": "发起者类型", + "details": "详情", + "originator-label": "发起者标签", + "assign": "分配", + "assignments": "分配记录", + "assignee": "受理人", + "assignee-id": "受理人 ID", + "assignee-first-name": "受理人名字", + "assignee-last-name": "受理人姓氏", + "assignee-email": "受理人 email", + "unassigned": "未分配", + "user-deleted": "用户已删除", + "assignee-not-set": "全部", + "status": "状态", + "alarm-details": "告警详情", + "start-time": "开始时间", + "assign-time": "分配时间", + "end-time": "结束时间", + "ack-time": "确认时间", + "clear-time": "清除时间", + "duration": "持续时间", + "alarm-severity": "告警严重程度", + "alarm-severity-list": "告警严重程度列表", + "any-severity": "任意严重程度", + "severity-critical": "严重", + "severity-major": "重要", + "severity-minor": "次要", + "severity-warning": "警告", + "severity-indeterminate": "不确定", + "acknowledge": "确认", + "clear": "清除", + "delete": "删除", + "search": "搜索告警", + "selected-alarms": "{ count, plural, =1 {1 个告警} other {# 个告警} }已选择", + "no-data": "无数据可显示", + "polling-interval": "告警轮询间隔(秒)", + "polling-interval-required": "告警轮询间隔为必填项。", + "min-polling-interval-message": "允许的最小轮询间隔为 1 秒。", + "aknowledge-alarms-title": "确认 { count, plural, =1 {1 个告警} other {# 个告警} }", + "aknowledge-alarms-text": "确定要确认 { count, plural, =1 {1 个告警} other {# 个告警} }吗?", + "aknowledge-alarm-title": "确认告警", + "aknowledge-alarm-text": "确定要确认告警吗?", + "selected-alarms-are-acknowledged": "所选告警已被确认", + "clear-alarms-title": "清除 { count, plural, =1 {1 个告警} other {# 个告警} }", + "clear-alarms-text": "确定要清除 { count, plural, =1 {1 个告警} other {# 个告警} }吗?", + "clear-alarm-title": "清除告警", + "clear-alarm-text": "确定要清除告警吗?", + "delete-alarms-title": "删除 { count, plural, =1 {1 个告警} other {# 个告警} }", + "delete-alarms-text": "确定要删除 { count, plural, =1 {1 个告警} other {# 个告警} }吗?", + "selected-alarms-are-cleared": "所选告警已被清除", + "alarm-status-filter": "告警状态过滤器", + "alarm-filter-title": "告警过滤器", + "assigned": "已分配", + "filter-title": "过滤器", + "max-count-load": "最大加载告警数量(0 表示无限制)", + "max-count-load-required": "最大加载告警数量为必填项。", + "max-count-load-error-min": "最小值为 0。", + "fetch-size": "获取数量", + "fetch-size-required": "获取数量为必填项。", + "fetch-size-error-min": "最小值为 10。", + "alarm-types": "告警类型", + "alarm-type-list": "告警类型列表", + "any-type": "任意类型", + "assigned-to-current-user": "已分配给当前用户", + "assigned-to-me": "分配给我", + "search-propagated-alarms": "搜索传播的告警", + "comments": "告警评论", + "show-more": "显示更多", + "additional-info": "附加信息", + "alarm-type": "告警类型", + "enter-alarm-type": "输入告警类型", + "no-alarm-types-matching": "未找到匹配“{{entitySubtype}}”的告警类型。", + "alarm-type-list-empty": "未选择告警类型。", + "system-comments": { + "acked-by-user": "告警已由用户 {{userName}} 确认", + "cleared-by-user": "告警已由用户 {{userName}} 清除", + "assigned-to-user": "告警已由用户 {{userName}} 分配给用户 {{assigneeName}}", + "unassigned-to-user": "告警已由用户 {{userName}} 取消分配", + "unassigned-from-deleted-user": "告警已取消分配,因为用户 {{userName}} 已被删除", + "comment-deleted": "用户 {{userName}} 删除了评论", + "severity-changed": "告警严重程度已从 {{oldSeverity}} 更新为 {{newSeverity}}" + } + }, + "alarm-activity": { + "add": "添加评论...", + "alarm-comment": "告警评论", + "comments": "评论", + "delete-alarm-comment": "确定要删除此评论吗?", + "refresh": "刷新", + "oldest-first": "最早优先", + "newest-first": "最新优先", + "activity": "活动", + "export": "导出为 CSV", + "author": "作者", + "created-date": "创建日期", + "edited-date": "编辑日期", + "text": "文本", + "system": "系统" + }, + "alias": { + "add": "添加别名", + "edit": "编辑别名", + "name": "别名名称", + "name-required": "别名名称为必填项", + "duplicate-alias": "同名别名已存在。", + "filter-type-single-entity": "单个实体", + "filter-type-entity-list": "实体列表", + "filter-type-entity-name": "实体名称", + "filter-type-entity-type": "实体类型", + "filter-type-state-entity": "来自仪表板状态的实体", + "filter-type-state-entity-description": "从仪表板状态参数获取的实体", + "filter-type-asset-type": "资产类型", + "filter-type-asset-type-description": "类型为“{{assetTypes}}”的资产", + "filter-type-asset-type-and-name-description": "类型为“{{assetTypes}}”且名称以“{{prefix}}”开头的资产", + "filter-type-device-type": "设备类型", + "filter-type-device-type-description": "类型为“{{deviceTypes}}”的设备", + "filter-type-device-type-and-name-description": "类型为“{{deviceTypes}}”且名称以“{{prefix}}”开头的设备", + "filter-type-entity-view-type": "实体视图类型", + "filter-type-entity-view-type-description": "类型为“{{entityViewTypes}}”的实体视图", + "filter-type-entity-view-type-and-name-description": "类型为“{{entityViewTypes}}”且名称以“{{prefix}}”开头的实体视图", + "filter-type-edge-type": "Edge 类型", + "filter-type-edge-type-description": "类型为“{{edgeTypes}}”的 Edge", + "filter-type-edge-type-and-name-description": "类型为“{{edgeTypes}}”且名称以“{{prefix}}”开头的 Edge", + "filter-type-relations-query": "关联查询", + "filter-type-relations-query-description": "与 {{rootEntity}} 具有 {{relationType}} 关联(方向:{{direction}})的 {{entities}}", + "filter-type-edge-search-query": "Edge 搜索查询", + "filter-type-edge-search-query-description": "类型为 {{edgeTypes}} 且与 {{rootEntity}} 具有 {{relationType}} 关联(方向:{{direction}})的 Edge", + "filter-type-asset-search-query": "资产搜索查询", + "filter-type-asset-search-query-description": "类型为 {{assetTypes}} 且与 {{rootEntity}} 具有 {{relationType}} 关联(方向:{{direction}})的资产", + "filter-type-device-search-query": "设备搜索查询", + "filter-type-device-search-query-description": "类型为 {{deviceTypes}} 且与 {{rootEntity}} 具有 {{relationType}} 关联(方向:{{direction}})的设备", + "filter-type-entity-view-search-query": "实体视图搜索查询", + "filter-type-entity-view-search-query-description": "类型为 {{entityViewTypes}} 且与 {{rootEntity}} 具有 {{relationType}} 关联(方向:{{direction}})的实体视图", + "filter-type-apiUsageState": "API 使用状态", + "entity-filter": "实体过滤器", + "resolve-multiple": "解析为多个实体", + "resolve-multiple-hint": "启用后可同时显示所有过滤实体的数据。\n禁用后,部件仅显示所选实体的数据。", + "filter-type": "过滤器类型", + "filter-type-required": "过滤器类型为必填项。", + "entity-filter-no-entity-matched": "未找到匹配指定过滤器的实体。", + "no-entity-filter-specified": "未指定实体过滤器", + "root-state-entity": "使用仪表板状态实体作为根", + "last-level-relation": "仅获取最后一级关联", + "root-entity": "根实体", + "state-entity-parameter-name": "状态实体参数名称", + "default-state-entity": "默认状态实体", + "default-entity-parameter-name": "默认", + "query-options": "查询选项", + "max-relation-level": "最大关联层级", + "unlimited-level": "无限层级", + "state-entity": "仪表板状态实体", + "all-entities": "所有实体", + "any-relation": "任意" + }, + "asset": { + "asset": "资产", + "assets": "资产", + "management": "资产管理", + "view-assets": "查看资产", + "add": "添加资产", + "asset-type-max-length": "资产类型应少于 256 个字符", + "assign-to-customer": "分配给客户", + "assign-asset-to-customer": "将资产分配给客户", + "assign-asset-to-customer-text": "请选择要分配给客户的资产", + "no-assets-text": "未找到资产", + "assign-to-customer-text": "请选择要分配资产的客户", + "public": "公开", + "assignedToCustomer": "已分配给客户", + "make-public": "将资产设为公开", + "make-private": "将资产设为私有", + "unassign-from-customer": "从客户取消分配", + "delete": "删除资产", + "asset-public": "资产为公开状态", + "asset-type": "资产类型", + "asset-type-required": "资产类型为必填项。", + "select-asset-type": "选择资产类型", + "enter-asset-type": "输入资产配置", + "any-asset": "任意资产", + "no-asset-types-matching": "未找到匹配“{{entitySubtype}}”的资产类型。", + "asset-type-list-empty": "未选择资产类型。", + "asset-types": "资产类型", + "name": "名称", + "name-required": "名称为必填项。", + "name-max-length": "名称应少于 256 个字符", + "label-max-length": "标签应少于 256 个字符", + "description": "描述", + "description-required": "描述为必填项。", + "type": "类型", + "type-required": "类型为必填项。", + "details": "详情", + "events": "事件", + "add-asset-text": "添加新资产", + "asset-details": "资产详情", + "assign-assets": "分配资产", + "assign-assets-text": "将 { count, plural, =1 {1 个资产} other {# 个资产} }分配给客户", + "assign-asset-to-edge-title": "将资产分配给 Edge", + "assign-asset-to-edge-text": "请选择要分配给 Edge 的资产", + "delete-assets": "删除资产", + "unassign-assets": "取消分配资产", + "unassign-assets-action-title": "从客户取消分配 { count, plural, =1 {1 个资产} other {# 个资产} }", + "assign-new-asset": "分配新资产", + "delete-asset-title": "确定要删除资产“{{assetName}}”吗?", + "delete-asset-text": "请注意,确认后该资产及所有相关数据将无法恢复。", + "delete-assets-title": "确定要删除 { count, plural, =1 {1 个资产} other {# 个资产} }吗?", + "delete-assets-action-title": "删除 { count, plural, =1 {1 个资产} other {# 个资产} }", + "delete-assets-text": "请注意,确认后所有选中的资产将被移除,所有相关数据将无法恢复。", + "make-public-asset-title": "确定要将资产“{{assetName}}”设为公开吗?", + "make-public-asset-text": "确认后,该资产及其所有数据将被设为公开,可被其他人访问。", + "make-private-asset-title": "确定要将资产“{{assetName}}”设为私有吗?", + "make-private-asset-text": "确认后,该资产及其所有数据将被设为私有,其他人将无法访问。", + "unassign-asset-title": "确定要取消分配资产“{{assetName}}”吗?", + "unassign-asset-text": "确认后,该资产将被取消分配,客户将无法访问。", + "unassign-asset": "取消分配资产", + "unassign-assets-title": "确定要取消分配 { count, plural, =1 {1 个资产} other {# 个资产} }吗?", + "unassign-assets-text": "确认后,所有选中的资产将被取消分配,客户将无法访问。", + "copyId": "复制资产 ID", + "idCopiedMessage": "资产 ID 已复制到剪贴板", + "select-asset": "选择资产", + "no-assets-matching": "未找到匹配“{{entity}}”的资产。", + "asset-required": "资产为必填项。", + "name-starts-with": "资产名称表达式", + "help-text": "根据需要使用“%”:“%asset_name_contains%”、“%asset_name_ends”、“asset_starts_with%”。", + "search": "搜索资产", + "import": "导入资产", + "asset-file": "资产文件", + "label": "标签", + "assign-asset-to-edge": "将资产分配给 Edge", + "unassign-asset-from-edge": "取消分配资产", + "unassign-asset-from-edge-title": "确定要取消分配资产“{{assetName}}”吗?", + "unassign-asset-from-edge-text": "确认后,该资产将被取消分配,Edge 将无法访问。", + "unassign-assets-from-edge-title": "确定要取消分配 { count, plural, =1 {1 个资产} other {# 个资产} }吗?", + "unassign-assets-from-edge-text": "确认后,所有选中的资产将被取消分配,Edge 将无法访问。", + "selected-assets": "{ count, plural, =1 {1 个资产} other {# 个资产} }已选择" + }, + "attribute": { + "attributes": "属性", + "latest-telemetry": "最新遥测", + "no-latest-telemetry": "无最新遥测", + "attributes-scope": "实体属性范围", + "scope-telemetry": "遥测", + "scope-latest-telemetry": "最新遥测", + "scope-client": "客户端属性", + "scope-server": "服务端属性", + "scope-shared": "共享属性", + "scope-client-short": "客户端", + "scope-server-short": "服务端", + "scope-shared-short": "共享", + "scope-latest-short": "最新", + "scope-any": "任意", + "add": "添加属性", + "key": "键", + "key-max-length": "键应少于 256 个字符", + "last-update-time": "最后更新时间", + "key-required": "属性键为必填项。", + "value": "值", + "value-required": "属性值为必填项。", + "telemetry-key-required": "遥测键为必填项", + "telemetry-value-required": "遥测值为必填项", + "delete-attributes-title": "确定要删除 { count, plural, =1 {1 个属性} other {# 个属性} }吗?", + "delete-attributes-text": "请注意,确认后所有选中的属性将被移除。", + "delete-attributes": "删除属性", + "enter-attribute-value": "输入属性值", + "show-on-widget": "在部件中显示", + "widget-mode": "部件模式", + "next-widget": "下一个部件", + "prev-widget": "上一个部件", + "add-to-dashboard": "添加到仪表板", + "add-widget-to-dashboard": "将部件添加到仪表板", + "selected-attributes": "{ count, plural, =1 {1 个属性} other {# 个属性} }已选择", + "selected-telemetry": "{ count, plural, =1 {1 个遥测单元} other {# 个遥测单元} }已选择", + "no-attributes-text": "未找到属性", + "no-telemetry-text": "未找到遥测", + "copy-key": "复制键", + "add-telemetry": "添加遥测", + "copy-value": "复制值", + "delete-timeseries": { + "start-time": "开始时间", + "ends-on": "结束于", + "strategy": "策略", + "delete-strategy": "删除策略", + "all-data": "删除所有数据", + "all-data-except-latest-value": "删除除最新值外的所有数据", + "latest-value": "删除最新值", + "all-data-for-time-period": "删除指定时间段的所有数据", + "rewrite-latest-value": "重写最新值" + } + }, + "api-usage": { + "api-features": "API 功能", + "api-usage": "API 使用量", + "alarm": "告警", + "alarms-created": "已创建的告警", + "queue-stats": "队列统计", + "processing-failures-and-timeouts": "处理失败和超时", + "exceptions": "异常", + "alarms-created-daily-activity": "已创建告警每日活动", + "alarms-created-hourly-activity": "已创建告警每小时活动", + "alarms-created-monthly-activity": "已创建告警每月活动", + "data-points": "数据点", + "data-points-storage-days": "数据点存储天数", + "data-points-storage-days-hourly-activity": "数据点存储天数每小时活动", + "data-points-storage-days-daily-activity": "数据点存储天数每日活动", + "data-points-storage-days-monthly-activity": "数据点存储天数每月活动", + "device-api": "设备 API", + "email": "Email", + "email-messages": "Email 消息", + "email-messages-daily-activity": "Email 消息每日活动", + "email-messages-monthly-activity": "Email 消息每月活动", + "executions": "执行次数", + "scripts": "脚本", + "scripts-hourly-activity": "脚本每小时活动", + "scripts-daily-activity": "脚本每日活动", + "scripts-monthly-activity": "脚本每月活动", + "javascript": "JavaScript", + "javascript-executions": "JavaScript 执行次数", + "tbel": "TBEL", + "tbel-executions": "TBEL 执行次数", + "latest-error": "最新错误", + "messages": "消息", + "notifications": "通知", + "notifications-email-sms": "通知(Email/SMS)", + "notifications-hourly-activity": "通知每小时活动", + "permanent-failures": "${entityName} 永久失败", + "permanent-timeouts": "${entityName} 永久超时", + "processing-failures": "${entityName} 处理失败", + "processing-timeouts": "${entityName} 处理超时", + "rule-chain": "规则链", + "rule-engine": "规则引擎", + "rule-engine-executions": "规则引擎执行次数", + "rule-engine-hourly-activity": "规则引擎每小时活动", + "rule-engine-daily-activity": "规则引擎每日活动", + "rule-engine-monthly-activity": "规则引擎每月活动", + "rule-engine-statistics": "规则引擎统计", + "rule-node": "规则节点", + "sms": "SMS", + "sms-messages": "SMS 消息", + "sms-messages-hourly-activity": "SMS 消息每小时活动", + "sms-messages-daily-activity": "SMS 消息每日活动", + "sms-messages-monthly-activity": "SMS 消息每月活动", + "successful": "${entityName} 成功", + "telemetry": "遥测", + "telemetry-persistence": "遥测持久化", + "telemetry-persistence-daily-activity": "遥测持久化每日活动", + "telemetry-persistence-hourly-activity": "遥测持久化每小时活动", + "telemetry-persistence-monthly-activity": "遥测持久化每月活动", + "transport": "传输", + "transport-msg-hourly-activity": "传输消息每小时活动", + "transport-msg-daily-activity": "传输消息每日活动", + "transport-msg-monthly-activity": "传输消息每月活动", + "transport-daily-activity": "传输每日活动", + "transport-data-points": "传输数据点", + "transport-data-points-hourly-activity": "传输数据点每小时活动", + "transport-data-points-daily-activity": "传输数据点每日活动", + "transport-data-points-monthly-activity": "传输数据点每月活动", + "view-details": "查看详情", + "view-statistics": "查看统计", + "transport-messages": "传输消息", + "transport-messages-hourly-activity": "传输消息每小时活动", + "transport-data-point-hourly-activity": "传输数据点每小时活动", + "javascript-function-executions": "JavaScript 函数执行次数", + "javascript-function-executions-hourly-activity": "JavaScript 函数执行每小时活动", + "javascript-function-executions-daily-activity": "JavaScript 函数执行每日活动", + "javascript-function-executions-monthly-activity": "JavaScript 函数执行每月活动", + "tbel-function-executions": "TBEL 函数执行次数", + "tbel-function-executions-hourly-activity": "TBEL 函数执行每小时活动", + "tbel-function-executions-daily-activity": "TBEL 函数执行每日活动", + "tbel-function-executions-monthly-activity": "TBEL 函数执行每月活动", + "created-reports": "已创建的报表", + "created-reports-hourly-activity": "已创建报表每小时活动", + "created-reports-daily-activity": "已创建报表每日活动", + "created-reports-monthly-activity": "已创建报表每月活动", + "emails": "Emails", + "emails-hourly-activity": "Emails 每小时活动", + "emails-daily-activity": "Emails 每日活动", + "emails-monthly-activity": "Emails 每月活动", + "status": { + "enabled": "已启用", + "disabled": "已禁用", + "warning": "警告" + } + }, + "api-limit": { + "cassandra-write-queries-core": "Rest API Cassandra 写入查询", + "cassandra-read-queries-core": "Rest API 和 WS 遥测 Cassandra 读取查询", + "cassandra-write-queries-rule-engine": "规则引擎遥测 Cassandra 写入查询", + "cassandra-read-queries-rule-engine": "规则引擎遥测 Cassandra 读取查询", + "cassandra-write-queries-monolith": "单体遥测 Cassandra 写入查询", + "cassandra-read-queries-monolith": "单体遥测 Cassandra 读取查询", + "entity-version-creation": "实体版本创建", + "entity-version-load": "实体版本加载", + "notification-requests": "通知请求", + "notification-requests-per-rule": "每规则通知请求", + "rest-api-requests": "REST API 请求", + "rest-api-requests-per-customer": "每客户 REST API 请求", + "transport-messages": "传输消息", + "transport-messages-per-device": "每设备传输消息", + "transport-messages-per-gateway": "每 Gateway 传输消息", + "transport-messages-per-gateway-device": "每 Gateway 设备传输消息", + "ws-updates-per-session": "每会话 WS 更新", + "edge-events": "Edge 事件", + "edge-events-per-edge": "每 Edge 事件", + "edge-uplink-messages": "Edge 上行消息", + "edge-uplink-messages-per-edge": "每 Edge 上行消息" + }, + "api-key": { + "api-key": "API 密钥", + "api-keys": "API 密钥", + "delete-api-key-title": "确定要删除 API 密钥“{{name}}”吗?", + "delete-api-key-text": "请注意,确认后密钥将无法恢复。", + "delete-api-keys-title": "确定要删除 { count, plural, =1 {1 个 API 密钥} other {# 个 API 密钥} }吗?", + "delete-api-keys-text": "请注意,确认后所有选中的密钥将无法恢复。", + "expiration-date": "过期日期", + "date": "日期", + "description": "描述", + "disable": "禁用", + "edit-description": "编辑描述", + "enable": "启用 API 密钥 ", + "expiration-time": "过期时间", + "expiration-time-never": "永不", + "expiration-time-custom": "自定义", + "generate": "生成", + "generate-title": "生成 API 密钥", + "generate-text": "注意:API 密钥继承创建它的用户的权限。", + "generated-api-key-title": "API 密钥已生成。请检查连接!", + "generated-api-key-copy": "请确保立即复制并保存您的 API 密钥,因为之后将无法再次查看。", + "generated-api-key-command": "使用以下说明检查连接。结果应返回当前用户信息:", + "generated-api-key-insecure-url": "通过不安全的 HTTP 连接执行命令将以未加密的方式发送您的 API 密钥,使其容易被拦截。", + "list": "{ count, plural, =1 {一个 API 密钥} other {# 个 API 密钥列表} }", + "manage": "管理", + "manage-api-keys": "管理 API 密钥", + "no-found": "未找到 API 密钥", + "selected-api-keys": "{ count, plural, =1 {1 个 API 密钥} other {# 个 API 密钥} }已选择", + "search": "搜索 API 密钥", + "status": "状态", + "status-active": "活动", + "status-inactive": "未激活", + "status-expired": "已过期" + }, + "audit-log": { + "audit": "审计", + "audit-logs": "审计日志", + "timestamp": "时间戳", + "entity-type": "实体类型", + "entity-name": "实体名称", + "user": "用户", + "type": "类型", + "status": "状态", + "details": "详情", + "type-added": "已添加", + "type-deleted": "已删除", + "type-updated": "已更新", + "type-attributes-updated": "属性已更新", + "type-attributes-deleted": "属性已删除", + "type-rpc-call": "RPC 调用", + "type-credentials-updated": "凭证已更新", + "type-assigned-to-customer": "已分配给客户", + "type-unassigned-from-customer": "已从客户取消分配", + "type-assigned-to-edge": "已分配给 Edge", + "type-unassigned-from-edge": "已从 Edge 取消分配", + "type-activated": "已激活", + "type-suspended": "已停用", + "type-credentials-read": "凭证已读取", + "type-attributes-read": "属性已读取", + "type-relation-add-or-update": "关联已更新", + "type-relation-delete": "关联已删除", + "type-relations-delete": "所有关联已删除", + "type-alarm-ack": "告警已确认", + "type-alarm-clear": "告警已清除", + "type-alarm-delete": "告警已删除", + "type-alarm-assign": "告警已分配", + "type-alarm-unassign": "告警已取消分配", + "type-added-comment": "已添加评论", + "type-updated-comment": "已更新评论", + "type-deleted-comment": "已删除评论", + "type-login": "登录", + "type-logout": "登出", + "type-lockout": "锁定", + "status-success": "成功", + "status-failure": "失败", + "audit-log-details": "审计日志详情", + "no-audit-logs-prompt": "未找到日志", + "action-data": "操作数据", + "failure-details": "失败详情", + "search": "搜索审计日志", + "clear-search": "清除搜索", + "type-assigned-from-tenant": "已从租户分配", + "type-assigned-to-tenant": "已分配给租户", + "type-provision-success": "设备已配置", + "type-provision-failure": "设备配置失败", + "type-timeseries-updated": "遥测已更新", + "type-timeseries-deleted": "遥测已删除", + "type-sms-sent": "SMS 已发送", + "any-type": "任意类型", + "audit-log-filter-title": "审计日志过滤器", + "filter-title": "过滤器", + "filter-types": "审计日志类型" + }, + "debug-settings": { + "label": "调试配置", + "on-failure": "仅失败(24/7)", + "all-messages": "所有消息({{time}})", + "failures": "失败", + "entity": "实体", + "hint": { + "main-limited": "每 {{time}} 将记录不超过 {{msg}} 条{{entity}}调试消息。", + "on-failure": "仅记录错误消息。", + "all-messages": "记录所有调试消息。" + } + }, + "calculated-fields": { + "expression": "表达式", + "no-found": "未找到计算字段", + "list": "{ count, plural, =1 {一个计算字段} other {# 个计算字段列表} }", + "add": "添加计算字段", + "selected-fields": "{ count, plural, =1 {1 个计算字段} other {# 个计算字段} }已选择", + "type": { + "simple": "简单", + "simple-hint": "基于输入参数的简单算术计算。", + "script": "脚本", + "script-hint": "使用 TBEL 脚本对定义的参数进行计算。", + "geofencing": "地理围栏", + "geofencing-hint": "评估实体 GPS 位置以及与配置的地理围栏区域组的转换关系。", + "propagation": "传播", + "propagation-hint": "根据关联方向和类型将数据传播到父级或子级实体。", + "related-entities-aggregation": "关联实体聚合", + "related-entities-aggregation-hint": "聚合来自关联实体的最新数据。", + "time-series-data-aggregation": "时间序列数据聚合", + "time-series-data-aggregation-hint": "聚合来自当前实体的历史数据。" + }, + "preview": "预览", + "arguments": "参数", + "decimals-by-default": "默认小数位数", + "debugging": "计算字段调试", + "calculated-field-details": "计算字段详情", + "argument-name": "参数名称", + "name": "名称", + "datasource": "数据源", + "add-argument": "添加参数", + "test-script-function": "测试脚本函数", + "no-arguments": "至少需要一个参数。", + "argument-settings": "参数设置", + "argument-current": "当前实体", + "argument-current-tenant": "当前租户", + "argument-device": "设备", + "argument-asset": "资产", + "argument-customer": "客户", + "argument-tenant": "当前租户", + "argument-owner": "当前所有者", + "argument-relation-query": "关联实体", + "argument-type": "参数类型", + "attribute": "属性", + "copy-argument-name": "复制参数名称", + "timeseries-key": "时间序列键", + "device-name": "设备名称", + "latest-telemetry": "最新遥测", + "rolling": "时间序列滚动", + "attribute-scope": "属性范围", + "server-attributes": "服务端属性", + "client-attributes": "客户端属性", + "shared-attributes": "共享属性", + "attribute-key": "属性键", + "default-value": "默认值", + "default-value-required": "默认值为必填项。", + "limit": "最大值数量", + "time-window": "时间窗口", + "customer-name": "客户名称", + "asset-name": "资产名称", + "timeseries": "时间序列", + "output": "输出", + "output-hint": "定义输出的处理方式。", + "create": "创建新的计算字段", + "file": "计算字段文件", + "invalid-file-error": "文件格式无效。请确保文件为有效的 JSON 文件。", + "import": "导入计算字段", + "export": "导出计算字段", + "export-failed-error": "无法导出计算字段:{{error}}", + "output-type": "输出类型", + "delete-title": "确定要删除计算字段“{{title}}”吗?", + "delete-text": "请注意,确认后该计算字段及所有相关数据将无法恢复。", + "delete-multiple-title": "确定要删除 { count, plural, =1 {1 个计算字段} other {# 个计算字段} }吗?", + "delete-multiple-text": "请注意,确认后所有选中的计算字段将被移除,所有相关数据将无法恢复。", + "test-with-this-message": "使用此消息测试", + "use-latest-timestamp": "使用最新时间戳", + "entity-coordinates": "实体坐标", + "latitude-time-series-key": "纬度时间序列键", + "latitude-time-series-key-required": "纬度时间序列键为必填项。", + "longitude-time-series-key": "经度时间序列键", + "longitude-time-series-key-required": "经度时间序列键为必填项。", + "geofencing-zone-groups": "地理围栏区域组", + "geofencing-zone-groups-settings": "地理围栏区域组设置", + "target-zone": "目标区域", + "perimeter-key": "周界键", + "report-strategy": "报告策略", + "no-zone-configured": "至少需要一个区域。", + "no-zone-configured-required": "至少需要配置一个区域组。", + "add-zone-group": "添加区域组", + "report-transition-event-only": "仅转换事件", + "report-presence-status-only": "仅存在状态", + "report-transition-event-and-presence": "存在状态和转换事件", + "perimeter-attribute-key": "周界属性键", + "perimeter-attribute-key-required": "周界属性键为必填项。", + "perimeter-attribute-key-pattern": "周界属性键无效。", + "entity-zone-relationship": "从实体到区域的路径", + "direction": "关联方向", + "direction-from": "从实体到区域", + "direction-to": "从区域到实体", + "relation-type": "关联类型", + "create-relation-with-matched-zones": "为源实体与匹配的区域创建关联", + "relation-level": "关联层级", + "fetch-last-available-level": "仅获取最后可用层级", + "zone-group-refresh-interval": "区域组刷新间隔", + "copy-zone-group-name": "复制区域组名称", + "open-details-page": "打开实体详情页", + "level": "层级", + "direction-level": "方向", + "direction-up": "向上", + "direction-up-parent": "向上到父级", + "direction-down": "向下", + "direction-down-child": "向下到子级", + "add-level": "添加层级", + "delete-level": "删除层级", + "no-level": "未配置层级", + "levels-required": "至少需要配置一个层级。", + "max-allowed-levels-error": "关联层级超过允许的最大值。", + "propagation-path-related-entities": "到关联实体的传播路径", + "propagate-type": { + "arguments-only": "仅参数", + "expression-result": "计算结果" + }, + "script": "脚本", + "data-propagate": "要传播的数据", + "output-key": "输出键", + "copy-output-key": "复制输出键", + "aggregation-path-related-entities": "到关联实体的聚合路径", + "deduplication-interval": "去重间隔", + "deduplication-interval-min": "去重间隔至少应为 {{ sec }} 秒。", + "deduplication-interval-hint": "遥测聚合之间的最小时间。", + "deduplication-interval-required": "去重间隔为必填项。", + "calculated-field-filter-title": "计算字段过滤器", + "filter-title": "过滤器", + "calculated-field-types": "计算字段类型", + "events": "事件", + "any-type": "任意类型", + "metrics": { + "metrics": "指标", + "metrics-empty": "至少需要配置一个指标。", + "metric-name": "指标名称", + "metric-name-required": "指标名称为必填项。", + "metric-name-pattern": "指标名称无效。", + "metric-name-duplicate": "同名指标已存在。", + "metric-name-max-length": "指标名称应少于 256 个字符。", + "metric-name-forbidden": "指标名称为保留名称,无法使用。", + "copy-metric-name": "复制指标名称", + "argument-name": "参数名称", + "aggregation": "聚合", + "aggregation-type": { + "avg": "平均值", "min": "最小值", "max": "最大值", - "avg": "平均值", - "sum": "求和", + "sum": "总和", "count": "计数", - "none": "无" - }, - "admin": { - "settings": "设置", - "general": "基本设置", - "general-settings": "基本设置", - "home-settings": "首页设置", - "home": "首页", - "outgoing-mail": "发送邮件", - "outgoing-mail-settings": "发送邮件设置", - "system-settings": "系统设置", - "test-mail-sent": "测试邮件发送成功!", - "base-url": "基本URL", - "base-url-required": "基本URL必填。", - "prohibit-different-url": "禁止从客户端请求头中使用主机名", - "prohibit-different-url-hint": "应为生产环境启用此设置。禁用时可能会导致安全问题", - "device-connectivity": { - "device-connectivity": "设备连接", - "http-s": "HTTP(s)", - "mqtt-s": "MQTT(s)", - "coap-s": "COAP(s)", - "http": "HTTP", - "https": "HTTPs", - "mqtt": "MQTT", - "mqtts": "MQTTs", - "coap": "COAP", - "coaps": "COAPs", - "hint": "如果主机或端口字段为空,将使用默认的协议值。", - "host": "主机", - "port": "端口", - "port-pattern": "端口必须是正整数。", - "port-range": "端口应在1到65535的范围内。" - }, - "mail-from": "邮件来自", - "mail-from-required": "邮件发件人必填。", - "smtp-protocol": "SMTP协议", - "smtp-host": "SMTP主机", - "smtp-host-required": "SMTP主机必填。", - "smtp-port": "SMTP端口", - "smtp-port-required": "您必须提供一个smtp端口。", - "smtp-port-invalid": "这看起来不是有效的smtp端口。", - "timeout-msec": "超时时间(毫秒)", - "timeout-required": "超时必填。", - "timeout-invalid": "这看起来不像有效的超时值。", - "enable-tls": "启用TLS", - "tls-version": "TLS版本", - "enable-proxy": "启用代理", - "proxy-host": "代理主机", - "proxy-host-required": "代理主机必填。", - "proxy-port": "代理端口", - "proxy-port-required": "代理端口必填。", - "proxy-port-range": "代理端口应在1到65535之间。", - "proxy-user": "代理用户", - "proxy-password": "代理密码", - "change-password": "修改密码", - "send-test-mail": "发送测试邮件", - "sms-provider": "SMS 服务商", - "sms-provider-settings": "SMS 服务商设置", - "sms-provider-type": "SMS 服务商类型", - "sms-provider-type-required": "SMS 服务商类型必填。", - "sms-provider-type-aws-sns": "亚马逊社交网站", - "sms-provider-type-twilio": "Twilio", - "sms-provider-type-smpp": "SMPP", - "aws-access-key-id": "AWS访问密钥ID", - "aws-access-key-id-required": "需要访问AWS密钥ID", - "aws-secret-access-key": "AWS秘密访问密钥", - "aws-secret-access-key-required": "AWS 访问密钥必填", - "aws-region": "AWS地区", - "aws-region-required": "AWS 区域必填", - "number-from": "发送方电话号码", - "number-from-required": "发送方电话号码必填。", - "number-to": "电话号码至", - "number-to-required": "电话号码必填。", - "phone-number-hint": "E.164格式的手机号码,例如 +19995550123", - "phone-number-hint-twilio": "E.164格式电话号码/电话号码的SID/消息服务的SID,例如 +19995550123/PNXXX/MGXXX", - "phone-number-pattern": "手机号码无效。应为E.164格式,例如 +19995550123。", - "phone-number-pattern-twilio": "无效的电话号码,应该使用E.164格式电话号码/电话号码的SID/消息服务的SID,例如+19995550123/PNXXX/MGXXX。", - "sms-message": "短信", - "sms-message-required": "短消息内容必填。", - "sms-message-max-length": "短信长度不能超过1600个字符", - "twilio-account-sid": "Twilio帐户SID", - "twilio-account-sid-required": "Twilio 帐户的 SID 必填", - "twilio-account-token": "Twilio帐户令牌", - "twilio-account-token-required": "Twilio 帐户令牌必填", - "send-test-sms": "发送测试短信", - "test-sms-sent": "测试短信发送成功!", - "security-settings": "安全设置", - "password-policy": "密码策略", - "minimum-password-length": "最小密码长度", - "minimum-password-length-required": "最小密码长度必填", - "minimum-password-length-range": "最小密码长度应在5到50之间", - "maximum-password-length": "密码最大长度", - "maximum-password-length-min": "密码最大长度应至少为6个字符", - "maximum-password-length-less-min": "密码最大长度应大于最小长度", - "minimum-uppercase-letters": "最少大写字母位数", - "minimum-uppercase-letters-range": "最少大写字母位数不能为负数", - "minimum-lowercase-letters": "最少小写字母位数", - "minimum-lowercase-letters-range": "最少小写字母位数不能为负数", - "minimum-digits": "最少数字位数", - "minimum-digits-range": "最少数字位数不能为负数", - "minimum-special-characters": "最少特殊字符位数", - "minimum-special-characters-range": "最少特殊字符位数不能为负数", - "password-expiration-period-days": "密码有效期(天)", - "password-expiration-period-days-range": "密码过期期限(天)不能为负", - "password-reuse-frequency-days": "密码重用频率(天)", - "password-reuse-frequency-days-range": "天内密码重用频率不能为负", - "allow-whitespace": "允许空格", - "force-reset-password-if-no-valid": "如果密码不可用则强制重置密码", - "force-reset-password-if-no-valid-hint": "启用此功能时请小心:它会要求使用不可用密码的用户通过电子邮件重置其密码。", - "general-policy": "基本策略", - "max-failed-login-attempts": "登录失败之前的最大登录尝试次数", - "minimum-max-failed-login-attempts-range": "登录失败次数不能为负数", - "user-lockout-notification-email": "如果用户帐户锁定,请发送通知到电子邮件", - "user-activation-token-ttl": "用户激活链接在1小时内", - "user-activation-token-ttl-range": "用户激活链接必须在1到24小时范围内", - "password-reset-token-ttl": "密码重置链接1小时内", - "password-reset-token-ttl-range": "密码重置链接必须在1到24小时内", - "mobile-secret-key-length": "移动端密钥长度", - "mobile-secret-key-length-range": "移动端密钥长度范围", - "domain-name": "域名", - "domain-name-unique": "域名和协议必须是唯一的。", - "domain-name-max-length": "域名应该小于256个字符。", - "error-verification-url": "域名不应包含符号 “/” 和 “:”。例:thingsboard.io", - "connection-settings": "连接设置", - "oauth2": { - "access-token-uri": "访问令牌URI", - "access-token-uri-required": "访问令牌URI必填。", - "activate-user": "激活用户", - "add-domain": "添加域", - "delete-domain": "删除域", - "add-provider": "添加Provider", - "delete-provider": "删除Provider", - "allow-user-creation": "允许用户创建", - "always-fullscreen": "始终全屏", - "authorization-uri": "授权URI", - "authorization-uri-required": "授权URI必填。", - "add-client": "添加OAuth2.0客户端", - "client-details": "OAuth2.0客户端详情", - "client": "OAuth2.0客户端", - "clients": "OAuth2.0客户端", - "no-oauth2-clients": "未找到OAuth2.0客户端", - "search-oauth2-clients": "搜索OAuth2.0客户端", - "delete-client-title": "您确定要删除OAuth2.0客户端'{{clientName}}'?", - "delete-client-text": "请注意,确认后客户端和所有相关数据将无法恢复。", - "delete-mobile-app-title": "您确定要删除移动应用程序'{{applicationName}}'?", - "delete-mobile-app-text": "请小心,确认后移动应用程序和所有相关数据将变得无法恢复。", - "title": "标题", - "client-title-required": "标题是必填项。", - "client-title-max-length": "标题长度应该小于100个字符。", - "advanced-settings": "高级设置", - "domain-details": "域名详情", - "no-domains": "未找到域名", - "search-domains": "搜索域名", - "mobile-app-details": "移动端详细信息", - "add-mobile-app": "添加应用程序", - "no-mobile-apps": "未配置应用程序", - "search-mobile-apps": "搜索移动端", - "send-token": "发送令牌", - "create-new": "创建", - "client-authentication-method": "客户端身份验证方法", - "client-id": "客户端ID", - "client-id-required": "客户端 ID 必填。", - "client-id-max-length": "客户端ID应该小于256个字符。", - "client-secret": "客户机密", - "client-secret-required": "需要客户端密码。", - "client-secret-max-length": "客户端密钥应该小于2049个字符。", - "custom-setting": "自定义设置", - "customer-name-pattern": "客户名称模式", - "customer-name-pattern-max-length": "客户名称模式应该小于256个字符。", - "default-dashboard-name": "默认仪表板名称", - "default-dashboard-name-max-length": "默认仪表板名称应该小于256个字符。", - "delete-domain-text": "请注意:确认后域名和所有提供商数据将不可恢复。", - "delete-domain-title": "确定要删除域'{{domainName}}'的设置吗?", - "delete-registration-text": "请注意:确认后提供商数据将不可恢复。", - "delete-registration-title": "确定要删除'{{name}}'提供商吗?", - "email-attribute-key": "电子邮件属性键", - "email-attribute-key-required": "电子邮件属性键必填。", - "email-attribute-key-max-length": "电子邮件属性键应该小于32个字符。", - "first-name-attribute-key": "First属性名称", - "first-name-attribute-key-max-length": "First属性名称应该小于32个字符。", - "general": "基本设置", - "jwk-set-uri": "JSON Web地址", - "last-name-attribute-key": "Last属性名称", - "last-name-attribute-key-max-length": "Last属性名称应该小于32个字符。", - "login-button-icon": "登录按钮图标", - "login-button-label": "标签", - "login-button-label-placeholder": "使用$(Provider label)登录", - "login-button-label-required": "标签必填。", - "login-provider": "登录提供商", - "mapper": "映射", - "new-domain": "新建", - "oauth2": "OAuth2.0", - "password-max-length": "密码应该小于256个字符。", - "redirect-uri-template": "重定向URI模板", - "copy-redirect-uri": "复制重定向URI", - "registration-id": "注册ID", - "registration-id-required": "注册ID必填。", - "registration-id-unique": "系统的注册ID必须是唯一的。", - "scope": "范围", - "scope-required": "范围必填。", - "tenant-name-pattern": "租户名称模式", - "tenant-name-pattern-required": "租户名称模式必填。", - "tenant-name-pattern-max-length": "租户名称模式应该小于256个字符。", - "tenant-name-strategy": "租户名称策略", - "type": "映射器类型", - "uri-pattern-error": "无效的URI格式。", - "url": "统一资源定位地址", - "url-pattern": "无效的URL格式。", - "url-required": "URL必填。", - "url-max-length": "URL应该小于256个字符。", - "user-info-uri": "用户信息URI", - "user-info-uri-required": "用户信息URI必填。", - "username-max-length": "用户名应该小于256个字符。", - "user-name-attribute-name": "用户名属性键", - "user-name-attribute-name-required": "用户名属性密钥必填", - "protocol": "协议", - "domain-schema-http": "HTTP", - "domain-schema-https": "HTTPS", - "domain-schema-mixed": "HTTP+HTTPS", - "enable": "启用OAuth2.0设置", - "disable": "禁用OAuth2.0设置", - "edge": "传播到Edge", - "edge-enable": "启用Edge", - "edge-disable": "禁用Edge", - "domains": "域名", - "mobile-apps": "移动端应用", - "mobile-package": "应用程序包", - "mobile-package-placeholder": "例如: my.example.app", - "mobile-package-hint": "Android:应用程序ID或iOS:产品标识符", - "mobile-package-unique": "应用程序包必须是唯一的。", - "mobile-package-required": "应用程序包必填。", - "mobile-package-max-length": "应用程序包应小于256。", - "mobile-package-spaces": "应用程序包不应包含空间。", - "mobile-app-secret": "应用程序密钥", - "mobile-app-secret-hint": "BASE64编码字符串不少于512位数据。", - "mobile-app-secret-required": "应用程序密钥必填。", - "mobile-app-secret-min-length": "应用程序密钥不少于512位数据。", - "mobile-app-secret-base64": "应用程序密钥必须为base64格式。", - "invalid-mobile-app-secret": "应用程序密钥必须只包含字母和数字字符,并且长度必须介于16到2048个字符之间。", - "copy-mobile-app-secret": "复制应用程序密钥", - "delete-mobile-app": "删除应用程序信息", - "providers": "供应商", - "platform-web": "Web", - "platform-android": "Android", - "platform-ios": "iOS", - "all-platforms": "所有平台", - "smtp-provider": "SMTP提供商", - "allowed-platforms": "允许的平台", - "authentication": "身份验证", - "basic": "基本", - "provider": "提供商", - "redirect-url": "重定向URL", - "domain-name": "域名", - "domain-name-required": "域名必填", - "redirect-url-template": "重定向URL模板", - "microsoft-tenant-id": "目录(租户)ID", - "microsoft-tenant-id-required": "需要目录(租户)ID", - "token-uri": "令牌URI", - "token-uri-required": "需要令牌URI", - "redirect-uri": "重定向URI", - "google-provider": "谷歌", - "microsoft-provider": "Office 365", - "sendgrid-provider": "Sendgrid", - "custom-provider": "自定义", - "generate-access-token": "生成访问令牌", - "update-access-token": "更新访问令牌", - "access-token-status": "访问令牌状态:", - "token-status-generated": "已生成", - "token-status-not-generated": "未生成" - }, - "smpp-provider": { - "smpp-version": "SMPP版本", - "smpp-host": "SMPP主机", - "smpp-host-required": "SMPP主机必填。", - "smpp-port": "SMPP端口", - "smpp-port-required": "SMPP端口必填。", - "system-id": "系统ID", - "system-id-required": "系统ID必填。", - "password": "密码", - "password-required": "密码必填。", - "type-settings": "类型设置", - "source-settings": "源设置", - "destination-settings": "目标设置", - "additional-settings": "其他设置", - "system-type": "系统类型", - "bind-type": "绑定类型", - "service-type": "服务类型", - "source-address": "源地址", - "source-ton": "源 TON (号码类型)", - "source-npi": "源 NPI (编号方案标识)", - "destination-ton": "目标 TON (号码类型)", - "destination-npi": "目标 NPI (编号方案标识)", - "address-range": "地址范围", - "coding-scheme": "编码方案", - "bind-type-tx": "发送端", - "bind-type-rx": "接收端", - "bind-type-trx": "发送/接收", - "ton-unknown": "未知", - "ton-international": "国际", - "ton-national": "国内", - "ton-network-specific": "网络特定", - "ton-subscriber-number": "用户号码", - "ton-alphanumeric": "字母数字", - "ton-abbreviated": "简写", - "npi-unknown": "0 - 未知", - "npi-isdn": "1 - ISDN/电话编号方案 (E163/E164)", - "npi-data-numbering-plan": "3 - 数据编号方案 (X.121)", - "npi-telex-numbering-plan": "4 - 电报编号方案 (F.69)", - "npi-land-mobile": "6 - 移动设备编号方案 (E.212)", - "npi-national-numbering-plan": "8 - 国家编号方案", - "npi-private-numbering-plan": "9 - 私有编号方案", - "npi-ermes-numbering-plan": "10 - ERMES 编号方案 (ETSI DE/PS 3 01-3)", - "npi-internet": "13 - 互联网 (IP)", - "npi-wap-client-id": "18 - WAP 客户端标识 (由 WAP 论坛定义)", - "scheme-smsc": "0 - SMSC 默认字母表 (ASCII 码用于短码和长码,GSM 编码用于免费电话)", - "scheme-ia5": "1 - IA5 码 (ASCII 码用于短码和长码,Latin 9 用于免费电话 (ISO-8859-9))", - "scheme-octet-unspecified-2": "2 - 八进制未指定 (8 位二进制)", - "scheme-latin-1": "3 - Latin 1 (ISO-8859-1)", - "scheme-octet-unspecified-4": "4 - 八进制未指定 (8 位二进制)", - "scheme-jis": "5 - JIS (X 0208-1990)", - "scheme-cyrillic": "6 - Cyrillic (ISO-8859-5)", - "scheme-latin-hebrew": "7 - Latin/Hebrew (ISO-8859-8)", - "scheme-ucs-utf": "8 - UCS2/UTF-16 (ISO/IEC-10646)", - "scheme-pictogram-encoding": "9 - 图标编码", - "scheme-music-codes": "10 - 音乐编码 (ISO-2022-JP)", - "scheme-extended-kanji-jis": "13 - 扩展汉字 JIS (X 0212-1990)", - "scheme-korean-graphic-character-set": "14 - 韩文图形字符集 (KS C 5601/KS X 1001)" - }, - "queue-select-name": "选择队列名称", - "queue-name": "名称", - "queue-name-required": "队列名称必填。", - "queues": "队列", - "queue-partitions": "分区", - "queue-submit-strategy": "提交策略", - "queue-processing-strategy": "处理策略", - "queue-configuration": "队列配置", - "repository-settings": "仓库设置", - "repository": "仓库", - "repository-url": "仓库 URL", - "repository-url-required": "仓库 URL 必填。", - "default-branch": "默认分支名称", - "repository-read-only": "只读", - "show-merge-commits": "显示合并提交", - "authentication-settings": "身份验证设置", - "auth-method": "身份验证方法", - "auth-method-username-password": "密码/访问令牌", - "auth-method-username-password-hint": "GitHub用户必须使用能够对仓库进行写入权限的访问令牌。", - "auth-method-private-key": "私钥", - "password-access-token": "密码/访问令牌", - "change-password-access-token": "更改密码/访问令牌", - "private-key": "私钥", - "drop-private-key-file-or": "拖放私钥文件或", - "passphrase": "口令", - "enter-passphrase": "输入口令", - "change-passphrase": "更改口令", - "check-access": "检查访问权限", - "check-repository-access-success": "已成功验证仓库访问!", - "delete-repository-settings-title": "确定要删除仓库设置吗?", - "delete-repository-settings-text": "请注意:确认后,仓库设置将被删除,版本控制功能将不可用。", - "auto-commit-settings": "自动提交设置", - "auto-commit": "自动提交", - "auto-commit-entities": "自动提交实体", - "no-auto-commit-entities-prompt": "没有设置自动提交的实体", - "delete-auto-commit-settings-title": "确定要删除自动提交设置吗?", - "delete-auto-commit-settings-text": "请注意:确认后,自动提交设置将被删除,所有实体的自动提交将被禁用。", - "mobile-app": { - "mobile-app": "移动应用", - "mobile-app-qr-code-widget-settings": "移动应用二维码部件设置", - "applications": "应用程序", - "default": "默认", - "custom": "自定义", - "android": "Android", - "ios": "iOS", - "appearance": "外观", - "appearance-on-home-page": "外观显示在首页", - "enabled": "启用", - "disabled": "禁用", - "badges": "徽章", - "label": "标签", - "label-required": "标签必填。", - "label-max-length": "标签应小于或等于50个字符", - "right": "右侧", - "left": "左侧", - "set": "设置", - "preview": "预览", - "connect-mobile-app": "连接移动端", - "use-system-settings": "使用系统设置" - }, - "2fa": { - "2fa": "双因素认证", - "available-providers": "可用选项", - "issuer-name": "发行者名称", - "issuer-name-required": "发行者名称必填。", - "max-verification-failures-before-user-lockout": "用户锁定前最大验证失败次数", - "max-verification-failures-before-user-lockout-pattern": "最大验证失败次数必须为正整数。", - "number-of-checking-attempts": "检查尝试次数", - "number-of-checking-attempts-pattern": "检查尝试次数必须为正整数。", - "number-of-checking-attempts-required": "检查尝试次数必填。", - "number-of-codes": "验证码数量", - "number-of-codes-pattern": "验证码数量必须为正整数。", - "number-of-codes-required": "验证码数量必填。", - "provider": "Provider", - "retry-verification-code-period": "重试验证码周期(秒)", - "retry-verification-code-period-pattern": "最短时间为5秒", - "retry-verification-code-period-required": "重试验证代码周期必填。", - "total-allowed-time-for-verification": "总允许验证时间(秒)", - "total-allowed-time-for-verification-pattern": "总允许验证最小时间为60秒", - "total-allowed-time-for-verification-required": "总允许验证时间必填。", - "use-system-two-factor-auth-settings": "使用系统双因素认证设置", - "verification-code-check-rate-limit": "验证码检查速率限制", - "verification-code-lifetime": "验证码生存期(秒)", - "verification-code-lifetime-pattern": "验证码生存期必须为正整数。", - "verification-code-lifetime-required": "验证码生存期必填。", - "verification-message-template": "验证消息模板", - "verification-limitations": "验证限制", - "verification-message-template-pattern": "验证消息需要包含模板:${code}", - "verification-message-template-required": "验证消息模板必填。", - "within-time": "在时间内 (秒)", - "within-time-pattern": "时间必须是正整数。", - "within-time-required": "时间必填。" - }, - "jwt": { - "security-settings": "JWT 安全设置", - "issuer-name": "发行者名称", - "issuer-name-required": "发行者名称必填。", - "signings-key": "签名密钥", - "signings-key-hint": "Base64编码的字符串至少512位数据。", - "signings-key-required": "签名密钥必填。", - "signings-key-min-length": "签名密钥必须至少为512位的数据。", - "signings-key-base64": "签名密钥必须是Base64格式。", - "expiration-time": "令牌过期时间(秒)", - "expiration-time-required": "令牌过期时间是必填。", - "expiration-time-max": "最大允许时间为2147483647秒(68年)。", - "expiration-time-min": "最小时间为60秒(1分钟)。", - "refresh-expiration-time": "刷新令牌过期时间(秒)", - "refresh-expiration-time-required": "刷新令牌过期时间必填。", - "refresh-expiration-time-max": "最大允许时间为2147483647秒(68年)。", - "refresh-expiration-time-min": "最小时间为900秒(15分钟)。", - "refresh-expiration-time-less-token": "刷新令牌时间必须大于令牌过期时间。", - "generate-key": "生成密钥", - "info-header": "所有用户将需要重新登录。", - "info-message": "更改JWT签名密钥将导致所有已发行的令牌无效,所有用户将需要重新登录,这也会影响使用Rest API/Websockets的脚本。" - }, - "resources": "资源", - "notifications": "通知", - "notifications-settings": "通知设置", - "slack-api-token": "Slack API令牌", - "slack": "Slack", - "slack-settings": "Slack设置", - "mobile-settings": "移动设置", - "firebase-service-account-file": "Firebase服务帐户JSON凭据文件", - "select-firebase-service-account-file": "拖放Firebase服务帐户凭据文件" - }, - "alarm": { - "alarm": "告警", - "alarms": "告警", - "all-alarms": "所有告警", - "select-alarm": "选择告警", - "no-alarms-matching": "未找到匹配'{{entity}}'的告警", - "alarm-required": "告警必填", - "alarm-filter": "告警筛选器", - "filter": "筛选器", - "alarm-status": "告警状态", - "alarm-status-list": "告警状态列表", - "any-status": "任何状态", - "search-status": { - "ANY": "所有", - "ACTIVE": "激活", - "CLEARED": "已清除", - "ACK": "已确认", - "UNACK": "未确认" - }, - "display-status": { - "ACTIVE_UNACK": "激活未确认", - "ACTIVE_ACK": "激活已确认", - "CLEARED_UNACK": "清除未确认", - "CLEARED_ACK": "清除已确认" - }, - "no-alarms-prompt": "未发现告警", - "created-time": "创建时间", - "type": "类型", - "severity": "严重程度", - "originator": "发起者", - "originator-type": "发起者类型", - "details": "详情", - "originator-label": "发起者标签", - "assign": "分配", - "assignments": "任务分配", - "assignee": "受托人", - "assignee-id": "受托人ID", - "assignee-first-name": "受托人姓氏", - "assignee-last-name": "受托人名字", - "assignee-email": "受托人邮箱", - "unassigned": "未分配", - "user-deleted": "用户已删除", - "assignee-not-set": "所有", - "status": "状态", - "alarm-details": "告警详细信息", - "start-time": "开始时间", - "assign-time": "分配时间", - "end-time": "结束时间", - "ack-time": "确认时间", - "clear-time": "清除时间", - "duration": "持续时间", - "alarm-severity": "警报严重程度", - "alarm-severity-list": "警报严重程度列表", - "any-severity": "任何严重程度", - "severity-critical": "危险", - "severity-major": "重要", - "severity-minor": "次要", - "severity-warning": "警告", - "severity-indeterminate": "不确定", - "acknowledge": "应答", - "clear": "清除", - "delete": "删除", - "search": "查找告警", - "selected-alarms": "已选择 { count, plural, =1 {1 个告警} other {# 个告警} }", - "no-data": "无数据显示", - "polling-interval": "告警轮询间隔(秒)", - "polling-interval-required": "告警轮询间隔必填。", - "min-polling-interval-message": "轮询间隔至少是1秒。", - "aknowledge-alarms-title": "确认 { count, plural, =1 {1 个告警} other {# 个告警} }", - "aknowledge-alarms-text": "确定要确认 { count, plural, =1 {1 个告警} other {# 个告警} }吗?", - "aknowledge-alarm-title": "确认告警", - "aknowledge-alarm-text": "确定要确认告警吗?", - "selected-alarms-are-acknowledged": "所选的告警已经被确认", - "clear-alarms-title": "清除 { count, plural, =1 {1 个告警} other {# 个告警} }", - "clear-alarms-text": "确定要清除 { count, plural, =1 {1 个告警} other {# 个告警} }?", - "clear-alarm-title": "清除告警", - "clear-alarm-text": "确定要清除告警吗?", - "delete-alarms-title": "删除 { count, plural, =1 {1 个告警} other {# 个告警} }", - "delete-alarms-text": "您确定要删除 { count, plural, =1 {1 个告警} other {# 个告警} } 吗?", - "selected-alarms-are-cleared": "所选的告警已清除", - "alarm-status-filter": "告警状态筛选器", - "alarm-filter-title": "告警筛选器", - "assigned": "已分配", - "filter-title": "筛选器", - "max-count-load": "要加载的最大告警数(0-无限制)", - "max-count-load-required": "加载的最大告警数必填。", - "max-count-load-error-min": "最小值为0。", - "fetch-size": "获取大小", - "fetch-size-required": "获取大小必填。", - "fetch-size-error-min": "最小值为10。", - "alarm-types": "告警类型", - "alarm-type-list": "告警类型列表", - "any-type": "任何类型", - "assigned-to-current-user": "分配给当前用户", - "assigned-to-me": "分配给我", - "search-propagated-alarms": "检索已传递的警报", - "comments": "告警评论", - "show-more": "显示更多", - "additional-info": "附加信息", - "alarm-type": "告警类型", - "enter-alarm-type": "输入告警类型", - "no-alarm-types-matching": "找不到与 '{{entitySubtype}}' 相匹配的告警类型。", - "alarm-type-list-empty": "未选择告警类型。" - }, - "alarm-activity": { - "add": "添加评论", - "alarm-comment": "告警评论", - "comments": "评论", - "delete-alarm-comment": "你想删除这条评论吗?", - "refresh": "刷新", - "oldest-first": "最早优先", - "newest-first": "最新优先", - "activity": "活动", - "export": "导出 CSV", - "author": "作者", - "created-date": "创建时间", - "edited-date": "编辑时间", - "text": "文本", - "system": "系统" - }, - "alias": { - "add": "添加别名", - "edit": "编辑别名", - "name": "别名", - "name-required": "别名必填", - "duplicate-alias": "别名已经存在。", - "filter-type-single-entity": "单个实体", - "filter-type-entity-list": "实体列表", - "filter-type-entity-name": "实体名称", - "filter-type-entity-type": "实体类型", - "filter-type-state-entity": "仪表板状态实体", - "filter-type-state-entity-description": "仪表板实体令牌状态参数", - "filter-type-asset-type": "资产类型", - "filter-type-asset-type-description": "类型为 '{{assetTypes}}' 的资产", - "filter-type-asset-type-and-name-description": "类型为 '{{assetTypes}}' 且以 '{{prefix}}' 开头的资产", - "filter-type-device-type": "设备类型", - "filter-type-device-type-description": "类型为 '{{deviceTypes}}' 的设备", - "filter-type-device-type-and-name-description": "类型为 '{{deviceTypes}}' 且以 '{{prefix}}' 开头的设备", - "filter-type-entity-view-type": "实体视图类型", - "filter-type-entity-view-type-description": "类型为 '{{entityViews}}' 的实体视图", - "filter-type-entity-view-type-and-name-description": "类型为 {{entityViews}}' 且以 '{{prefix}}' 开头的实体视图", - "filter-type-edge-type": "边缘类型", - "filter-type-edge-type-description": "类型为 '{{edgeTypes}}' 的边缘", - "filter-type-edge-type-and-name-description": "类型为 '{{edgeTypes}}' 且以 '{{prefix}}' 开头的边缘", - "filter-type-relations-query": "关联查询", - "filter-type-relations-query-description": "具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的 {{entities}} ", - "filter-type-asset-search-query": "资产搜索查询", - "filter-type-asset-search-query-description": "类型为 {{assetTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的资产", - "filter-type-device-search-query": "设备搜索查询", - "filter-type-device-search-query-description": "类型为 {{deviceTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的设备", - "filter-type-entity-view-search-query": "实体视图搜索查询", - "filter-type-entity-view-search-query-description": "类型为 {{entityViewTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的实体视图", - "filter-type-apiUsageState": "Api使用状态", - "filter-type-edge-search-query": "边缘搜索查询", - "filter-type-edge-search-query-description": "类型为 {{edgeTypes}} 且具有 {{relationType}} 关联 {{direction}} {{rootEntity}} 的边缘", - "entity-filter": "实体筛选器", - "resolve-multiple": "解析为多实体", - "resolve-multiple-hint": "启用所有过滤实体的数据同时渲染,\n如果禁用部件仅显示来自选定实体的数据。", - "filter-type": "筛选器类型", - "filter-type-required": "筛选器类型必填。", - "entity-filter-no-entity-matched": "未找到匹配指定筛选条件的实体。", - "no-entity-filter-specified": "没有指定实体筛选器", - "root-state-entity": "使用仪表板状态实体作为根实体", - "last-level-relation": "仅获取最后一级关联", - "root-entity": "根实体", - "state-entity-parameter-name": "状态实体参数名称", - "default-state-entity": "默认状态实体", - "default-entity-parameter-name": "默认", - "max-relation-level": "最大关联层级", - "unlimited-level": "不限层级", - "state-entity": "仪表板状态实体", - "all-entities": "所有实体", - "any-relation": "不限" - }, - "asset": { - "asset": "资产", - "assets": "资产", - "management": "资产管理", - "view-assets": "查看资产", - "add": "添加资产", - "asset-type-max-length": "资产类型应该小于256个字符", - "assign-to-customer": "分配给客户", - "assign-asset-to-customer": "将资产分配给客户", - "assign-asset-to-customer-text": "请选择要分配给客户的资产", - "no-assets-text": "未找到资产", - "assign-to-customer-text": "请选择客户以分配资产", - "public": "公开", - "assignedToCustomer": "分配客户", - "make-public": "资产设为公开", - "make-private": "资产设为私有", - "unassign-from-customer": "取消分配客户", - "delete": "删除资产", - "asset-public": "资产公开", - "asset-type": "资产类型", - "asset-type-required": "资产类型必填。", - "select-asset-type": "选择资产类型", - "enter-asset-type": "输入资产类型", - "any-asset": "任何资产", - "no-asset-types-matching": "未找到匹配'{{entitySubtype}}'的资产类型。", - "asset-type-list-empty": "资产类型未选择。", - "asset-types": "资产类型", - "name": "名称", - "name-required": "名称必填。", - "name-max-length": "名称应该小于256个字符。", - "label-max-length": "标签应该小于256个字符。", - "description": "说明", - "type": "类型", - "type-required": "类型必填。", - "details": "详情", - "events": "事件", - "add-asset-text": "添加资产", - "asset-details": "资产详情", - "assign-assets": "分配资产", - "assign-assets-text": "分配 { count, plural, =1 {1 个资产} other {# 个资产} } 给客户", - "assign-asset-to-edge-title": "将资产分配给边缘", - "assign-asset-to-edge-text": "请选择要分配给边缘的资产", - "delete-assets": "删除资产", - "unassign-assets": "取消分配资产", - "unassign-assets-action-title": "从客户处取消分配 { count, plural, =1 {1 个资产} other {# 个资产} }", - "assign-new-asset": "分配新资产", - "delete-asset-title": "确定要删除资产 '{{assetName}}'吗?", - "delete-asset-text": "请注意:确认后,资产及其所有相关数据将不可恢复。", - "delete-assets-title": "确定要删除 { count, plural, =1 {1 个资产} other {# 个资产} }吗?", - "delete-assets-action-title": "删除 { count, plural, =1 {1 个资产} other {# 个资产} }", - "delete-assets-text": "请注意:确认后,所有选定的资产将被删除,所有相关的数据将不可恢复。", - "make-public-asset-title": "确定要将资产 '{{assetName}}' 设为公开吗?", - "make-public-asset-text": "确认后,资产及其所有数据将被公开并被他人访问。", - "make-private-asset-title": "确定要将资产 '{{assetName}}' 设为私有吗?", - "make-private-asset-text": "确认后,资产及其所有数据将被私有化,无法被他人访问。", - "unassign-asset-title": "确定要取消对'{{assetName}}'资产的分配吗?", - "unassign-asset-text": "确认后,资产将未分配,客户无法访问。", - "unassign-asset": "未分配资产", - "unassign-assets-title": "确定要取消分配 { count, plural, =1 {1 个资产} other {# 个资产} }吗?", - "unassign-assets-text": "确认后,所有选定的资产将被分配,客户无法访问。", - "unassign-assets-from-edge": "取消分配边缘", - "copyId": "复制资产ID", - "idCopiedMessage": "资产ID已经复制到粘贴板", - "select-asset": "选择资产", - "no-assets-matching": "未找到匹配 '{{entity}}' 的资产。", - "asset-required": "资产必填", - "name-starts-with": "资产名称以此开头", - "help-text": "根据需要可以使用'%'进行匹配,例如:'%asset_name_contains%', '%asset_name_ends', 'asset_starts_with'。", - "import": "导入资产", - "asset-file": "资产档案", - "label": "标签", - "search": "查找资产", - "assign-asset-to-edge": "将资产分配给边缘", - "unassign-asset-from-edge": "取消分配边缘", - "unassign-asset-from-edge-title": "确定要取消对'{{assetName}}'资产的分配吗?", - "unassign-asset-from-edge-text": "确认后,所有选定的资产将被分配,边缘无法访问。", - "unassign-assets-from-edge-title": "确定要取消分配 { count, plural, =1 {1 资产} other {# 个资产} }吗?", - "unassign-assets-from-edge-text": "确认后,所有选定的资产将被分配,边缘无法访问。", - "selected-assets": "已选择 { count, plural, =1 {1 个资产} other {# 个资产} }" - }, - "attribute": { - "attributes": "属性", - "latest-telemetry": "最新遥测数据", - "no-latest-telemetry": "没有最新的遥测数据", - "attributes-scope": "设备属性范围", - "scope-telemetry": "遥测", - "scope-latest-telemetry": "最新遥测数据", - "scope-client": "客户端属性", - "scope-server": "服务端属性", - "scope-shared": "共享属性", - "scope-client-short": "客户端", - "scope-server-short": "服务端", - "scope-shared-short": "共享", - "scope-latest-short": "最新", - "scope-any": "任意", - "add": "添加属性", - "key": "键名", - "key-max-length": "键应该小于256个字符。", - "last-update-time": "最后更新时间", - "key-required": "属性键必填。", - "value": "数值", - "value-required": "属性值必填。", - "telemetry-key-required": "需要遥测键", - "telemetry-value-required": "需要遥测数值", - "delete-attributes-title": "确定要删除 { count, plural, =1 {1 个属性} other {# 个属性} }吗?", - "delete-attributes-text": "注意,确认后所有选中的属性都会被删除。", - "delete-attributes": "删除属性", - "enter-attribute-value": "输入属性值", - "show-on-widget": "在部件上显示", - "widget-mode": "部件模式", - "next-widget": "下一个部件", - "prev-widget": "上一个部件", - "add-to-dashboard": "添加到仪表板", - "add-widget-to-dashboard": "将部件添加到仪表板", - "selected-attributes": "已选择 { count, plural, =1 {1 个属性} other {# 个属性} }", - "selected-telemetry": "已选择 { count, plural, =1 {1 遥测装置} other {# 遥测装置} }", - "no-attributes-text": "未找到属性", - "no-telemetry-text": "未找到遥测数据", - "copy-key": "复制键", - "add-telemetry": "添加遥测数据", - "copy-value": "复制数值", - "delete-timeseries": { - "start-time": "开始时间", - "ends-on": "结束时间", - "strategy": "策略", - "delete-strategy": "删除策略", - "all-data": "删除所有数据", - "all-data-except-latest-value": "删除除最新值外的所有数据", - "latest-value": "删除最新值", - "all-data-for-time-period": "删除指定时间段的所有数据", - "rewrite-latest-value": "重写最新值" - } + "count-unique": "唯一计数" + }, + "filtered": "已过滤", + "value-source": "值来源", + "value-source-hint": "定义聚合值的获取方式。", + "value-source-type": { + "key": "键", + "function": "函数" + }, + "no-metrics-configured": "至少需要一个指标。", + "add-metric": "添加指标", + "max-metrics": "已达到最大指标数量。", + "metric-settings": "指标设置", + "filter": "过滤器", + "filter-hint": "启用聚合期间的实体过滤。过滤函数必须返回布尔值,并且可以使用所有已配置的参数。" }, - "api-usage": { - "api-features": "Api统计", - "api-usage": "Api 使用统计", - "alarm": "告警", - "alarms-created": "创建告警数", - "queue-stats": "队列统计信息", - "processing-failures-and-timeouts": "处理失败和超时", - "exceptions": "异常", - "alarms-created-daily-activity": "每天产生的告警", - "alarms-created-hourly-activity": "每小时产生的告警", - "alarms-created-monthly-activity": "每月产生的告警", - "data-points": "数据点", - "data-points-storage-days": "存储点天", - "device-api": "设备 API", - "email": "邮件", - "email-messages": "邮件消息", - "email-messages-daily-activity": "每天产生的邮件消息", - "email-messages-monthly-activity": "每月产生的邮件消息", - "executions": "执行数", - "scripts": "脚本", - "scripts-hourly-activity": "脚本每小时活动", - "scripts-daily-activity": "脚本每日活动", - "scripts-monthly-activity": "脚本每月活动", - "javascript": "JavaScript", - "javascript-executions": "JavaScript执行数", - "tbel": "TBEL", - "tbel-executions": "TBEL执行数", - "latest-error": "最新错误", - "messages": "消息", - "notifications": "通知", - "notifications-email-sms": "通知(Email/SMS)", - "notifications-hourly-activity": "每小时的通知", - "permanent-failures": "${entityName}永久性故障", - "permanent-timeouts": "${entityName}永久超时", - "processing-failures": "${entityName}处理失败", - "processing-timeouts": "${entityName}处理超时", - "rule-chain": "规则链", - "rule-engine": "规则引擎", - "rule-engine-daily-activity": "每天的规则引擎活动", - "rule-engine-executions": "规则引擎执行", - "rule-engine-hourly-activity": "每小时的规则引擎活动", - "rule-engine-monthly-activity": "每月的规则引擎活动", - "rule-engine-statistics": "规则引擎统计信息", - "rule-node": "规则节点", - "sms": "短信", - "sms-messages": "短信消息", - "sms-messages-daily-activity": "每天的短信息", - "sms-messages-monthly-activity": "每小时的短信息", - "successful": "${entityName} 成功", - "telemetry": "遥测数据", - "telemetry-persistence": "遥测持久化", - "telemetry-persistence-daily-activity": "每天的遥测持久化", - "telemetry-persistence-hourly-activity": "每小时的遥测持久化", - "telemetry-persistence-monthly-activity": "每月的遥测持久化", - "transport": "传输", - "transport-daily-activity": "每天传输数据量", - "transport-data-points": "传输数据点", - "transport-hourly-activity": "每小时传输数据量", - "transport-messages": "传输消息", - "transport-monthly-activity": "每月传输数据量", - "view-details": "查看详细信息", - "view-statistics": "查看统计信息" - }, - "api-limit": { - "cassandra-queries": "Cassandra查询", - "entity-version-creation": "实体版本创建", - "entity-version-load": "实体版本加载", - "notification-requests": "通知请求", - "notification-requests-per-rule": "每个规则的通知请求", - "rest-api-requests": "REST API请求次数", - "rest-api-requests-per-customer": "每个客户的REST API请求", - "transport-messages": "传输消息次数", - "transport-messages-per-device": "每个设备的传输消息", - "transport-messages-per-gateway": "每个网关的传输消息", - "transport-messages-per-gateway-device": "每个网关设备传输消息", - "ws-updates-per-session": "每个会话的WS更新", - "edge-events": "Edge事件", - "edge-events-per-edge": "Edge事件", - "edge-uplink-messages": "Edge上行", - "edge-uplink-messages-per-edge": "Edge下行" - }, - "audit-log": { - "audit": "审计", - "audit-logs": "审计日志", - "timestamp": "时间戳", - "entity-type": "实体类型", - "entity-name": "实体名称", - "user": "用户", - "type": "类型", - "status": "状态", - "details": "详情", - "type-added": "添加", - "type-deleted": "删除", - "type-updated": "更新", - "type-attributes-updated": "更新属性", - "type-attributes-deleted": "删除属性", - "type-rpc-call": "RPC调用", - "type-credentials-updated": "更新凭据", - "type-assigned-to-customer": "分配给客户", - "type-unassigned-from-customer": "未分配给客户", - "type-assigned-to-edge": "分配给边缘", - "type-unassigned-from-edge": "未分配给边缘", - "type-activated": "激活", - "type-suspended": "暂停", - "type-credentials-read": "读取凭据", - "type-attributes-read": "读取属性", - "type-relation-add-or-update": "关联已更新", - "type-relation-delete": "关联已删除", - "type-relations-delete": "删除所有关联", - "type-alarm-ack": "已确认", - "type-alarm-clear": "已清除", - "type-alarm-delete": "Alarm deleted", - "type-alarm-assign": "已分配", - "type-alarm-unassign": "未分配", - "type-added-comment": "添加评论", - "type-updated-comment": "更新评论", - "type-deleted-comment": "删除评论", - "type-login": "登录", - "type-logout": "注销", - "type-lockout": "锁定", - "status-success": "成功", - "status-failure": "失败", - "audit-log-details": "审计日志详情", - "no-audit-logs-prompt": "未找到日志", - "action-data": "活动数据", - "failure-details": "失败详情", - "search": "查找审计日志", - "clear-search": "清空查找", - "type-assigned-from-tenant": "从租户分配", - "type-assigned-to-tenant": "分配给租户", - "type-provision-success": "设备已预配置", - "type-provision-failure": "设备预配置失败", - "type-timeseries-updated": "遥测数据已更新", - "type-timeseries-deleted": "遥测数据已删除", - "type-sms-sent": "短信发送" - }, - "debug-settings": { - "label": "调试配置", - "on-failure": "仅失败(24/7)", - "all-messages": "所有消息({{time}})", - "failures": "失败", - "entity": "实体", - "rule-node": "规则节点", - "hint": { - "main": "所有节点调试消息将被限速,限制为:", - "main-limited": "所有{{entity}}的调试消息将受到速率限制,每{{time}}允许最多{{msg}}条消息。", - "on-failure": "保存所有失败调试事件,无时间限制。", - "all-messages": "在时间限制内保存所有调试事件。" - } + "output-strategy": { + "strategy": "策略", + "process-right-away": "立即处理", + "process-rule-chains": "通过规则链处理", + "save-time-series": "保存到时间序列", + "save-database": "保存到数据库", + "save-latest-values": "保存到最新值", + "send-web-sockets": "发送到 WebSockets", + "save-calculated-fields": "发送到计算字段", + "update-attribute-only-on-value-change": "仅在值变更时更新属性", + "send-attributes-updated-notification": "发送属性更新通知", + "ttl": "自定义 TTL", + "ttl-required": "TTL 为必填项", + "ttl-min": "最小 TTL 仅允许为 0", + "processing-parameters": "处理参数", + "hint": { + "strategy": "控制结果是立即处理还是发送到规则链进行额外处理。", + "processing-options": "处理选项", + "update-attribute-only-on-value-change": "在每条传入消息上更新属性,无论值是否已更改。这会增加 API 使用量并降低性能。", + "update-attribute-only-on-value-change-enabled": "仅在值更改时更新属性。如果值未更改,则不更新时间戳且不发送通知。", + "send-attributes-updated-notification": "向默认规则链发送属性更新事件。", + "save-time-series": "将时间序列数据保存到数据库的 ts_kv 表中。", + "save-database": "将属性数据保存到数据库。", + "save-latest-values": "如果新值更新,则更新数据库 ts_kv_latest 表中的时间序列数据。", + "send-web-sockets-attribute": "通知 WebSocket 订阅有关属性数据的更新。", + "send-web-sockets-time-series": "通知 WebSocket 订阅有关时间序列数据的更新。", + "save-calculated-fields-attribute": "通知计算字段有关属性数据的更新。", + "save-calculated-fields-time-series": "通知计算字段有关时间序列数据的更新。", + "ttl": "定义时间序列数据的保留期限。如果禁用,则使用租户配置的 TTL。" + } }, - "confirm-on-exit": { - "message": "有未保存的更改,确定要离开此页吗?", - "html-message": "有未保存的更改。
确定要离开此页面吗?", - "title": "未保存的更改" - }, - "contact": { - "country": "国家", - "country-required": "国家必填。", - "city": "城市", - "state": "省份", - "postal-code": "邮政编码", - "postal-code-invalid": "只允许数字。", - "address": "地址", - "address2": "地址二", - "phone": "手机号码", - "email": "电子邮件", - "no-address": "无地址", - "no-country-found": "未找不到国家。", - "no-country-matching": "未匹配到'{{country}}'。", - "state-max-length": "省份长度应该小于256个字符", - "phone-max-length": "手机号码长度应该小于256个字符", - "city-max-length": "城市应该小于256个字符" - }, - "common": { - "username": "用户名", - "password": "密码", - "enter-username": "输入用户名", - "enter-password": "输入密码", - "enter-search": "输入检索条件", - "created-time": "创建时间", - "disabled": "已禁用", - "loading": "正在加载中...", - "proceed": "继续", - "open-details-page": "打开详情页", - "not-found": "未找到", - "documentation": "文档", - "time-left": "剩余{{time}}" - }, - "content-type": { - "json": "Json", - "text": "文本", - "binary": "二进制(Base64)" - }, - "customer": { - "customer": "客户", - "customers": "客户", - "management": "客户管理", - "dashboard": "客户仪表板", - "dashboards": "客户仪表板", - "devices": "客户设备", - "entity-views": "客户实体视图", - "assets": "客户资产", - "public-dashboards": "公共仪表板", - "public-devices": "公共设备", - "public-assets": "公共资产", - "public-edges": "公共边缘", - "public-entity-views": "公共实体视图", - "add": "添加客户", - "delete": "删除此客户", - "manage-customer-users": "管理客户用户", - "manage-customer-devices": "管理客户设备", - "manage-customer-dashboards": "管理客户仪表板", - "manage-public-devices": "管理公共设备", - "manage-public-dashboards": "管理公共仪表板", - "manage-customer-assets": "管理客户资产", - "manage-public-assets": "管理公共资产", - "manage-customer-edges": "管理客户边缘", - "manage-public-edges": "管理公共边缘", - "add-customer-text": "添加客户", - "no-customers-text": "未找到客户", - "customer-details": "客户详情", - "delete-customer-title": "确定要删除客户'{{customerTitle}}'吗?", - "delete-customer-text": "请注意:确认后,客户及其所有相关数据将不可恢复。", - "delete-customers-title": "确定要删除 { count, plural, =1 {1 个客户} other {# 个客户} }吗?", - "delete-customers-action-title": "删除 { count, plural, =1 {1 个客户} other {# 个客户} }", - "delete-customers-text": "请注意:确认后,所有选定的客户将被删除,所有相关数据将不可恢复。", - "manage-users": "管理用户", - "manage-assets": "管理资产", - "manage-devices": "管理设备", - "manage-dashboards": "管理仪表板", - "title": "标题", - "title-required": "标题必填。", - "title-max-length": "标题应该小于256个字符", - "description": "说明", - "details": "详情", - "events": "事件", - "copyId": "复制客户ID", - "idCopiedMessage": "客户ID已复制到粘贴板", - "select-customer": "选择客户", - "no-customers-matching": "未找到匹配'{{entity}}'的客户。", - "customer-required": "客户必填", - "select-default-customer": "选择默认的客户", - "default-customer": "默认客户", - "default-customer-required": "为了调试租户级别上的仪表板,需要默认客户。", - "search": "查找客户", - "selected-customers": "已选择 { count, plural, =1 {1 个客户} other {# 个客户} }", - "edges": "客户边缘实例", - "manage-edges": "管理边缘" - }, - "css-size": { - "size-value-required": "尺寸值必填。", - "invalid-size-value": "尺寸值无效" - }, - "date": { - "last-update-n-ago": "N分钟前的最后更新", - "last-update-n-ago-text": "最后更新{{ agoText }}", - "custom-date": "自定义日期", - "format": "格式", - "preview": "预览", - "auto": "自动", - "time-granularity-formats": "时间格式", - "unit-year": "年", - "unit-month": "月", - "unit-day": "日", - "unit-hour": "时", - "unit-minute": "分", - "unit-second": "秒", - "unit-millisecond": "毫秒" - }, - "datetime": { - "date-from": "开始日期", - "time-from": "开始时间", - "date-to": "结束日期", - "time-to": "结束时间", - "from": "从", - "to": "到" - }, - "dashboard": { - "dashboard": "仪表板", - "dashboards": "仪表板", - "management": "仪表板管理", - "view-dashboards": "查看仪表板", - "add": "添加仪表板", - "assign-dashboard-to-customer": "将仪表板分配给客户", - "assign-dashboard-to-customer-text": "请选择要分配给客户的仪表板", - "assign-dashboard-to-edge-title": "将仪表板分配给边缘", - "assign-to-customer-text": "请选择客户分配仪表板", - "assign-to-customer": "分配给客户", - "unassign-from-customer": "取消分配客户", - "make-public": "仪表板设为公开", - "make-private": "仪表板设为私有", - "manage-assigned-customers": "管理已分配的客户", - "assigned-customers": "已分配的客户", - "assign-to-customers": "将仪表板分配给客户", - "assign-to-customers-text": "请选择客户指定仪表板", - "unassign-from-customers": "客户未分配仪表板", - "unassign-from-customers-text": "请选择从仪表板中取消分配的客户", - "no-dashboards-text": "未找到仪表板", - "no-widgets": "没有配置部件", - "add-widget": "添加部件", - "add-widget-button-text": "添加部件", - "title": "标题", - "image": "仪表板图片", - "mobile-app-settings": "移动端应用设置", - "mobile-order": "移动端应用中的仪表板排序", - "mobile-hide": "在移动端应用中隐藏仪表板", - "update-image": "更新仪表板图片", - "take-screenshot": "截图", - "select-widget-title": "选择部件", - "select-widget-value": "{{title}}: 选择部件", - "select-widget-subtitle": "可用的部件类型列表", - "delete": "删除仪表板", - "title-required": "标题必填。", - "title-max-length": "标题应该小于256个字符。", - "description": "说明", - "details": "详情", - "dashboard-details": "仪表板详情", - "add-dashboard-text": "添加仪表板", - "assign-dashboards": "分配仪表板", - "assign-new-dashboard": "分配新的仪表板", - "assign-dashboards-text": "分配 { count, plural, =1 {1 个仪表板} other {# 个仪表板} } 给客户", - "unassign-dashboards-action-text": "取消分配 { count, plural, =1 {1 个仪表板} other {# 个仪表板} } 给客户", - "delete-dashboards": "删除仪表板", - "unassign-dashboards": "取消分配仪表板", - "unassign-dashboards-action-title": "取消分配此客户 { count, plural, =1 {1 个仪表板} other {# 个仪表板} }", - "delete-dashboard-title": "确定要删除仪表板 '{{dashboardTitle}}'吗?", - "delete-dashboard-text": "请注意:确认后,仪表板及其所有相关数据将不可恢复。", - "delete-dashboards-title": "确定要删除 { count, plural, =1 {1 个仪表板} other {# 个仪表板} }吗?", - "delete-dashboards-action-title": "删除 { count, plural, =1 {1 个仪表板} other {# 个仪表板} }", - "delete-dashboards-text": "请注意:确认后,所有选定的仪表板将被删除,所有相关数据将不可恢复。", - "unassign-dashboard-title": "确定要取消分配仪表板 '{{dashboardTitle}}'吗?", - "unassign-dashboard-text": "确认后,面板将被取消分配,客户将无法访问。", - "unassign-dashboard": "取消分配仪表板", - "unassign-dashboards-title": "确定要取消分配仪表板 { count, plural, =1 {1 个仪表板} other {# 个仪表板} } 吗?", - "unassign-dashboards-text": "确认后,所有选定的仪表板将被取消分配,客户将无法访问。", - "public-dashboard-title": "仪表板现已公开", - "public-dashboard-text": "仪表板 {{dashboardTitle}} 已被公开,可通过如下 链接访问:", - "public-dashboard-notice": "提示: 不要忘记将相关设备公开以访问其数据。", - "make-private-dashboard-title": "确定要将仪表板 '{{dashboardTitle}}' 设为私有吗?", - "make-private-dashboard-text": "确认后,仪表板将被设为私有,不能被其他人访问。", - "make-private-dashboard": "仪表板设为私有", - "socialshare-text": "'{{dashboardTitle}}' 由Thingsboard提供支持", - "socialshare-title": "'{{dashboardTitle}}' 由Thingsboard提供支持", - "select-dashboard": "选择仪表板", - "no-dashboards-matching": "未找到匹配 '{{entity}}' 的仪表板。", - "dashboard-required": "仪表板必填。", - "select-existing": "选择现有仪表板", - "create-new": "创建仪表板", - "new-dashboard-title": "新仪表板标题", - "open-dashboard": "打开仪表板", - "set-background": "设置背景", - "background-color": "背景颜色", - "background-image": "背景图片", - "background-size-mode": "背景大小模式", - "no-image": "无图像选择", - "empty-image": "No image", - "drop-image": "拖拽图像或单击以选择要上传的文件。", - "maximum-upload-file-size": "最大上传文件大小: {{ size }}", - "cannot-upload-file": "无法上传文件", - "settings": "设置", - "move-all-widgets": "移动所有部件", - "move-by": "移动", - "cols": "列", - "rows": "行", - "layout": "布局", - "layout-type-default": "默认", - "layout-type-scada": "SCADA", - "layout-type-divider": "分隔线", - "layout-settings-type": "布局设置:{{ type }}", - "columns-count": "列数", - "columns-count-required": "需要列数。", - "min-columns-count-message": "只允许最少10列", - "max-columns-count-message": "只允许最多1000列", - "min-layout-width": "最小布局宽度", - "columns-suffix": "列", - "widgets-margins": "部件间边距", - "margin-required": "边距值必填。", - "min-margin-message": "最小边距值只允许为0。", - "max-margin-message": "仅允许50作为最大边距值。", - "horizontal-margin": "水平边距", - "horizontal-margin-required": "需要水平边距值。", - "min-horizontal-margin-message": "只允许0作为最小水平边距值。", - "max-horizontal-margin-message": "只允许50作为最大水平边距值。", - "vertical-margin": "垂直边距", - "vertical-margin-required": "需要垂直边距值。", - "min-vertical-margin-message": "只允许0作为最小垂直边距值。", - "max-vertical-margin-message": "只允许50作为最大垂直边距值。", - "apply-outer-margin": "为布局的两侧设置边距", - "autofill-height": "自动填充布局高度", - "mobile-layout": "移动端布局设置", - "mobile-row-height": "移动端行高距(px)", - "mobile-row-height-required": "移动端行高距必填。", - "min-mobile-row-height-message": "移动端行高距至少5px。", - "max-mobile-row-height-message": "移动端行高距最多200px。", - "row-height": "行高", - "row-height-required": "行高必填。", - "min-row-height-message": "仅允许5个像素作为最小行高值。", - "max-row-height-message": "仅允许200个像素作为最大行高值。", - "display-first-in-mobile-view": "优先在移动视图中显示", - "title-settings": "标题设置", - "display-title": "显示仪表板标题", - "title-color": "标题颜色", - "toolbar-settings": "工具栏设置", - "hide-toolbar": "隐藏工具栏", - "toolbar-always-open": "工具栏常驻", - "display-dashboards-selection": "显示仪表板选项", - "display-entities-selection": "显示实体选项", - "display-filters": "显示筛选器", - "display-dashboard-timewindow": "显示时间窗口", - "display-dashboard-export": "显示导出", - "display-update-dashboard-image": "显示更新仪表板图像", - "dashboard-logo-settings": "Logo设置", - "display-dashboard-logo": "在仪表板全屏模式下显示 Logo", - "dashboard-logo-image": "仪表板 Logo 图片", - "advanced-settings": "高级设置", - "dashboard-css": "仪表板样式", - "import": "导入仪表板", - "export": "导出仪表板", - "export-failed-error": "无法导出仪表板: {{error}}", - "export-prompt": "嵌入仪表板图像和资源", - "create-new-dashboard": "创建仪表板", - "dashboard-file": "仪表板文件", - "invalid-dashboard-file-error": "无法导入仪表板: 仪表板数据结构无效。", - "dashboard-import-missing-aliases-title": "配置导入仪表板使用的别名", - "create-new-widget": "创建部件", - "import-widget": "导入部件", - "widget-file": "部件文件", - "invalid-widget-file-error": "无法导入窗口部件: 窗口部件数据结构无效。", - "widget-import-missing-aliases-title": "配置导入的窗口部件使用的别名", - "open-toolbar": "显示工具栏", - "close-toolbar": "收起工具栏", - "configuration-error": "配置错误", - "alias-resolution-error-title": "仪表板别名配置错误", - "invalid-aliases-config": "无法找到与某些别名筛选器匹配的任何设备。
请联系您的管理员以解决此问题。", - "select-devices": "选择设备", - "assignedToCustomer": "分配给客户", - "assignedToCustomers": "分配给客户", - "public": "公开", - "copyId": "复制仪表板ID", - "idCopiedMessage": "仪表板ID已复制到剪贴板", - "public-link": "公共链接", - "copy-public-link": "复制公共链接", - "public-link-copied-message": "仪表板的公共链接已被复制到剪贴板", - "manage-states": "仪表板状态管理", - "states": "仪表板状态", - "states-short": "状态", - "search-states": "仪表板状态检索", - "selected-states": "已选择 { count, plural, =1 {1 个仪表板状态} other {# 个仪表板状态} }", - "edit-state": "仪表板状态编辑", - "delete-state": "删除仪表板状态", - "add-state": "添加仪表板状态", - "no-states-text": "未找到状态", - "state": "仪表板状态", - "state-name": "名称", - "state-name-required": "仪表板状态名必填。", - "state-id": "状态ID", - "state-id-required": "仪表板状态ID必填。", - "state-id-exists": "仪表板状态ID已经存在。", - "is-root-state": "根状态", - "delete-state-title": "删除仪表板状态", - "delete-state-text": "确定要删除仪表板状态 '{{stateName}}' 吗?", - "show-details": "显示详情", - "hide-details": "隐藏详情", - "select-state": "选择目标状态", - "state-controller": "状态控制", - "state-controller-default": "静态(已弃用)", - "search": "查找仪表板", - "selected-dashboards": "已选择 { count, plural, =1 {1 个仪表盘} other {# 个仪表盘} }", - "home-dashboard": "首页仪表板", - "home-dashboard-hide-toolbar": "隐藏首页仪表板工具栏", - "unassign-dashboard-from-edge-text": "确认后,所有选定的仪表板将被取消分配,边缘将无法访问。", - "unassign-dashboards-from-edge-title": "确定要取消分配仪表板 { count, plural, =1 {1 个仪表板} other {# 个仪表板} } 吗?", - "unassign-dashboards-from-edge-text": "确认后,所有选定的仪表板将被取消分配,边缘将无法访问。", - "assign-dashboard-to-edge": "将仪表板分配给边缘", - "assign-dashboard-to-edge-text": "请选择要分配给边缘的仪表板", - "non-existent-dashboard-state-error": "找不到ID为 '{{ stateId }}' 的仪表板状态。", - "edit-mode": "编辑模式", - "duplicate-state-action": "复制状态", - "breakpoint-value": "断点({{ value }})", - "breakpoints-id": { - "default": "默认", - "xs": "xs", - "sm": "sm", - "md": "md", - "lg": "lg", - "xl": "xl" - }, - "view-format-type-grid": "网格", - "view-format-type-list": "列表", - "view-format": "类型" - }, - "datakey": { - "settings": "设置", - "general": "常规", - "advanced": "高级", - "key": "键", - "label": "标签", - "color": "颜色", - "units": "单位符号", - "decimals": "小数位数", - "data-generation-func": "数据生成功能", - "use-data-post-processing-func": "使用数据后处理功能", - "configuration": "数据键配置", - "timeseries": "时间序列", - "attributes": "属性", - "entity-field": "实体字段", - "alarm": "告警字段", - "timeseries-required": "实体时间序列必填。", - "timeseries-or-attributes-required": "实体时间序列/属性必填。", - "alarm-fields-timeseries-or-attributes-required": "告警字段或实体时间序列/属性必填。", - "maximum-timeseries-or-attributes": "最多允许 { count, plural, =1 {1 个 timeseries/属性。} other {# 个 timeseries/属性。} }", - "alarm-fields-required": "告警字段必填。", - "function-types": "函数类型", - "function-type": "函数类型", - "function-types-required": "函数类型必填。", - "data-keys": "数据键", - "data-key": "数据键", - "data-keys-required": "数据键是必须的。", - "data-key-required": "数据键是必须的。", - "alarm-keys": "告警数据键", - "alarm-key": "告警数据键", - "alarm-key-functions": "告警数据键函数", - "alarm-key-function": "告警数据键函数", - "latest-keys": "最新值数据键", - "latest-key": "最新值数据键", - "latest-key-functions": "最新值数据键函数", - "latest-key-function": "最新值数据键函数", - "timeseries-keys": "时间序列数据键", - "timeseries-key": "时间序列数据键", - "timeseries-key-functions": "时间序列数据键函数", - "timeseries-key-function": "时间序列数据键函数", - "maximum-function-types": "最多允许 { count, plural, =1 {1 个函数类型} other {# 个函数类型} }", - "time-description": "当前值的时间戳;", - "value-description": "当前值;", - "prev-value-description": "上一次函数调用的结果;", - "time-prev-description": "上一个值的时间戳;", - "prev-orig-value-description": "先前的原始值;", - "aggregation": "聚合", - "aggregation-type-hint-common": "出于性能原因,聚合值的计算仅适用于固定的时间间隔,如“当前天”,“当前月”等,而不适用于滑动窗口间隔,例如“过去30分钟”或“过去24小时”。", - "aggregation-type-none-hint": "获取最新值。", - "aggregation-type-min-hint": "在所选时间窗口内找出数据点中的最小值。", - "aggregation-type-max-hint": "在所选时间窗口内找出数据点中的最大值。", - "aggregation-type-avg-hint": "在所选时间窗口内计算数据点的平均值。", - "aggregation-type-sum-hint": "在所选时间窗口内计算数据点所有值的总和。", - "aggregation-type-count-hint": "在所选时间窗口内计算数据点的总数。", - "delta-calculation": "增量计算", - "enable-delta-calculation": "启用增量计算", - "enable-delta-calculation-hint": "启用后将根据选定时间窗口和指定比较时段的聚合值计算数据键值。出于性能原因,增量计算仅适用于历史时间窗口,而不适用于实时值。例如,您可以计算昨天的能耗与前天的能耗之间的差值。", - "delta-calculation-result": "增量计算结果", - "delta-calculation-result-previous-value": "上个值", - "delta-calculation-result-delta-absolute": "绝对值", - "delta-calculation-result-delta-percent": "百分比", - "source": "来源", - "latest": "最新", - "latest-value": "最新值", - "delta": "增量", - "percent": "百分比", - "absolute": "绝对值" - }, - "datasource": { - "type": "数据源类型", - "name": "名称", - "label": "标签", - "add-datasource-prompt": "请添加数据源" - }, - "details": { - "details": "详情", - "edit-mode": "编辑模式", - "edit-json": "编辑JSON", - "toggle-edit-mode": "切换编辑模式" - }, - "device": { - "device": "设备", - "device-required": "设备必填", - "devices": "设备", - "management": "设备管理", - "view-devices": "查看设备", - "device-alias": "设备别名", - "device-type-max-length": "设备类型应该小于256个字符", - "aliases": "设备别名", - "no-alias-matching": "'{{alias}}' 未找到。", - "no-aliases-found": "未找到别名。", - "no-key-matching": "'{{key}}' 未找到。", - "no-keys-found": "未找到密钥。", - "create-new-alias": "创建别名", - "create-new-key": "创建键", - "duplicate-alias-error": "找到重复别名 '{{alias}}'。
设备别名必须是唯一的。", - "configure-alias": "配置 '{{alias}}' 别名", - "no-devices-matching": "未找到与 '{{entity}}' 匹配的设备。", - "alias": "别名", - "alias-required": "设备别名必填。", - "remove-alias": "删除设备别名", - "add-alias": "添加设备别名", - "name-starts-with": "名称前缀", - "help-text": "根据需要可以使用'%'进行匹配,例如:'%device_name_contains%','%device_name_ends','device_starts_with'。", - "device-list": "设备列表", - "use-device-name-filter": "使用筛选器", - "device-list-empty": "没有被选中的设备", - "device-name-filter-required": "设备名称筛选器必填。", - "device-name-filter-no-device-matched": "未找到以'{{device}}' 开头的设备。", - "add": "添加设备", - "assign-to-customer": "分配给客户", - "assign-device-to-customer": "将设备分配给客户", - "assign-device-to-customer-text": "请选择要分配给客户的设备", - "assign-device-to-edge-title": "将设备分配给边缘", - "assign-device-to-edge-text": "请选择要分配给边缘的设备", - "make-public": "公开", - "make-private": "私有", - "no-devices-text": "未找到设备", - "assign-to-customer-text": "请选择客户分配设备", - "device-details": "设备详细信息", - "add-device-text": "添加设备", - "credentials": "凭据", - "manage-credentials": "管理凭据", - "delete": "删除设备", - "assign-devices": "分配设备", - "assign-devices-text": "将 {count,plural,=1 {1 个设备} other {# 个设备} }分配给客户", - "delete-devices": "删除设备", - "unassign-from-customer": "取消分配客户", - "unassign-devices": "取消分配设备", - "unassign-devices-action-title": "取消分配此客户 {count,plural,=1 {1 个设备} other {# 个设备} }", - "unassign-device-from-edge-title": "确定要取消分配设备 '{{deviceName}}' 吗?", - "unassign-device-from-edge-text": "确认后,设备将被取消分配,边缘将无法访问。", - "unassign-devices-from-edge": "取消分配边缘", - "assign-new-device": "分配新设备", - "make-public-device-title": "确定要将设备 '{{deviceName}}' 设为公开吗?", - "make-public-device-text": "确认后,设备及其所有数据将被设为公开并可被其他人访问。", - "make-private-device-title": "确定要将设备 '{{deviceName}}' 设为私有吗?", - "make-private-device-text": "确认后,设备及其所有数据将被设为私有,不被其他人访问。", - "view-credentials": "查看凭据", - "delete-device-title": "确定要删除设备的{{deviceName}}吗?", - "delete-device-text": "请注意:确认后,设备及其所有相关数据将不可恢复。", - "delete-devices-title": "确定要删除{count,plural,=1 {1 个设备} other {# 个设备} } 吗?", - "delete-devices-action-title": "删除 {count,plural,=1 {1 个设备} other {# 个设备} }", - "delete-devices-text": "请注意:确认后,所有选定的设备将被删除,所有相关数据将不可恢复。", - "unassign-device-title": "确定要取消分配设备 '{{deviceName}}' 吗?", - "unassign-device-text": "确认后,设备将被取消分配,客户将无法访问。", - "unassign-device": "取消分配设备", - "unassign-devices-title": "确定要取消分配 {count,plural,=1 {1 个设备} other {# 个设备} } 吗?", - "unassign-devices-text": "确认后,所有选定的设备将被取消分配,并且客户将无法访问。", - "device-credentials": "设备凭据", - "loading-device-credentials": "加载设备凭据...", - "credentials-type": "凭据类型", - "access-token": "访问令牌", - "access-token-required": "访问令牌必填", - "access-token-invalid": "访问令牌长度必须为1到32个字符。", - "certificate-pem-format": "PEM 格式的证书", - "certificate-pem-format-required": "需要提供证书。", - "copy-access-token": "复制访问令牌", - "copy-certificate": "复制证书", - "copy-client-id": "复制客户端ID", - "copy-user-name": "复制用户名", - "copy-password": "复制密码", - "generate-client-id": "生成客户端ID", - "generate-user-name": "生成用户名", - "generate-password": "生成密码", - "generate-access-token": "生成访问令牌", - "lwm2m-security-config": { - "identity": "客户端身份", - "identity-required": "需要提供客户端身份。", - "identity-tooltip": "PSK 标识符是一个任意的 PSK 标识符,最多可以为 128 字节,如标准 [RFC7925] 中所述。", - "client-key": "客户端密钥", - "client-key-required": "需要提供客户端密钥。", - "client-key-tooltip-prk": "RPK 公钥或 ID 必须符合标准 [RFC7250] 并编码为 Base64 格式!", - "client-key-tooltip-psk": "PSK 密钥必须符合标准 [RFC4279],使用十六进制表示:32、64、128 个字符!", - "endpoint": "终端客户端名称", - "endpoint-required": "需要提供终端客户端名称。", - "client-public-key": "客户端公钥", - "client-public-key-hint": "如果客户端公钥为空,则将使用可信证书", - "client-public-key-tooltip": "X509 公钥必须采用 DER 编码的 X509v3 格式,并且仅支持 EC 算法,然后编码为 Base64 格式!", - "mode": "安全配置模式", - "client-tab": "客户端安全配置", - "client-certificate": "客户端证书", - "bootstrap-tab": "启动客户端", - "bootstrap-server": "启动服务器", - "lwm2m-server": "LwM2M服务器", - "client-publicKey-or-id": "客户端公钥或ID", - "client-publicKey-or-id-required": "客户端公钥或ID必填。", - "client-publicKey-or-id-tooltip-psk": "SK标识符是最多128字节,如标准[RFC7925]所述。", - "client-publicKey-or-id-tooltip-rpk": "RPK必须满足标准[RFC7250]而且编码为 Base64!", - "client-publicKey-or-id-tooltip-x509": "X509公钥必须是X509v3格式而且支持EC算法", - "client-secret-key": "客户端密钥", - "client-secret-key-required": "客户端密钥必填。", - "client-secret-key-tooltip-psk": "PSK键必须是[RFC4279]标准和HexDec格式: 32, 64, 128i字符!", - "client-secret-key-tooltip-prk": "RPK密钥必须是PKCS_8格式(DER编码, 标准[RFC5958]) 并编码为Base64!", - "client-secret-key-tooltip-x509": "X509密钥必须是PKCS_8格式(DER编码, 标准[RFC5958])!" - }, - "client-id": "客户端ID", - "client-id-pattern": "包含无效字符。", - "user-name": "用户名", - "user-name-required": "用户名必填。", - "client-id-or-user-name-necessary": "客户端ID或用户名是必需的", - "password": "密码", - "secret": "密钥", - "secret-required": "密钥必填", - "device-type": "设备类型", - "device-type-required": "设备类型必填。", - "select-device-type": "选择设备类型", - "enter-device-type": "输入设备类型", - "any-device": "任意设备", - "no-device-types-matching": "未找到匹配 '{{entitySubtype}}' 的设备类型。", - "device-type-list-empty": "未选择设备类型", - "device-profile-type-list-empty": "至少应选择一个设备配置。", - "device-types": "设备类型", - "name": "名称", - "name-required": "名称必填。", - "name-max-length": "名称应该小于256个字符。", - "label-max-length": "标签应该小于256个字符。", - "description": "说明", - "label": "标签", - "events": "事件", - "details": "详情", - "copyId": "复制设备ID", - "copyAccessToken": "复制访问令牌", - "copy-mqtt-authentication": "复制MQTT凭据", - "idCopiedMessage": "设备ID已复制到剪贴板", - "accessTokenCopiedMessage": "设备访问令牌已复制到剪贴板", - "mqtt-authentication-copied-message": "设备MQTT身份验证已复制到剪贴板", - "assignedToCustomer": "分配给客户", - "unable-delete-device-alias-title": "无法删除设备别名", - "unable-delete-device-alias-text": "设备别名 '{{deviceAlias}}' 不能够被删除,因为它被下列部件所使用:
{{widgetsList}}", - "is-gateway": "是否网关", - "overwrite-activity-time": "覆盖已连接设备的活动时间", - "device-filter": "设备筛选器", - "device-filter-title": "设备筛选器", - "filter-title": "筛选器", - "device-state": "设备状态", - "state": "状态", - "any": "所有", - "active": "活动", - "inactive": "非活动", - "public": "公开", - "device-public": "设备公开", - "select-device": "选择设备", - "import": "导入设备", - "device-file": "设备文件", - "search": "查找设备", - "selected-devices": "已选择 { count, plural, =1 {1 个设备} other {# 个设备} }", - "device-configuration": "设备配置", - "transport-configuration": "传输配置", - "wizard": { - "device-details": "设备详细信息" - }, - "unassign-devices-from-edge-title": "确定要取消分配 { count, plural, =1 {1 个设备} other {# 个设备} } 吗?", - "unassign-devices-from-edge-text": "确认后,设备将被取消分配,边缘将无法访问。", - "time": "时间", - "connectivity": { - "check-connectivity": "检查连通性", - "device-created-check-connectivity": "设备已创建,检查连通性!", - "loading-check-connectivity-command": "正在加载检查连通性命令...", - "use-following-instructions": "请使用以下说明代表设备发送遥测数据", - "execute-following-command": "执行以下命令", - "install-curl-windows": "从 Windows 10 b17063 开始,cURL 已默认安装", - "install-curl-macos": "从 Mac OS X 10.2 6C115(Jaguar)开始,cURL 已默认安装", - "install-mqtt-windows": "使用说明下载、安装、设置和运行 mosquitto_pub", - "install-coap-client": "使用说明下载、安装、设置和运行 coap-client", - "install-necessary-client-tools": "安装必要的客户端工具", - "mqtts-x509-command": "使用以下文档通过 MQTT 连接带有 X509 授权的设备", - "coaps-x509-command": "使用以下文档通过基于 DTLS 的 CoAP 连接带有 X509 授权的设备", - "snmp-command": "使用以下文档通过 SNMP 连接设备。", - "sparkplug-command": "使用以下文档通过 MQTT Sparkplug 连接设备。", - "lwm2m-command": "使用以下文档通过 LWM2M 连接设备。" - } + "aggregate-interval-type": "聚合间隔类型", + "aggregate-interval-value": "聚合间隔值", + "aggregate-interval-value-required": "聚合间隔值为必填项。", + "aggregate-interval-value-min": "聚合间隔值至少应为 { sec, plural, =0 {0 秒} =1 {1 秒} other {# 秒} }。", + "aggregate-interval-value-step-multiple-of": "聚合间隔值必须是 1 天的因子或倍数。", + "aggregate-period": { + "hour": "小时", + "day": "天", + "week": "周(周一至周日)", + "week-sun-sat": "周(周日至周六)", + "month": "月", + "quarter": "季度", + "year": "年", + "custom": "自定义" }, - "dynamic-form": { - "property": { - "properties": "属性", - "property": "属性", - "id": "ID", - "name": "名称", - "type": "类型", - "type-text": "文本", - "type-password": "密码", - "type-textarea": "文本区域", - "type-number": "数字", - "type-switch": "开关", - "type-select": "选择框", - "type-radios": "单选按钮", - "type-datetime": "日期/时间", - "type-image": "图片", - "type-javascript": "JavaScript", - "type-json": "JSON", - "type-html": "HTML", - "type-css": "CSS", - "type-markdown": "Markdown", - "type-color": "颜色", - "type-color-settings": "颜色设置", - "type-font": "字体", - "type-units": "单位", - "type-icon": "图标", - "type-fieldset": "字段集", - "type-array": "数组", - "type-html-section": "HTML区域", - "group-title": "分组标题", - "no-properties": "没有配置属性", - "add-property": "添加属性", - "property-settings": "属性设置", - "remove-property": "移除属性", - "default-value": "默认值", - "value-required": "值是必需的", - "number-settings": "数字设置", - "min": "最小值", - "max": "最大值", - "step": "步长", - "selected-options-limit": "选项限制", - "advanced-ui-settings": "高级UI设置", - "disable-on-property": "基于属性禁用", - "display-condition-function": "显示条件函数", - "sub-label": "子标签", - "vertical-divider-after": "垂直分隔符后", - "input-field-suffix": "输入框后缀", - "property-row-classes": "属性行类", - "property-field-classes": "属性字段类", - "not-unique-property-ids-error": "属性ID必须唯一!", - "enable-multiple-select": "启用多选", - "allow-empty-select-option": "允许空选项", - "select-options": "选择项", - "not-unique-select-option-value-error": "选择项值必须唯一!", - "value": "值", - "label": "标签", - "add-option": "添加选项", - "no-options": "没有配置选项", - "remove-option": "移除选项", - "textarea-rows": "文本区行数", - "help-id": "帮助ID", - "buttons-direction": "按钮方向", - "direction-row": "行", - "direction-column": "列", - "radio-button-options": "单选按钮选项", - "datetime-type": "日期/时间字段类型", - "datetime-type-date": "日期", - "datetime-type-time": "时间", - "datetime-type-datetime": "日期/时间", - "enable-clear-button": "启用清除按钮", - "html-section-settings": "HTML区域设置", - "html-section-classes": "HTML区域类", - "html-section-content": "HTML区域内容", - "array-item": "数组项", - "item-type": "项类型", - "item-name": "项名称", - "no-items": "没有项" - }, - "clear-form": "清除表单", - "clear-form-prompt": "确定要移除所有表单属性吗?", - "import-form": "从JSON导入表单", - "export-form": "导出表单为JSON", - "json-file": "JSON文件", - "json-content": "JSON内容", - "invalid-form-json-file-error": "无法从JSON导入表单:无效的表单JSON数据结构。" - }, - "asset-profile": { - "asset-profile": "资产配置", - "asset-profiles": "资产配置", - "all-asset-profiles": "全部", - "add": "添加资产配置", - "edit": "编辑资产配置", - "asset-profile-details": "资产配置详情", - "no-asset-profiles-text": "未找到资产配置", - "search": "搜索资产配置", - "selected-asset-profiles": "已选择 { count, plural, =1 {1 个资产配置} other {# 个资产配置} }", - "no-asset-profiles-matching": "未找到与 '{{entity}}' 匹配的资产配置。", - "asset-profile-required": "资产配置必填", - "idCopiedMessage": "资产配置ID已复制到剪贴板", - "set-default": "设为默认资产配置", - "delete": "删除资产配置", - "copyId": "复制资产配置ID", - "name-max-length": "名称长度必须小于256个字符", - "new-device-profile-name": "资产配置名称", - "new-device-profile-name-required": "资产配置名称必填。", - "name": "名称", - "name-required": "名称必填。", - "image": "资产配置图片", - "description": "说明", - "default": "默认", - "default-rule-chain": "默认规则链", - "default-edge-rule-chain": "默认边缘规则链", - "default-edge-rule-chain-hint": "作为规则链,用于在边缘上处理属于此资产配置中资产的传入数据。", - "mobile-dashboard": "移动端仪表板", - "mobile-dashboard-hint": "被移动端应用用作资产详情仪表板", - "select-queue-hint": "从下拉列表中选择。", - "delete-asset-profile-title": "确定要删除 '{{assetProfileName}}' 资产配置吗?", - "delete-asset-profile-text": "请注意:确认后,资产配置和所有相关数据将不可恢复。", - "delete-asset-profiles-title": "确定要删除 { count, plural, =1 {1 个资产配置} other {# 个资产配置} }吗?", - "delete-asset-profiles-text": "请注意:确认后,所有选定的资产配置将被删除,所有相关数据将不可恢复。", - "set-default-asset-profile-title": "确定要将 '{{assetProfileName}}' 设为默认资产配置吗?", - "set-default-asset-profile-text": "确认后,资产配置将被标记为默认,并将用于未指定配置的新资产。", - "no-asset-profiles-found": "未不到资产配置。", - "create-new-asset-profile": "创建资产配置", - "create-asset-profile": "创建资产配置", - "import": "导入资产配置", - "export": "导出资产配置", - "export-failed-error": "无法导出资产配置: {{error}}", - "asset-profile-file": "资产配置", - "invalid-asset-profile-file-error": "无法导入资产配置:无效的资产配置数据结构。" - }, - "device-profile": { - "device-profile": "设备配置", - "device-profiles": "设备配置", - "all-device-profiles": "全部", - "add": "添加设备配置", - "edit": "编辑设备配置", - "device-profile-details": "设备配置详情", - "no-device-profiles-text": "未找到设备配置", - "search": "查找设备配置", - "selected-device-profiles": "已选择 { count, plural, =1 {1 个设备配置} other {# 个设备配置} }", - "no-device-profiles-matching": "未找到与 '{{entity}}' 匹配的设备配置。", - "device-profile-required": "设备配置必填", - "idCopiedMessage": "设备配置 ID 已复制到剪贴板", - "set-default": "设为默认设备配置", - "delete": "删除设备配置", - "copyId": "复制设备配置 ID", - "name-max-length": "名称长度必须小于256个字符", - "name": "名称", - "name-required": "名称是必需的。", - "type": "配置类型", - "type-required": "配置类型必填。", - "type-default": "默认", - "image": "设备配置图片", - "transport-type": "传输方式", - "transport-type-required": "传输方式必填。", - "transport-type-default": "默认", - "transport-type-default-hint": "支持基本MQTT、HTTP和CoAP传输", - "transport-type-mqtt": "MQTT", - "transport-type-mqtt-hint": "启用高级MQTT传输设置", - "transport-type-coap": "CoAP", - "transport-type-coap-hint": "启用高级 CoAP 传输设置", - "transport-type-lwm2m": "LWM2M", - "transport-type-lwm2m-hint": "LWM2M传输类型", - "transport-type-snmp": "SNMP", - "transport-type-snmp-hint": "指定 SNMP 传输配置", - "transport-type-http": "HTTP", - "description": "说明", - "default": "默认", - "profile-configuration": "配置", - "transport-configuration": "传输配置", - "default-rule-chain": "默认规则链", - "default-edge-rule-chain": "默认边缘规则链", - "default-edge-rule-chain-hint": "作为规则链,用于在边缘上处理属于此设备配置中设备的传入数据。", - "mobile-dashboard": "移动端仪表板", - "mobile-dashboard-hint": "被移动端应用用作设备详情仪表板", - "select-queue-hint": "从下拉列表中选择或添加自定义名称。", - "delete-device-profile-title": "确定要删除'{{deviceProfileName}}'设备配置吗?", - "delete-device-profile-text": "请注意:确认后,设备配置和所有相关数据将不可恢复。", - "delete-device-profiles-title": "确定要删除 { count, plural, =1 {1 个设备配置} other {# 个设备配置} }吗?", - "delete-device-profiles-text": "请注意:确认后,所有选定的设备配置将被删除,所有相关数据将不可恢复。", - "set-default-device-profile-title": "确定要将设备配置'{{deviceProfileName}}'设为默认值吗?", - "set-default-device-profile-text": "确认后,设备配置将被标记为默认,并将用于未指定配置的新设备。", - "no-device-profiles-found": "未找到设备配置。", - "create-new-device-profile": "创建设备配置", - "mqtt-device-topic-filters": "MQTT设备主题筛选器", - "mqtt-device-topic-filters-unique": "MQTT设备主题筛选器必须唯一。", - "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B边缘网络(EoN)节点", - "mqtt-device-topic-filters-spark-plug-hint": "允许来自具备Sparkplug B负载和主题格式的边缘网络(EoN)节点的连接。", - "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "将SparkPlug指标存储为属性。", - "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "将作为设备属性存储的SparkPlug指标的名称。其他指标将作为设备遥测数据进行存储。", - "mqtt-device-payload-type": "MQTT设备Payload", - "mqtt-device-payload-type-json": "JSON", - "mqtt-device-payload-type-proto": "Protobuf", - "mqtt-enable-compatibility-with-json-payload-format": "启用与其他Payload格式兼容。", - "mqtt-enable-compatibility-with-json-payload-format-hint": "启用后平台将默认使用Protobuf的Payload格式,如果解析失败平台将尝试使用JSON的Payload格式。对于固件更新期间的向后兼容性很有用,例如固件的初始版本使用Json而新版本使用Protobuf在设备队列的固件更新过程中,需要同时支持Protobuf和JSON。兼容模式会导致一点的性能下降,因此建议在所有设备更新后禁用此模式。", - "mqtt-use-json-format-for-default-downlink-topics": "缺省下行主题采用Json格式", - "mqtt-use-json-format-for-default-downlink-topics-hint": "启用后平台将使用Json的Playload格式通过以下主题推送属性和RPC:v1/devices/me/attributes/response/$request_id、v1/devices/me/attributes 、v1/devices/me/rpc/request/$request_id、v1/devices/me/rpc/response/$request_id。此设置不会影响使用新(v2)主题发送的属性和rpc订阅:v2/a/res/$request_id、v2/a、v2/r /req/$request_id,v2/r/res/$request_id。其中$request_id是一个整数请求标识符。", - "mqtt-send-ack-on-validation-exception": "发布消息验证失败时发送PUBACK", - "mqtt-send-ack-on-validation-exception-hint": "默认情况下平台将关闭相关消息验证失败的MQTT会话,启用后平台将发布确认而不是关闭会话。", - "snmp-add-mapping": "添加SNMP映射", - "snmp-mapping-not-configured": "OID到时间序列/遥测的映射未配置", - "snmp-timseries-or-attribute-name": "用于映射的时间序列/属性名称", - "snmp-timseries-or-attribute-type": "用于映射的时间序列/属性类型", - "snmp-method-pdu-type-get-request": "GetRequest", - "snmp-method-pdu-type-get-next-request": "GetNextRequest", - "snmp-oid": "OID", - "transport-device-payload-type-json": "JSON", - "transport-device-payload-type-proto": "Protobuf", - "mqtt-payload-type-required": "Payload类型必填。", - "coap-device-type": "CoAP设备类型", - "coap-device-payload-type": "CoAP设备消息Payload", - "coap-device-type-required": "CoAP设备类型必填。", - "coap-device-type-default": "默认", - "coap-device-type-efento": "Efento NB-IoT", - "support-level-wildcards": "支持单[+]和多级[#]通配符。", - "telemetry-topic-filter": "遥测数据主题筛选器", - "telemetry-topic-filter-required": "遥测数据主题筛选器必填。", - "attributes-topic-filter": "属性主题筛选器", - "attributes-subscribe-topic-filter": "订阅属性的主题筛选器", - "attributes-topic-filter-required": "属性的主题筛选器必填。", - "attributes-subscribe-topic-filter-required": "订阅属性的主题筛选器必填。", - "telemetry-proto-schema": "遥测数据 Proto Schema", - "telemetry-proto-schema-required": "遥测数据 Proto Schema 必填。", - "attributes-proto-schema": "属性 Proto Schema", - "attributes-proto-schema-required": "属性 Proto Schema 必填。", - "rpc-response-proto-schema": "RPC 响应 Proto Schema", - "rpc-response-proto-schema-required": "RPC 响应 Proto Schema 必填。", - "rpc-response-topic-filter": "RPC响应主题筛选器", - "rpc-response-topic-filter-required": "RPC响应主题筛选器必填。", - "rpc-request-proto-schema": "RPC 请求 Proto Schema", - "rpc-request-proto-schema-required": "RPC 请求 Proto Schema 必填。", - "rpc-request-proto-schema-hint": "RPC 请求消息应始终包含字段:string method = 1; int32 requestId = 2; 和params = 3的任何数据类型。", - "not-valid-pattern-topic-filter": "无效的主题筛选器模式", - "not-valid-single-character": "单级通配符的使用无效", - "not-valid-multi-character": "多级通配符的使用无效", - "single-level-wildcards-hint": "[+] 适用于任何主题过滤级别。例如:v1/devices/+/telemetry or +/devices/+/attributes。", - "multi-level-wildcards-hint": "[#]可以替换主题筛选器本身,并且必须是主题的最后一个符号。例如:# or v1/devices/me/#。", - "alarm-rules": "告警规则", - "alarm-rules-with-count": "告警规则({{count}})", - "no-alarm-rules": "未配置告警规则", - "add-alarm-rule": "添加告警规则", - "edit-alarm-rule": "编辑告警规则", - "alarm-type": "告警类型", - "alarm-type-required": "告警类型必填。", - "alarm-type-unique": "警报类型在设备配置警报规则中必须唯一。", - "alarm-type-max-length": "告警类型应该小于256字符。", - "create-alarm-pattern": "创建 {{alarmType}} 告警", - "create-alarm-rules": "创建告警规则", - "no-create-alarm-rules": "未配置创建条件", - "add-create-alarm-rule-prompt": "请添加创建告警规则", - "clear-alarm-rule": "清除告警规则", - "no-clear-alarm-rule": "未配置明确条件", - "add-create-alarm-rule": "添加创建条件", - "add-clear-alarm-rule": "添加清除条件", - "select-alarm-severity": "选择告警严重性", - "alarm-severity-required": "告警严重级别必填。", - "condition-duration": "条件持续时间", - "condition-duration-value": "持续时间值", - "condition-duration-time-unit": "时间单位", - "condition-duration-value-range": "持续时间值应在1到2147483647之间。", - "condition-duration-value-pattern": "持续时间值应为整数。", - "condition-duration-value-required": "持续时间值必填。", - "condition-duration-time-unit-required": "时间单位必填。", - "advanced-settings": "高级设置", - "alarm-rule-additional-info": "附加信息", - "edit-alarm-rule-additional-info": "编辑附加信息", - "alarm-rule-additional-info-placeholder": "请在此处提供评论和调整,以便在附加信息下的告警详情中显示", - "alarm-rule-additional-info-hint": "提示: 使用${keyName}来替代告警规则条件中使用的属性或遥测键的值。", - "alarm-rule-mobile-dashboard": "移动端仪表板", - "alarm-rule-mobile-dashboard-hint": "作为移动端告警详情仪表板使用。", - "alarm-rule-no-mobile-dashboard": "未选择仪表板。", - "propagate-alarm": "传递警报", - "alarm-rule-relation-types-list": "要传递的关联类型", - "alarm-rule-relation-types-list-hint": "如果未选择传递关联类型,则将不按关联类型过滤而传递告警。", - "propagate-alarm-to-owner": "将告警传播给实体所有者(客户或租户)。", - "propagate-alarm-to-tenant": "将告警传播给租户", - "alarm-rule-condition": "告警规则条件", - "enter-alarm-rule-condition-prompt": "请添加告警规则条件", - "edit-alarm-rule-condition": "编辑告警规则条件", - "device-provisioning": "设备预配置", - "provision-strategy": "预配置策略", - "provision-strategy-required": "预配置策略必填。", - "provision-strategy-disabled": "禁用", - "provision-strategy-created-new": "允许创建设备", - "provision-strategy-check-pre-provisioned": "检查预配置的设备", - "provision-device-key": "预配置设备密钥名", - "provision-device-key-required": "预配置设备密钥名必填。", - "copy-provision-key": "复制预配置密钥名", - "provision-key-copied-message": "预配置密钥名已复制到剪贴板", - "provision-device-secret": "预配置设备密钥", - "provision-device-secret-required": "预配置设备密钥必填。", - "copy-provision-secret": "复制预配置密钥", - "provision-secret-copied-message": "预配置密钥已复制到剪贴板", - "provision-strategy-x509": { - "certificate-chain": "X509 证书链", - "certificate-chain-hint": "X.509 证书策略用于通过客户端证书在双向TLS通信中提供设备的方式。", - "allow-create-new-devices": "创建设备", - "allow-create-new-devices-hint": "如果选择创建设备,则将客户端证书用作设备凭据。", - "certificate-value": "PEM 格式的证书", - "certificate-value-required": "PEM 格式的证书必填。", - "cn-regex-variable": "CN 正则表达式变量", - "cn-regex-variable-required": "CN 正则表达式变量必填。", - "cn-regex-variable-hint": "需要从设备 X509 证书的通用名称中获取设备名称。" - }, - "condition": "条件", - "condition-type": "条件类型", - "condition-type-simple": "简单", - "condition-type-duration": "持续时间", - "condition-during": "在{{during}}期间", - "condition-during-dynamic": " \"{{ attribute }}\" ({{during}})期间", - "condition-type-repeating": "重复", - "condition-type-required": "条件类型必填。", - "condition-repeating-value": "事件计数", - "condition-repeating-value-range": "事件计数应在1到2147483647之间。", - "condition-repeating-value-pattern": "事件计数应为整数。", - "condition-repeating-value-required": "事件计数值必填。", - "condition-repeat-times": "重复 { count, plural, =1 {1 次} other {# 次} }", - "condition-repeat-times-dynamic": "重复 \"{ attribute }\" ({ count, plural, =1 {1 time} other {# times} })", - "schedule-type": "计划程序类型", - "schedule-type-required": "计划类型必填。", - "schedule": "启用规则:", - "edit-schedule": "编辑告警日程表", - "schedule-any-time": "始终启用", - "schedule-specific-time": "定时启用", - "schedule-custom": "自定义启用", - "schedule-day": { - "monday": "星期一", - "tuesday": "星期二", - "wednesday": "星期三", - "thursday": "星期四", - "friday": "星期五", - "saturday": "星期六", - "sunday": "星期日" - }, - "schedule-days": "天", - "schedule-time": "时间", - "schedule-time-from": "从", - "schedule-time-to": "到", - "schedule-days-of-week-required": "每周至少选择一天。", - "create-device-profile": "创建设备配置", - "import": "导入设备配置", - "export": "导出设备配置", - "export-failed-error": "无法导出设备配置: {{error}}", - "device-profile-file": "设备配置", - "invalid-device-profile-file-error": "无法导入设备配置:无效的设备配置数据结构。", - "power-saving-mode": "节能模式", - "power-saving-mode-type": { - "default": "使用设备配置的节能模式", - "psm": "节能模式(PSM)", - "drx": "非连续接收(DRX)", - "edrx": "连续接收(eDRX)" - }, - "edrx-cycle": "eDRX循环", - "edrx-cycle-required": "eDRX循环必填。", - "edrx-cycle-pattern": "eDRX循环必须是一个正整数。", - "edrx-cycle-min": "eDRX循环的最小值{{ min }}秒。", - "paging-transmission-window": "分页传输窗口", - "paging-transmission-window-required": "分页传输窗口必填。", - "paging-transmission-window-pattern": "分页传输窗口必须是正整数。", - "paging-transmission-window-min": "分页传输窗口的最小值{{ min }}秒。", - "psm-activity-timer": "PSM活动计时器", - "psm-activity-timer-required": "PSM活动计时器必填。", - "psm-activity-timer-pattern": "PSM活动计时器必须是正整数", - "psm-activity-timer-min": "PSM活动计时器的最小数量为{{ min }}秒。", - "lwm2m": { - "object-list": "Object列表", - "object-list-empty": "没有选择边object。", - "no-objects-found": "没有选择边object。", - "no-objects-matching": "没有找到匹配的object'{{object}}'。", - "model-tab": "LWM2M模式", - "add-new-instances": "添加新实例", - "instances-list": "实例列表", - "instances-list-required": "实例列表必填。", - "instance-id-pattern": "实例id必须是一个正整数。", - "instance-id-max": "实例id最大值是{{max}}", - "instance": "实例", - "resource-label": "#ID资源名称", - "observe-label": "观察者", - "attribute-label": "属性", - "telemetry-label": "遥测", - "edit-observe-select": "选择观察者编辑遥测或属性", - "edit-attributes-select": "选择要编辑的遥测或属性", - "no-attributes-set": "没有设置属性", - "key-name": "键名", - "key-name-required": "键名必填", - "attribute-name": "属性名称", - "attribute-name-required": "属性名称必填。", - "attribute-value": "属性值", - "attribute-value-required": "属性值必填。", - "attribute-value-pattern": "属性值必须是一个正整数。", - "edit-attributes": "编辑属性:{{ name }}", - "view-attributes": "查看属性:{{ name }}", - "add-attribute": "添加属性", - "edit-attribute": "编辑属性", - "view-attribute": "查看属性", - "remove-attribute": "移除属性", - "delete-server-text": "请注意,确认删除后,服务器配置将无法恢复。", - "delete-server-title": "确定要删除服务器吗?", - "mode": "安全配置模式", - "bootstrap-tab": "Bootstrap", - "bootstrap-server-legend": "Bootstrap服务...", - "lwm2m-server-legend": "LwM2M服务...", - "server": "服务器", - "short-id": "服务器ID", - "short-id-tooltip": "服务器ID用作关联服务器对象实例的链接。", - "short-id-tooltip-bootstrap": "服务器短ID用作关联服务器对象实例的链接,\n 该标识符唯一地标识了为LWM2M客户端配置的每个LWM2M服务器,\n 当Bootstrap-Server资源的值为‘false’时必须设置资源。", - "short-id-required": "服务器ID必填。", - "short-id-range": "服务器ID应在{{ min }}到{{ max }}范围内。", - "short-id-pattern": "服务器ID必须是一个正整数。", - "lifetime": "客户端注册生命周期", - "lifetime-required": "客户端注册生命周期必填。", - "lifetime-pattern": "客户端注册生命周期必须是一个正整数。", - "default-min-period": "最小期限", - "default-min-period-tooltip": "LWM2M客户端在观察中不包含此参数时使用的默认值。", - "default-min-period-required": "最小期限必填。", - "default-min-period-pattern": "最小期限必须是一个正整数。", - "notification-storing": "禁用或离线时通知存储", - "binding": "绑定", - "binding-type": { - "u": "U: 客户端通过UDP绑定。", - "m": "M: 客户端通过MQTT绑定。", - "h": "H: 客户端通过HTTP绑定。", - "t": "T: 客户端通过TCP绑定。", - "s": "S: 客户端通过SMS绑定。", - "n": "N: 客户端通过非IP绑定将响应发送到请求(支持LWM2M 1.1)。", - "uq": "UQ: 通过UDP队列模式连接(不支持LWM2M 1.1)。", - "uqs": "UQS: 通过UDP和SMS活动连接(不支持LWM2M 1.1)。", - "tq": "TQ: 通过TCP队列模式连接(不支持LWM2M 1.1)。", - "tqs": "TQS: 通过TCP和SMS活动连接(不支持LWM2M 1.1)。", - "sq": "SQ: 通过队列模式的SMS连接(不支持LWM2M 1.1)。" - }, - "binding-tooltip": "这是LwM2M服务器对象的绑定资源列表 - /1/x/7。", - "bootstrap-server": "Bootstrap Server", - "lwm2m-server": "LwM2M Server", - "include-bootstrap-server": "包含Bootstrap Server更新", - "bootstrap-update-title": "你已经配置了Bootstrap Server,您确定要排除更新吗?", - "bootstrap-update-text": "请注意确认更新后Bootstrap Server配置数据将无法恢复。", - "server-host": "主机", - "server-host-required": "主机必填。", - "server-port": "端口", - "server-port-required": "端口必填。", - "server-port-pattern": "端口必须是一个正整数。", - "server-port-range": "端口应在1到65535范围内。", - "server-public-key": "服务器公钥", - "server-public-key-required": "服务器公钥必填。", - "client-hold-off-time": "停留时间", - "client-hold-off-time-required": "停留时间必填。", - "client-hold-off-time-pattern": "停留时间必须是一个正整数。", - "client-hold-off-time-tooltip": "客户端仅与Bootstrap-Server共用停留时间", - "account-after-timeout": "帐户超时", - "account-after-timeout-required": "帐户超时必填。", - "account-after-timeout-pattern": "帐户超时必须是一个正整数。", - "account-after-timeout-tooltip": "Bootstrap-Server帐户资源的超时值。", - "server-type": "服务器类型", - "add-new-server-title": "添加新的服务器配置", - "add-server-config": "添加服务器配置", - "add-lwm2m-server-config": "添加LwM2M服务器", - "no-config-servers": "没有服务器配置", - "others-tab": "其它设置", - "client-strategy": "客户端连接策略", - "client-strategy-label": "策略", - "client-strategy-only-observe": "只在初始连接后观察对客户的请求", - "client-strategy-read-all": "注册后阅读所有资源并观察对客户的请求", - "fw-update": "固件升级", - "fw-update-strategy": "固件升级策略", - "fw-update-strategy-data": "发布固件升级二制文件使用Object 19和Resource 0数据。", - "fw-update-strategy-package": "发布固件升级二制文件使用Object 5和Resource 0包", - "fw-update-strategy-package-uri": "自动生成唯一的CoAP地址下载包和发布软件更新作为Object 5和Resource 1(软件包URI)。", - "sw-update": "软件更新", - "sw-update-strategy": "软件更新策略", - "sw-update-strategy-package": "发布二制文件使用Object 9和Resource 2(包)", - "sw-update-strategy-package-uri": "自动生成唯一的CoAP地址下载包和发布软件更新作为Object 9和Resource 3(软件包URI)。", - "fw-update-resource": "固件更新COAP资源", - "fw-update-resource-required": "固件更新COAP资源必填。", - "sw-update-resource": "软件更新COAP资源", - "sw-update-resource-required": "软件更新COAP资源必填。", - "config-json-tab": "设备配置JSON", - "attributes-name": { - "min-period": "最小周期", - "max-period": "最大周期", - "greater-than": "大于", - "less-than": "小于", - "step": "步长", - "min-evaluation-period": "最小评估周期", - "max-evaluation-period": "最大评估周期" - }, - "default-object-id": "默认对象版本(属性)", - "default-object-id-ver": { - "v1-0": "1.0", - "v1-1": "1.1" - } - }, - "snmp": { - "add-communication-config": "添加通信配置", - "add-mapping": "添加映射", - "authentication-passphrase": "身份验证密码", - "authentication-passphrase-required": "身份验证密码必填。", - "authentication-protocol": "身份验证协议", - "authentication-protocol-required": "身份验证协议必填。", - "communication-configs": "通信配置", - "community": "Community字符串", - "community-required": "Community字符串必填。", - "context-name": "上下文名称", - "data-key": "数据键", - "data-key-required": "数据键必填。", - "data-type": "数据类型", - "data-type-required": "数据类型必填。", - "engine-id": "引擎ID", - "host": "主机", - "host-required": "主机必填。", - "oid": "OID", - "oid-pattern": "无效OID格式", - "oid-required": "OID必填。", - "please-add-communication-config": "请添加通信配置", - "please-add-mapping-config": "请添加映射配置", - "port": "端口", - "port-format": "端口格式无效", - "port-required": "端口必填。", - "privacy-passphrase": "私有密码", - "privacy-passphrase-required": "私有密码必填。", - "privacy-protocol": "私有协议", - "privacy-protocol-required": "私有协议必填。", - "protocol-version": "协议版本", - "protocol-version-required": "协议版本必填。", - "querying-frequency": "查询频率(ms)", - "querying-frequency-invalid-format": "查询频率必须是一个正整数。", - "querying-frequency-required": "查询频率必填。", - "retries": "重试", - "retries-invalid-format": "重试必须是一个正整数。", - "retries-required": "重试必填。", - "scope": "范围", - "scope-required": "范围必填。", - "security-name": "Security名称", - "security-name-required": "Security名称必填。", - "timeout-ms": "超时(ms)", - "timeout-ms-invalid-format": "超时必须是一个正整数。", - "timeout-ms-required": "超时必填。", - "user-name": "用户名", - "user-name-required": "用户名必填。" - } + "aggregate-period-hint-offset": "您的聚合间隔将为:{{ interval }}", + "aggregate-period-hint-offset-and-so-on": "您的聚合间隔将为:{{ interval }} 以此类推。", + "entity-aggregation": { + "argument-hint": "数据将从当前实体获取。", + "argument-title-hint": "定义用于聚合的输入参数。", + "argument-setting-hint": "此计算字段仅支持最新遥测作为参数类型。", + "aggregation-interval": "聚合间隔", + "aggregation-interval-hint": "定义执行聚合的频率。例如:每 1 小时在 00:00、01:00、02:00 等时间点聚合数据。聚合结果使用聚合间隔开始时间的时间戳存储。", + "apply-offset": "应用偏移到聚合间隔", + "apply-offset-hint": "定义每个聚合周期开始的偏移量(例如,+10 分钟 - 00:10、01:10)。", + "offset-value": "偏移值", + "offset-value-required": "偏移值为必填项。", + "offset-value-min": "偏移值必须为正整数。", + "offset-value-max": "偏移值应小于聚合间隔值。", + "wait-delay": "对延迟遥测应用等待超时", + "wait-delay-hint": "定义间隔结束后等待延迟遥测的时间。如果此类遥测到达,将重新计算该间隔的结果。", + "duration": "持续时间", + "duration-required": "持续时间为必填项。", + "duration-min": "持续时间至少为 1 分钟。", + "duration-hint": "间隔结束后等待延迟数据的时间。", + "produce-intermediate-result": "生成中间结果", + "produce-intermediate-result-hint": "在当前间隔内计算指标以生成中间结果。更新频率不超过每 {{ time }} 一次。" }, - "dialog": { - "close": "关闭对话框", - "error-message-title": "错误信息:", - "error-details-title": "错误详细信息" + "hint": { + "arguments-simple-with-rolling": "简单类型的计算字段不应包含时间序列滚动类型的键。", + "arguments-propagate-arguments-with-rolling": "“时间序列滚动”类型与“仅参数”传播不兼容。", + "arguments-propagate-argument-entity-type": "实体类型与“仅参数”传播不兼容。", + "arguments-propagate-argument-must-current-entity": "至少需要配置一个源实体类型为“当前实体”的参数。", + "arguments-empty": "至少需要指定一个参数。", + "expression-required": "表达式为必填项。", + "expression-invalid": "表达式无效", + "expression-max-length": "表达式长度应少于 255 个字符。", + "argument-name-required": "参数名称为必填项。", + "argument-name-pattern": "参数名称无效。", + "argument-name-duplicate": "同名参数已存在。", + "argument-name-max-length": "参数名称应少于 256 个字符。", + "argument-name-forbidden": "参数名称为保留名称,无法使用。", + "output-key-required": "输出键为必填项。", + "output-key-pattern": "输出键无效。", + "output-key-duplicate": "同名键已存在。", + "output-key-max-length": "输出键应少于 256 个字符。", + "output-key-forbidden": "输出键为保留名称,无法使用。", + "entity-type-required": "实体类型为必填项", + "name-required": "名称为必填项。", + "name-pattern": "名称无效。", + "name-duplicate": "同名名称已存在。", + "name-max-length": "名称应少于 256 个字符。", + "name-forbidden": "名称为保留名称,无法使用。", + "argument-type-required": "参数类型为必填项。", + "max-args": "已达到最大参数数量。", + "decimals-range": "默认小数位数应为 0 到 15 之间的数字。", + "expression": "默认表达式演示如何将温度从华氏度转换为摄氏度。", + "arguments-entity-not-found": "未找到参数目标实体。", + "use-latest-timestamp": "启用后,计算值将使用参数遥测中的最新时间戳进行持久化,而非服务器时间。", + "entity-coordinates": "指定提供实体 GPS 坐标(纬度和经度)的时间序列键。", + "geofencing-zone-groups": "定义一个或多个要检查的地理围栏区域组(例如“allowedZones”、“restrictedZones”)。每个组必须有唯一的名称,该名称用作计算字段输出遥测键的前缀。", + "perimeter-attribute-key": "设置包含地理围栏区域周界定义的属性键。周界始终取自区域实体的服务端属性。", + "report-strategy": "存在状态报告实体当前是否在区域组内部或外部。转换事件报告实体何时进入或离开区域组。", + "create-relation-with-matched-zones": "自动创建并维护实体与其当前所在区域之间的关联。当实体离开区域时移除关联,进入新区域时创建关联。", + "relation-type-required": "关联类型为必填项。", + "relation-level-required": "关联层级为必填项。", + "relation-level-min": "最小关联层级值为 1。", + "relation-level-max": "最大关联层级值为 {{max}}。", + "geofencing-empty": "至少需要配置一个区域组。", + "geofencing-entity-not-found": "未找到地理围栏目标实体。", + "max-geofencing-zone": "已达到最大地理围栏区域数量。", + "zone-group-refresh-interval": "定义通过关联实体配置的区域组的刷新频率。", + "zone-group-refresh-interval-required": "区域组刷新间隔为必填项。", + "zone-group-refresh-interval-min": "区域组刷新间隔至少应为 {{ min }} 秒。", + "propagation-path-related-entities": "定义基于所选方向和关联类型到关联实体的直接单级路径。仅支持设备、资产、客户和租户实体之间的关联。关联路径解析的最大实体数为 {{ max }}。", + "data-propagate": "定义要从下方配置的参数中传播的数据。“仅参数”直接使用检索到的数据,而“计算结果”从该数据计算新值。", + "aggregation-path-related-entities": "定义基于方向和关联类型通过与父级或子级实体的直接关联进行的单级聚合路径。仅支持设备、资产、客户和租户实体之间的关联。关联路径解析的最大实体数为 {{ max }}。", + "arguments-aggregation": "定义用于过滤和聚合的输入参数。", + "setting-arguments-aggregation": "数据将从聚合路径中配置的关联实体获取。", + "metrics": "定义基于已配置参数进行聚合的指标。", + "entity-aggregation-metrics": "定义基于已配置参数在指定时间间隔内进行聚合的指标。", + "import-invalid-calculated-field-type": "无法导入计算字段:计算字段结构无效。", + "simple-expression-title": "定义计算值计算方式的算术表达式。", + "script-title": "定义计算逻辑和输出值的 TBEL 脚本。", + "simple-arguments": "定义计算值计算方式的算术表达式。", + "script-arguments": "定义脚本可用的输入参数。" + } + }, + "alarm-rule": { + "alarm-rules-tab": "告警规则", + "alarm-rule": "告警规则", + "alarm-rules": "告警规则", + "alarm-rules-old": "旧版", + "alarm-rules-actual": "当前", + "severities": "严重程度", + "cleared": "清除条件", + "delete-title": "确定要删除告警规则“{{title}}”吗?", + "delete-text": "请注意,确认后告警规则及所有相关数据将无法恢复。", + "delete-multiple-title": "确定要删除{ count, plural, =1 {1 条告警规则} other {# 条告警规则} }吗?", + "delete-multiple-text": "请注意,确认后所有选中的告警规则将被移除,所有相关数据将无法恢复。", + "create": "创建新告警规则", + "add": "添加告警规则", + "copy": "复制告警规则配置", + "details": "告警规则详情", + "no-found": "未找到告警规则", + "list": "{ count, plural, =1 {1 条告警规则} other {# 条告警规则列表} }", + "selected-fields": "已选择{ count, plural, =1 {1 条告警规则} other {# 条告警规则} }", + "import": "导入告警规则", + "file": "告警规则文件", + "export": "导出告警规则", + "export-failed-error": "无法导出告警规则:{{error}}", + "entity-type": "实体类型", + "entity-type-required": "实体类型为必填项。", + "alarm-type": "告警类型", + "alarm-type-hint": "在告警发起者(设备、资产等)范围内的唯一标识符(例如 HighTempAlarm),以防止冲突。", + "alarm-type-required": "告警类型为必填项。", + "alarm-type-pattern": "告警类型无效。", + "alarm-type-max-length": "告警类型应少于 256 个字符。", + "clear-alarm": "清除告警", + "value-argument": "参数", + "value-argument-required": "参数为必填项。", + "static-settings": "静态设置", + "configuration": "配置", + "static-schedule": "静态", + "dynamic-schedule": "动态", + "operation-and": "AND", + "operation-or": "OR", + "condition-during": "持续 {{during}}", + "condition-during-dynamic": "持续\"{{ attribute }}\"", + "condition-repeat-times": "重复{ count, plural, =1 {1 次} other {# 次} }", + "condition-repeat-times-dynamic": "重复\"{{ attribute }}\"次", + "filter-preview": "过滤器预览", + "condition-settings": "条件设置", + "static": "静态", + "dynamic": "动态", + "argument-filters": "参数过滤器", + "argument-name": "参数名称", + "value-type": "值类型", + "general": "通用", + "filters": "过滤器", + "date-time-hint": "参数必须为纪元毫秒。示例:1698839340000 等于 2023-11-01 12:49:00 UTC。", + "operation": "操作", + "value-source": "值来源", + "value": "值", + "ignore-case": "忽略大小写", + "condition": "条件", + "script": "脚本", + "add-filter": "添加参数过滤器", + "edit-filter": "参数过滤器", + "remove-filter": "移除参数过滤器", + "no-filter": "至少需要一个过滤器。", + "conditions": { + "simple": "简单", + "duration": "持续时间", + "repeating": "重复" }, - "direction": { - "column": "列", - "row": "排" + "schedule-title": "调度", + "edit-schedule": "编辑告警调度", + "schedule-type": "调度器类型", + "schedule-type-required": "调度器类型为必填项。", + "schedule": { + "any-time": "始终激活", + "specific-time": "在特定时间激活", + "custom": "自定义" }, - "edge": { - "edge": "边缘", - "edge-instances": "边缘实例", - "instances": "边缘实例", - "edge-file": "边缘文件", - "name-max-length": "名称长度必须小于256个字符", - "label-max-length": "标签长度必须小于256个字符", - "type-max-length": "类型长度必须小于256个字符", - "management": "边缘管理", - "no-edges-matching": "未找到匹配的边缘 '{{entity}}'。", - "add": "增加边缘", - "no-edges-text": "未找到边缘", - "edge-details": "边缘详情", - "add-edge-text": "增加新的边缘", - "delete": "删除边缘", - "delete-edge-title": "确定删除边缘 '{{edgeName}}'吗?", - "delete-edge-text": "当心, 确认后,边缘以及所有关联数据将不可恢复。", - "delete-edges-title": "确定删除 { count, plural, =1 {1 个边缘} other {# 个边缘} }吗?", - "delete-edges-text": "当心, 确认后,选定的边缘以及所有关联数据将不可恢复。", - "name": "名称", - "name-starts-with": "边缘名称前缀", - "name-required": "名称必填。", - "description": "说明", - "details": "详情", - "events": "事件", - "copy-id": "复制边缘编号", - "id-copied-message": "边缘编号已经复制到剪切板", - "sync": "同步边缘", - "edge-required": "边缘必填。", - "edge-type": "边缘类型", - "edge-type-required": "边缘类型必填。", - "event-action": "事件行动", - "entity-id": "实体编号", - "select-edge-type": "选择边缘类型", - "assign-to-customer": "分配给客户", - "assign-to-customer-text": "请选择需要分配给边缘的客户", - "assign-edge-to-customer": "分配边缘给客户", - "assign-edge-to-customer-text": "请选择需要分配给边缘的客户", - "assignedToCustomer": "分配给客户", - "edge-public": "边缘公开", - "assigned-to-customer": "分配给: {{customerTitle}}", - "unassign-from-customer": "取消分配客户", - "unassign-edge-title": "确定取消分配边缘 '{{edgeName}}' 吗?", - "unassign-edge-text": "确定后,边缘将被取消分配,并且客户将无法访问。", - "unassign-edges-title": "确定要取消分配 {count,plural, =1 {1 个边缘} other {# 个边缘} } 吗?", - "unassign-edges-text": "确定后,所有选定的边缘将被取消分配,并且客户将无法访问。", - "make-public": "公开", - "make-public-edge-title": "确定要将边缘 '{{edgeName}}' 设为公开吗?", - "make-public-edge-text": "确认后,边缘及其所有数据将被设为公开并可被其他人访问。", - "make-private": "私有", - "public": "公开", - "make-private-edge-title": "确定要将边缘 '{{edgeName}}' 设为私有吗?", - "make-private-edge-text": "确认后,边缘及其所有数据将被设为私有,不被其他人访问。", - "import": "导入边缘", - "install-connect-instructions": "安装和连接说明", - "install-connect-instructions-edge-created": "边缘已创建!请检查安装和连接说明", - "loading-edge-instructions": "正在加载边缘说明...", - "label": "标签", - "load-entity-error": "加载数据失败,实体已经被删除。", - "assign-new-edge": "分配新边缘", - "unassign-from-edge": "取消分配边缘", - "edge-key": "边缘键", - "copy-edge-key": "复制边缘键", - "edge-key-copied-message": "边缘键已经被复制到剪切板", - "edge-secret": "边缘密钥", - "copy-edge-secret": "复制边缘密钥", - "edge-secret-copied-message": "边缘密钥已经被复制到剪切板", - "manage-assets": "管理资产", - "manage-devices": "管理设备", - "manage-entity-views": "管理实体视图", - "manage-dashboards": "管理仪表板", - "manage-rulechains": "管理规则链", - "assets": "边缘资产", - "devices": "边缘设备", - "entity-views": "边缘实体视图", - "dashboard": "边缘仪表板", - "dashboards": "边缘仪表板", - "rulechain-templates": "规则链模版", - "edge-rulechain-templates": "边缘规则链模板", - "rulechains": "规则链", - "search": "搜索边缘", - "selected-edges": "{ count, plural, =1 {1 个边缘} other {# 个边缘} } 被选中", - "any-edge": "任何边缘", - "no-edge-types-matching": "未找到匹配的边缘类型 '{{entitySubtype}}'。", - "edge-type-list-empty": "没有选择边缘类型。", - "edge-types": "边缘类型", - "enter-edge-type": "输入边缘类型", - "deployed": "已部署", - "pending": "待定", - "downlinks": "下行", - "no-downlinks-prompt": "未找到下行", - "sync-process-started-successfully": "同步处理开始成功!", - "missing-related-rule-chains-title": "边缘缺少关联规则链", - "missing-related-rule-chains-text": "分配给边缘的规则链使用规则节点将消息转发给未分配给当前边缘的规则链。

缺少的规则链列表:
{{missingRuleChains}}", - "upgrade-instructions": "升级说明", - "widget-datasource-error": "组件只支持边缘实体数据源", - "connected": "已连接", - "disconnected": "已断开连接" - }, - "edge-event": { - "type-dashboard": "仪表板", - "type-asset": "资产", - "type-device": "设备", - "type-device-profile": "设备概要", - "type-asset-profile": "资产配置", - "type-entity-view": "实体视图", - "type-alarm": "告警", - "type-rule-chain": "规则链", - "type-rule-chain-metadata": "规则链元数据", - "type-edge": "边缘", - "type-user": "用户", - "type-tenant": "租户", - "type-tenant-profile": "租户配置", - "type-customer": "客户", - "type-relation": "关联", - "type-widgets-bundle": "部件包", - "type-widgets-type": "部件类型", - "type-admin-settings": "管理员设置", - "type-ota-package": "OTA包", - "type-queue": "队列", - "action-type-added": "增加", - "action-type-deleted": "删除", - "action-type-updated": "更新", - "action-type-post-attributes": "推送属性", - "action-type-attributes-updated": "属性更新", - "action-type-attributes-deleted": "属性删除", - "action-type-timeseries-updated": "时间序列更新", - "action-type-credentials-updated": "认证更新", - "action-type-assigned-to-customer": "分配给客户", - "action-type-unassigned-from-customer": "取消分配客户", - "action-type-relation-add-or-update": "关联增加或更新", - "action-type-relation-deleted": "关联删除", - "action-type-rpc-call": "RPC调用", - "action-type-alarm-ack": "告警确认", - "action-type-alarm-clear": "告警清除", - "action-type-alarm-assigned": "告警已分配", - "action-type-alarm-unassigned": "告警未分配", - "action-type-assigned-to-edge": "分配给边缘", - "action-type-unassigned-from-edge": "取消分配边缘", - "action-type-credentials-request": "认证请求", - "action-type-entity-merge-request": "实体合并请求" + "schedule-day": { + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期日" }, - "error": { - "unable-to-connect": "无法连接到服务器!请检查您的互联网连接。", - "unhandled-error-code": "未处理的错误代码: {{errorCode}}", - "unknown-error": "未知错误" - }, - "entity": { - "entity": "实体", - "entities": "实体", - "entities-count": "实体数量", - "alarms-count": "告警数量", - "aliases": "别名", - "aliases-short": "别名", - "entity-alias": "别名", - "unable-delete-entity-alias-title": "无法删除实体别名", - "unable-delete-entity-alias-text": "实体别名'{{entityAlias}}'被以下部件使用不能删除:
{{widgetsList}}", - "duplicate-alias-error": "别名'{{alias}}'重复,同一仪表板别名必须唯一。", - "missing-entity-filter-error": "别名'{{alias}}'缺少筛选器", - "configure-alias": "别名'{{alias}}'配置", - "alias": "别名", - "alias-required": "实体别名必填。", - "remove-alias": "移除实体别名", - "add-alias": "添加实体别名", - "entity-list": "实体列表", - "entity-type": "实体类型", - "entity-types": "实体类型", - "entity-type-list": "实体类型列表", - "any-entity": "任意实体", - "add-entity-type": "添加实体类型", - "enter-entity-type": "输入实体类型", - "no-entities-matching": "未找到匹配'{{entity}}'的实体。", - "no-entities-text": "找不到实体", - "no-entity-types-matching": "未找到匹配'{{entityType}}'类型的实体。", - "name-starts-with": "名称开始于", - "help-text": "根据需要可以使用'%'进行匹配例如:'%entity_name_contains%', '%entity_name_ends', 'entity_starts_with'。", - "use-entity-name-filter": "用户筛选器", - "entity-list-empty": "没有选择实体。", - "entity-type-list-required": "至少应选择一个实体类型。", - "entity-name-filter-required": "实体名筛选器必填。", - "entity-name-filter-no-entity-matched": "未找到以'{{entity}}'开头的实体", - "all-subtypes": "全部", - "select-entities": "选择实体", - "no-aliases-found": "未找到别名", - "no-alias-matching": "未找到'{{alias}}'", - "create-new-alias": "创建别名", - "create-new": "创建", - "key": "键名", - "key-name": "键名", - "no-keys-found": "未找到键名", - "no-key-matching": "未找到键名'{{key}}'", - "create-new-key": "创建键", - "type": "类型", - "type-required": "实体类型必填。", - "type-device": "设备", - "type-devices": "设备", - "list-of-devices": "{ count, plural, =1 {1 个设备} other {# 个设备} }", - "device-name-starts-with": "以 '{{prefix}}' 开头的设备", - "type-device-profile": "设备配置", - "type-device-profiles": "设备配置", - "clear-selected-profiles": "清除已选择的配置", - "list-of-device-profiles": "{ count, plural, =1 {1 个设备配置} other {# 个设备配置} }", - "device-profile-name-starts-with": "名称以 '{{prefix}}' 开头的设备配置", - "type-asset-profile": "资产配置", - "type-asset-profiles": "资产配置", - "list-of-asset-profiles": "{ count, plural, =1 {1 个资产配置} other {# 个资产配置} }", - "asset-profile-name-starts-with": "名称以 '{{prefix}}' 开头的资产配置", - "type-asset": "资产", - "type-assets": "资产", - "list-of-assets": "{ count, plural, =1 {1 个资产} other {# 个资产} }", - "asset-name-starts-with": "以 '{{prefix}}' 开头的资产", - "type-entity-view": "实体视图", - "type-entity-views": "实体视图", - "list-of-entity-views": "{ count, plural, =1 {1 个实体视图} other {# 个实体视图} }", - "entity-view-name-starts-with": "以 '{{prefix}}' 开头的实体视图", - "type-rule": "规则", - "type-rules": "规则", - "list-of-rules": "{ count, plural, =1 {1 个规则} other {# 个规则} }", - "rule-name-starts-with": "以 '{{prefix}}' 开头的规则", - "type-plugin": "插件", - "type-plugins": "插件", - "list-of-plugins": "{ count, plural, =1 {1 个插件} other {# 个插件} }", - "plugin-name-starts-with": "以 '{{prefix}}' 开头的插件", - "type-tenant": "租户", - "type-tenants": "租户", - "list-of-tenants": "{ count, plural, =1 {1 个租户} other {# 个租户} }", - "tenant-name-starts-with": "以 '{{prefix}}' 开头的租户", - "type-tenant-profile": "租户简介", - "type-tenant-profiles": "租户配置", - "list-of-tenant-profiles": "{ count, plural, =1 {1 个租户配置} other {# 个租户配置} }", - "tenant-profile-name-starts-with": "名称以 '{{prefix}}' 开头的租户配置", - "type-customer": "客户", - "type-customers": "客户", - "list-of-customers": "{ count, plural, =1 {1 个客户} other {# 个客户} }", - "customer-name-starts-with": "以 '{{prefix}}' 开头的客户", - "type-user": "用户", - "type-users": "用户", - "list-of-users": "{ count, plural, =1 {1 个用户} other {# 个用户} }", - "user-name-starts-with": "以 '{{prefix}}' 开头的用户", - "type-dashboard": "仪表板", - "type-dashboards": "仪表板", - "list-of-dashboards": "{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }", - "dashboard-name-starts-with": "以 '{{prefix}}' 开头的仪表板", - "type-alarm": "告警", - "type-alarms": "告警", - "list-of-alarms": "{ count, plural, =1 {1 个告警} other {# 个告警} }", - "alarm-name-starts-with": "以 '{{prefix}}' 开头的告警", - "type-rulechain": "规则链", - "type-rulechains": "规则链库", - "list-of-rulechains": "{ count, plural, =1 {1 个规则链} other {# 个规则链} }", - "rulechain-name-starts-with": "规则链前缀名称 '{{prefix}}'", - "type-rulenode": "规则节点", - "type-rulenodes": "规则节点", - "list-of-rulenodes": "{ count, plural, =1 {1 个规则节点} other {# 个规则节点} }", - "rulenode-name-starts-with": "名称以 '{{prefix}}' 开头的规则节点", - "type-current-customer": "当前客户", - "type-current-tenant": "当前租户", - "type-current-user": "当前用户", - "type-current-user-owner": "当前用户所有者", - "type-widgets-bundle": "部件包", - "type-widgets-bundles": "部件包", - "list-of-widgets-bundles": "{ count, plural, =1 {1 个部件包} other {# 个部件包} }", - "type-widget": "部件", - "type-widgets": "部件", - "list-of-widgets": "{ count, plural, =1 {1 个部件} other {# 个部件} }", - "search": "实体检索", - "selected-entities": "已选择 { count, plural, =1 {1 个实体} other {# 个实体} }", - "entity-name": "实体名", - "entity-label": "实体标签", - "details": "实体详情", - "no-entities-prompt": "未找到实体", - "no-data": "无数据", - "columns-to-display": "要显示的列", - "type-api-usage-state": "Api使用状态", - "type-edge": "边缘", - "type-edges": "边缘", - "list-of-edges": "{ count, plural, =1 {1 个边缘} other {列表 # 个边缘} }", - "edge-name-starts-with": "以'{{prefix}}'开头的边缘", - "version-conflict": { - "message": "您要覆盖现有版本还是丢弃更改并加载最新版本?", - "link": "您可以使用此下载的{{entityType}}版本", - "overwrite": "覆盖", - "discard": "丢弃" - }, - "type-tb-resource": "资源", - "type-tb-resources": "资源", - "list-of-tb-resources": "{ count, plural, =1 {1 个资源} other {# 个资源} }", - "type-ota-package": "OTA包", - "type-rpc": "RPC", - "type-queue": "队列", - "type-queue-stats": "队列统计", - "type-queues-stats": "队列统计", - "type-notification": "通知", - "type-notification-rule": "通知规则", - "type-notification-rules": "通知规则", - "list-of-notification-rules": "{ count, plural, =1 {1 个通知规则} other {# 个通知规则} }", - "type-notification-target": "通知收件人", - "type-notification-targets": "通知接收者", - "list-of-notification-targets": "{ count, plural, =1 {1 个通知接收者} other {# 个通知接收者} }", - "type-notification-request": "通知请求", - "type-notification-template": "通知模板", - "type-notification-templates": "通知模板", - "list-of-notification-templates": "{ count, plural, =1 {1 个通知模板} other {# 个通知模板} }", - "link": "连接", - "type-oauth2-client": "OAuth2.0客户端", - "type-oauth2-clients": "OAuth2.0客户端", - "list-of-oauth2-clients": "{ count, plural, =1 {一个 OAuth2.0客户端} other {# 个 OAuth2.0客户端列表} }", - "type-domain": "域名", - "type-domains": "域名", - "list-of-domains": "{ count, plural, =1 {一个域名} other {# 个域名列表} }", - "type-mobile-app": "移劝端", - "type-mobile-apps": "移动端", - "list-of-mobile-apps": "{ count, plural, =1 {一个移动应用} other {# 个移动应用列表} }", - "type-mobile-app-bundle": "移动应用包", - "type-mobile-app-bundles": "移动应用包", - "list-of-mobile-app-bundles": "{ count, plural, =1 {一个移动应用包} other {# 个移动应用包列表} }" - }, - "entity-field": { - "created-time": "创建时间", - "name": "名称", - "type": "类型", - "first-name": "名字", - "last-name": "姓氏", - "email": "电子邮件", - "title": "标题", - "country": "国家", - "state": "省/州", - "city": "城市", - "address": "地址", - "address2": "地址二", - "zip": "邮政编码", - "phone": "电话", - "label": "标签", - "queue-name": "对列名称", - "service-id": "服务Id", - "owner-name": "所有者名称", - "owner-type": "所有者类型" - }, - "entity-view": { - "entity-view": "实体视图", - "entity-view-required": "实体视图必填。", - "entity-views": "实体视图", - "management": "实体视图管理", - "view-entity-views": "查看实体视图", - "entity-view-alias": "实体视图别名", - "aliases": "实体视图别名", - "no-alias-matching": "未找到匹配'{{alias}}'的别名。", - "no-aliases-found": "未找到别名。", - "no-key-matching": "'{{key}}' 未找到。", - "no-keys-found": "未找到密钥。", - "create-new-alias": "创建别名", - "create-new-key": "创建键", - "duplicate-alias-error": "找到重复别名'{{alias}}'。
实体视图别名必须是唯一的。", - "configure-alias": "配置 '{{alias}}' 别名", - "no-entity-views-matching": "未找到与'{{entity}}'匹配的实体视图。", - "public": "公开", - "alias": "别名", - "alias-required": "实体视图别名必填。", - "remove-alias": "删除实体视图别名", - "add-alias": "添加实体视图别名", - "name-starts-with": "名称前缀", - "help-text": "根据需要可以使用'%'进行匹配,例如:'%entity-view_name_contains%', '%entity-view_name_ends', 'entity-view_starts_with'。", - "entity-view-list": "实体视图列表", - "use-entity-view-name-filter": "使用筛选器", - "entity-view-list-empty": "没有被选中的实体视图", - "entity-view-name-filter-required": "实体视图名称筛选器必填。", - "entity-view-name-filter-no-entity-view-matched": "未找到以'{{entityView}}' 开头的实体视图。", - "add": "添加实体视图", - "entity-view-public": "实体视图是公共的", - "assign-to-customer": "分配给客户", - "assign-entity-view-to-customer": "将实体视图分配给客户", - "assign-entity-view-to-customer-text": "请选择要分配给客户的实体视图", - "assign-entity-view-to-edge-title": "将实体视图分配给边缘", - "no-entity-views-text": "未找到实体视图", - "assign-to-customer-text": "请选择客户分配实体视图", - "entity-view-details": "实体视图详细信息", - "add-entity-view-text": "添加实体视图", - "delete": "删除实体视图", - "assign-entity-views": "分配实体视图", - "assign-entity-views-text": "分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 给客户", - "delete-entity-views": "删除实体视图", - "unassign-from-customer": "取消分配客户", - "unassign-entity-views": "取消分配实体视图", - "unassign-entity-views-action-title": "从客户处取消分配{count,plural,=1 {1 实体视图} other {# 实体视图} }", - "assign-new-entity-view": "分配新实体视图", - "delete-entity-view-title": "确定要删除实体视图 '{{entityViewName}}'吗?", - "delete-entity-view-text": "请注意:确认后实体视图及其所有相关数据将不可恢复。", - "delete-entity-views-title": "确定要删除 { count, plural, =1 {1 实体视图} other {# 实体视图} }吗?", - "delete-entity-views-action-title": "删除 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }", - "delete-entity-views-text": "请注意:确认后,所有选定的实体视图将被删除,所有相关的数据将不可恢复。", - "unassign-entity-view-title": "确定要取消对 '{{entityViewName}}' 实体视图的分配吗?", - "unassign-entity-view-text": "确认后,实体视图将未分配,客户无法访问。", - "unassign-entity-view": "未分配实体视图", - "unassign-entity-views-title": "确定要取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 吗?", - "unassign-entity-views-text": "确认后,所有选定的实体视图将被分配,客户无法访问。", - "entity-view-type": "实体视图类型", - "entity-view-type-required": "实体视图类型必填。", - "select-entity-view-type": "选择实体视图类型", - "enter-entity-view-type": "输入实体视图类型", - "any-entity-view": "任何实体视图", - "no-entity-view-types-matching": "未找到匹配 '{{entitySubtype}}' 的实体视图类型。", - "entity-view-type-list-empty": "实体视图类型未选择。", - "entity-view-types": "实体视图类型", - "created-time": "创建时间", - "name": "名称", - "name-required": "名称必填。", - "name-max-length": "名称应该小于256个字符。", - "type-max-length": "实体视图类型应该小于256个字符。", - "description": "说明", - "events": "事件", - "details": "详情", - "copyId": "复制实体视图ID", - "idCopiedMessage": "实体视图ID已复制到剪贴板", - "assignedToCustomer": "分配给客户", - "unable-entity-view-device-alias-title": "无法删除实体视图别名", - "unable-entity-view-device-alias-text": "实体视图别名 '{{entityViewAlias}}' 不能够被删除,因为它被下列部件所使用:
{{widgetsList}}", - "select-entity-view": "选择实体视图", - "make-public": "实体视图设为公开", - "make-private": "实体视图设为私有", - "start-ts": "开始时间", - "end-ts": "结束时间", - "date-limits": "日期限制", - "client-attributes": "客户端属性", - "shared-attributes": "共享属性", - "server-attributes": "服务端属性", - "timeseries": "时间序列", - "client-attributes-placeholder": "客户端属性", - "shared-attributes-placeholder": "共享属性", - "server-attributes-placeholder": "服务端属性", - "timeseries-placeholder": "时间序列", - "target-entity": "目标实体", - "attributes-propagation": "属性传播", - "attributes-propagation-hint": "每次保存或更新这个实体视图时,实体视图将自动从目标实体复制指定的属性。由于性能原因,目标实体属性不会在每次属性更改时传递到实体视图。您可以通过配置\"copy to view\"规则链中的规则节点,并将\"Post attributes\"和\"attributes Updated\"消息链接到新规则节点,从而启用自动传递。", - "timeseries-data": "时间序列数据", - "timeseries-data-hint": "配置目标实体的时间序列数据键,以便实体视图可以访问这些键。此时间序列数据是只读的。", - "search": "查找实体视图", - "selected-entity-views": "已选择 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }", - "make-public-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为公开吗?", - "make-public-entity-view-text": "确认后,实体视图及其所有数据将被公开并被他人访问。", - "make-private-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为私有吗?", - "make-private-entity-view-text": "确认后,实体视图及其所有数据将被私有化,无法被他人访问。", - "assign-entity-view-to-edge": "将实体视图分配给边缘", - "assign-entity-view-to-edge-text": "请选择要分配给边缘的实体视图", - "unassign-entity-view-from-edge-title": "确定要取消对 '{{entityViewName}}' 实体视图的分配吗?", - "unassign-entity-view-from-edge-text": "确认后,实体视图将未分配,边缘无法访问。", - "unassign-entity-views-from-edge-action-title": "从边缘处取消分配{count,plural,=1 {1 实体视图} other {# 实体视图} }", - "unassign-entity-view-from-edge": "未分配实体视图", - "unassign-entity-views-from-edge-title": "确定要取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 吗?", - "unassign-entity-views-from-edge-text": "确认后,所有选定的实体视图将被分配,边缘无法访问。" - }, - "event": { - "event-type": "事件类型", - "events-filter": "事件筛选器", - "clean-events": "清除事件", - "type-error": "错误", - "type-lc-event": "生命周期事件", - "type-stats": "类型统计", - "type-debug-rule-node": "调试", - "type-debug-rule-chain": "调试", - "no-events-prompt": "未找到事件", - "error": "错误", - "alarm": "告警", - "event-time": "事件时间", - "server": "服务器", - "body": "整体", - "method": "方法", - "type": "类型", - "message": "消息", - "message-id": "消息ID", - "copy-message-id": "复制消息ID", - "message-type": "消息类型", - "data-type": "数据类型", - "relation-type": "关联类型", - "metadata": "元数据", - "data": "数据", - "event": "事件", - "status": "状态", - "success": "成功", - "failed": "失败", - "messages-processed": "消息处理", - "max-messages-processed": "最大处理消息数", - "min-messages-processed": "最少处理消息数", - "errors-occurred": "错误发生", - "max-errors-occurred": "最大发生错误数", - "min-errors-occurred": "最少发生错误数", - "min-value": "最小值为 0。", - "all-events": "全部", - "has-error": "有错误", - "entity-id": "实体ID", - "copy-entity-id": "复制实体ID", - "entity-type": "实体类型", - "clear-filter": "清除筛选器", - "clear-request-title": "清除所有事件", - "clear-request-text": "你确定要清除所有事件吗?", - "started": "已开始", - "updated": "已更新", - "stopped": "已停止" - }, - "extension": { - "extensions": "扩展", - "selected-extensions": "已选择 { count, plural, =1 {1 个扩展} other {# 个扩展} }", - "type": "类型", - "key": "键名", - "value": "价值", - "id": "ID", - "extension-id": "扩展ID", - "extension-type": "扩展类型", - "transformer-json": "JSON *", - "unique-id-required": "当前扩展ID已经存在。", - "delete": "删除扩展", - "add": "添加扩展", - "edit": "编辑扩展", - "delete-extension-title": "确定要删除扩展 '{{extensionId}}'吗?", - "delete-extension-text": "请注意:确认后,扩展和所有相关数据将不可恢复。", - "delete-extensions-title": "确定要删除 { count, plural, =1 {1 个扩展} other {# 个扩展} }吗?", - "delete-extensions-text": "请注意:确认后,所有选定的扩展将被删除,所有相关数据将不可恢复。", - "converters": "转换器", - "converter-id": "转换器序号", - "configuration": "配置", - "converter-configurations": "转换器的配置", - "token": "安全令牌", - "add-converter": "添加转换器", - "add-config": "添加转换器配置", - "device-name-expression": "设备名称表达式", - "device-type-expression": "设备类型表达式", - "custom": "定制", - "to-double": "加倍", - "transformer": "转换器", - "json-required": "转换器JSON必填。", - "json-parse": "无法解析转换器JSON。", - "attributes": "属性", - "add-attribute": "添加属性", - "add-map": "添加映射元素", - "timeseries": "时间序列", - "add-timeseries": "添加时间序列", - "field-required": "必填字段", - "brokers": "代理服务器组", - "add-broker": "添加代理服务器", - "host": "主机", - "port": "端口", - "port-range": "端口应该在1到65535的范围内。", - "ssl": "SSL", - "credentials": "证书", - "username": "用户名", - "password": "密码", - "retry-interval": "重试间隔(毫秒)", - "anonymous": "匿名", - "basic": "基本", - "pem": "PEM", - "ca-cert": "CA证书文件*", - "private-key": "私钥文件*", - "cert": "证书文件*", - "no-file": "没有选择文件。", - "drop-file": "删除文件或单击以选择要上载的文件。", - "mapping": "映射", - "topic-filter": "主题筛选器", - "converter-type": "转换类型", - "converter-json": "Json", - "json-name-expression": "设备名称JSON表达式", - "topic-name-expression": "设备名称主题表达式", - "json-type-expression": "设备类型JSON表达式", - "topic-type-expression": "设备类型主题表达式", - "attribute-key-expression": "属性键名表达式", - "attr-json-key-expression": "属性键JSON表达式", - "attr-topic-key-expression": "属性键名主题表达式", - "request-id-expression": "请求ID表达式", - "request-id-json-expression": "请求ID JSON表达式", - "request-id-topic-expression": "请求ID主题表达式", - "response-topic-expression": "响应主题表达式", - "value-expression": "值表达式", - "topic": "主题", - "timeout": "超时时间(毫秒)", - "converter-json-required": "转换JSON必填。", - "converter-json-parse": "无法解析转换JSON。", - "filter-expression": "筛选条件表达式", - "connect-requests": "连接请求", - "add-connect-request": "添加连接请求", - "disconnect-requests": "断开请求", - "add-disconnect-request": "添加断开请求", - "attribute-requests": "属性请求", - "add-attribute-request": "添加属性请求", - "attribute-updates": "属性更新", - "add-attribute-update": "添加属性更新", - "server-side-rpc": "服务端RPC", - "add-server-side-rpc-request": "添加服务端RPC请求", - "device-name-filter": "设备名称筛选器", - "attribute-filter": "属性筛选器", - "method-filter": "方法筛选器", - "request-topic-expression": "请求主题表达式", - "response-timeout": "响应超时(毫秒)", - "topic-expression": "主题表达", - "client-scope": "客户范围", - "add-device": "添加服务器", - "opc-server": "服务器组", - "opc-add-server": "添加服务器", - "opc-add-server-prompt": "请添加服务器", - "opc-application-name": "应用名称", - "opc-application-uri": "应用URI", - "opc-scan-period-in-seconds": "秒级扫描周期", - "opc-security": "安全性", - "opc-identity": "身份", - "opc-keystore": "密钥库", - "opc-type": "类型", - "opc-keystore-type": "类型", - "opc-keystore-location": "Location *", - "opc-keystore-password": "密码", - "opc-keystore-alias": "别名", - "opc-keystore-key-password": "密钥密码", - "opc-device-node-pattern": "设备节点模式", - "opc-device-name-pattern": "设备名称模式", - "modbus-server": "Servers/slaves", - "modbus-add-server": "添加 server/slave", - "modbus-add-server-prompt": "请添加 server/slave", - "modbus-transport": "Transport", - "modbus-tcp-reconnect": "自动重新连接", - "modbus-rtu-over-tcp": "RTU over TCP", - "modbus-port-name": "串口名称", - "modbus-encoding": "编码", - "modbus-parity": "奇偶性", - "modbus-baudrate": "波特率", - "modbus-databits": "数据位", - "modbus-stopbits": "停止位", - "modbus-databits-range": "数据位应该在7到8的范围内。", - "modbus-stopbits-range": "停止位应该在1到2的范围内。", - "modbus-unit-id": "单位编号", - "modbus-unit-id-range": "单位ID应该在1到247的范围内", - "modbus-device-name": "设备名称", - "modbus-poll-period": "轮询周期 (毫秒)", - "modbus-attributes-poll-period": "轮询属性周期 (毫秒)", - "modbus-timeseries-poll-period": "时间序列数据轮询周期 (毫秒)", - "modbus-poll-period-range": "轮询周期应为正值。", - "modbus-tag": "标签", - "modbus-function": "函数", - "modbus-register-address": "寄存器地址", - "modbus-register-address-range": "寄存器地址应该在0到65535的范围内。", - "modbus-register-bit-index": "位索引", - "modbus-register-bit-index-range": "位索引应该在0到15的范围内。", - "modbus-register-count": "寄存器计数", - "modbus-register-count-range": "寄存器计数应该是一个正值。", - "modbus-byte-order": "字节顺序", - "sync": { - "status": "状态", - "sync": "同步", - "not-sync": "不同步", - "last-sync-time": "最后同步时间", - "not-available": "无法使用" - }, - "export-extensions-configuration": "导出扩展配置", - "import-extensions-configuration": "导入扩展配置", - "import-extensions": "导入扩展", - "import-extension": "导入扩展", - "export-extension": "导出扩展", - "file": "扩展文件", - "invalid-file-error": "无效的扩展文件" - }, - "feature": { - "advanced-features": "高级功能" - }, - "filter": { - "add": "添加筛选器", - "edit": "编辑筛选器", - "name": "筛选器名称", - "name-required": "筛选器名称必填。", - "duplicate-filter": "同名筛选器已存在。", - "filters": "筛选器", - "unable-delete-filter-title": "无法删除筛选器", - "unable-delete-filter-text": "无法删除筛选器 '{{filter}}' ,因为它由以下部件使用:
{{widgetsList}}", - "duplicate-filter-error": "找到重复的筛选器 '{{filter}}'。
筛选器在仪表板中必须是唯一的。", - "missing-key-filters-error": "筛选器 '{{filter}}' 的键名筛选条件缺失。", - "filter": "筛选器", - "editable": "可编辑", - "no-filters-found": "未找到筛选器。", - "no-filter-text": "未指定筛选器", - "add-filter-prompt": "请添加筛选器", - "no-filter-matching": "未找到 '{{filter}}' 。", - "create-new-filter": "请新增!", - "create-new": "创建", - "filter-required": "筛选器必填。", - "operation": { - "operation": "操作", - "equal": "等于", - "not-equal": "不等于", - "starts-with": "开始于", - "ends-with": "结束于", - "contains": "包含", - "not-contains": "不包含", - "greater": "大于", - "less": "小于", - "greater-or-equal": "大于或等于", - "less-or-equal": "小于或等于", - "and": "和", - "or": "或", - "in": "存在", - "not-in": "不存在" - }, - "ignore-case": "忽略大小写", - "value": "数值", - "remove-filter": "删除筛选器", - "duplicate-filter-action": "重复过滤器", - "preview": "筛选器预览", - "no-filters": "未配置筛选器", - "add-filter": "添加筛选器", - "add-complex-filter": "添加复合筛选器", - "add-complex": "添加复合", - "complex-filter": "复合筛选器", - "edit-complex-filter": "编辑复合筛选器", - "edit-filter-user-params": "编辑筛选器谓词用户参数", - "filter-user-params": "过滤谓词用户参数", - "user-parameters": "用户参数", - "display-label": "要显示的标签", - "order-priority": "字段顺序优先级", - "key-filter": "键名筛选器", - "key-filters": "键名筛选器", - "key-name": "键名", - "key-name-required": "键名必填。", - "key-type": { - "key-type": "键类型", - "attribute": "属性", - "timeseries": "时间序列", - "entity-field": "实体", - "constant": "常量", - "client-attribute": "客户端属性", - "server-attribute": "服务端属性", - "shared-attribute": "共享属性" - }, - "value-type": { - "value-type": "值类型", - "string": "字符串", - "numeric": "数字", - "boolean": "布尔值", - "date-time": "日期时间" - }, - "value-type-required": "键值类型是必需的。", - "key-value-type-change-title": "确定要更改键值类型吗?", - "key-value-type-change-message": "如果您确认新的值类型,所有输入的键过滤器将被删除。", - "no-key-filters": "未配置键名筛选器", - "add-key-filter": "添加键名筛选器", - "remove-key-filter": "删除键名筛选器", - "edit-key-filter": "编辑键名筛选器", - "date": "日期", - "time": "时间", - "current-tenant": "当前租户", - "current-customer": "当前客户", - "current-user": "当前用户", - "current-device": "当前设备", - "default-value": "默认值", - "default-comma-separated-values": "默认用逗号分隔", - "dynamic-source-type": "动态源类型", - "dynamic-value": "动态值", - "no-dynamic-value": "无动态值", - "source-attribute": "源属性", - "switch-to-dynamic-value": "切换到动态值", - "switch-to-default-value": "切换到默认值", - "inherit-owner": "从所有者继承", - "source-attribute-not-set": "如果未设置源属性" - }, - "fullscreen": { - "expand": "展开到全屏", - "exit": "退出全屏", - "toggle": "切换全屏模式", - "fullscreen": "全屏" - }, - "function": { - "function": "函数" + "schedule-days": "天数", + "schedule-time": "时间", + "schedule-time-from": "从", + "schedule-time-to": "到", + "schedule-days-of-week-required": "至少应选择一个星期中的某天。", + "tbel": "TBEL", + "expression-type": { + "simple": "简单", + "script": "脚本" }, - "gateway": { - "gateway-name": "网关名称", - "gateway-name-required": "网关名称必填。", - "gateways": "网关", - "create-new-gateway": "创建网关", - "create-new-gateway-text": "确定要创建名为 '{{gatewayName}}' 的新网关?", - "launch-command": "启动命令", - "no-gateway-found": "未找到网关。", - "no-gateway-matching": "未找到 '{{item}}' 。" - }, - "grid": { - "delete-item-title": "确定要删除此项吗?", - "delete-item-text": "请注意,确认后,项目及其所有相关数据将不可恢复。", - "delete-items-title": "确定删除{ count, plural, =1 {1 项} other {# 项} }吗?", - "delete-items-action-title": "删除{ count, plural, =1 {1 个元素} other {# 个元素} }", - "delete-items-text": "请注意确认后所有选择的项目将被删除,所有相关数据将不可恢复。", - "add-item-text": "添加项目", - "no-items-text": "未找到项目", - "item-details": "项目详细信息", - "delete-item": "删除项目", - "delete-items": "删除项目", - "scroll-to-top": "滚动到顶部" - }, - "help": { - "goto-help-page": "查看帮助", - "show-help": "显示帮助" + "operation-type": { + "and": "与", + "or": "或" }, - "home": { - "home": "首页", - "profile": "属性", - "logout": "注销", - "menu": "菜单", - "avatar": "头像", - "open-user-menu": "打开用户菜单" - }, - "file-input": { - "browse-file": "浏览文件", - "browse-files": "浏览文件" - }, - "image": { - "gallery": "图像库", - "search": "搜索图像", - "selected-images": "已选择{ count, plural, =1 {1 个图像} other {# 个图像} }", - "created-time": "创建时间", - "name": "名称", - "name-required": "名称不能为空。", - "resolution": "分辨率", - "size": "尺寸", - "system": "系统", - "download-image": "下载图像", - "export-image": "导出图像为JSON", - "import-image": "从JSON导入图像", - "upload-image": "上传图像", - "edit-image": "编辑图像", - "image-details": "图像详情", - "no-images": "未找到图像", - "delete-image": "删除图像", - "delete-image-title": "确定要删除图像 '{{imageTitle}}' 吗?", - "delete-image-text": "请注意,确认后图像将无法恢复。", - "delete-images-title": "确定要删除{ count, plural, =1 {1 个图像} other {# 个图像} } 吗?", - "delete-images-text": "请注意,确认后所有选定的图像都将被删除,并且所有相关数据将无法恢复。", - "list-mode": "列表视图", - "grid-mode": "网格视图", - "image-preview": "图像预览", - "update-image": "更新图像", - "export-failed-error": "无法导出图像:{{error}}", - "image-json-file": "图像JSON文件", - "invalid-image-json-file-error": "无法从JSON导入图像:无效的图像JSON数据结构。", - "image-is-in-use": "图像被其他实体使用", - "images-are-in-use": "图像被其他实体使用", - "image-is-in-use-text": "无法删除图像'{{title}}',因为它被以下实体使用:", - "images-are-in-use-text": "由于图像被其他实体使用,无法删除所有图像。
您可以通过单击相应图像行上的引用按钮查看引用的实体。
如果仍然要删除这些图像,请在下方的表格中选择它们,然后点击删除所选按钮。", - "delete-image-in-use-text": "如果仍然要删除该图像,请点击无论如何删除按钮。", - "system-entities": "系统实体:", - "entities": "实体:", - "references": "引用", - "include-system-images": "包含系统图像", - "clear-image": "清除图像", - "no-image": "无图像", - "no-image-selected": "未选择图像", - "browse-from-gallery": "从图像库浏览", - "set-link": "设置链接", - "image-link": "图像链接", - "link": "链接", - "copy-image-link": "复制图像链接", - "embed-image": "嵌入图像", - "embed-to-html": "嵌入到HTML", - "embed-to-html-hint": "此功能将使链接对任何未经授权的用户可用。", - "embed-to-html-text": "使用以下代码片段,您可以将图像嵌入到基于纯HTML的组件中。
此类组件包括HTML卡片小部件、单元格内容函数等。", - "embed-to-angular-template": "嵌入到Angular HTML模板", - "embed-to-angular-template-text": "使用以下代码片段,您可以将图像嵌入到Angular HTML模板中。
此类组件包括Markdown小部件、小部件编辑器中的HTML部分、自定义操作等。" - }, - "image-input": { - "drop-images-or": "拖放一张或多张图片", - "drag-and-drop": "拖放", - "or": "或", - "browse": "浏览", - "no-images": "未选择任何图片", - "images": "图片" - }, - "import": { - "no-file": "没有选择文件", - "drop-file": "拖放一个JSON文件或者单击以选择要上传的文件。", - "drop-json-file-or": "拖放一个 JSON 文件或者", - "drop-file-csv": "拖放一个CSV文件或单击以选择要上载的文件。", - "drop-file-csv-or": "拖放一个 CSV 文件或者", - "column-value": "数值", - "column-title": "标题", - "column-example": "示例值数据", - "column-key": "属性/遥测键", - "credentials": "凭据", - "csv-delimiter": "CSV分隔符", - "csv-first-line-header": "第一行包含列名", - "csv-update-data": "更新属性/遥测", - "details": "详情", - "import-csv-number-columns-error": "一个文件至少应该包含两列", - "import-csv-invalid-format-error": "文件格式无效。行: '{{line}}'", - "column-type": { - "name": "名称", - "type": "类型", - "label": "标签", - "column-type": "列类型", - "client-attribute": "客户端属性", - "shared-attribute": "共享属性", - "server-attribute": "服务器属性", - "timeseries": "时间序列", - "entity-field": "实体字段", - "access-token": "访问令牌", - "x509": "X.509", - "mqtt": { - "client-id": "MQTT客户端ID", - "user-name": "MQTT用户名", - "password": "MQTT密码" - }, - "lwm2m": { - "client-endpoint": "客户端终节点名称", - "security-config-mode": "安全配置模式", - "client-identity": "客户标识", - "client-key": "客户端公钥", - "client-cert": "客户端证书", - "bootstrap-server-security-mode": "LwM2M bootstrap server安全模式", - "bootstrap-server-secret-key": "LwM2M bootstrap server密钥", - "bootstrap-server-public-key-id": "LwM2M bootstrap server公钥", - "lwm2m-server-security-mode": "LwM2M server安全模式", - "lwm2m-server-secret-key": "LwM2M server证书密钥", - "lwm2m-server-public-key-id": "LwM2M server公钥" - }, - "snmp": { - "host": "SNMP 主机地址", - "port": "SNMP 端口", - "version": "SNMP 版本 (例如:v1, v2c, 或 v3)", - "community-string": "SNMP 团体字符串" - }, - "isgateway": "是否网关", - "activity-time-from-gateway-device": "来自网关设备的活动时间", - "description": "说明", - "routing-key": "边缘键", - "secret": "边缘密钥" - }, - "stepper-text": { - "select-file": "选择一个文件", - "configuration": "导入配置", - "column-type": "选择列类型", - "creat-entities": "创建实体" - }, - "message": { - "create-entities": "{{count}} 个新实体已成功创建。", - "update-entities": "{{count}} 个实体已成功更新。", - "error-entities": "创建 {{count}} 个实体时出错。" - } + "filter-predicate-type": { + "string": "字符串", + "numeric": "数值", + "boolean": "布尔", + "complex": "复合" }, - "scada": { - "symbols": "组态库", - "search": "搜索组态图形", - "selected-symbols": "选中{ count, plural, =1 {1个图形} other {#个图形} }", - "download-symbol": "下载图形", - "export-symbol": "导出图形", - "import-symbol": "导入图形", - "upload-symbol": "上传图形", - "update-symbol": "更新图形", - "edit-symbol": "编辑图形", - "symbol-details": "图形详情", - "mode-svg": "SVG", - "mode-xml": "XML", - "no-symbols": "未找到图形", - "show-hidden-elements": "显示元素", - "hide-hidden-elements": "隐藏元素", - "delete-symbol": "删除图形", - "delete-symbol-title": "确定要删除'{{imageTitle}}'组态图形吗?", - "delete-symbol-text": "请注意在确认后组态图形将无法恢复。", - "delete-symbols-title": "确定要删除{ count, plural, =1 {1个图形} other {#个图形} }吗?", - "delete-symbols-text": "请注意在确认后所有选择的组态图形将被删除且相关数据将无法恢复。", - "include-system-symbols": "包含系统组态图形", - "symbol-preview": "图形预览", - "general": "常规", - "tags": "标签", - "properties": "属性", - "title": "标题", - "description": "描述", - "search-tags": "标签", - "widget-size": "尺寸", - "cols": "列", - "rows": "行", - "state-render-function": "函数", - "preview": "预览", - "preview-widget-action-text": "部件动作'{{type}}'调用成功!", - "no-symbol": "没有图形", - "no-symbol-selected": "没有选中图形。", - "clear-symbol": "清除图形缓存", - "browse-symbol-from-gallery": "查看图形库中的图形", - "zoom-in": "放大", - "zoom-out": "缩小", - "create-widget": "创建部件", - "create-widget-from-symbol": "根据图形创建部件", - "hidden": "隐藏", - "tag": { - "tag": "标签", - "on-click-action": "事件", - "no-tags": "没有配置标签", - "delete-tag-text": "您确定要从{{elementType}}元素删除{{tag}}标签吗?", - "update-tag": "更新标签", - "enter-tag": "确定标签", - "tag-settings": "标签设置", - "remove-tag": "移除标签", - "add-tag": "添加标签" - }, - "behavior": { - "behavior": "行为", - "id": "序号", - "name": "名称", - "type": "类型", - "no-behaviors": "无任何行为配置", - "add-behavior": "添加行为", - "type-action": "动作", - "type-value": "数值", - "type-widget-action": "部件动作", - "behavior-settings": "行为设置", - "remove-behavior": "移除行为", - "hint": "提示", - "group-title": "分组标题", - "value-type": "数据类型", - "default-value": "默认数据", - "true-label": "True标签", - "false-label": "False标签", - "state-label": "State标签", - "default-payload": "默认payload", - "not-unique-behavior-ids-error": "行为ID必须是唯一的!", - "default-settings": "默认设置" - }, - "symbol": { - "symbol": "图形", - "fluid-presence": "液体", - "fluid-presence-hint": "指示管道中是否显示液体。", - "fluid-present": "液体显示", - "present": "显示", - "absent": "隐藏", - "flow-presence": "流动", - "flow-presence-hint": "指示液体是否在管道中流动。", - "flow-present": "存在流动", - "flow-direction": "方向", - "flow-direction-hint": "指示液体流动方向。", - "forward": "向前", - "reverse": "向后", - "flow-animation-speed": "速度", - "flow-animation-speed-hint": "数值表示流动的动画速度:1正常速度 0无动画 <1动画较慢 >1动画较快。", - "leak": "裂纹", - "leak-hint": "指示管道是否存在裂纹。", - "leak-present": "裂纹存在", - "fluid-color": "液体颜色", - "pipe-color": "管道颜色", - "horizontal-pipe": "水平管", - "vertical-pipe": "垂直管", - "horizontal-fluid-color": "水平液体颜色", - "vertical-fluid-color": "垂直液体颜色", - "left-pipe": "左管", - "right-pipe": "右管", - "top-pipe": "顶管", - "bottom-pipe": "底管", - "left-fluid-color": "左液体颜色", - "right-fluid-color": "正常液体颜色", - "top-fluid-color": "顶液体颜色", - "bottom-fluid-color": "底液体颜色", - "display": "显示", - "value": "数值", - "units": "单位", - "flow-meter-value-hint": "在流量计上显示数值", - "value-hint": "浮点数表示当前值", - "running": "运行", - "running-hint": "指示组件是否处于运行状态。", - "warning-state": "警告状态", - "warning": "警告", - "warning-click": "警告点击", - "warning-state-hint": "指示组件是否处于警告状态。", - "critical-state": "严重状态", - "critical": "严重", - "critical-click": "严重状态点击", - "critical-state-hint": "指示组件是否处于严重状态。", - "critical-state-animation": "状态动画", - "critical-state-animation-hint": "当组件处于严重状态时显示闪烁动画。", - "warning-critical-state-animation": "警告/严重状态动画", - "warning-critical-state-animation-hint": "当组件处于警告或严重状态时,是否启用闪烁动画。", - "animation": "动画", - "broken": "裂纹", - "broken-hint": "指示组件是否存在裂纹。", - "on-display-click": "显示单击", - "on-display-click-hint": "当用户单击时请调用显示操作。", - "pipe": "管道", - "default-border-color": "默认边框颜色", - "active-border-color": "主动边框颜色", - "warning-border-color": "警告边框颜色", - "critical-border-color": "严重边框颜色", - "background-color": "背景颜色", - "rotation-animation-speed": "动画速度", - "rotation-animation-speed-hint": "数值表示旋转动画速度:1正常速度 0无动画 <1动画较慢 >1动画较快。", - "on-click": "单击", - "on-click-hint": "当用户单击组件时调用操作。", - "right-top-connector": "右上连接", - "right-bottom-connector": "右下连接", - "left-top-connector": "左上连接", - "left-bottom-connector": "左下连接", - "top-left-connector": "上左连接", - "top-right-connector": "右左连接", - "running-color": "运行颜色", - "stopped-color": "停止颜色", - "stopped": "已停止", - "warning-color": "警告颜色", - "critical-color": "严重颜色", - "opened": "已经打开", - "opened-hint": "指示组件是否处于打开状态。", - "open": "打开", - "open-hint": "当用户单击打开组件时请调用操作。", - "close": "关闭", - "close-hint": "当用户单击关闭组件时请调用操作。", - "close-state-animation": "关闭状态动画", - "close-state-animation-hint": "当组件处于关闭状态时启用闪烁动画。", - "opened-color": "打开时颜色", - "closed-color": "关闭时颜色", - "opened-rotation-angle": "打开角度", - "closed-rotation-angle": "关闭角度", - "tank-capacity": "容器容量", - "tank-capacity-hint": "双值表示总储罐容量。", - "current-volume": "当前容量", - "current-volume-hint": "双重值表示当前占用的体积。", - "tank-color": "容器颜色", - "value-box": "显示文本框", - "value-text": "单位", - "scale": "显示刻度", - "transparent-mode": "透明模式", - "major-ticks": "数值刻度", - "intervals": "间隔", - "major-ticks-color": "数值颜色", - "normal": "正常", - "minor-ticks": "线条刻度", - "minor-ticks-color": "线条颜色", - "temperature": "温度", - "temperature-hint": "表示当前温度。", - "update-temperature": "温度更新", - "update-temperature-hint": "当用户单击更改当前温度时调用动作。", - "run": "运行", - "run-hint": "当用户单击组件时调用运行操作。", - "stop": "停止", - "stop-hint": "当用户单击组件时调用停止操作。", - "temperature-step": "温度步长", - "heat-pump-color": "背景颜色", - "power-button-background": "按钮颜色", - "value-box-background": "文本框颜色", - "value-units": "显示单位", - "filtration-mode": "过滤模式", - "filtration-mode-hint": "指示当前的过滤模式。", - "filtration-mode-update": "状态更新", - "filtration-mode-update-hint": "当用户单击更改当前过滤模式时调用操作。", - "filter-mode": "过滤", - "waste-mode": "废弃", - "backwash-mode": "清洗", - "recirculate-mode": "循环", - "rinse-mode": "冲洗", - "closed-mode": "关闭", - "sand-filter-color": "沙滤器颜色", - "mode-box-background": "按钮颜色", - "border-color": "状态颜色", - "label-color": "文本颜色", - "water-leak-hint": "指示是否泄漏。", - "default-color": "默认颜色", - "leak-color": "泄漏颜色", - "full-value": "全部数值", - "full-value-hint": "表示全部数值。", - "label": "标签", - "icon": "图标", - "button-color": "按钮颜色", - "on-label": "'开启' 标签文本", - "off-label": "'关闭' 标签文本", - "arrow-presence": "箭头存在", - "arrow-presence-hint": "指示连接器中是否存在箭头。", - "arrow-present": "箭头存在", - "arrow-direction": "箭头方向", - "arrow-direction-hint": "指示流动方向。", - "main-line": "主线", - "line": "线", - "line-color": "线颜色", - "arrow-color": "箭头颜色", - "target-value": "目标值", - "target-value-hint": "指示刻度上的目标点。", - "min-max-value": "最小值和最大值", - "min-value": "最小值", - "max-value": "最大值", - "warning-scale-color": "警告刻度颜色", - "critical-scale-color": "关键刻度颜色", - "scale-color": "刻度颜色", - "target": "目标", - "high-warning-state": "高警告状态", - "show-high-warning-scale": "显示高警告刻度", - "high-warning-scale": "高警告刻度", - "high-warning-state-hint": "双重值表示高警告范围,直到高关键或最大值。", - "low-warning-state": "低警告状态", - "show-low-warning-scale": "显示低警告刻度", - "low-warning-scale": "低警告刻度", - "low-warning-state-hint": "双重值表示低警告范围,直到低关键或最小值。", - "high-critical-state": "高关键状态", - "show-high-critical-scale": "显示高关键刻度", - "high-critical-scale": "高关键刻度", - "high-critical-state-hint": "双重值表示高关键范围,直到最大值刻度。", - "low-critical-state": "低关键状态", - "show-low-critical-scale": "显示低关键刻度", - "low-critical-scale": "低关键刻度", - "low-critical-state-hint": "双重值表示低关键范围,直到最小值刻度。", - "filter-color": "滤色器颜色", - "colors": "颜色", - "alarm-colors": "警报颜色" - } + "alarm-rule-additional-info": "附加信息", + "edit-alarm-rule-additional-info": "编辑附加信息", + "alarm-rule-additional-info-placeholder": "请在此处提供您的备注和调整内容,以便在告警详情的附加信息中显示", + "alarm-rule-additional-info-hint": "提示:使用 ${参数名称} 替换告警规则条件中使用的参数值。", + "alarm-rule-additional-info-icon-hint": "使用参数名称替换告警规则条件中使用的参数值。", + "alarm-rule-mobile-dashboard": "移动端仪表板", + "alarm-rule-mobile-dashboard-hint": "移动应用程序用作告警详情仪表板。", + "alarm-rule-no-mobile-dashboard": "未选择仪表板", + "alarm-rule-condition": "告警规则条件", + "enter-alarm-rule-condition-prompt": "添加条件", + "enter-alarm-rule-clear-condition-prompt": "添加清除条件", + "edit-alarm-rule-condition": "告警条件", + "condition-type": "条件类型", + "condition-type-hint": "当过滤器中使用了“缺失超过”操作时,“持续时间”和“重复”选项不可用。", + "select-alarm-severity": "选择告警严重程度", + "add-create-alarm-rule-prompt": "至少需要一个触发条件。", + "add-create-alarm-rule": "添加触发条件", + "add-clear-alarm-rule": "添加清除条件", + "condition-duration": "条件持续时间", + "condition-duration-value": "持续时间值", + "condition-duration-time-unit": "时间单位", + "condition-duration-value-range": "持续时间值应在 1 到 2147483647 的范围内。", + "condition-duration-value-pattern": "持续时间值应为整数。", + "condition-duration-value-required": "持续时间值为必填项。", + "condition-duration-time-unit-required": "时间单位为必填项。", + "condition-repeating-value": "事件次数", + "condition-repeating-value-hint": "告警规则参数的任何更新都将被计为一次事件", + "condition-repeating-value-range": "事件次数应在 1 到 2147483647 的范围内。", + "condition-repeating-value-pattern": "事件次数应为整数。", + "condition-repeating-value-required": "事件次数为必填项。", + "create-conditions": "触发条件", + "clear-condition": "清除条件", + "no-clear-alarm-rule": "未配置清除条件。", + "advanced-settings": "高级设置", + "propagate-alarm": "将告警传播到关联实体", + "alarm-rule-relation-types-list": "关联类型", + "alarm-rule-relation-types-list-hint": "定义关联类型以过滤关联实体。如果未设置,告警将传播到所有关联实体。", + "propagate-alarm-to-owner": "将告警传播到实体所有者(客户或租户)", + "propagate-alarm-to-tenant": "将告警传播到租户", + "alarm-rule-filter-title": "告警规则过滤器", + "filter-title": "过滤器", + "debugging": "告警规则调试", + "any-type": "任意类型", + "enter-alarm-rule-type": "输入告警类型", + "no-alarm-rule-types-matching": "未找到匹配“{{entitySubtype}}”的告警类型。", + "alarm-rule-type-list-empty": "未选择告警类型。", + "alarm-rule-type-list": "告警类型列表", + "alarm-rule-entity-list": "实体列表", + "missing-for": "缺失超过", + "time-unit": "单位", + "mode": "模式", + "type": "类型", + "value-required": "值为必填项。", + "min-value": "值必须大于等于 1。", + "argument-in-use": "参数已被用作通用参数。", + "import-invalid-alarm-rule-type": "无法导入告警规则:告警规则结构无效。", + "no-filter-preview": "未指定过滤器", + "filter-operation": { + "and": "与", + "or": "或" + } + }, + "ai-models": { + "ai-models": "AI 模型", + "ai-model": "AI 模型", + "model": "模型", + "name": "名称", + "ai-provider": "AI 提供商", + "no-found": "未找到 AI 模型", + "list": "{ count, plural, =1 {1 个模型} other {# 个模型列表} }", + "selected-fields": "已选择{ count, plural, =1 {1 个模型} other {# 个模型} }", + "add": "添加模型", + "delete-model-title": "确定要删除模型“{{modelName}}”吗?", + "delete-model-text": "请注意,确认后模型及所有相关数据将无法恢复。", + "delete-models-title": "确定要删除{ count, plural, =1 {1 个模型} other {# 个模型} }吗?", + "delete-models-text": "请注意,确认后所有选中的模型将被移除,所有相关数据将无法恢复。", + "ai-providers": { + "openai": "OpenAI", + "azure-openai": "Azure OpenAI", + "google-ai-gemini": "Google AI Gemini", + "google-vertex-ai-gemini": "Google Vertex AI Gemini", + "mistral-ai": "Mistral AI", + "anthropic": "Anthropic", + "amazon-bedrock": "Amazon Bedrock", + "github-models": "GitHub Models", + "ollama": "Ollama" + }, + "name-required": "名称为必填项。", + "name-max-length": "名称不得超过 255 个字符。", + "provider": "提供商", + "api-key": "API 密钥", + "api-key-required": "API 密钥为必填项。", + "api-key-open-ai-required": "使用官方 OpenAI API 时,API 密钥为必填项。", + "project-id": "Project ID", + "project-id-required": "Project ID 为必填项", + "location": "位置", + "location-required": "位置为必填项。", + "service-account-key-file": "服务账号密钥文件", + "service-account-key-file-required": "服务账号密钥文件为必填项。", + "no-file": "未选择文件。", + "drop-file": "拖放文件或点击选择要上传的文件。", + "personal-access-token": "个人访问 Token", + "personal-access-token-required": "个人访问 Token 为必填项。", + "configuration": "配置", + "model-id": "模型 ID", + "model-id-required": "模型 ID 为必填项。", + "deployment-name": "部署名称", + "deployment-name-required": "部署名称为必填项", + "set": "设置", + "region": "区域", + "region-required": "区域为必填项。", + "access-key-id": "访问密钥 ID", + "access-key-id-required": "访问密钥 ID 为必填项。", + "secret-access-key": "秘密访问密钥", + "secret-access-key-required": "秘密访问密钥为必填项。", + "temperature": "温度", + "temperature-hint": "调整模型输出的随机性级别。值越高随机性越大,值越低随机性越小。", + "temperature-min": "必须大于等于 0。", + "top-p": "Top P", + "top-p-hint": "为模型创建一个最可能 Token 的候选池。值越高候选池越大越多样化,值越低候选池越小。", + "top-p-min-max": "必须大于 0 且不超过 1。", + "top-k": "Top K", + "top-k-hint": "将模型的选择限制为固定的\"K\"个最可能的 Token。", + "top-k-min": "必须大于等于 0。", + "presence-penalty": "存在惩罚", + "presence-penalty-hint": "如果某个 Token 已出现在文本中,则对其可能性施加固定惩罚。", + "frequency-penalty": "频率惩罚", + "frequency-penalty-hint": "根据 Token 在文本中出现的频率对其可能性施加递增惩罚。", + "max-output-tokens": "最大输出 Token 数", + "max-output-tokens-hint": "设置模型在单次响应中可以\n生成的最大 Token 数。", + "context-length": "上下文长度", + "context-length-hint": "定义上下文窗口的大小(以 Token 为单位)。此值设置模型的总内存限制,包括用户输入和生成的响应。", + "endpoint": "Endpoint", + "endpoint-required": "Endpoint 为必填项。", + "baseurl": "Base URL", + "baseurl-required": "Base URL 为必填项。", + "service-version": "服务版本", + "check-connectivity": "检查连接", + "check-connectivity-success": "测试请求成功", + "check-connectivity-failed": "测试请求失败", + "no-model-matching": "未找到匹配“{{entity}}”的模型。", + "model-required": "模型为必填项。", + "no-model-text": "未找到模型。", + "authentication": "认证", + "authentication-basic-hint": "使用标准 HTTP Basic 认证。用户名和密码将被组合、Base64 编码,并在每次请求 Ollama 服务器时通过\"Authorization\"请求头发送。", + "authentication-token-hint": "使用 Bearer Token 认证。提供的 Token 将直接在每次请求 Ollama 服务器时通过\"Authorization\"请求头发送。", + "authentication-type": { + "none": "无", + "basic": "Basic", + "token": "Token" + }, + "username": "用户名", + "username-required": "用户名为必填项。", + "password": "密码", + "password-required": "密码为必填项。", + "token": "Token", + "token-required": "Token 为必填项。" + }, + "confirm-on-exit": { + "message": "您有未保存的更改。确定要离开此页面吗?", + "html-message": "您有未保存的更改。
确定要离开此页面吗?", + "title": "未保存的更改" + }, + "contact": { + "country": "国家", + "country-required": "国家为必填项。", + "country-object-required": "请从列表中选择有效的国家。", + "city": "城市", + "state": "州/省", + "postal-code": "邮政编码", + "postal-code-invalid": "邮政编码格式无效。", + "address": "地址", + "address2": "地址 2", + "phone": "电话", + "email": "Email", + "no-address": "无地址", + "no-country-found": "未找到国家。", + "no-country-matching": "未找到匹配“{{country}}”的国家。", + "state-max-length": "州/省长度应少于 256", + "phone-max-length": "电话号码应少于 256", + "city-max-length": "城市名称应少于 256" + }, + "common": { + "name": "名称", + "type": "类型", + "general": "通用", + "username": "用户名", + "password": "密码", + "data": "数据", + "timestamp": "时间戳", + "enter-username": "输入用户名", + "enter-password": "输入密码", + "enter-search": "输入搜索内容", + "created-time": "创建时间", + "disabled": "已禁用", + "loading": "加载中...", + "proceed": "继续", + "open-details-page": "打开详情页", + "not-found": "未找到", + "value": "值", + "documentation": "文档", + "time-left": "剩余 {{time}}", + "output": "输出", + "sort-asc": "升序", + "sort-desc": "降序", + "suffix": { + "s": "s", + "ms": "ms" + }, + "hint": { + "name-required": "名称为必填项。", + "name-pattern": "名称无效。", + "name-max-length": "名称应少于 256 个字符。", + "title-required": "标题为必填项。", + "title-pattern": "标题无效。", + "title-max-length": "标题应少于 256 个字符。", + "key-required": "键为必填项。", + "key-pattern": "键无效。", + "key-max-length": "键应少于 256 个字符。" + }, + "required-fields": "缺少必填字段" + }, + "content-type": { + "json": "Json", + "text": "文本", + "binary": "二进制 (Base64)" + }, + "color": { + "color": "颜色" + }, + "customer": { + "customer": "客户", + "customers": "客户", + "management": "客户管理", + "dashboard": "客户仪表板", + "dashboards": "客户仪表板", + "devices": "客户设备", + "entity-views": "客户实体视图", + "assets": "客户资产", + "public-dashboards": "公开仪表板", + "public-devices": "公开设备", + "public-assets": "公开资产", + "public-entity-views": "公开实体视图", + "add": "添加客户", + "delete": "删除客户", + "manage-customer-users": "管理客户用户", + "manage-customer-devices": "管理客户设备", + "manage-customer-dashboards": "管理客户仪表板", + "manage-public-devices": "管理公开设备", + "manage-public-dashboards": "管理公开仪表板", + "manage-customer-assets": "管理客户资产", + "manage-customer-edges": "管理客户 Edge", + "manage-public-assets": "管理公开资产", + "add-customer-text": "添加新客户", + "no-customers-text": "未找到客户", + "customer-details": "客户详情", + "delete-customer-title": "确定要删除客户“{{customerTitle}}”吗?", + "delete-customer-text": "请注意,确认后客户及所有相关数据将无法恢复。", + "delete-customers-title": "确定要删除{ count, plural, =1 {1 个客户} other {# 个客户} }吗?", + "delete-customers-action-title": "删除{ count, plural, =1 {1 个客户} other {# 个客户} }", + "delete-customers-text": "请注意,确认后所有选中的客户将被移除,所有相关数据将无法恢复。", + "manage-users": "管理用户", + "manage-assets": "管理资产", + "manage-devices": "管理设备", + "manage-dashboards": "管理仪表板", + "title": "标题", + "title-required": "标题为必填项。", + "title-max-length": "标题应少于 256", + "description": "描述", + "details": "详情", + "events": "事件", + "copyId": "复制客户 Id", + "idCopiedMessage": "客户 Id 已复制到剪贴板", + "select-customer": "选择客户", + "no-customers-matching": "未找到匹配“{{entity}}”的客户。", + "customer-required": "客户为必填项。", + "select-default-customer": "选择默认客户", + "default-customer": "默认客户", + "default-customer-required": "在租户级别调试仪表板时需要默认客户", + "search": "搜索客户", + "selected-customers": "已选择{ count, plural, =1 {1 个客户} other {# 个客户} }", + "edges": "客户 Edge 实例", + "manage-edges": "管理 Edge" + }, + "css-size": { + "size-value-required": "尺寸值为必填项", + "invalid-size-value": "尺寸值无效" + }, + "date": { + "last-update-n-ago": "上次更新于 N 前", + "last-update-n-ago-text": "上次更新于 {{ agoText }}", + "custom-date": "自定义日期", + "format": "格式", + "preview": "预览", + "auto": "自动", + "time-granularity-formats": "时间粒度格式", + "unit-year": "年", + "unit-month": "月", + "unit-day": "日", + "unit-hour": "小时", + "unit-minute": "分钟", + "unit-second": "秒", + "unit-millisecond": "毫秒" + }, + "datetime": { + "date-from": "起始日期", + "time-from": "起始时间", + "date-to": "结束日期", + "time-to": "结束时间", + "from": "从", + "to": "到" + }, + "dashboard": { + "dashboard": "仪表板", + "dashboards": "仪表板", + "management": "仪表板管理", + "view-dashboards": "查看仪表板", + "add": "添加仪表板", + "assign-dashboard-to-customer": "将仪表板分配给客户", + "assign-dashboard-to-customer-text": "请选择要分配给客户的仪表板", + "assign-to-customer-text": "请选择要分配仪表板的客户", + "assign-to-customer": "分配给客户", + "unassign-from-customer": "取消分配客户", + "make-public": "公开仪表板", + "make-private": "将仪表板设为私有", + "manage-assigned-customers": "管理已分配的客户", + "assigned-customers": "已分配的客户", + "assign-to-customers": "将仪表板分配给客户", + "assign-to-customers-text": "请选择要分配仪表板的客户", + "unassign-from-customers": "取消分配仪表板的客户", + "unassign-from-customers-text": "请选择要取消分配仪表板的客户", + "no-dashboards-text": "未找到仪表板", + "no-widgets": "未配置部件", + "add-widget": "添加新部件", + "add-widget-button-text": "添加部件", + "title": "标题", + "image": "仪表板图片", + "mobile-app-settings": "移动应用设置", + "mobile-order": "仪表板在移动应用中的排序", + "mobile-hide": "在移动应用中隐藏仪表板", + "update-image": "更新仪表板图片", + "update-new-version": "上传新版本", + "upload-file-to-update": "上传文件以更新", + "take-screenshot": "截图", + "select-widget-title": "选择部件", + "select-widget-value": "{{title}}:选择部件", + "select-widget-subtitle": "可用部件类型列表", + "delete": "删除仪表板", + "title-required": "标题为必填项。", + "title-max-length": "标题应少于 256", + "description": "描述", + "details": "详情", + "dashboard-details": "仪表板详情", + "add-dashboard-text": "添加新仪表板", + "assign-dashboards": "分配仪表板", + "assign-new-dashboard": "分配新仪表板", + "assign-dashboards-text": "将{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }分配给客户", + "unassign-dashboards-action-text": "取消分配客户的{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }", + "delete-dashboards": "删除仪表板", + "unassign-dashboards": "取消分配仪表板", + "unassign-dashboards-action-title": "取消分配客户的{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }", + "delete-dashboard-title": "确定要删除仪表板“{{dashboardTitle}}”吗?", + "delete-dashboard-text": "请注意,确认后仪表板及所有相关数据将无法恢复。", + "delete-dashboards-title": "确定要删除{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }吗?", + "delete-dashboards-action-title": "删除{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }", + "delete-dashboards-text": "请注意,确认后所有选中的仪表板将被移除,所有相关数据将无法恢复。", + "unassign-dashboard-title": "确定要取消分配仪表板“{{dashboardTitle}}”吗?", + "unassign-dashboard-text": "确认后仪表板将被取消分配,客户将无法访问。", + "unassign-dashboard": "取消分配仪表板", + "unassign-dashboards-title": "确定要取消分配{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }吗?", + "unassign-dashboards-text": "确认后所有选中的仪表板将被取消分配,客户将无法访问。", + "public-dashboard-title": "仪表板现已公开", + "public-dashboard-text": "您的仪表板{{dashboardTitle}}现已公开,可通过以下公开链接访问:", + "public-dashboard-notice": "注意:请勿忘记将相关设备设为公开以访问其数据。", + "make-private-dashboard-title": "确定要将仪表板“{{dashboardTitle}}”设为私有吗?", + "make-private-dashboard-text": "确认后仪表板将设为私有,其他人将无法访问。", + "make-private-dashboard": "将仪表板设为私有", + "socialshare-text": "“{{dashboardTitle}}”由 ThingsBoard 提供支持", + "socialshare-title": "“{{dashboardTitle}}”由 ThingsBoard 提供支持", + "select-dashboard": "选择仪表板", + "no-dashboards-matching": "未找到匹配“{{entity}}”的仪表板。", + "dashboard-required": "仪表板为必填项。", + "select-existing": "选择已有仪表板", + "create-new": "创建新仪表板", + "new-dashboard-title": "新仪表板标题", + "open-dashboard": "打开仪表板", + "set-background": "设置背景", + "background-color": "背景颜色", + "background-image": "背景图片", + "background-size-mode": "背景尺寸模式", + "no-image": "未选择图片", + "empty-image": "无图片", + "drop-image": "拖放图片或点击选择要上传的文件。", + "maximum-upload-file-size": "最大上传文件大小:{{ size }}", + "cannot-upload-file": "无法上传文件", + "settings": "设置", + "move-all-widgets": "移动所有部件", + "move-by": "移动", + "cols": "列", + "rows": "行", + "layout": "布局", + "layout-type-default": "默认", + "layout-type-scada": "SCADA", + "layout-type-divider": "分隔线", + "layout-settings-type": "布局设置:{{ type }} 断点", + "columns-count": "列数", + "columns-count-required": "列数为必填项。", + "min-columns-count-message": "最小列数只允许为 10。", + "max-columns-count-message": "最大列数只允许为 1000。", + "min-layout-width": "最小布局宽度", + "columns-suffix": "列", + "widgets-margins": "部件间距", + "margin-required": "间距值为必填项。", + "min-margin-message": "最小间距值只允许为 0。", + "max-margin-message": "最大间距值只允许为 50。", + "horizontal-margin": "水平间距", + "horizontal-margin-required": "水平间距值为必填项。", + "min-horizontal-margin-message": "最小水平间距值只允许为 0。", + "max-horizontal-margin-message": "最大水平间距值只允许为 50。", + "vertical-margin": "垂直间距", + "vertical-margin-required": "垂直间距值为必填项。", + "min-vertical-margin-message": "最小垂直间距值只允许为 0。", + "max-vertical-margin-message": "最大垂直间距值只允许为 50。", + "apply-outer-margin": "将间距应用到布局两侧", + "autofill-height": "自动填充布局高度", + "mobile-layout": "移动端布局设置", + "mobile-row-height": "移动端行高", + "mobile-row-height-required": "移动端行高值为必填项。", + "min-mobile-row-height-message": "移动端最小行高只允许为 5 像素。", + "max-mobile-row-height-message": "移动端最大行高只允许为 200 像素。", + "row-height": "行高", + "row-height-required": "行高值为必填项。", + "min-row-height-message": "最小行高只允许为 5 像素。", + "max-row-height-message": "最大行高只允许为 200 像素。", + "display-first-in-mobile-view": "在移动端视图中优先显示", + "title-settings": "标题设置", + "display-title": "显示仪表板标题", + "title-color": "标题颜色", + "toolbar-settings": "工具栏设置", + "hide-toolbar": "隐藏工具栏", + "toolbar-always-open": "保持工具栏展开", + "display-dashboards-selection": "显示仪表板选择", + "display-entities-selection": "显示实体选择", + "display-filters": "显示过滤器", + "display-dashboard-timewindow": "显示时间窗口", + "display-dashboard-export": "显示导出", + "display-update-dashboard-image": "显示更新仪表板图片", + "dashboard-logo-settings": "仪表板 Logo 设置", + "display-dashboard-logo": "在仪表板全屏模式下显示 Logo", + "dashboard-logo-image": "仪表板 Logo 图片", + "advanced-settings": "高级设置", + "dashboard-css": "仪表板 CSS", + "import": "导入仪表板", + "export": "导出仪表板", + "export-failed-error": "无法导出仪表板:{{error}}", + "export-prompt": "嵌入仪表板图片和资源", + "create-new-dashboard": "创建新仪表板", + "dashboard-file": "仪表板文件", + "invalid-dashboard-file-error": "无法导入仪表板:仪表板数据结构无效。", + "dashboard-import-missing-aliases-title": "配置导入仪表板使用的别名", + "create-new-widget": "创建新部件", + "import-widget": "导入部件", + "widget-file": "部件文件", + "invalid-widget-file-error": "无法导入部件:部件数据结构无效。", + "widget-import-missing-aliases-title": "配置导入部件使用的别名", + "open-toolbar": "打开仪表板工具栏", + "close-toolbar": "关闭工具栏", + "configuration-error": "配置错误", + "alias-resolution-error-title": "仪表板别名配置错误", + "invalid-aliases-config": "无法找到匹配某些别名过滤器的设备。
请联系管理员以解决此问题。", + "select-devices": "选择设备", + "assignedToCustomer": "已分配给客户", + "assignedToCustomers": "已分配给客户", + "public": "公开", + "copyId": "复制仪表板 Id", + "idCopiedMessage": "仪表板 Id 已复制到剪贴板", + "public-link": "公开链接", + "copy-public-link": "复制公开链接", + "public-link-copied-message": "仪表板公开链接已复制到剪贴板", + "manage-states": "管理仪表板状态", + "states": "仪表板状态", + "states-short": "状态", + "search-states": "搜索仪表板状态", + "selected-states": "已选择{ count, plural, =1 {1 个仪表板状态} other {# 个仪表板状态} }", + "edit-state": "编辑仪表板状态", + "delete-state": "删除仪表板状态", + "add-state": "添加仪表板状态", + "no-states-text": "未找到状态", + "state": "仪表板状态", + "state-name": "名称", + "state-name-required": "仪表板状态名称为必填项。", + "state-id": "状态 Id", + "state-id-required": "仪表板状态 Id 为必填项。", + "state-id-exists": "具有相同 Id 的仪表板状态已存在。", + "is-root-state": "根状态", + "delete-state-title": "删除仪表板状态", + "delete-state-text": "确定要删除名为“{{stateName}}”的仪表板状态吗?", + "show-details": "显示详情", + "hide-details": "隐藏详情", + "select-state": "选择目标状态", + "state-controller": "状态控制器", + "state-controller-default": "静态(已弃用)", + "search": "搜索仪表板", + "selected-dashboards": "已选择{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }", + "home-dashboard": "主页仪表板", + "home-dashboard-hide-toolbar": "隐藏主页仪表板工具栏", + "unassign-dashboard-from-edge-text": "确认后仪表板将被取消分配,Edge 将无法访问。", + "unassign-dashboards-from-edge-title": "确定要取消分配{ count, plural, =1 {1 个仪表板} other {# 个仪表板} }吗?", + "unassign-dashboards-from-edge-text": "确认后所有选中的仪表板将被取消分配,Edge 将无法访问。", + "assign-dashboard-to-edge": "将仪表板分配给 Edge", + "assign-dashboard-to-edge-text": "请选择要分配给 Edge 的仪表板", + "non-existent-dashboard-state-error": "未找到 Id 为\"{{ stateId }}\"的仪表板状态", + "edit-mode": "编辑模式", + "duplicate-state-action": "复制状态", + "breakpoint-value": "断点({{ value }})", + "breakpoints-id": { + "default": "默认", + "xs": "手机 (xs)", + "sm": "平板 (sm)", + "md": "笔记本 (md)", + "lg": "桌面 (lg)", + "xl": "桌面 (xl)" + }, + "view-format-type-grid": "网格", + "view-format-type-list": "列表", + "view-format": "视图格式" + }, + "datakey": { + "settings": "设置", + "general": "通用", + "advanced": "高级", + "key": "键", + "keys": "键", + "label": "标签", + "color": "颜色", + "units": "值旁显示的特殊符号", + "decimals": "小数点后位数", + "data-generation-func": "数据生成函数", + "use-data-post-processing-func": "使用数据后处理函数", + "configuration": "数据键配置", + "timeseries": "时间序列", + "attributes": "属性", + "entity-field": "实体字段", + "alarm": "告警字段", + "timeseries-required": "实体时间序列为必填项。", + "timeseries-or-attributes-required": "实体时间序列/属性为必填项。", + "alarm-fields-timeseries-or-attributes-required": "告警字段或实体时间序列/属性为必填项。", + "maximum-timeseries-or-attributes": "最多允许{ count, plural, =1 {1 个时间序列/属性。} other {# 个时间序列/属性} }", + "alarm-fields-required": "告警字段为必填项。", + "function-types": "函数类型", + "function-type": "函数类型", + "function-types-required": "函数类型为必填项。", + "data-keys": "数据键", + "data-key": "数据键", + "data-keys-required": "数据键为必填项。", + "data-key-required": "数据键为必填项。", + "alarm-keys": "告警数据键", + "alarm-key": "告警数据键", + "alarm-key-functions": "告警键函数", + "alarm-key-function": "告警键函数", + "latest-keys": "最新数据键", + "latest-key": "最新数据键", + "latest-key-functions": "最新键函数", + "latest-key-function": "最新键函数", + "timeseries-keys": "时间序列数据键", + "timeseries-key": "时间序列数据键", + "timeseries-key-functions": "时间序列键函数", + "timeseries-key-function": "时间序列键函数", + "maximum-function-types": "最多允许{ count, plural, =1 {1 个函数类型。} other {# 个函数类型} }", + "time-description": "当前值的时间戳;", + "value-description": "当前值;", + "prev-value-description": "上次函数调用的结果;", + "time-prev-description": "上一个值的时间戳;", + "prev-orig-value-description": "原始上一个值;", + "aggregation": "聚合", + "aggregation-type-hint-common": "出于性能考虑,聚合值计算仅适用于固定时间间隔(如“当天”、“当月”等),不适用于滑动窗口间隔(如“最近 30 分钟”或“最近 24 小时”)。", + "aggregation-type-none-hint": "取最新值。", + "aggregation-type-min-hint": "在选定时间窗口内的数据点中找到最小值。", + "aggregation-type-max-hint": "在选定时间窗口内的数据点中找到最大值。", + "aggregation-type-avg-hint": "计算选定时间窗口内数据点的平均值。", + "aggregation-type-sum-hint": "对选定时间窗口内所有数据点的值求和。", + "aggregation-type-count-hint": "选定时间窗口内数据点的总数。", + "delta-calculation": "增量计算", + "enable-delta-calculation": "启用增量计算", + "enable-delta-calculation-hint": "启用后,数据键的值将根据选定时间窗口和指定比较周期的聚合值进行计算。出于性能考虑,增量计算仅适用于历史时间窗口,不适用于实时值。例如,您可以计算昨天的能耗与前天的能耗之间的增量。", + "delta-calculation-result": "增量计算结果", + "delta-calculation-result-previous-value": "前一个值", + "delta-calculation-result-delta-absolute": "增量(绝对值)", + "delta-calculation-result-delta-percent": "增量(百分比)", + "source": "来源", + "latest": "最新", + "latest-value": "最新值", + "delta": "增量", + "percent": "百分比", + "absolute": "绝对值" + }, + "datasource": { + "type": "数据源类型", + "name": "名称", + "label": "标签", + "add-datasource-prompt": "请添加数据源" + }, + "details": { + "details": "详情", + "edit-mode": "编辑模式", + "edit-json": "编辑 JSON", + "toggle-edit-mode": "切换编辑模式" + }, + "device": { + "device": "设备", + "device-required": "设备为必填项。", + "devices": "设备", + "management": "设备管理", + "view-devices": "查看设备", + "device-alias": "设备别名", + "device-type-max-length": "设备类型应少于 256", + "aliases": "设备别名", + "no-alias-matching": "未找到“{{alias}}”。", + "no-aliases-found": "未找到别名。", + "no-key-matching": "未找到“{{key}}”。", + "no-keys-found": "未找到键。", + "create-new-alias": "创建新别名!", + "create-new-key": "创建新键!", + "duplicate-alias-error": "发现重复别名“{{alias}}”。
设备别名在仪表板内必须唯一。", + "configure-alias": "配置“{{alias}}”别名", + "no-devices-matching": "未找到匹配“{{entity}}”的设备。", + "alias": "别名", + "alias-required": "设备别名为必填项。", + "remove-alias": "移除设备别名", + "add-alias": "添加设备别名", + "name-starts-with": "设备名称表达式", + "help-text": "根据需要使用“%”:“%device_name_contains%”、“%device_name_ends”、“device_starts_with%”。", + "device-list": "设备列表", + "use-device-name-filter": "使用过滤器", + "device-list-empty": "未选择设备。", + "device-name-filter-required": "设备名称过滤器为必填项。", + "device-name-filter-no-device-matched": "未找到以“{{device}}”开头的设备。", + "add": "添加设备", + "assign-to-customer": "分配给客户", + "assign-device-to-customer": "将设备分配给客户", + "assign-device-to-customer-text": "请选择要分配给客户的设备", + "make-public": "公开设备", + "make-private": "将设备设为私有", + "no-devices-text": "未找到设备", + "assign-to-customer-text": "请选择要分配设备的客户", + "device-details": "设备详情", + "add-device-text": "添加新设备", + "credentials": "凭证", + "manage-credentials": "管理凭证", + "delete": "删除设备", + "assign-devices": "分配设备", + "assign-devices-text": "将{ count, plural, =1 {1 个设备} other {# 个设备} }分配给客户", + "delete-devices": "删除设备", + "unassign-from-customer": "取消分配客户", + "unassign-devices": "取消分配设备", + "unassign-devices-action-title": "取消分配客户的{ count, plural, =1 {1 个设备} other {# 个设备} }", + "unassign-device-from-edge-title": "确定要取消分配设备“{{deviceName}}”吗?", + "unassign-device-from-edge-text": "确认后设备将被取消分配,Edge 将无法访问。", + "unassign-devices-from-edge": "从 Edge 取消分配设备", + "assign-new-device": "分配新设备", + "make-public-device-title": "确定要将设备“{{deviceName}}”设为公开吗?", + "make-public-device-text": "确认后设备及其所有数据将设为公开,其他人可访问。", + "make-private-device-title": "确定要将设备“{{deviceName}}”设为私有吗?", + "make-private-device-text": "确认后设备及其所有数据将设为私有,其他人将无法访问。", + "view-credentials": "查看凭证", + "delete-device-title": "确定要删除设备“{{deviceName}}”吗?", + "delete-device-text": "请注意,确认后设备及所有相关数据将无法恢复。", + "delete-devices-title": "确定要删除{ count, plural, =1 {1 个设备} other {# 个设备} }吗?", + "delete-devices-action-title": "删除{ count, plural, =1 {1 个设备} other {# 个设备} }", + "delete-devices-text": "请注意,确认后所有选中的设备将被移除,所有相关数据将无法恢复。", + "unassign-device-title": "确定要取消分配设备“{{deviceName}}”吗?", + "unassign-device-text": "确认后设备将被取消分配,客户将无法访问。", + "unassign-device": "取消分配设备", + "unassign-devices-title": "确定要取消分配{ count, plural, =1 {1 个设备} other {# 个设备} }吗?", + "unassign-devices-text": "确认后所有选中的设备将被取消分配,客户将无法访问。", + "device-credentials": "设备凭证", + "loading-device-credentials": "正在加载设备凭证...", + "credentials-type": "凭证类型", + "access-token": "访问 Token", + "access-token-required": "访问 Token 为必填项。", + "access-token-invalid": "访问 Token 长度必须在 1 到 32 个字符之间。", + "certificate-pem-format": "PEM 格式证书", + "certificate-pem-format-required": "证书为必填项。", + "copy-access-token": "复制访问 Token", + "copy-certificate": "复制证书", + "copy-client-id": "复制 Client ID", + "copy-user-name": "复制用户名", + "copy-password": "复制密码", + "generate-client-id": "生成 Client ID", + "generate-user-name": "生成用户名", + "generate-password": "生成密码", + "generate-access-token": "生成访问 Token", + "lwm2m-security-config": { + "identity": "Client Identity", + "identity-required": "Client Identity 为必填项。", + "identity-tooltip": "PSK 标识符是最多 128 字节的任意 PSK 标识符,如标准 [RFC7925] 所述。\nPSK 标识符必须先转换为字符串,然后使用 UTF-8 编码为八位字节。", + "client-key": "Client Key", + "client-key-required": "Client Key 为必填项。", + "client-key-tooltip-prk": "RPK 公钥或 id 必须符合标准 [RFC7250] 并编码为 Base64 格式!", + "client-key-tooltip-psk": "PSK 密钥必须符合标准 [RFC4279] 且为 HexDec 格式:32、64、128 字符!", + "endpoint": "Endpoint Client Name", + "endpoint-required": "Endpoint Client Name 为必填项。", + "client-public-key": "客户端公钥", + "client-public-key-hint": "如果客户端公钥为空,将使用受信任的证书", + "client-public-key-tooltip": "X509 公钥必须为 DER 编码的 X509v3 格式,仅支持 EC 算法,然后编码为 Base64 格式!", + "mode": "安全配置模式", + "client-tab": "客户端安全配置", + "client-certificate": "客户端证书", + "bootstrap-tab": "Bootstrap 客户端", + "bootstrap-server": "Bootstrap 服务器", + "lwm2m-server": "LwM2M 服务器", + "client-reboot": "注册更新触发器", + "bootstrap-reboot": "Bootstrap 请求触发器", + "client-publicKey-or-id": "客户端公钥或 Id", + "client-publicKey-or-id-required": "客户端公钥或 Id 为必填项。", + "client-publicKey-or-id-tooltip-psk": "PSK 标识符是最多 128 字节的任意 PSK 标识符,如标准 [RFC7925] 所述。\nPSK 标识符必须先转换为字符串,然后使用 UTF-8 编码为八位字节。", + "client-publicKey-or-id-tooltip-rpk": "RPK 公钥或 id 必须符合标准 [RFC7250] 并编码为 Base64 格式!", + "client-publicKey-or-id-tooltip-x509": "X509 公钥必须为 DER 编码的 X509v3 格式,仅支持 EC 算法,然后编码为 Base64 格式", + "client-secret-key": "客户端密钥", + "client-secret-key-required": "客户端密钥为必填项。", + "client-secret-key-tooltip-psk": "PSK 密钥必须符合标准 [RFC4279] 且为 HexDec 格式:32、64、128 字符!", + "client-secret-key-tooltip-prk": "RPK 密钥必须为 PKCS_8 格式(DER 编码,标准 [RFC5958]),然后编码为 Base64 格式!", + "client-secret-key-tooltip-x509": "X509 密钥必须为 PKCS_8 格式(DER 编码,标准 [RFC5958]),然后编码为 Base64 格式!" + }, + "client-id": "Client ID", + "client-id-pattern": "包含无效字符。", + "user-name": "用户名", + "user-name-required": "用户名为必填项。", + "client-id-or-user-name-necessary": "Client ID 和/或用户名为必填项", + "password": "密码", + "secret": "密钥", + "secret-required": "密钥为必填项。", + "device-type": "设备配置", + "device-type-required": "设备类型为必填项。", + "select-device-type": "选择设备类型", + "enter-device-type": "输入设备配置", + "any-device": "任意设备", + "no-device-types-matching": "未找到匹配“{{entitySubtype}}”的设备配置。", + "device-type-list-empty": "未选择设备配置!", + "device-profile-type-list-empty": "至少应选择一个设备配置。", + "device-types": "设备类型", + "name": "名称", + "name-required": "名称为必填项。", + "name-max-length": "名称应少于 256", + "label-max-length": "标签应少于 256", + "description": "描述", + "label": "标签", + "events": "事件", + "details": "详情", + "copyId": "复制设备 Id", + "copyAccessToken": "复制访问 Token", + "copy-mqtt-authentication": "复制 MQTT 凭证", + "idCopiedMessage": "设备 Id 已复制到剪贴板", + "accessTokenCopiedMessage": "设备访问 Token 已复制到剪贴板", + "mqtt-authentication-copied-message": "设备 MQTT 认证信息已复制到剪贴板", + "assignedToCustomer": "已分配给客户", + "unable-delete-device-alias-title": "无法删除设备别名", + "unable-delete-device-alias-text": "设备别名“{{deviceAlias}}”无法删除,因为以下部件正在使用它:
{{widgetsList}}", + "is-gateway": "是否为 Gateway", + "overwrite-activity-time": "覆盖已连接设备的活动时间", + "device-filter-title": "设备过滤器", + "filter-title": "过滤器", + "device-state": "设备状态", + "state": "状态", + "any": "任意", + "active": "活跃", + "inactive": "不活跃", + "public": "公开", + "device-public": "设备已公开", + "select-device": "选择设备", + "import": "导入设备", + "device-file": "设备文件", + "search": "搜索设备", + "selected-devices": "已选择{ count, plural, =1 {1 个设备} other {# 个设备} }", + "device-configuration": "设备配置", + "transport-configuration": "传输配置", + "wizard": { + "device-details": "设备详情" + }, + "unassign-devices-from-edge-title": "确定要取消分配{ count, plural, =1 {1 个设备} other {# 个设备} }吗?", + "unassign-devices-from-edge-text": "确认后所有选中的设备将被取消分配,Edge 将无法访问。", + "time": "时间", + "connectivity": { + "check-connectivity": "检查连接", + "device-created-check-connectivity": "设备已创建。让我们检查连接!", + "loading-check-connectivity-command": "正在加载检查连接命令...", + "use-following-instructions": "使用以下说明通过 shell 代表设备发送遥测数据", + "execute-following-command": "执行以下命令", + "install-curl-windows": "从 Windows 10 b17063 开始,cURL 默认可用", + "install-curl-macos": "从 Mac OS X 10.2 6C115 (Jaguar) 开始,cURL 默认可用", + "install-mqtt-windows": "使用说明下载、安装、设置和运行 mosquitto_pub", + "install-coap-client": "使用说明下载、安装、设置和运行 coap-client", + "install-necessary-client-tools": "安装必要的客户端工具", + "mqtts-x509-command": "使用以下文档通过 MQTT 及 X509 授权连接设备", + "coaps-x509-command": "使用以下文档通过 CoAP over DTLS 及 X509 授权连接设备", + "snmp-command": "使用以下文档通过 SNMP 连接设备。", + "sparkplug-command": "使用以下文档通过 MQTT Sparkplug 连接设备。", + "lwm2m-command": "使用以下文档通过 LWM2M 连接设备。" + } + }, + "dynamic-form": { + "property": { + "properties": "属性", + "property": "属性", + "id": "Id", + "name": "名称", + "type": "类型", + "type-text": "文本", + "type-password": "密码", + "type-textarea": "文本区域", + "type-number": "数字", + "type-switch": "开关", + "type-select": "下拉选择", + "type-radios": "单选按钮", + "type-datetime": "日期/时间", + "type-image": "图片", + "type-javascript": "JavaScript", + "type-json": "JSON", + "type-html": "HTML", + "type-css": "CSS", + "type-markdown": "Markdown", + "type-color": "颜色", + "type-color-settings": "颜色设置", + "type-font": "字体", + "type-units": "单位", + "type-icon": "图标", + "type-fieldset": "字段集", + "type-array": "数组", + "type-html-section": "HTML 区段", + "group-title": "分组标题", + "no-properties": "未配置属性", + "add-property": "添加属性", + "property-settings": "属性设置", + "remove-property": "移除属性", + "default-value": "默认值", + "value-required": "值为必填项", + "number-settings": "数字设置", + "min": "最小值", + "max": "最大值", + "step": "步长", + "selected-options-limit": "已选选项限制", + "advanced-ui-settings": "高级 UI 设置", + "disable-on-property": "根据属性禁用", + "disable-on-property-none": "无(字段始终启用)", + "display-condition-function": "显示条件函数", + "sub-label": "子标签", + "vertical-divider-after": "之后的垂直分隔线", + "input-field-suffix": "输入字段后缀", + "property-row-classes": "属性行样式类", + "property-field-classes": "属性字段样式类", + "not-unique-property-ids-error": "属性 Id 必须唯一!", + "enable-multiple-select": "启用多选", + "allow-empty-select-option": "允许空选项", + "select-options": "选择选项", + "not-unique-select-option-value-error": "选择选项值必须唯一!", + "value": "值", + "label": "标签", + "add-option": "添加选项", + "no-options": "未配置选项", + "remove-option": "移除选项", + "textarea-rows": "文本区域行数", + "help-id": "帮助 Id", + "buttons-direction": "按钮方向", + "direction-row": "行", + "direction-column": "列", + "radio-button-options": "单选按钮选项", + "datetime-type": "日期/时间字段类型", + "datetime-type-date": "日期", + "datetime-type-time": "时间", + "datetime-type-datetime": "日期/时间", + "enable-clear-button": "启用清除按钮", + "html-section-settings": "HTML 区段设置", + "html-section-classes": "HTML 区段样式类", + "html-section-content": "HTML 区段内容", + "array-item": "数组项", + "item-type": "项类型", + "item-name": "项名称", + "no-items": "无项目", + "support-unit-conversion": "支持单位转换" }, - "item": { - "selected": "选择" - }, - "js-func": { - "no-return-error": "函数必须返回值!", - "return-type-mismatch": "函数必须返回'{{type}}'类型的值!", - "tidy": "整理", - "mini": "迷你", - "modules": "模块", - "remove-module": "移除模块", - "no-modules": "未配置模块", - "add-module": "添加模块", - "module-alias": "别名", - "module-resource": "JS模块资源", - "not-unique-module-aliases-error": "模块别名必须是唯一的!", - "show-module-info": "显示模块信息", - "show-module-source-code": "显示模块源代码", - "module-members": "模块成员", - "module-no-members": "模块没有导出的成员", - "module-load-error": "模块加载错误", - "source-code": "源代码", - "source-code-load-error": "源代码加载错误", - "no-js-module-text": "未找到JS模块", - "no-js-module-matching": "未找到与 '{{module}}' 匹配的JS模块" + "clear-form": "清除表单", + "clear-form-prompt": "确定要移除所有表单属性吗?", + "import-form": "从 JSON 导入表单", + "export-form": "将表单导出为 JSON", + "json-file": "JSON 文件", + "json-content": "JSON 内容", + "invalid-form-json-file-error": "无法从 JSON 导入表单:表单 JSON 数据结构无效。" + }, + "asset-profile": { + "asset-profile": "资产配置", + "asset-profiles": "资产配置", + "all-asset-profiles": "全部", + "add": "添加资产配置", + "edit": "编辑资产配置", + "asset-profile-details": "资产配置详情", + "no-asset-profiles-text": "未找到资产配置", + "search": "搜索资产配置", + "selected-asset-profiles": "已选择{ count, plural, =1 {1 个资产配置} other {# 个资产配置} }", + "no-asset-profiles-matching": "未找到匹配“{{entity}}”的资产配置。", + "asset-profile-required": "资产配置为必填项", + "idCopiedMessage": "资产配置 Id 已复制到剪贴板", + "set-default": "设为默认资产配置", + "delete": "删除资产配置", + "copyId": "复制资产配置 Id", + "name-max-length": "名称应少于 256", + "new-device-profile-name": "资产配置名称", + "new-device-profile-name-required": "资产配置名称为必填项。", + "name": "名称", + "name-required": "名称为必填项。", + "image": "资产配置图片", + "description": "描述", + "default": "默认", + "default-rule-chain": "默认规则链", + "default-edge-rule-chain": "默认 Edge 规则链", + "default-edge-rule-chain-hint": "在 Edge 上用作处理此资产配置资产传入数据的规则链", + "mobile-dashboard": "移动端仪表板", + "mobile-dashboard-hint": "移动应用程序用作资产详情仪表板", + "select-queue-hint": "从下拉列表中选择。", + "delete-asset-profile-title": "确定要删除资产配置“{{assetProfileName}}”吗?", + "delete-asset-profile-text": "请注意,确认后资产配置及所有相关数据将无法恢复。", + "delete-asset-profiles-title": "确定要删除{ count, plural, =1 {1 个资产配置} other {# 个资产配置} }吗?", + "delete-asset-profiles-text": "请注意,确认后所有选中的资产配置将被移除,所有相关数据将无法恢复。", + "set-default-asset-profile-title": "确定要将资产配置“{{assetProfileName}}”设为默认吗?", + "set-default-asset-profile-text": "确认后资产配置将被标记为默认,并用于未指定配置的新资产。", + "no-asset-profiles-found": "未找到资产配置。", + "create-new-asset-profile": "创建新资产配置!", + "create-asset-profile": "创建新资产配置", + "import": "导入资产配置", + "export": "导出资产配置", + "export-failed-error": "无法导出资产配置:{{error}}", + "asset-profile-file": "资产配置文件", + "invalid-asset-profile-file-error": "无法导入资产配置:资产配置数据结构无效。" + }, + "device-profile": { + "device-profile": "设备配置", + "device-profiles": "设备配置", + "all-device-profiles": "全部", + "add": "添加设备配置", + "edit": "编辑设备配置", + "device-profile-details": "设备配置详情", + "no-device-profiles-text": "未找到设备配置", + "search": "搜索设备配置", + "selected-device-profiles": "{ count, plural, =1 {1 个设备配置} other {# 个设备配置} }已选择", + "no-device-profiles-matching": "未找到与 '{{entity}}' 匹配的设备配置。", + "device-profile-required": "设备配置为必填项", + "idCopiedMessage": "设备配置 Id 已复制到剪贴板", + "set-default": "设为默认设备配置", + "delete": "删除设备配置", + "copyId": "复制设备配置 Id", + "name-max-length": "名称长度不能超过 256", + "name": "名称", + "name-required": "名称为必填项。", + "type": "配置类型", + "type-required": "配置类型为必填项。", + "type-default": "默认", + "image": "设备配置图片", + "transport-type": "传输类型", + "transport-type-required": "传输类型为必填项。", + "transport-type-default": "默认", + "transport-type-default-hint": "支持基本 MQTT、HTTP 和 CoAP 传输", + "transport-type-mqtt": "MQTT", + "transport-type-mqtt-hint": "启用高级 MQTT 传输设置", + "transport-type-coap": "CoAP", + "transport-type-coap-hint": "启用高级 CoAP 传输设置", + "transport-type-lwm2m": "LWM2M", + "transport-type-lwm2m-hint": "LWM2M 传输类型", + "transport-type-snmp": "SNMP", + "transport-type-snmp-hint": "指定 SNMP 传输配置", + "transport-type-http": "HTTP", + "description": "描述", + "default": "默认", + "profile-configuration": "配置设定", + "transport-configuration": "传输配置", + "default-rule-chain": "默认规则链", + "default-edge-rule-chain": "默认 Edge 规则链", + "default-edge-rule-chain-hint": "在 Edge 上用作规则链,处理此设备配置的设备传入数据", + "mobile-dashboard": "移动端仪表板", + "mobile-dashboard-hint": "移动应用使用此仪表板作为设备详情仪表板", + "select-queue-hint": "从下拉列表中选择。", + "delete-device-profile-title": "确定要删除设备配置 '{{deviceProfileName}}' 吗?", + "delete-device-profile-text": "请注意,确认后设备配置及所有相关数据(包括关联的 OTA 更新)将无法恢复。", + "delete-device-profiles-title": "确定要删除 { count, plural, =1 {1 个设备配置} other {# 个设备配置} } 吗?", + "delete-device-profiles-text": "请注意,确认后所有选中的设备配置将被移除,所有相关数据(包括关联的 OTA 更新)将无法恢复。", + "set-default-device-profile-title": "确定要将设备配置 '{{deviceProfileName}}' 设为默认吗?", + "set-default-device-profile-text": "确认后,该设备配置将被标记为默认,并将用于未指定配置的新设备。", + "no-device-profiles-found": "未找到设备配置。", + "create-new-device-profile": "创建新的设备配置!", + "mqtt-device-topic-filters": "MQTT 设备主题过滤器", + "mqtt-device-topic-filters-unique": "MQTT 设备主题过滤器必须唯一。", + "mqtt-device-topic-filters-spark-plug": "MQTT Sparkplug B Edge of Network (EoN) 节点。", + "mqtt-device-topic-filters-spark-plug-hint": "允许来自使用 Sparkplug B 负载和主题格式的 EoN 节点的连接。", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names": "作为属性存储的 SparkPlug 指标。", + "mqtt-device-topic-filters-spark-plug-attribute-metric-names-hint": "将作为设备属性存储的 SparkPlug 指标名称。其他所有指标将作为设备遥测存储。", + "mqtt-device-payload-type": "MQTT 设备负载", + "mqtt-device-payload-type-json": "JSON", + "mqtt-device-payload-type-proto": "Protobuf", + "mqtt-enable-compatibility-with-json-payload-format": "启用与其他负载格式的兼容性。", + "mqtt-enable-compatibility-with-json-payload-format-hint": "启用后,平台将默认使用 Protobuf 负载格式。如果解析失败,平台将尝试使用 JSON 负载格式。适用于固件更新期间的向后兼容性。例如,初始版本的固件使用 Json,而新版本使用 Protobuf。在设备群的固件更新过程中,需要同时支持 Protobuf 和 JSON。兼容模式会引入轻微的性能下降,因此建议在所有设备更新完成后禁用此模式。", + "mqtt-use-json-format-for-default-downlink-topics": "对默认下行主题使用 Json 格式", + "mqtt-use-json-format-for-default-downlink-topics-hint": "启用后,平台将使用 Json 负载格式通过以下主题推送属性和 RPC:v1/devices/me/attributes/response/$request_id、v1/devices/me/attributes、v1/devices/me/rpc/request/$request_id、v1/devices/me/rpc/response/$request_id。此设置不影响使用新 (v2) 主题发送的属性和 RPC 订阅:v2/a/res/$request_id、v2/a、v2/r/req/$request_id、v2/r/res/$request_id。其中 $request_id 是整数请求标识符。", + "mqtt-send-ack-on-validation-exception": "在 PUBLISH 消息验证失败时发送 PUBACK", + "mqtt-send-ack-on-validation-exception-hint": "默认情况下,平台在消息验证失败时将关闭 MQTT 会话。启用后,平台将发送发布确认而不是关闭会话。", + "mqtt-protocol-version": "协议版本", + "snmp-add-mapping": "添加 SNMP 映射", + "snmp-mapping-not-configured": "未配置 OID 到时间序列/遥测的映射", + "snmp-timseries-or-attribute-name": "映射的时间序列/属性名称", + "snmp-timseries-or-attribute-type": "映射的时间序列/属性类型", + "snmp-method-pdu-type-get-request": "GetRequest", + "snmp-method-pdu-type-get-next-request": "GetNextRequest", + "snmp-oid": "OID", + "transport-device-payload-type-json": "JSON", + "transport-device-payload-type-proto": "Protobuf", + "mqtt-payload-type-required": "负载类型为必填项。", + "coap-device-type": "CoAP 设备类型", + "coap-device-payload-type": "CoAP 设备负载", + "coap-device-type-required": "CoAP 设备类型为必填项。", + "coap-device-type-default": "默认", + "coap-device-type-efento": "Efento NB-IoT", + "support-level-wildcards": "支持单级 [+] 和多级 [#] 通配符。", + "telemetry-topic-filter": "遥测主题过滤器", + "telemetry-topic-filter-required": "遥测主题过滤器为必填项。", + "attributes-topic-filter": "属性发布主题过滤器", + "attributes-subscribe-topic-filter": "属性订阅主题过滤器", + "attributes-topic-filter-required": "属性发布主题过滤器为必填项。", + "attributes-subscribe-topic-filter-required": "属性订阅主题为必填项", + "telemetry-proto-schema": "遥测 proto 模式", + "telemetry-proto-schema-required": "遥测 proto 模式为必填项。", + "attributes-proto-schema": "属性 proto 模式", + "attributes-proto-schema-required": "属性 proto 模式为必填项。", + "rpc-response-proto-schema": "RPC 响应 proto 模式", + "rpc-response-proto-schema-required": "RPC 响应 proto 模式为必填项。", + "rpc-response-topic-filter": "RPC 响应主题过滤器", + "rpc-response-topic-filter-required": "RPC 响应主题过滤器为必填项。", + "rpc-request-proto-schema": "RPC 请求 proto 模式", + "rpc-request-proto-schema-required": "RPC 请求 proto 模式为必填项。", + "rpc-request-proto-schema-hint": "RPC 请求消息应始终包含以下字段:string method = 1;int32 requestId = 2;以及任意数据类型的 params = 3。", + "not-valid-pattern-topic-filter": "无效的模式主题过滤器", + "not-valid-single-character": "单级通配符使用无效", + "not-valid-multi-character": "多级通配符使用无效", + "single-level-wildcards-hint": "[+] 适用于任何主题过滤器级别。例如:v1/devices/+/telemetry 或 +/devices/+/attributes。", + "multi-level-wildcards-hint": "[#] 可以替代主题过滤器本身,且必须是主题的最后一个符号。例如:# 或 v1/devices/me/#。", + "alarm-rules": "告警规则", + "alarm-rules-with-count": "告警规则 ({{count}})", + "no-alarm-rules": "未配置告警规则", + "add-alarm-rule": "添加告警规则", + "edit-alarm-rule": "编辑告警规则", + "alarm-type": "告警类型", + "alarm-type-required": "告警类型为必填项。", + "alarm-type-unique": "告警类型在设备配置告警规则中必须唯一。", + "alarm-type-max-length": "告警类型长度不能超过 256", + "create-alarm-pattern": "创建 {{alarmType}} 告警", + "create-alarm-rules": "创建告警规则", + "no-create-alarm-rules": "未配置创建条件", + "add-create-alarm-rule-prompt": "请添加创建告警规则", + "clear-alarm-rule": "清除告警规则", + "no-clear-alarm-rule": "未配置清除条件", + "add-create-alarm-rule": "添加创建条件", + "add-clear-alarm-rule": "添加清除条件", + "select-alarm-severity": "选择告警严重程度", + "alarm-severity-required": "告警严重程度为必填项。", + "condition-duration": "条件持续时间", + "condition-duration-value": "持续时间值", + "condition-duration-time-unit": "时间单位", + "condition-duration-value-range": "持续时间值应在 1 到 2147483647 的范围内。", + "condition-duration-value-pattern": "持续时间值应为整数。", + "condition-duration-value-required": "持续时间值为必填项。", + "condition-duration-time-unit-required": "时间单位为必填项。", + "advanced-settings": "高级设置", + "alarm-rule-additional-info": "附加信息", + "edit-alarm-rule-additional-info": "编辑附加信息", + "alarm-rule-additional-info-placeholder": "请在此提供您的备注和调整内容,以在告警详情的附加信息中显示", + "alarm-rule-additional-info-hint": "提示:使用 ${keyName} 替换告警规则条件中使用的属性或遥测键的值。", + "alarm-rule-mobile-dashboard": "移动端仪表板", + "alarm-rule-mobile-dashboard-hint": "移动应用使用此仪表板作为告警详情仪表板", + "alarm-rule-no-mobile-dashboard": "未选择仪表板", + "propagate-alarm": "将告警传播到关联实体", + "alarm-rule-relation-types-list": "关联类型", + "alarm-rule-relation-types-list-hint": "定义关联类型以过滤关联实体。如果未设置,告警将传播到所有关联实体。", + "propagate-alarm-to-owner": "将告警传播到实体所有者(客户或租户)", + "propagate-alarm-to-tenant": "将告警传播到租户", + "alarm-rule-condition": "告警规则条件", + "enter-alarm-rule-condition-prompt": "请添加告警规则条件", + "edit-alarm-rule-condition": "编辑告警规则条件", + "device-provisioning": "设备预配置", + "provision-strategy": "预配置策略", + "provision-strategy-required": "预配置策略为必填项。", + "provision-strategy-disabled": "已禁用", + "provision-strategy-created-new": "允许创建新设备", + "provision-strategy-check-pre-provisioned": "检查预配置设备", + "provision-device-key": "设备预配置密钥", + "provision-device-key-required": "设备预配置密钥为必填项。", + "copy-provision-key": "复制预配置密钥", + "provision-key-copied-message": "预配置密钥已复制到剪贴板", + "provision-device-secret": "设备预配置密码", + "provision-device-secret-required": "设备预配置密码为必填项。", + "copy-provision-secret": "复制预配置密码", + "provision-secret-copied-message": "预配置密码已复制到剪贴板", + "provision-strategy-x509": { + "certificate-chain": "X509 证书链", + "certificate-chain-hint": "X.509 证书策略用于在双向 TLS 通信中通过客户端证书预配置设备。", + "allow-create-new-devices": "创建新设备", + "allow-create-new-devices-hint": "如果选中,将创建新设备,并使用客户端证书作为设备凭据。", + "certificate-value": "PEM 格式证书", + "certificate-value-required": "PEM 格式证书为必填项", + "cn-regex-variable": "CN 正则表达式变量", + "cn-regex-variable-required": "CN 正则表达式变量为必填项", + "cn-regex-variable-hint": "需要从设备 X509 证书的通用名称中提取设备名称。" + }, + "condition": "条件", + "condition-type": "条件类型", + "condition-type-simple": "简单", + "condition-type-duration": "持续时间", + "condition-during": "持续 {{during}}", + "condition-during-dynamic": "持续 \"{{ attribute }}\" ({{during}})", + "condition-type-repeating": "重复", + "condition-type-required": "条件类型为必填项。", + "condition-repeating-value": "事件次数", + "condition-repeating-value-range": "事件次数应在 1 到 2147483647 的范围内。", + "condition-repeating-value-pattern": "事件次数应为整数。", + "condition-repeating-value-required": "事件次数为必填项。", + "condition-repeat-times": "重复 { count, plural, =1 {1 次} other {# 次} }", + "condition-repeat-times-dynamic": "重复 \"{ attribute }\" ({ count, plural, =1 {1 次} other {# 次} })", + "schedule-type": "调度器类型", + "schedule-type-required": "调度器类型为必填项。", + "schedule": "调度", + "edit-schedule": "编辑告警调度", + "schedule-any-time": "始终活动", + "schedule-specific-time": "在特定时间活动", + "schedule-custom": "自定义", + "schedule-day": { + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六", + "sunday": "星期日" + }, + "schedule-days": "天数", + "schedule-time": "时间", + "schedule-time-from": "从", + "schedule-time-to": "到", + "schedule-days-of-week-required": "至少应选择一个星期几。", + "create-device-profile": "创建新设备配置", + "import": "导入设备配置", + "export": "导出设备配置", + "export-failed-error": "无法导出设备配置:{{error}}", + "device-profile-file": "设备配置文件", + "invalid-device-profile-file-error": "无法导入设备配置:无效的设备配置数据结构。", + "power-saving-mode": "省电模式", + "power-saving-mode-type": { + "default": "使用设备配置省电模式", + "psm": "省电模式 (PSM)", + "drx": "非连续接收 (DRX)", + "edrx": "扩展非连续接收 (eDRX)" + }, + "edrx-cycle": "eDRX 周期", + "edrx-cycle-required": "eDRX 周期为必填项。", + "edrx-cycle-pattern": "eDRX 周期必须为正整数。", + "edrx-cycle-min": "eDRX 周期最小值为 {{ min }} 秒。", + "paging-transmission-window": "寻呼传输窗口", + "paging-transmission-window-required": "寻呼传输窗口为必填项。", + "paging-transmission-window-pattern": "寻呼传输窗口必须为正整数。", + "paging-transmission-window-min": "寻呼传输窗口最小值为 {{ min }} 秒。", + "psm-activity-timer": "PSM 活动定时器", + "psm-activity-timer-required": "PSM 活动定时器为必填项。", + "psm-activity-timer-pattern": "PSM 活动定时器必须为正整数。", + "psm-activity-timer-min": "PSM 活动定时器最小值为 {{ min }} 秒。", + "lwm2m": { + "object-list": "对象列表", + "object-list-empty": "未选择任何对象。", + "no-objects-found": "未找到对象。", + "no-objects-matching": "未找到与 '{{object}}' 匹配的对象。", + "model-tab": "LWM2M 模型", + "add-new-instances": "添加新实例", + "instances-list": "实例列表", + "instances-list-required": "实例列表为必填项。", + "instance-id-pattern": "实例 ID 必须为正整数。", + "instance-id-max": "实例 ID 最大值为 {{max}}。", + "instance": "实例", + "resource-label": "#ID 资源名称", + "observe-label": "观察", + "attribute-label": "属性", + "telemetry-label": "遥测", + "edit-observe-select": "要编辑观察,请选择遥测或属性", + "edit-attributes-select": "要编辑属性,请选择遥测或属性", + "no-attributes-set": "未设置属性", + "key-name": "键名称", + "key-name-required": "键名称为必填项", + "attribute-name": "属性名称", + "attribute-name-required": "属性名称为必填项。", + "attribute-value": "属性值", + "attribute-value-required": "属性值为必填项。", + "attribute-value-pattern": "属性值必须为正整数。", + "edit-attributes": "编辑属性:{{ name }}", + "view-attributes": "查看属性:{{ name }}", + "add-attribute": "添加属性", + "edit-attribute": "编辑属性", + "view-attribute": "查看属性", + "remove-attribute": "移除属性", + "delete-server-text": "请注意,确认后服务器配置将无法恢复。", + "delete-server-title": "确定要删除该服务器吗?", + "mode": "安全配置模式", + "bootstrap-tab": "Bootstrap", + "bootstrap-server-legend": "Bootstrap 服务器 (ShortId...)", + "lwm2m-server-legend": "LwM2M 服务器 (ShortId...)", + "server": "服务器", + "short-id": "服务器短 ID", + "short-id-tooltip": "服务器短 Id。用作关联服务器对象实例的链接。\n此标识符唯一标识为 LwM2M 客户端配置的每个 LwM2M 服务器。\n当 Bootstrap-Server 资源的值为 'false' 时,必须设置此资源。\nID:0 和 ID:65535 的值不得用于标识 LwM2M 服务器。", + "short-id-tooltip-bootstrap": "服务器短 Id。用作关联服务器对象实例的链接。\n此标识符唯一标识为 LwM2M 客户端配置的每个 LwM2M 服务器。\n当 Bootstrap-Server 资源的值为 'false' 时,必须设置此资源。", + "short-id-required": "服务器短 ID 为必填项。", + "short-id-range": "服务器短 ID 应在 {{ min }} 到 {{ max }} 的范围内。", + "short-id-pattern": "服务器短 ID 必须为正整数。", + "short-id-pattern-bs": "服务器短 ID 必须仅为 null", + "lifetime": "客户端注册生命周期", + "lifetime-required": "客户端注册生命周期为必填项。", + "lifetime-pattern": "客户端注册生命周期必须为正整数。", + "default-min-period": "两次通知之间的最小周期(秒)", + "default-min-period-tooltip": "在观察中未包含此参数时,LwM2M 客户端应使用的默认最小观察周期值。", + "default-min-period-required": "最小周期为必填项。", + "default-min-period-pattern": "最小周期必须为正整数。", + "notification-storing": "禁用或离线时的通知存储", + "binding": "绑定", + "binding-type": { + "u": "U:客户端可随时通过 UDP 绑定访问。", + "m": "M:客户端可随时通过 MQTT 绑定访问。", + "h": "H:客户端可随时通过 HTTP 绑定访问。", + "t": "T:客户端可随时通过 TCP 绑定访问。", + "s": "S:客户端可随时通过 SMS 绑定访问。", + "n": "N:客户端必须通过非 IP 绑定发送此类请求的响应(自 LWM2M 1.1 起支持)。", + "uq": "UQ:UDP 队列模式连接(自 LWM2M 1.1 起不再支持)", + "uqs": "UQS:UDP 和 SMS 连接同时活动;UDP 为队列模式,SMS 为标准模式(自 LWM2M 1.1 起不再支持)", + "tq": "TQ:TCP 队列模式连接(自 LWM2M 1.1 起不再支持)", + "tqs": "TQS:TCP 和 SMS 连接同时活动;TCP 为队列模式,SMS 为标准模式(自 LWM2M 1.1 起不再支持)", + "sq": "SQ:SMS 队列模式连接(自 LWM2M 1.1 起不再支持)" + }, + "binding-tooltip": "这是 LwM2M 服务器对象 - /1/x/7 中“binding”资源的列表。\n指示 LwM2M 客户端支持的绑定模式。\n此值应与设备对象 (/3/0/16) 中“Supported Binding and Modes”资源的值相同。\n虽然支持多种传输方式,但在整个传输会话期间只能使用一种传输绑定。\n例如,当同时支持 UDP 和 SMS 时,LwM2M 客户端和 LwM2M 服务器可以选择在整个传输会话期间通过 UDP 或 SMS 进行通信。", + "bootstrap-server": "Bootstrap 服务器", + "lwm2m-server": "LwM2M 服务器", + "include-bootstrap-server": "包含 Bootstrap 服务器更新", + "bootstrap-update-title": "您已配置了 Bootstrap 服务器。确定要排除更新吗?", + "bootstrap-update-text": "请注意,确认后 Bootstrap 服务器配置数据将无法恢复。", + "server-host": "主机", + "server-host-required": "主机为必填项。", + "server-port": "端口", + "server-port-required": "端口为必填项。", + "server-port-pattern": "端口必须为正整数。", + "server-port-range": "端口应在 1 到 65535 的范围内。", + "server-public-key": "服务器公钥", + "server-public-key-required": "服务器公钥为必填项。", + "client-hold-off-time": "Hold Off 时间", + "client-hold-off-time-required": "Hold Off 时间为必填项。", + "client-hold-off-time-pattern": "Hold Off 时间必须为正整数。", + "client-hold-off-time-tooltip": "Client Hold Off 时间仅用于 Bootstrap 服务器", + "account-after-timeout": "超时后的账户", + "account-after-timeout-required": "超时后的账户为必填项。", + "account-after-timeout-pattern": "超时后的账户必须为正整数。", + "account-after-timeout-tooltip": "Bootstrap 服务器在此资源给定的超时值后的账户。", + "server-type": "服务器类型", + "add-new-server-title": "添加新服务器配置", + "add-server-config": "添加服务器配置", + "add-lwm2m-server-config": "添加 LwM2M 服务器", + "no-config-servers": "未配置服务器", + "others-tab": "其他设置", + "ota-update": "OTA 更新", + "use-object-19-for-ota-update": "使用 Object 19 获取 OTA 文件元数据(校验和、大小、版本、名称)", + "use-object-19-for-ota-update-hint": "使用 Resource ObjectId = 19 进行 OTA 更新:FirmWare → InstanceId = 65534,SoftWare → InstanceId = 65535。数据格式为 Base64 包装的 JSON。此 JSON 包含 OTA 文件元数据(文件信息):\"Checksum\" (SHA256)。附加字段:\"Title\"(OTA 名称)、\"Version\"(OTA 版本)、\"File Name\"(在客户端存储 OTA 的文件名)、\"File Size\"(OTA 大小,以字节为单位)。", + "client-strategy": "连接时的客户端策略", + "client-strategy-label": "策略", + "client-strategy-only-observe": "初始连接后仅向客户端发送 Observe 请求", + "client-strategy-read-all": "注册后读取所有资源并向客户端发送 Observe 请求", + "fw-update": "固件更新", + "fw-update-strategy": "固件更新策略", + "fw-update-strategy-data": "使用 Object 19 和 Resource 0 (Data) 以二进制文件推送固件更新", + "fw-update-strategy-package": "使用 Object 5 和 Resource 0 (Package) 以二进制文件推送固件更新", + "fw-update-strategy-package-uri": "自动生成唯一 CoAP URL 下载软件包,并以 Object 5 和 Resource 1 (Package URI) 推送固件更新", + "sw-update": "软件更新", + "sw-update-strategy": "软件更新策略", + "sw-update-strategy-package": "使用 Object 9 和 Resource 2 (Package) 推送二进制文件", + "sw-update-strategy-package-uri": "自动生成唯一 CoAP URL 下载软件包,并使用 Object 9 和 Resource 3 (Package URI) 推送软件更新", + "fw-update-resource": "固件更新 CoAP 资源", + "fw-update-resource-required": "固件更新 CoAP 资源为必填项。", + "sw-update-resource": "软件更新 CoAP 资源", + "sw-update-resource-required": "软件更新 CoAP 资源为必填项。", + "config-json-tab": "Json 配置设备配置", + "attributes-name": { + "min-period": "最小周期", + "max-period": "最大周期", + "greater-than": "大于", + "less-than": "小于", + "step": "步长", + "min-evaluation-period": "最小评估周期", + "max-evaluation-period": "最大评估周期" + }, + "default-object-id": "默认对象版本(属性)", + "default-object-id-ver": { + "v1-0": "1.0", + "v1-1": "1.1", + "v1-2": "1.2" + }, + "observe-strategy": { + "observe-strategy": "观察策略", + "single": "单个", + "single-description": "每个资源一个 Observe 请求(精度更高,网络流量更大)", + "composite-all": "全部组合", + "composite-all-description": "所有资源使用单个 Composite Observe 请求进行观察(更高效,灵活性较低)", + "composite-by-object": "按对象组合", + "composite-by-object-description": "资源按对象类型分组,使用单独的 Composite Observe 请求进行观察(平衡方案)" + }, + "init-attr-tel-as-obs-strategy": "使用观察策略初始化属性和遥测", + "init-attr-tel-as-obs-strategy-hint": "如果为 false,属性和遥测通过逐一读取其值来初始化。\\n如果为 true,属性和遥测通过使用观察策略订阅其值来初始化。" + }, + "snmp": { + "add-communication-config": "添加通信配置", + "add-mapping": "添加映射", + "authentication-passphrase": "认证密码短语", + "authentication-passphrase-required": "认证密码短语为必填项。", + "authentication-protocol": "认证协议", + "authentication-protocol-required": "认证协议为必填项。", + "communication-configs": "通信配置", + "community": "Community 字符串", + "community-required": "Community 字符串为必填项。", + "context-name": "上下文名称", + "data-key": "数据键", + "data-key-required": "数据键为必填项。", + "data-type": "数据类型", + "data-type-required": "数据类型为必填项。", + "engine-id": "Engine ID", + "host": "主机", + "host-required": "主机为必填项。", + "oid": "OID", + "oid-pattern": "OID 格式无效。", + "oid-required": "OID 为必填项。", + "please-add-communication-config": "请添加通信配置", + "please-add-mapping-config": "请添加映射配置", + "port": "端口", + "port-format": "端口格式无效。", + "port-required": "端口为必填项。", + "privacy-passphrase": "隐私密码短语", + "privacy-passphrase-required": "隐私密码短语为必填项。", + "privacy-protocol": "隐私协议", + "privacy-protocol-required": "隐私协议为必填项。", + "protocol-version": "协议版本", + "protocol-version-required": "协议版本为必填项。", + "querying-frequency": "查询频率(毫秒)", + "querying-frequency-invalid-format": "查询频率必须为正整数。", + "querying-frequency-required": "查询频率为必填项。", + "retries": "重试次数", + "retries-invalid-format": "重试次数必须为正整数。", + "retries-required": "重试次数为必填项。", + "scope": "范围", + "scope-required": "范围为必填项。", + "security-name": "安全名称", + "security-name-required": "安全名称为必填项。", + "timeout-ms": "超时(毫秒)", + "timeout-ms-invalid-format": "超时必须为正整数。", + "timeout-ms-required": "超时为必填项。", + "user-name": "用户名", + "user-name-required": "用户名为必填项。" + } + }, + "dialog": { + "close": "关闭对话框", + "error-message-title": "错误消息:", + "error-details-title": "错误详情" + }, + "direction": { + "column": "列", + "row": "行" + }, + "edge": { + "edge": "Edge", + "edge-instances": "Edge 实例", + "instances": "实例", + "edge-file": "Edge 文件", + "name-max-length": "名称长度不能超过 256", + "label-max-length": "标签长度不能超过 256", + "type-max-length": "类型长度不能超过 256", + "management": "Edge 管理", + "no-edges-matching": "未找到与 '{{entity}}' 匹配的 Edge。", + "add": "添加 Edge", + "no-edges-text": "未找到 Edge", + "edge-details": "Edge 详情", + "add-edge-text": "添加新 Edge", + "delete": "删除 Edge", + "delete-edge-title": "确定要删除 Edge '{{edgeName}}' 吗?", + "delete-edge-text": "请注意,确认后 Edge 及所有相关数据将无法恢复。", + "delete-edges-title": "确定要删除 { count, plural, =1 {1 个 Edge} other {# 个 Edge} } 吗?", + "delete-edges-text": "请注意,确认后所有选中的 Edge 将被移除,所有相关数据将无法恢复。", + "name": "名称", + "name-starts-with": "Edge 名称开头为", + "name-required": "名称为必填项。", + "description": "描述", + "details": "详情", + "events": "事件", + "copy-id": "复制 Edge Id", + "id-copied-message": "Edge Id 已复制到剪贴板", + "sync": "同步 Edge", + "edge-required": "Edge 为必填项", + "edge-type": "Edge 类型", + "edge-type-required": "Edge 类型为必填项。", + "event-action": "事件操作", + "entity-id": "实体 ID", + "select-edge-type": "选择 Edge 类型", + "assign-to-customer": "分配给客户", + "assign-to-customer-text": "请选择要分配 Edge 的客户", + "assign-edge-to-customer": "将 Edge 分配给客户", + "assign-edge-to-customer-text": "请选择要分配给客户的 Edge", + "assignedToCustomer": "已分配给客户", + "edge-public": "Edge 为公开", + "assigned-to-customer": "已分配给:{{customerTitle}}", + "unassign-from-customer": "取消分配客户", + "unassign-edge-title": "确定要取消分配 Edge '{{edgeName}}' 吗?", + "unassign-edge-text": "确认后,Edge 将被取消分配,客户将无法访问。", + "unassign-edges-title": "确定要取消分配 { count, plural, =1 {1 个 Edge} other {# 个 Edge} } 吗?", + "unassign-edges-text": "确认后,所有选中的 Edge 将被取消分配,客户将无法访问。", + "make-public": "将 Edge 设为公开", + "make-public-edge-title": "确定要将 Edge '{{edgeName}}' 设为公开吗?", + "make-public-edge-text": "确认后,Edge 及其所有数据将设为公开并可被其他人访问。", + "make-private": "将 Edge 设为私有", + "public": "公开", + "make-private-edge-title": "确定要将 Edge '{{edgeName}}' 设为私有吗?", + "make-private-edge-text": "确认后,Edge 及其所有数据将设为私有,其他人将无法访问。", + "import": "导入 Edge", + "install-connect-instructions": "安装和连接说明", + "install-connect-instructions-edge-created": "Edge 已创建!请查看安装和连接说明", + "loading-edge-instructions": "正在加载 Edge 说明...", + "label": "标签", + "load-entity-error": "加载数据失败。实体已被删除。", + "assign-new-edge": "分配新 Edge", + "unassign-from-edge": "取消分配 Edge", + "edge-key": "Edge 密钥", + "copy-edge-key": "复制 Edge 密钥", + "edge-key-copied-message": "Edge 密钥已复制到剪贴板", + "edge-secret": "Edge 密码", + "copy-edge-secret": "复制 Edge 密码", + "edge-secret-copied-message": "Edge 密码已复制到剪贴板", + "manage-assets": "管理资产", + "manage-devices": "管理设备", + "manage-entity-views": "管理实体视图", + "manage-dashboards": "管理仪表板", + "manage-rulechains": "管理规则链", + "assets": "Edge 资产", + "devices": "Edge 设备", + "entity-views": "Edge 实体视图", + "dashboard": "Edge 仪表板", + "dashboards": "Edge 仪表板", + "rulechain-templates": "规则链模板", + "edge-rulechain-templates": "Edge 规则链模板", + "rulechains": "Edge 规则链", + "search": "搜索 Edge", + "selected-edges": "{ count, plural, =1 {1 个 Edge} other {# 个 Edge} }已选择", + "any-edge": "任意 Edge", + "no-edge-types-matching": "未找到与 '{{entitySubtype}}' 匹配的 Edge 类型。", + "edge-type-list-empty": "未选择 Edge 类型。", + "edge-types": "Edge 类型", + "enter-edge-type": "输入 Edge 类型", + "deployed": "已部署", + "pending": "待处理", + "downlinks": "下行链路", + "no-downlinks-prompt": "未找到下行链路", + "sync-process-started-successfully": "同步过程已成功启动!", + "missing-related-rule-chains-title": "Edge 缺少关联的规则链", + "missing-related-rule-chains-text": "分配给 Edge 的规则链使用了将消息转发到未分配给此 Edge 的规则链的规则节点。

缺少的规则链列表:
{{missingRuleChains}}", + "widget-datasource-error": "此部件仅支持 Edge 实体数据源", + "upgrade-instructions": "升级说明", + "connected": "已连接", + "disconnected": "已断开" + }, + "edge-event": { + "type-dashboard": "仪表板", + "type-asset": "资产", + "type-device": "设备", + "type-device-profile": "设备配置", + "type-asset-profile": "资产配置", + "type-entity-view": "实体视图", + "type-alarm": "告警", + "type-rule-chain": "规则链", + "type-rule-chain-metadata": "规则链元数据", + "type-edge": "Edge", + "type-user": "用户", + "type-tenant": "租户", + "type-tenant-profile": "租户配置", + "type-customer": "客户", + "type-relation": "关联", + "type-widgets-bundle": "部件包", + "type-widgets-type": "部件类型", + "type-admin-settings": "管理员设置", + "type-ota-package": "OTA 软件包", + "type-queue": "队列", + "action-type-added": "已添加", + "action-type-deleted": "已删除", + "action-type-updated": "已更新", + "action-type-post-attributes": "发布属性", + "action-type-attributes-updated": "属性已更新", + "action-type-attributes-deleted": "属性已删除", + "action-type-timeseries-updated": "时间序列已更新", + "action-type-credentials-updated": "凭据已更新", + "action-type-assigned-to-customer": "已分配给客户", + "action-type-unassigned-from-customer": "已取消分配客户", + "action-type-relation-add-or-update": "关联添加或更新", + "action-type-relation-deleted": "关联已删除", + "action-type-rpc-call": "RPC 调用", + "action-type-alarm-ack": "告警确认", + "action-type-alarm-clear": "告警清除", + "action-type-alarm-assigned": "告警已分配", + "action-type-alarm-unassigned": "告警已取消分配", + "action-type-assigned-to-edge": "已分配到 Edge", + "action-type-unassigned-from-edge": "已取消分配 Edge", + "action-type-credentials-request": "凭据请求", + "action-type-entity-merge-request": "实体合并请求" + }, + "error": { + "unable-to-connect": "无法连接到服务器!请检查您的网络连接。", + "unhandled-error-code": "未处理的错误代码:{{errorCode}}", + "unknown-error": "未知错误" + }, + "entity": { + "entity": "实体", + "entities": "实体", + "entities-count": "实体数量", + "alarms-count": "告警数量", + "aliases": "实体别名", + "aliases-short": "别名", + "entity-alias": "实体别名", + "unable-delete-entity-alias-title": "无法删除实体别名", + "unable-delete-entity-alias-text": "实体别名 '{{entityAlias}}' 无法删除,因为它被以下部件使用:
{{widgetsList}}", + "duplicate-alias-error": "发现重复别名 '{{alias}}'。
实体别名在仪表板中必须唯一。", + "missing-entity-filter-error": "别名 '{{alias}}' 缺少过滤器。", + "configure-alias": "配置 '{{alias}}' 别名", + "alias": "别名", + "alias-required": "实体别名为必填项。", + "remove-alias": "移除实体别名", + "add-alias": "添加实体别名", + "edit-alias": "编辑实体别名", + "entity-list": "实体列表", + "entity-type": "实体类型", + "entity-types": "实体类型", + "entity-type-list": "实体类型列表", + "any-entity": "任意实体", + "add-entity-type": "添加实体类型", + "enter-entity-type": "输入实体类型", + "no-entities-matching": "未找到与 '{{entity}}' 匹配的实体。", + "no-entities-text": "未找到实体", + "no-entity-types-matching": "未找到与 '{{entityType}}' 匹配的实体类型。", + "name-starts-with": "名称表达式", + "help-text": "根据需要使用 '%':'%entity_name_contains%'、'%entity_name_ends'、'entity_starts_with%'。", + "use-entity-name-filter": "使用过滤器", + "entity-list-empty": "未选择实体。", + "entity-type-list-required": "至少应选择一种实体类型。", + "entity-name-filter-required": "实体名称过滤器为必填项。", + "entity-name-filter-no-entity-matched": "未找到以 '{{entity}}' 开头的实体。", + "all-subtypes": "全部", + "select-entities": "选择实体", + "no-aliases-found": "未找到别名。", + "no-alias-matching": "未找到 '{{alias}}'。", + "create-new-alias": "创建新别名!", + "create-new": "创建新的", + "key": "键", + "key-name": "键名称", + "no-keys-found": "未找到键。", + "no-key-matching": "未找到 '{{key}}'。", + "create-new-key": "创建新键!", + "type": "类型", + "type-required": "实体类型为必填项。", + "type-device": "设备", + "type-devices": "设备", + "list-of-devices": "{ count, plural, =1 {1 个设备} other {# 个设备列表} }", + "device-name-starts-with": "名称以 '{{prefix}}' 开头的设备", + "type-device-profile": "设备配置", + "type-device-profiles": "设备配置", + "clear-selected-profiles": "清除已选配置", + "list-of-device-profiles": "{ count, plural, =1 {1 个设备配置} other {# 个设备配置列表} }", + "device-profile-name-starts-with": "名称以 '{{prefix}}' 开头的设备配置", + "type-asset-profile": "资产配置", + "type-asset-profiles": "资产配置", + "list-of-asset-profiles": "{ count, plural, =1 {1 个资产配置} other {# 个资产配置列表} }", + "asset-profile-name-starts-with": "名称以 '{{prefix}}' 开头的资产配置", + "type-asset": "资产", + "type-assets": "资产", + "list-of-assets": "{ count, plural, =1 {1 个资产} other {# 个资产列表} }", + "asset-name-starts-with": "名称以 '{{prefix}}' 开头的资产", + "type-entity-view": "实体视图", + "type-entity-views": "实体视图", + "list-of-entity-views": "{ count, plural, =1 {1 个实体视图} other {# 个实体视图列表} }", + "entity-view-name-starts-with": "名称以 '{{prefix}}' 开头的实体视图", + "type-rule": "规则", + "type-rules": "规则", + "list-of-rules": "{ count, plural, =1 {1 条规则} other {# 条规则列表} }", + "rule-name-starts-with": "名称以 '{{prefix}}' 开头的规则", + "type-plugin": "插件", + "type-plugins": "插件", + "list-of-plugins": "{ count, plural, =1 {1 个插件} other {# 个插件列表} }", + "plugin-name-starts-with": "名称以 '{{prefix}}' 开头的插件", + "type-tenant": "租户", + "type-tenants": "租户", + "list-of-tenants": "{ count, plural, =1 {1 个租户} other {# 个租户列表} }", + "tenant-name-starts-with": "名称以 '{{prefix}}' 开头的租户", + "type-tenant-profile": "租户配置", + "type-tenant-profiles": "租户配置", + "list-of-tenant-profiles": "{ count, plural, =1 {1 个租户配置} other {# 个租户配置列表} }", + "tenant-profile-name-starts-with": "名称以 '{{prefix}}' 开头的租户配置", + "type-customer": "客户", + "type-customers": "客户", + "list-of-customers": "{ count, plural, =1 {1 个客户} other {# 个客户列表} }", + "customer-name-starts-with": "名称以 '{{prefix}}' 开头的客户", + "type-user": "用户", + "type-users": "用户", + "list-of-users": "{ count, plural, =1 {1 个用户} other {# 个用户列表} }", + "user-name-starts-with": "名称以 '{{prefix}}' 开头的用户", + "type-dashboard": "仪表板", + "type-dashboards": "仪表板", + "list-of-dashboards": "{ count, plural, =1 {1 个仪表板} other {# 个仪表板列表} }", + "dashboard-name-starts-with": "名称以 '{{prefix}}' 开头的仪表板", + "type-alarm": "告警", + "type-alarms": "告警", + "list-of-alarms": "{ count, plural, =1 {1 个告警} other {# 个告警列表} }", + "alarm-name-starts-with": "名称以 '{{prefix}}' 开头的告警", + "type-rulechain": "规则链", + "type-rulechains": "规则链", + "list-of-rulechains": "{ count, plural, =1 {1 条规则链} other {# 条规则链列表} }", + "rulechain-name-starts-with": "名称以 '{{prefix}}' 开头的规则链", + "type-rulenode": "规则节点", + "type-rulenodes": "规则节点", + "list-of-rulenodes": "{ count, plural, =1 {1 个规则节点} other {# 个规则节点列表} }", + "rulenode-name-starts-with": "名称以 '{{prefix}}' 开头的规则节点", + "type-api-key": "API 密钥", + "type-api-keys": "API 密钥", + "type-current-customer": "当前客户", + "type-current-tenant": "当前租户", + "type-current-user": "当前用户", + "type-current-user-owner": "当前用户所有者", + "type-calculated-field": "计算字段", + "type-calculated-fields": "计算字段", + "type-ai-model": "AI 模型", + "type-ai-models": "AI 模型", + "type-widgets-bundle": "部件包", + "type-widgets-bundles": "部件包", + "list-of-widgets-bundles": "{ count, plural, =1 {1 个部件包} other {# 个部件包列表} }", + "type-widget": "部件", + "type-widgets": "部件", + "list-of-widgets": "{ count, plural, =1 {1 个部件} other {# 个部件列表} }", + "search": "搜索实体", + "selected-entities": "{ count, plural, =1 {1 个实体} other {# 个实体} }已选择", + "entity-name": "实体名称", + "entity-label": "实体标签", + "details": "实体详情", + "no-entities-prompt": "未找到实体", + "no-data": "无数据可显示", + "show-all-columns": "显示全部", + "columns-to-display": "显示列", + "type-api-usage-state": "API 使用状态", + "type-edge": "Edge", + "type-edges": "Edge", + "list-of-edges": "{ count, plural, =1 {1 个 Edge} other {# 个 Edge 列表} }", + "edge-name-starts-with": "名称以 '{{prefix}}' 开头的 Edge", + "version-conflict": { + "message": "您要覆盖现有版本还是放弃更改并加载最新版本?", + "link": "您可以使用此链接下载您的 {{entityType}} 版本", + "overwrite": "覆盖版本", + "discard": "放弃更改" + }, + "type-tb-resource": "资源", + "type-tb-resources": "资源", + "list-of-tb-resources": "{ count, plural, =1 {1 个资源} other {# 个资源列表} }", + "type-ota-package": "OTA 软件包", + "type-ota-packages": "OTA 软件包", + "list-of-ota-packages": "{ count, plural, =1 {1 个 OTA 软件包} other {# 个 OTA 软件包列表} }", + "type-rpc": "RPC", + "type-queue": "队列", + "type-queue-stats": "队列统计", + "type-queues-stats": "队列统计", + "type-notification": "通知", + "type-notification-rule": "通知规则", + "type-notification-rules": "通知规则", + "list-of-notification-rules": "{ count, plural, =1 {1 个通知规则} other {# 个通知规则列表} }", + "type-notification-target": "通知接收者", + "type-notification-targets": "通知接收者", + "list-of-notification-targets": "{ count, plural, =1 {1 个通知接收者} other {# 个通知接收者列表} }", + "type-notification-request": "通知请求", + "type-notification-template": "通知模板", + "type-notification-templates": "通知模板", + "list-of-notification-templates": "{ count, plural, =1 {1 个通知模板} other {# 个通知模板列表} }", + "link": "链接", + "type-oauth2-client": "OAuth 2.0 客户端", + "type-oauth2-clients": "OAuth 2.0 客户端", + "list-of-oauth2-clients": "{ count, plural, =1 {1 个 OAuth 2.0 客户端} other {# 个 OAuth 2.0 客户端列表} }", + "type-domain": "域名", + "type-domains": "域名", + "list-of-domains": "{ count, plural, =1 {1 个域名} other {# 个域名列表} }", + "type-mobile-app": "移动应用", + "type-mobile-apps": "移动应用", + "list-of-mobile-apps": "{ count, plural, =1 {1 个移动应用} other {# 个移动应用列表} }", + "type-mobile-app-bundle": "移动包", + "type-mobile-app-bundles": "移动包", + "list-of-mobile-app-bundles": "{ count, plural, =1 {1 个移动包} other {# 个移动包列表} }", + "limit-reached": "已达上限", + "limit-reached-text": "您已达到 {{ entities }} 的上限。要添加更多,请联系系统管理员增加您的 {{ entity }} 上限。", + "request-limit-increase": "请求增加上限", + "request-sysadmin-text": "您是系统管理员吗?", + "login-here": "在此登录", + "to-increase-limit": "以增加上限。", + "increase-limit-request-sent-title": "我们已向您的系统管理员发送了自动请求以增加上限", + "increase-limit-request-sent-text": "请等待他们审核请求并更新设置。您可能需要刷新此页面以查看更改。" + }, + "entity-field": { + "created-time": "创建时间", + "name": "名称", + "type": "类型", + "first-name": "名", + "last-name": "姓", + "email": "Email", + "title": "标题", + "country": "国家", + "state": "省/州", + "city": "城市", + "address": "地址", + "address2": "地址 2", + "zip": "邮编", + "phone": "电话", + "label": "标签", + "queue-name": "队列名称", + "service-id": "服务 Id", + "owner-name": "所有者名称", + "owner-type": "所有者类型" + }, + "entity-view": { + "entity-view": "实体视图", + "entity-view-required": "实体视图为必填项。", + "entity-views": "实体视图", + "management": "实体视图管理", + "view-entity-views": "查看实体视图", + "entity-view-alias": "实体视图别名", + "aliases": "实体视图别名", + "no-alias-matching": "未找到 '{{alias}}'。", + "no-aliases-found": "未找到别名。", + "no-key-matching": "未找到 '{{key}}'。", + "no-keys-found": "未找到键。", + "create-new-alias": "创建新别名!", + "create-new-key": "创建新键!", + "duplicate-alias-error": "发现重复别名 '{{alias}}'。
实体视图别名在仪表板中必须唯一。", + "configure-alias": "配置 '{{alias}}' 别名", + "no-entity-views-matching": "未找到与 '{{entity}}' 匹配的实体视图。", + "public": "公开", + "alias": "别名", + "alias-required": "实体视图别名为必填项。", + "remove-alias": "移除实体视图别名", + "add-alias": "添加实体视图别名", + "name-starts-with": "实体视图名称表达式", + "help-text": "根据需要使用 '%':'%entity-view_name_contains%'、'%entity-view_name_ends'、'entity-view_starts_with%'。", + "entity-view-list": "实体视图列表", + "use-entity-view-name-filter": "使用过滤器", + "entity-view-list-empty": "未选择实体视图。", + "entity-view-name-filter-required": "实体视图名称过滤器为必填项。", + "entity-view-name-filter-no-entity-view-matched": "未找到以 '{{entityView}}' 开头的实体视图。", + "add": "添加实体视图", + "entity-view-public": "实体视图为公开", + "assign-to-customer": "分配给客户", + "assign-entity-view-to-customer": "将实体视图分配给客户", + "assign-entity-view-to-customer-text": "请选择要分配给客户的实体视图", + "no-entity-views-text": "未找到实体视图", + "assign-to-customer-text": "请选择要分配实体视图的客户", + "entity-view-details": "实体视图详情", + "add-entity-view-text": "添加新实体视图", + "delete": "删除实体视图", + "assign-entity-views": "分配实体视图", + "assign-entity-views-text": "将 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 分配给客户", + "delete-entity-views": "删除实体视图", + "make-public": "将实体视图设为公开", + "make-private": "将实体视图设为私有", + "unassign-from-customer": "取消分配客户", + "unassign-entity-views": "取消分配实体视图", + "unassign-entity-views-action-title": "取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }", + "assign-new-entity-view": "分配新实体视图", + "delete-entity-view-title": "确定要删除实体视图 '{{entityViewName}}' 吗?", + "delete-entity-view-text": "请注意,确认后实体视图及所有相关数据将无法恢复。", + "delete-entity-views-title": "确定要删除 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 吗?", + "delete-entity-views-action-title": "删除 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }", + "delete-entity-views-text": "请注意,确认后所有选中的实体视图将被移除,所有相关数据将无法恢复。", + "make-public-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为公开吗?", + "make-public-entity-view-text": "确认后,实体视图及其所有数据将设为公开并可被其他人访问。", + "make-private-entity-view-title": "确定要将实体视图 '{{entityViewName}}' 设为私有吗?", + "make-private-entity-view-text": "确认后,实体视图及其所有数据将设为私有,其他人将无法访问。", + "unassign-entity-view-title": "确定要取消分配实体视图 '{{entityViewName}}' 吗?", + "unassign-entity-view-text": "确认后,实体视图将被取消分配,客户将无法访问。", + "unassign-entity-view": "取消分配实体视图", + "unassign-entity-views-title": "确定要取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 吗?", + "unassign-entity-views-text": "确认后,所有选中的实体视图将被取消分配,客户将无法访问。", + "entity-view-type": "实体视图类型", + "entity-view-type-required": "实体视图类型为必填项。", + "select-entity-view-type": "选择实体视图类型", + "enter-entity-view-type": "输入实体视图类型", + "any-entity-view": "任意实体视图", + "no-entity-view-types-matching": "未找到与 '{{entitySubtype}}' 匹配的实体视图类型。", + "entity-view-type-list-empty": "未选择实体视图类型。", + "entity-view-types": "实体视图类型", + "created-time": "创建时间", + "name": "名称", + "name-required": "名称为必填项。", + "name-max-length": "名称长度不能超过 256", + "type-max-length": "实体视图类型长度不能超过 256", + "description": "描述", + "events": "事件", + "details": "详情", + "copyId": "复制实体视图 Id", + "idCopiedMessage": "实体视图 Id 已复制到剪贴板", + "assignedToCustomer": "已分配给客户", + "unable-entity-view-device-alias-title": "无法删除实体视图别名", + "unable-entity-view-device-alias-text": "设备别名 '{{entityViewAlias}}' 无法删除,因为它被以下部件使用:
{{widgetsList}}", + "select-entity-view": "选择实体视图", + "start-ts": "开始时间", + "end-ts": "结束时间", + "date-limits": "日期限制", + "client-attributes": "客户端属性", + "shared-attributes": "共享属性", + "server-attributes": "服务端属性", + "timeseries": "时间序列", + "client-attributes-placeholder": "客户端属性", + "shared-attributes-placeholder": "共享属性", + "server-attributes-placeholder": "服务端属性", + "timeseries-placeholder": "时间序列", + "target-entity": "目标实体", + "attributes-propagation": "属性传播", + "attributes-propagation-hint": "实体视图将在每次保存或更新此实体视图时自动从目标实体复制指定的属性。出于性能考虑,每次属性更改时不会自动将目标实体属性传播到实体视图。您可以通过在规则链中配置“copy to view”规则节点并将“Post attributes”和“Attributes Updated”消息链接到新规则节点来启用自动传播。", + "timeseries-data": "时间序列数据", + "timeseries-data-hint": "配置实体视图可访问的目标实体时间序列数据键。此时间序列数据为只读。", + "search": "搜索实体视图", + "selected-entity-views": "{ count, plural, =1 {1 个实体视图} other {# 个实体视图} }已选择", + "assign-entity-view-to-edge": "将实体视图分配到 Edge", + "assign-entity-view-to-edge-text": "请选择要分配到 Edge 的实体视图", + "unassign-entity-view-from-edge-title": "确定要取消分配实体视图 '{{entityViewName}}' 吗?", + "unassign-entity-view-from-edge-text": "确认后,实体视图将被取消分配,Edge 将无法访问。", + "unassign-entity-views-from-edge-action-title": "取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} }", + "unassign-entity-view-from-edge": "取消分配实体视图", + "unassign-entity-views-from-edge-title": "确定要取消分配 { count, plural, =1 {1 个实体视图} other {# 个实体视图} } 吗?", + "unassign-entity-views-from-edge-text": "确认后,所有选中的实体视图将被取消分配,Edge 将无法访问。" + }, + "event": { + "event-type": "事件类型", + "events-filter": "事件过滤器", + "clean-events": "清除事件", + "type-error": "错误", + "type-lc-event": "生命周期事件", + "type-stats": "统计", + "type-debug-rule-node": "调试", + "type-debug-rule-chain": "调试", + "type-debug-calculated-field": "调试", + "arguments": "参数", + "result": "结果", + "no-events-prompt": "未找到事件", + "error": "错误", + "alarm": "告警", + "event-time": "事件时间", + "server": "服务器", + "body": "消息体", + "method": "方法", + "type": "类型", + "metadata": "元数据", + "message": "消息", + "message-id": "消息 Id", + "copy-message-id": "复制消息 Id", + "message-type": "消息类型", + "data-type": "数据类型", + "relation-type": "关联类型", + "data": "数据", + "event": "事件", + "status": "状态", + "success": "成功", + "failed": "失败", + "messages-processed": "已处理消息数", + "max-messages-processed": "最大已处理消息数", + "min-messages-processed": "最小已处理消息数", + "errors-occurred": "发生的错误数", + "max-errors-occurred": "最大错误数", + "min-errors-occurred": "最小错误数", + "min-value": "最小值为 0。", + "all-events": "全部", + "has-error": "有错误", + "entity-id": "实体 Id", + "copy-entity-id": "复制实体 Id", + "entity-type": "实体类型", + "clear-filter": "清除过滤器", + "clear-request-title": "清除所有事件", + "clear-request-text": "确定要清除所有事件吗?", + "started": "已启动", + "updated": "已更新", + "stopped": "已停止" + }, + "extension": { + "extensions": "扩展", + "selected-extensions": "{ count, plural, =1 {1 个扩展} other {# 个扩展} }已选择", + "type": "类型", + "key": "键", + "value": "值", + "id": "Id", + "extension-id": "扩展 Id", + "extension-type": "扩展类型", + "transformer-json": "JSON *", + "unique-id-required": "当前扩展 Id 已存在。", + "delete": "删除扩展", + "add": "添加扩展", + "edit": "编辑扩展", + "delete-extension-title": "确定要删除扩展 '{{extensionId}}' 吗?", + "delete-extension-text": "请注意,确认后扩展及所有相关数据将无法恢复。", + "delete-extensions-title": "确定要删除 { count, plural, =1 {1 个扩展} other {# 个扩展} } 吗?", + "delete-extensions-text": "请注意,确认后所有选中的扩展将被移除。", + "converters": "转换器", + "converter-id": "转换器 Id", + "configuration": "配置", + "converter-configurations": "转换器配置", + "token": "安全 Token", + "add-converter": "添加转换器", + "add-config": "添加转换器配置", + "device-name-expression": "设备名称表达式", + "device-type-expression": "设备类型表达式", + "custom": "自定义", + "to-double": "转为 Double", + "transformer": "转换器", + "json-required": "转换器 JSON 为必填项。", + "json-parse": "无法解析转换器 JSON。", + "attributes": "属性", + "add-attribute": "添加属性", + "add-map": "添加映射元素", + "timeseries": "时间序列", + "add-timeseries": "添加时间序列", + "field-required": "字段为必填项", + "brokers": "代理", + "add-broker": "添加代理", + "host": "主机", + "port": "端口", + "port-range": "端口应在 1 到 65535 的范围内。", + "ssl": "SSL", + "credentials": "凭据", + "username": "用户名", + "password": "密码", + "retry-interval": "重试间隔(毫秒)", + "anonymous": "匿名", + "basic": "基本", + "pem": "PEM", + "ca-cert": "CA 证书文件 *", + "private-key": "私钥文件 *", + "cert": "证书文件 *", + "no-file": "未选择文件。", + "drop-file": "拖放文件或点击选择要上传的文件。", + "mapping": "映射", + "topic-filter": "主题过滤器", + "converter-type": "转换器类型", + "converter-json": "Json", + "json-name-expression": "设备名称 JSON 表达式", + "topic-name-expression": "设备名称主题表达式", + "json-type-expression": "设备类型 JSON 表达式", + "topic-type-expression": "设备类型主题表达式", + "attribute-key-expression": "属性键表达式", + "attr-json-key-expression": "属性键 JSON 表达式", + "attr-topic-key-expression": "属性键主题表达式", + "request-id-expression": "请求 ID 表达式", + "request-id-json-expression": "请求 ID JSON 表达式", + "request-id-topic-expression": "请求 ID 主题表达式", + "response-topic-expression": "响应主题表达式", + "value-expression": "值表达式", + "topic": "主题", + "timeout": "超时(毫秒)", + "converter-json-required": "转换器 JSON 为必填项。", + "converter-json-parse": "无法解析转换器 JSON。", + "filter-expression": "过滤器表达式", + "connect-requests": "连接请求", + "add-connect-request": "添加连接请求", + "disconnect-requests": "断开连接请求", + "add-disconnect-request": "添加断开连接请求", + "attribute-requests": "属性请求", + "add-attribute-request": "添加属性请求", + "attribute-updates": "属性更新", + "add-attribute-update": "添加属性更新", + "server-side-rpc": "服务端 RPC", + "add-server-side-rpc-request": "添加服务端 RPC 请求", + "device-name-filter": "设备名称过滤器", + "attribute-filter": "属性过滤器", + "method-filter": "方法过滤器", + "request-topic-expression": "请求主题表达式", + "response-timeout": "响应超时(毫秒)", + "topic-expression": "主题表达式", + "client-scope": "客户端范围", + "add-device": "添加设备", + "opc-server": "服务器", + "opc-add-server": "添加服务器", + "opc-add-server-prompt": "请添加服务器", + "opc-application-name": "应用名称", + "opc-application-uri": "应用 URI", + "opc-scan-period-in-seconds": "扫描周期(秒)", + "opc-security": "安全", + "opc-identity": "身份", + "opc-keystore": "密钥库", + "opc-type": "类型", + "opc-keystore-type": "类型", + "opc-keystore-location": "位置 *", + "opc-keystore-password": "密码", + "opc-keystore-alias": "别名", + "opc-keystore-key-password": "密钥密码", + "opc-device-node-pattern": "设备节点模式", + "opc-device-name-pattern": "设备名称模式", + "modbus-server": "服务器/从机", + "modbus-add-server": "添加服务器/从机", + "modbus-add-server-prompt": "请添加服务器/从机", + "modbus-transport": "传输", + "modbus-tcp-reconnect": "自动重连", + "modbus-rtu-over-tcp": "RTU over TCP", + "modbus-port-name": "串口名称", + "modbus-encoding": "编码", + "modbus-parity": "校验", + "modbus-baudrate": "波特率", + "modbus-databits": "数据位", + "modbus-stopbits": "停止位", + "modbus-databits-range": "数据位应在 7 到 8 的范围内。", + "modbus-stopbits-range": "停止位应在 1 到 2 的范围内。", + "modbus-unit-id": "Unit ID", + "modbus-unit-id-range": "Unit ID 应在 1 到 247 的范围内。", + "modbus-device-name": "设备名称", + "modbus-poll-period": "轮询周期(毫秒)", + "modbus-attributes-poll-period": "属性轮询周期(毫秒)", + "modbus-timeseries-poll-period": "时间序列轮询周期(毫秒)", + "modbus-poll-period-range": "轮询周期应为正数值。", + "modbus-tag": "标签", + "modbus-function": "功能", + "modbus-register-address": "寄存器地址", + "modbus-register-address-range": "寄存器地址应在 0 到 65535 的范围内。", + "modbus-register-bit-index": "位索引", + "modbus-register-bit-index-range": "位索引应在 0 到 15 的范围内。", + "modbus-register-count": "寄存器数量", + "modbus-register-count-range": "寄存器数量应为正数值。", + "modbus-byte-order": "字节序", + "sync": { + "status": "状态", + "sync": "已同步", + "not-sync": "未同步", + "last-sync-time": "最后同步时间", + "not-available": "不可用" + }, + "export-extensions-configuration": "导出扩展配置", + "import-extensions-configuration": "导入扩展配置", + "import-extensions": "导入扩展", + "import-extension": "导入扩展", + "export-extension": "导出扩展", + "file": "扩展文件", + "invalid-file-error": "无效的扩展文件" + }, + "feature": { + "advanced-features": "高级功能" + }, + "filter": { + "add": "添加过滤器", + "edit": "编辑过滤器", + "name": "过滤器名称", + "name-required": "过滤器名称为必填项。", + "duplicate-filter": "已存在同名过滤器。", + "filters": "过滤器", + "unable-delete-filter-title": "无法删除过滤器", + "unable-delete-filter-text": "过滤器“{{filter}}”无法删除,因为它被以下部件使用:
{{widgetsList}}", + "duplicate-filter-error": "发现重复过滤器“{{filter}}”。
过滤器在仪表板中必须唯一。", + "missing-key-filters-error": "过滤器“{{filter}}”缺少键过滤器。", + "filter": "过滤器", + "editable": "可编辑", + "editable-hint": "允许用户在仪表板中更改过滤器值。", + "no-filters-found": "未找到过滤器。", + "no-filter-text": "未指定过滤器", + "add-filter-prompt": "请添加过滤器", + "no-filter-matching": "未找到“{{filter}}”。", + "create-new-filter": "创建新过滤器!", + "create-new": "创建", + "filter-required": "过滤器为必填项。", + "operation": { + "operation": "操作", + "equal": "等于", + "not-equal": "不等于", + "starts-with": "以…开头", + "ends-with": "以…结尾", + "contains": "包含", + "not-contains": "不包含", + "greater": "大于", + "less": "小于", + "greater-or-equal": "大于或等于", + "less-or-equal": "小于或等于", + "and": "且", + "or": "或", + "in": "在…中", + "not-in": "不在…中" + }, + "ignore-case": "忽略大小写", + "value": "值", + "remove-filter": "移除过滤器", + "duplicate-filter-action": "复制过滤器", + "preview": "过滤器预览", + "no-filters": "未配置过滤器", + "add-filter": "添加过滤器", + "add-complex-filter": "添加复合过滤器", + "add-complex": "添加复合", + "complex-filter": "复合过滤器", + "edit-complex-filter": "编辑复合过滤器", + "edit-filter-user-params": "编辑过滤器谓词用户参数", + "filter-user-params": "过滤器谓词用户参数", + "user-parameters": "用户参数", + "display-label": "显示标签", + "custom-label": "自定义标签", + "custom-label-hint": "启用后可设置自定义过滤器标签。禁用时将自动生成标签。", + "order-priority": "显示顺序", + "key-filter": "键过滤器", + "key-filters": "键过滤器", + "key-name": "键名称", + "key-name-required": "键名称为必填项。", + "key-type": { + "key-type": "键类型", + "attribute": "属性", + "timeseries": "时间序列", + "entity-field": "实体字段", + "constant": "常量", + "client-attribute": "客户端属性", + "server-attribute": "服务端属性", + "shared-attribute": "共享属性" + }, + "value-type": { + "value-type": "值类型", + "string": "字符串", + "numeric": "数值", + "boolean": "布尔值", + "date-time": "日期时间" + }, + "value-type-required": "键值类型为必填项。", + "key-value-type-change-title": "确定要更改键值类型吗?", + "key-value-type-change-message": "如果确认新值类型,所有已输入的键过滤器将被移除。", + "no-key-filters": "未配置键过滤器", + "add-key-filter": "添加键过滤器", + "remove-key-filter": "移除键过滤器", + "edit-key-filter": "编辑键过滤器", + "date": "日期", + "time": "时间", + "current-tenant": "当前租户", + "current-customer": "当前客户", + "current-user": "当前用户", + "current-device": "当前设备", + "default-value": "默认值", + "default-comma-separated-values": "默认逗号分隔值", + "dynamic-source-type": "动态源类型", + "dynamic-value": "动态值", + "no-dynamic-value": "无动态值", + "source-attribute": "源属性", + "switch-to-dynamic-value": "切换到动态值", + "switch-to-default-value": "切换到默认值", + "inherit-owner": "从所有者继承", + "source-attribute-not-set": "如果源属性未设置", + "unit": "单位" + }, + "fullscreen": { + "expand": "展开为全屏", + "exit": "退出全屏", + "toggle": "切换全屏模式", + "fullscreen": "全屏" + }, + "function": { + "function": "函数" + }, + "gateway": { + "gateway-name": "Gateway 名称", + "gateway-name-required": "Gateway 名称为必填项。", + "gateways": "Gateways", + "create-new-gateway": "创建新 Gateway", + "create-new-gateway-text": "确定要创建名为“{{gatewayName}}”的新 Gateway 吗?", + "launch-command": "启动命令", + "no-gateway-found": "未找到 Gateway。", + "no-gateway-matching": " 未找到“{{item}}”。" + }, + "grid": { + "delete-item-title": "确定要删除此项目吗?", + "delete-item-text": "请注意,确认后此项目及所有相关数据将无法恢复。", + "delete-items-title": "确定要删除 { count, plural, =1 {1 个项目} other {# 个项目} } 吗?", + "delete-items-action-title": "删除 { count, plural, =1 {1 个项目} other {# 个项目} }", + "delete-items-text": "请注意,确认后所有选中的项目将被移除,且所有相关数据将无法恢复。", + "add-item-text": "添加新项目", + "no-items-text": "未找到项目", + "item-details": "项目详情", + "delete-item": "删除项目", + "delete-items": "删除项目", + "scroll-to-top": "滚动到顶部" + }, + "help": { + "goto-help-page": "前往帮助页面", + "show-help": "显示帮助" + }, + "home": { + "home": "首页", + "profile": "个人资料", + "logout": "退出登录", + "menu": "菜单", + "avatar": "头像", + "open-user-menu": "打开用户菜单" + }, + "file-input": { + "browse-file": "浏览文件", + "browse-files": "浏览文件" + }, + "image": { + "gallery": "图片库", + "search": "搜索图片", + "selected-images": "已选择 { count, plural, =1 {1 张图片} other {# 张图片} }", + "created-time": "创建时间", + "name": "名称", + "name-required": "名称为必填项。", + "resolution": "分辨率", + "size": "大小", + "system": "系统", + "download-image": "下载图片", + "export-image": "导出图片为 JSON", + "import-image": "从 JSON 导入图片", + "upload-image": "上传图片", + "edit-image": "编辑图片", + "image-details": "图片详情", + "no-images": "未找到图片", + "delete-image": "删除图片", + "delete-image-title": "确定要删除图片“{{imageTitle}}”吗?", + "delete-image-text": "请注意,确认后图片将无法恢复。", + "delete-images-title": "确定要删除 { count, plural, =1 {1 张图片} other {# 张图片} } 吗?", + "delete-images-text": "请注意,确认后所有选中的图片将被移除,且所有相关数据将无法恢复。", + "list-mode": "列表视图", + "grid-mode": "网格视图", + "image-preview": "图片预览", + "update-image": "更新图片", + "export-failed-error": "无法导出图片:{{error}}", + "image-json-file": "图片 JSON 文件", + "invalid-image-json-file-error": "无法从 JSON 导入图片:无效的图片 JSON 数据结构。", + "image-is-in-use": "图片被其他实体使用", + "images-are-in-use": "图片被其他实体使用", + "image-is-in-use-text": "图片 “{{title}}” 未被删除,因为它被以下实体使用:", + "images-are-in-use-text": "并非所有图片都已删除,因为它们被其他实体使用。
您可以通过点击对应图片行中的 引用 按钮查看引用实体。
如果仍要删除这些图片,请在下表中选中它们并点击 删除选中 按钮。", + "delete-image-in-use-text": "如果仍要删除该图片,请点击 仍然删除 按钮。", + "system-entities": "系统实体:", + "entities": "实体:", + "references": "引用", + "include-system-images": "包含系统图片", + "clear-image": "清除图片", + "no-image": "无图片", + "no-image-selected": "未选择图片", + "browse-from-gallery": "从图片库浏览", + "set-link": "设置链接", + "image-link": "图片链接", + "link": "链接", + "copy-image-link": "复制图片链接", + "embed-image": "嵌入图片", + "embed-to-html": "嵌入到 HTML", + "embed-to-html-hint": "此功能将使链接对任何未授权用户可用。", + "embed-to-html-text": "使用以下代码片段,您可以将图片嵌入到基于纯 HTML 的组件中。
此类组件包括 HTML 卡片部件、单元格内容函数等。", + "embed-to-angular-template": "嵌入到 Angular HTML 模板", + "embed-to-angular-template-text": "使用以下代码片段,您可以将图片嵌入到用于组件的 Angular HTML 模板中。
此类组件包括 Markdown 部件、部件编辑器中的 HTML 部分、自定义操作等。" + }, + "image-input": { + "drop-images-or": "拖放图片或", + "drag-and-drop": "拖放", + "or": "或", + "browse": "浏览", + "no-images": "未选择图片", + "images": "图片" + }, + "import": { + "no-file": "未选择文件", + "drop-file": "拖放 JSON 文件或点击选择要上传的文件。", + "drop-json-file-or": "拖放 JSON 文件或", + "drop-file-csv": "拖放 CSV 文件或点击选择要上传的文件。", + "drop-file-csv-or": "拖放 CSV 文件或", + "column-value": "值", + "column-title": "标题", + "column-example": "示例值数据", + "column-key": "属性/遥测键", + "credentials": "凭证", + "csv-delimiter": "CSV 分隔符", + "csv-first-line-header": "第一行包含列名", + "csv-update-data": "更新属性/遥测", + "details": "详情", + "import-csv-number-columns-error": "文件应至少包含两列", + "import-csv-invalid-format-error": "无效的文件格式。行:'{{line}}'", + "column-type": { + "name": "名称", + "type": "类型", + "label": "标签", + "column-type": "列类型", + "client-attribute": "客户端属性", + "shared-attribute": "共享属性", + "server-attribute": "服务端属性", + "timeseries": "时间序列", + "entity-field": "实体字段", + "access-token": "访问 Token", + "x509": "X.509", + "mqtt": { + "client-id": "MQTT Client ID", + "user-name": "MQTT 用户名", + "password": "MQTT 密码" + }, + "lwm2m": { + "client-endpoint": "LwM2M endpoint 客户端名称", + "security-config-mode": "LwM2M 安全配置模式", + "client-identity": "LwM2M 客户端标识", + "client-key": "LwM2M 客户端密钥", + "client-cert": "LwM2M 客户端公钥", + "bootstrap-server-security-mode": "LwM2M 引导服务器安全模式", + "bootstrap-server-secret-key": "LwM2M 引导服务器密钥", + "bootstrap-server-public-key-id": "LwM2M 引导服务器公钥或 ID", + "lwm2m-server-security-mode": "LwM2M 服务器安全模式", + "lwm2m-server-secret-key": "LwM2M 服务器密钥", + "lwm2m-server-public-key-id": "LwM2M 服务器公钥或 ID" + }, + "snmp": { + "host": "SNMP 主机", + "port": "SNMP 端口", + "version": "SNMP 版本(v1、v2c 或 v3)", + "community-string": "SNMP 团体字符串" + }, + "isgateway": "是否为 Gateway", + "activity-time-from-gateway-device": "来自 Gateway 设备的活动时间", + "description": "描述", + "routing-key": "Edge 密钥", + "secret": "Edge 密钥" + }, + "stepper-text": { + "select-file": "选择文件", + "configuration": "导入配置", + "column-type": "选择列类型", + "creat-entities": "创建新实体" + }, + "message": { + "create-entities": "已成功创建 {{count}} 个新实体。", + "update-entities": "已成功更新 {{count}} 个实体。", + "error-entities": "创建 {{count}} 个实体时出错。" + } + }, + "scada": { + "symbols": "SCADA 符号", + "search": "搜索符号", + "selected-symbols": "已选择 { count, plural, =1 {1 个符号} other {# 个符号} }", + "download-symbol": "下载 SCADA 符号", + "export-symbol": "导出 SCADA 符号为 JSON", + "import-symbol": "从 JSON 导入 SCADA 符号", + "upload-symbol": "上传 SCADA 符号", + "update-symbol": "更新 SCADA 符号", + "edit-symbol": "编辑 SCADA 符号", + "symbol-details": "SCADA 符号详情", + "mode-svg": "SVG", + "mode-xml": "XML", + "no-symbols": "未找到符号", + "show-hidden-elements": "显示隐藏元素", + "hide-hidden-elements": "隐藏隐藏元素", + "delete-symbol": "删除 SCADA 符号", + "delete-symbol-title": "确定要删除 SCADA 符号“{{imageTitle}}”吗?", + "delete-symbol-text": "请注意,确认后 SCADA 符号将无法恢复。", + "delete-symbols-title": "确定要删除 { count, plural, =1 {1 个 SCADA 符号} other {# 个 SCADA 符号} } 吗?", + "delete-symbols-text": "请注意,确认后所有选中的 SCADA 符号将被移除,且所有相关数据将无法恢复。", + "include-system-symbols": "包含系统符号", + "symbol-preview": "符号预览", + "general": "通用", + "tags": "标签", + "properties": "属性", + "title": "标题", + "description": "描述", + "search-tags": "搜索标签", + "widget-size": "部件大小", + "cols": "列", + "rows": "行", + "state-render-function": "状态渲染函数", + "preview": "预览", + "preview-widget-action-text": "部件操作“{{type}}”已成功调用!", + "no-symbol": "无 SCADA 符号", + "no-symbol-selected": "未选择 SCADA 符号", + "clear-symbol": "清除 SCADA 符号", + "browse-symbol-from-gallery": "从图库浏览 SCADA 符号", + "zoom-in": "放大", + "zoom-out": "缩小", + "create-widget": "创建部件", + "create-widget-from-symbol": "从 SCADA 符号创建部件", + "hidden": "隐藏", + "tag": { + "tag": "标签", + "on-click-action": "点击操作", + "no-tags": "未配置标签", + "delete-tag-text": "确定要从 {{elementType}} 元素中删除标签
{{tag}} 吗?", + "update-tag": "更新标签", + "enter-tag": "输入标签", + "tag-settings": "标签设置", + "remove-tag": "移除标签", + "add-tag": "添加标签" + }, + "behavior": { + "behavior": "行为", + "id": "ID", + "name": "名称", + "type": "类型", + "no-behaviors": "未配置行为", + "add-behavior": "添加行为", + "type-action": "操作", + "type-value": "值", + "type-widget-action": "部件操作", + "behavior-settings": "行为设置", + "remove-behavior": "移除行为", + "hint": "提示", + "group-title": "分组标题", + "value-type": "值类型", + "default-value": "默认值", + "true-label": "True 标签", + "false-label": "False 标签", + "state-label": "状态标签", + "default-payload": "默认有效载荷", + "not-unique-behavior-ids-error": "行为 ID 必须唯一!", + "default-settings": "默认设置" + }, + "symbol": { + "symbol": "SCADA 符号", + "fluid-presence": "流体存在", + "fluid-presence-hint": "指示管道中是否存在流体。", + "fluid-present": "流体存在", + "present": "存在", + "absent": "不存在", + "flow-presence": "流动存在", + "flow-presence-hint": "指示管道中流体是否在流动。", + "flow-present": "流动存在", + "flow-direction": "流动方向", + "flow-direction-hint": "指示流体流动方向。", + "forward": "正向", + "reverse": "反向", + "flow-animation-speed": "流动动画速度", + "flow-animation-speed-hint": "表示流动动画速度的双精度值。1 - 正常速度,0 - 无动画,< 1 - 较慢动画,> 1 - 较快动画。", + "leak": "泄漏", + "leak-hint": "指示管道中是否存在泄漏。", + "leak-present": "存在泄漏", + "fluid-color": "流体颜色", + "pipe-color": "管道颜色", + "horizontal-pipe": "水平管道", + "vertical-pipe": "垂直管道", + "horizontal-fluid-color": "水平流体颜色", + "vertical-fluid-color": "垂直流体颜色", + "left-pipe": "左侧管道", + "right-pipe": "右侧管道", + "top-pipe": "顶部管道", + "bottom-pipe": "底部管道", + "left-fluid-color": "左侧流体颜色", + "right-fluid-color": "右侧流体颜色", + "top-fluid-color": "顶部流体颜色", + "bottom-fluid-color": "底部流体颜色", + "display": "显示", + "display-format": "显示格式", + "value": "值", + "decimals": "小数位数", + "units": "单位", + "flow-meter-value-hint": "流量计显示屏上显示的双精度值", + "value-hint": "表示当前值的双精度值", + "running": "运行中", + "running-hint": "指示组件是否处于运行状态。", + "warning-state": "警告状态", + "warning": "警告", + "warning-click": "警告点击", + "warning-state-hint": "指示组件是否处于警告状态。", + "critical-state": "严重状态", + "critical": "严重", + "critical-click": "严重点击", + "critical-state-hint": "指示组件是否处于严重状态。", + "critical-state-animation": "严重状态动画", + "critical-state-animation-hint": "是否在组件处于严重状态时启用闪烁动画。", + "warning-critical-state-animation": "警告/严重状态动画", + "warning-critical-state-animation-hint": "是否在组件处于警告或严重状态时启用闪烁动画。", + "animation": "动画", + "broken": "损坏", + "broken-hint": "指示组件是否已损坏。", + "on-display-click": "点击显示", + "on-display-click-hint": "用户点击显示时调用的操作。", + "pipe": "管道", + "default-border-color": "默认边框颜色", + "active-border-color": "活跃边框颜色", + "warning-border-color": "警告边框颜色", + "critical-border-color": "严重边框颜色", + "background-color": "背景颜色", + "rotation-animation-speed": "旋转动画速度", + "rotation-animation-speed-hint": "表示旋转动画速度的双精度值。1 - 正常速度,0 - 无动画,< 1 - 较慢动画,> 1 - 较快动画。", + "on-click": "点击", + "on-click-hint": "用户点击组件时调用的操作。", + "connectors-positions": "连接器位置", + "right-connector": "右侧连接器", + "right-top-connector": "右上连接器", + "right-bottom-connector": "右下连接器", + "left-connector": "左侧连接器", + "left-top-connector": "左上连接器", + "left-bottom-connector": "左下连接器", + "top-left-connector": "左上连接器", + "top-right-connector": "右上连接器", + "top-connector": "顶部连接器", + "bottom-connector": "底部连接器", + "running-color": "运行颜色", + "stopped-color": "停止颜色", + "stopped": "已停止", + "warning-color": "警告颜色", + "critical-color": "严重颜色", + "opened": "已打开", + "opened-hint": "指示组件是否处于打开状态。", + "open": "打开", + "open-hint": "用户点击打开组件时调用的操作。", + "close": "关闭", + "close-hint": "用户点击关闭组件时调用的操作。", + "close-state-animation": "关闭状态动画", + "close-state-animation-hint": "是否在组件处于关闭状态时启用闪烁动画。", + "opened-color": "打开颜色", + "closed-color": "关闭颜色", + "opened-rotation-angle": "打开旋转角度", + "closed-rotation-angle": "关闭旋转角度", + "tank-capacity": "储罐容量", + "tank-capacity-hint": "表示储罐总容量的双精度值。", + "current-volume": "当前容积", + "current-volume-hint": "表示当前已占用容积的双精度值。", + "tank-color": "储罐颜色", + "value-box": "值框", + "value-text": "值文本", + "scale": "刻度", + "transparent-mode": "透明模式", + "major-ticks": "主刻度", + "intervals": "间隔", + "major-ticks-color": "主刻度颜色", + "normal": "正常", + "minor-ticks": "次刻度", + "minor-ticks-color": "次刻度颜色", + "temperature": "温度", + "temperature-hint": "表示当前温度的双精度值。", + "update-temperature": "更新温度", + "update-temperature-hint": "用户点击更改当前温度时调用的操作。", + "run": "运行", + "run-hint": "用户点击运行组件时调用的操作。", + "stop": "停止", + "stop-hint": "用户点击停止组件时调用的操作。", + "temperature-step": "温度步进增量", + "heat-pump-color": "热泵颜色", + "power-button-background": "电源按钮背景", + "value-box-background": "值框背景", + "value-units": "值单位", + "enable-units-scale": "在刻度上启用单位", + "filtration-mode": "过滤模式", + "filtration-mode-hint": "表示当前过滤模式的整数值。", + "filtration-mode-update": "过滤模式更新状态", + "filtration-mode-update-hint": "用户点击更改当前过滤模式时调用的操作。", + "filter-mode": "过滤", + "waste-mode": "排废", + "backwash-mode": "反冲洗", + "recirculate-mode": "循环", + "rinse-mode": "冲洗", + "closed-mode": "关闭", + "sand-filter-color": "砂滤器颜色", + "mode-box-background": "模式框背景", + "border-color": "边框颜色", + "label-color": "标签颜色", + "water-leak-hint": "指示是否存在泄漏。", + "default-color": "默认颜色", + "leak-color": "泄漏颜色", + "full-value": "满值", + "full-value-hint": "表示满值的双精度值。", + "label": "标签", + "icon": "图标", + "button-color": "按钮颜色", + "on-label": "“开”标签文本", + "off-label": "“关”标签文本", + "arrow-presence": "箭头存在", + "arrow-presence-hint": "指示连接器中是否存在箭头。", + "arrow-present": "箭头存在", + "arrow-direction": "流动方向", + "arrow-direction-hint": "指示流动方向。", + "flow-animation": "流动存在", + "flow-animation-hint": "指示连接器中流体是否在流动。", + "flow": "流动", + "flow-line": "线条", + "flow-line-style": "线条样式", + "flow-style-hint": "设置虚线和间隔值,使其总和可被 100 整除,以实现完美的动画同步。", + "flow-dash-cap": "虚线端点", + "dash-cap-butt": "平头", + "dash-cap-round": "圆头", + "dash-cap-square": "方头", + "dash": "虚线", + "gap": "间隔", + "main-line": "主线条", + "line": "线条", + "line-color": "线条颜色", + "arrow-color": "箭头颜色", + "target-value": "目标值", + "target-value-hint": "指示刻度上的目标点。", + "min-max-value": "最小值和最大值", + "min-value": "最小值", + "max-value": "最大值", + "progress-bar": "进度条", + "progress-arrow": "进度箭头", + "warning-scale-color": "警告刻度颜色", + "critical-scale-color": "严重刻度颜色", + "scale-color": "刻度颜色", + "target": "目标", + "high-warning-state": "高警告状态", + "show-high-warning-scale": "显示高警告刻度", + "high-warning-scale": "高警告刻度", + "high-warning-state-hint": "表示从高警告范围到高严重或最大值的双精度值。", + "low-warning-state": "低警告状态", + "show-low-warning-scale": "显示低警告刻度", + "low-warning-scale": "低警告刻度", + "low-warning-state-hint": "表示从低警告范围到低严重或最小值的双精度值。", + "high-critical-state": "高严重状态", + "show-high-critical-scale": "显示高严重刻度", + "high-critical-scale": "高严重刻度", + "high-critical-state-hint": "表示从高严重范围到最大刻度值的双精度值。", + "low-critical-state": "低严重状态", + "show-low-critical-scale": "显示低严重状态", + "low-critical-scale": "低严重状态", + "low-critical-state-hint": "表示从低严重范围到最小刻度值的双精度值。", + "filter-color": "过滤器颜色", + "colors": "颜色", + "indicator-colors": "指示器颜色", + "enabled": "已启用", + "disabled": "已禁用", + "on": "开", + "off": "关", + "on-off-state": "开/关状态", + "on-off-state-hint": "指示组件是处于开还是关状态。", + "on-update-state": "开更新状态", + "on-update-state-hint": "用户点击将状态更新为开时调用的操作。", + "off-update-state": "关更新状态", + "off-update-state-hint": "用户点击将状态更新为关时调用的操作。", + "voltage": "电压", + "input-voltage": "输入电压", + "input-voltage-hint": "表示输入电压值的双精度值。", + "output-voltage": "输出电压", + "output-voltage-hint": "表示输出电压值的双精度值。", + "first-phase-voltage": "第一相电压", + "second-phase-voltage": "第二相电压", + "third-phase-voltage": "第三相电压", + "phase-voltage-hint": "表示当前相电压值的双精度值", + "voltage-hint": "表示当前电压的双精度值", + "current-voltage-color": "当前电压颜色", + "phase-indicator-color": "相位指示器颜色", + "measured": "测量值", + "measured-hint": "表示千瓦时能耗的双精度值", + "day-rate": "日间费率", + "night-rate": "夜间费率", + "off-peak-rate": "低谷费率", + "peak-rate": "高峰费率", + "export-rate": "输出费率", + "operating-mode": "运行模式", + "bypass-mode": "旁路", + "operating-mode-hint": "表示当前运行模式的整数值(0 - 关,1 - 开,2 - 旁路)", + "connected": "已连接", + "connected-hint": "指示组件是否处于已连接状态。", + "disconnected": "已断开", + "indicator": "指示器", + "operation-mode": "运行模式", + "operation-mode-hint": "指示逆变器处于市电还是逆变器模式。", + "operation-mode-indicators-color": "运行模式指示器颜色", + "mains-on-mode": "市电开", + "inverter-on-mode": "逆变器开", + "charging-mode": "充电模式", + "charging-mode-hint": "表示当前充电模式的整数值(1 - 恒流,2 - 恒压,3 - 浮充)", + "charging-mode-indicators-color": "充电模式指示器颜色", + "inverter-faults": "故障", + "inverter-fault-indicators-color": "故障指示器颜色", + "overload-fault": "过载", + "overload-fault-hint": "指示逆变器是否处于过载状态。", + "low-battery-fault": "低电量", + "low-battery-fault-hint": "指示电池是否过度放电。", + "temperature-fault": "温度", + "temperature-fault-hint": "指示逆变器中是否存在高温。", + "triangle": "三角形", + "socket": "插座", + "left-button": "左按钮", + "right-button": "右按钮", + "alarm-colors": "告警颜色", + "hook-color": "挂钩颜色" + } + }, + "item": { + "selected": "已选择" + }, + "js-func": { + "no-return-error": "函数必须返回值!", + "return-type-mismatch": "函数必须返回“{{type}}”类型的值!", + "tidy": "整理", + "mini": "压缩", + "modules": "模块", + "remove-module": "移除模块", + "no-modules": "未配置模块", + "add-module": "添加模块", + "module-alias": "别名", + "invalid-module-alias-name": "无效的别名名称", + "module-resource": "JS 模块资源", + "not-unique-module-aliases-error": "模块别名必须唯一!", + "show-module-info": "显示模块信息", + "show-module-source-code": "显示模块源代码", + "module-members": "模块成员", + "module-no-members": "模块没有导出成员", + "module-load-error": "模块加载错误", + "source-code": "源代码", + "source-code-load-error": "源代码加载错误", + "no-js-module-text": "未找到 JS 模块", + "no-js-module-matching": "未找到与“{{module}}”匹配的 JS 模块。" + }, + "key-val": { + "key": "键", + "value": "值", + "remove-entry": "移除条目", + "add-entry": "添加条目", + "no-data": "无条目" + }, + "layout": { + "layout": "布局", + "layouts": "布局", + "manage": "管理布局", + "settings": "布局设置", + "color": "颜色", + "main": "主", + "right": "右", + "left": "左", + "select": "选择目标布局", + "percentage-width": "百分比宽度(%)", + "fixed-width": "固定宽度(px)", + "left-width": "左列(%)", + "right-width": "右列(%)", + "pick-fixed-side": "固定侧:", + "layout-fixed-width": "固定宽度(px)", + "value-min-error": "值必须大于 {{min}}{{unit}}", + "value-max-error": "值必须小于 {{max}}{{unit}}", + "layout-fixed-width-required": "固定宽度为必填项", + "right-width-percentage-required": "右侧百分比为必填项", + "left-width-percentage-required": "左侧百分比为必填项", + "divider": "分隔线", + "right-side": "右侧布局", + "left-side": "左侧布局", + "add-new-breakpoint": "添加新断点", + "breakpoint": "断点", + "breakpoints": "断点", + "copy-from": "复制自", + "size": "大小", + "delete-breakpoint-title": "确定要删除断点“{{name}}”吗?", + "delete-breakpoint-text": "请注意,确认后断点将无法恢复,设置将恢复为默认断点。" + }, + "legend": { + "direction": "方向", + "position": "位置", + "show-values": "显示值", + "min-option": "最小值", + "max-option": "最大值", + "average-option": "平均值", + "total-option": "总计", + "latest-option": "最新值", + "sort-legend": "在图例中排序数据键", + "show-max": "显示最大值", + "show-min": "显示最小值", + "show-avg": "显示平均值", + "show-total": "显示总计值", + "show-latest": "显示最新值", + "settings": "图例设置", + "min": "最小", + "max": "最大", + "avg": "平均", + "total": "总计", + "latest": "最新", + "Min": "最小", + "Max": "最大", + "Avg": "平均", + "Total": "总计", + "Latest": "最新", + "comparison-time-ago": { + "previousInterval": "(前一区间)", + "customInterval": "(自定义区间)", + "days": "(一天前)", + "weeks": "(一周前)", + "months": "(一月前)", + "years": "(一年前)" + }, + "column-title": "列标题", + "label": "标签", + "value": "值" + }, + "login": { + "login": "登录", + "request-password-reset": "请求重置密码", + "reset-password": "重置密码", + "create-password": "创建密码", + "two-factor-authentication": "双因素认证", + "passwords-mismatch-error": "输入的密码必须一致!", + "password-again": "再次输入密码", + "sign-in": "登录", + "username": "用户名(Email)", + "remember-me": "记住我", + "forgot-password": "忘记密码?", + "password-reset": "密码重置", + "expired-password-reset-message": "您的密码已过期!\n请输入新密码。", + "new-password": "新密码", + "new-password-again": "确认新密码", + "password-link-sent-message": "重置链接已发送", + "email": "Email", + "invalid-email-format": "无效的 Email 格式。", + "sign-in-with": "使用 {{name}} 登录", + "sign-in-to-your-account": "登录您的账户", + "or": "或", + "error": "登录错误", + "verify-your-identity": "验证您的身份", + "select-way-to-verify": "选择验证方式", + "resend-code": "重新发送验证码", + "resend-code-wait": "在 { time, plural, =1 {1 秒} other {# 秒} } 后重新发送验证码", + "try-another-way": "尝试其他方式", + "totp-auth-description": "请输入您的身份验证器应用中的安全码。", + "totp-auth-placeholder": "验证码", + "sms-auth-description": "安全码已发送到您的手机号码 {{contact}}。", + "sms-auth-placeholder": "短信验证码", + "email-auth-description": "安全码已发送到您的 Email 地址 {{contact}}。", + "email-auth-placeholder": "Email 验证码", + "backup-code-auth-description": "请输入您的备用验证码之一。", + "backup-code-auth-placeholder": "备用验证码", + "activation-link-expired": "激活链接已过期", + "activation-link-expired-message": "激活您个人资料的链接已过期。您可以返回登录页面以接收新的 Email。", + "reset-password-link-expired": "密码重置链接已过期", + "reset-password-link-expired-message": "重置密码的链接已过期。您可以返回登录页面以接收新的 Email。", + "two-fa": "双因素认证", + "two-fa-required": "需要双因素认证", + "set-up-verification-method": "设置验证方式以继续", + "set-up-verification-method-login": "设置验证方式或登录", + "enable-authenticator-app": "启用身份验证器应用", + "enable-authenticator-app-description": "请输入您的身份验证器应用中的安全码", + "enable-authenticator-sms": "启用短信验证", + "enable-authenticator-sms-description": "请输入我们刚发送到以下号码的 6 位验证码:", + "enable-authenticator-email": "启用 Email 验证", + "enable-authenticator-email-description": "安全码已发送到您的 Email 地址:", + "enter-key-manually": "或手动输入此 32 位密钥:", + "continue": "继续", + "confirm": "确认", + "authenticator-app-success": "身份验证器应用已成功启用", + "authenticator-app-success-description": "下次登录时,您需要提供双因素认证验证码", + "authenticator-sms-success": "短信验证已成功启用", + "authenticator-sms-success-description": "下次登录时,系统将提示您输入发送到该手机号码的安全码", + "authenticator-email-success": "Email 验证已成功启用", + "authenticator-email-success-description": "下次登录时,系统将提示您输入发送到您 Email 地址的安全码", + "authenticator-backup-code-success": "备用验证码已成功启用", + "authenticator-backup-code-success-description": "下次登录时,系统将提示您输入安全码或使用备用验证码之一。", + "add-verification-method": "添加验证方式", + "get-backup-code": "获取备用验证码", + "copy-key": "复制密钥", + "send-code": "发送验证码", + "email-label": "Email", + "email-description": "输入用于身份验证的 Email。", + "sms-description": "输入用于身份验证的手机号码。", + "backup-code-description": "打印这些验证码,以便在需要登录账户时随时使用。每个备用验证码只能使用一次。", + "backup-code-warn": "离开此页面后,这些验证码将无法再次显示。请使用以下选项安全保存。", + "download-txt": "下载(txt)", + "print": "打印", + "verification-code": "6 位验证码", + "verification-code-invalid": "无效的验证码格式", + "verification-code-incorrect": "验证码不正确", + "verification-code-many-request": "验证码校验请求过多", + "scan-qr-code": "使用您的验证应用扫描此二维码", + "phone-input": { + "phone-input-label": "手机号码", + "phone-input-required": "手机号码为必填项", + "phone-input-validation": "手机号码无效或不可用", + "phone-input-pattern": "无效的手机号码。应为 E.164 格式,例如 {{phoneNumber}}", + "phone-input-hint": "E.164 格式的手机号码,例如 {{phoneNumber}}" + } + }, + "mobile": { + "add-application": "添加应用程序", + "app-id": "App Site Association ID", + "app-id-required": "App Site Association ID 为必填项", + "app-id-pattern": "App Site Association ID 格式无效", + "app-store-link": "App Store 链接", + "app-store-link-required": "App Store 链接为必填项", + "application-details": "应用程序详情", + "application-package": "应用程序包", + "application-secret": "Application Secret", + "application-secret-required": "Application Secret 为必填项", + "application": "应用程序", + "applications": "应用程序", + "copy-app-id": "复制 App ID", + "copy-app-store-link": "复制 App Store 链接", + "copy-application-package": "复制应用程序包", + "copy-application-secret": "复制 Application Secret", + "copy-google-play-link": "复制 Google Play 链接", + "copy-sha256-certificate-fingerprints": "复制 SHA256 证书指纹", + "delete-application": "删除应用程序", + "delete-application-button-text": "我了解后果,删除应用程序", + "delete-application-text": "此操作无法撤销。这将永久删除您的应用程序。
如果不想永久删除,可以暂时 暂停 应用程序。
要删除 应用程序,请输入 \"{{phrase}}\" 以确认。", + "delete-application-title-short": "确定要删除应用程序“{{name}}”吗?", + "delete-application-text-short": "请注意,确认后应用程序及所有相关数据将无法恢复。", + "delete-application-phrase": "delete application", + "delete-applications-bundle-text": "请注意,确认后移动包及所有相关数据将无法恢复。", + "delete-applications-bundle-title": "确定要删除移动包“{{bundleName}}”吗?", + "generate-application-secret": "生成 Application Secret", + "google-play-link": "Google Play 链接", + "google-play-link-required": "Google Play 链接为必填项", + "latest-version": "最新版本", + "min-version": "最低版本", + "invalid-version-pattern": "无效的版本格式。请使用格式:主版本.次版本.修订版本(例如 1.0.0)。", + "mobile-center": "移动中心", + "mobile-package": "应用程序包", + "mobile-package-max-length": "应用程序包应少于 256 个字符", + "mobile-package-required": "应用程序包为必填项。", + "mobile-package-pattern": "应用程序包格式无效", + "mobile-package-title": "应用程序标题", + "mobile-package-title-max-length": "应用程序标题应少于 256 个字符", + "no-application": "未找到应用程序", + "no-bundles": "未找到包", + "platform-type": "平台类型", + "search-application": "搜索应用程序", + "search-bundles": "搜索包", + "set": "设置", + "sha256-certificate-fingerprints": "SHA256 证书指纹", + "sha256-certificate-fingerprints-required": "SHA256 证书指纹为必填项", + "sha256-certificate-fingerprints-pattern": "SHA256 证书指纹格式无效", + "show-hidden-pages": "显示隐藏页面", + "status": "状态", + "status-type": { + "deprecated": "已弃用", + "draft": "草稿", + "published": "已发布", + "suspended": "已暂停" + }, + "store-information": "商店信息", + "version-information": "版本信息", + "min-version-release-notes": "最低版本发布说明", + "latest-version-release-notes": "最新版本发布说明", + "bundle": "包", + "bundles": "包", + "add-bundle": "添加包", + "title": "标题", + "title-required": "标题为必填项", + "title-cannot-contain-only-spaces": "标题不能仅包含空格", + "title-max-length": "标题应少于 256 个字符", + "oauth-clients": "OAuth 2.0 客户端", + "android-app": "Android 应用", + "android-application": "Android 应用程序", + "ios-app": "iOS 应用", + "ios-application": "iOS 应用程序", + "invalid-store-link": "无效的商店链接", + "enable-oauth": "启用 OAuth 2.0", + "enable-self-registration": "启用自助注册", + "edit-bundle": "编辑包", + "description": "描述", + "basic-settings": "基本设置", + "no-application-matching": "未找到与“{{entity}}”匹配的应用程序。", + "no-bundle-matching": "未找到与“{{entity}}”匹配的包。", + "application-required": "应用程序为必填项。", + "bundle-required": "包为必填项。", + "no-application-text": "未找到应用程序", + "no-bundle-text": "未找到包", + "layout": "布局", + "pages": "页面", + "hide-all-pages": "隐藏所有页面", + "reset-to-default-pages": "重置为默认页面", + "add-specific-page": "添加特定页面", + "visible": "可见", + "hidden": "隐藏", + "reset-to-page-default": "将页面重置为默认", + "mobile-599": "手机(最大 599px)", + "tablet-959": "平板电脑(最大 959px)", + "max-element-number": "最大元素数量", + "page-name": "页面名称", + "page-name-required": "页面名称为必填项。", + "page-name-cannot-contain-only-spaces": "页面名称不能仅包含空格。", + "page-name-max-length": "页面名称应少于 256 个字符", + "page-type": "页面类型", + "pages-types": { + "dashboard": "仪表板", + "web-view": "Web 视图", + "custom": "自定义" + }, + "url": "URL", + "invalid-url-format": "无效的 URL 格式", + "path": "路径", + "invalid-path-format": "无效的路径格式", + "custom-page": "自定义页面", + "edit-page": "编辑页面", + "edit-custom-page": "编辑自定义页面", + "delete-page": "删除页面", + "qr-code-widget": "二维码部件", + "type-here": "在此输入", + "configuration-dialog": "配置对话框", + "configuration-app": "配置应用", + "configuration-step": { + "prepare-environment-title": "准备开发环境", + "prepare-environment-text": "Flutter ThingsBoard 移动应用程序需要 Flutter SDK。请按照说明设置 Flutter SDK。", + "get-source-code-title": "获取应用源代码", + "get-source-code-text": "您可以通过从 GitHub 仓库克隆来获取 Flutter ThingsBoard 移动应用程序的源代码:", + "configure-app-settings-title": "配置应用设置", + "configure-app-settings-text": "下载配置文件并将其放置到上一步克隆的项目根目录中。", + "download-file": "下载文件", + "run-app-title": "运行应用", + "run-app-text": "按照 IDE 中的说明运行应用。\n如果使用终端,请使用以下命令运行应用:", + "more-information": "详细信息请参阅我们的入门文档。", + "getting-started": "入门指南" + } + }, + "notification": { + "action-button": "操作按钮", + "action-type": "操作类型", + "active": "活跃", + "add-notification-recipients-group": "添加通知收件人组", + "add-notification-template": "添加通知模板", + "add-recipient": "添加收件人", + "add-recipients": "添加收件人", + "add-rule": "添加规则", + "add-stage": "添加阶段", + "add-template": "添加模板", + "after": "之后", + "alarm-assignment-trigger-settings": "告警分配触发器设置", + "alarm-comment-trigger-settings": "告警评论触发器设置", + "alarm-trigger-settings": "告警触发器设置", + "all": "全部", + "api-feature-hint": "如果该字段为空,触发器将应用于所有 API 功能", + "api-usage-trigger-settings": "API 使用触发器设置", + "new-platform-version-trigger-settings": "新平台版本触发器设置", + "rate-limits-trigger-settings": "超出速率限制触发器设置", + "task-processing-failure-trigger-settings": "任务处理失败触发器设置", + "resources-shortage-trigger-settings": "资源不足触发器设置", + "at-least-one-should-be-selected": "至少需要选择一项", + "basic-settings": "基本设置", + "button-text": "按钮文本", + "button-text-required": "按钮文本为必填项", + "button-text-max-length": "按钮文本长度不得超过 {{ length }} 个字符", + "compose": "编写", + "conversation": "会话", + "conversation-required": "会话为必填项", + "copy-notification-template": "复制通知模板", + "copy-rule": "复制规则", + "copy-template": "复制模板", + "create-new": "新建", + "created": "已创建", + "customize-messages": "自定义消息", + "cpu-threshold": "CPU 阈值", + "delete-notification-text": "请注意,确认后该通知将无法恢复。", + "delete-notification-title": "确定要删除该通知吗?", + "delete-notifications-text": "请注意,确认后通知将无法恢复。", + "delete-notifications-title": "确定要删除 { count, plural, =1 {1 条通知} other {# 条通知} } 吗?", + "delete-recipient-text": "请注意,确认后该收件人将无法恢复。", + "delete-recipient-title": "确定要删除收件人“{{recipientName}}”吗?", + "delete-recipients-text": "请注意,确认后收件人将无法恢复。", + "delete-recipients-title": "确定要删除 { count, plural, =1 {1 个收件人} other {# 个收件人} } 吗?", + "delete-request-text": "请注意,确认后该请求将无法恢复。", + "delete-request-title": "确定要删除该请求吗?", + "delete-requests-text": "请注意,确认后请求将无法恢复。", + "delete-requests-title": "确定要删除 { count, plural, =1 {1 个请求} other {# 个请求} } 吗?", + "delete-rule-text": "请注意,确认后该规则将无法恢复。", + "delete-rule-title": "确定要删除规则“{{ruleName}}”吗?", + "delete-rules-text": "请注意,确认后规则将无法恢复。", + "delete-rules-title": "确定要删除 { count, plural, =1 {1 条规则} other {# 条规则} } 吗?", + "delete-template-text": "请注意,确认后该模板将无法恢复。", + "delete-template-title": "确定要删除模板“{{templateName}}”吗?", + "delete-templates-text": "请注意,确认后模板将无法恢复。", + "delete-templates-title": "确定要删除 { count, plural, =1 {1 个模板} other {# 个模板} } 吗?", + "deleted": "已删除", + "delivery-method": { + "delivery-method": "发送方式", + "email": "Email", + "email-preview": "Email 通知预览", + "slack": "Slack", + "slack-preview": "Slack 通知预览", + "microsoft-teams": "Microsoft Teams", + "microsoft-teams-preview": "Microsoft Teams 通知预览", + "sms": "SMS", + "sms-preview": "SMS 通知预览", + "web": "Web", + "web-preview": "Web 通知预览", + "mobile-app": "移动应用", + "mobile-app-preview": "移动应用通知预览" + }, + "delivery-method-not-configure-click": "发送方式未配置。点击进行设置。", + "delivery-method-not-configure-contact": "发送方式未配置。请联系系统管理员。", + "delivery-methods": "发送方式", + "description": "描述", + "device-activity-trigger-settings": "设备活跃触发器设置", + "device-list-rule-hint": "如果该字段为空,触发器将应用于所有设备", + "device-profiles-list-rule-hint": "如果该字段为空,触发器将应用于所有设备配置", + "disabled": "已禁用", + "edge-trigger-settings": "Edge 触发器设置", + "edge-list-rule-hint": "如果该字段为空,触发器将应用于所有 Edge 实例", + "edit-notification-recipients-group": "编辑通知收件人组", + "edit-notification-template": "编辑通知模板", + "edit-rule": "编辑规则", + "edit-template": "编辑模板", + "enabled": "已启用", + "entities-limit-trigger-settings": "实体限制触发器设置", + "entity-action-trigger-settings": "实体操作触发器设置", + "entity-type": "实体类型", + "escalation-chain": "升级链", + "failed-send": "发送失败", + "fails": "{ count, plural, =1 {1 次失败} other {# 次失败} }", + "filter": "过滤", + "first-recipient": "第一收件人", + "inactive": "不活跃", + "inbox": "收件箱", + "notification-inbox": "通知 / 收件箱", + "input-field-support-templatization": "输入字段支持模板化。", + "input-fields-support-templatization": "输入字段支持模板化。", + "link": "链接", + "link-required": "链接为必填项", + "link-max-length": "链接长度不得超过 {{ length }} 个字符", + "link-type": { + "dashboard": "打开仪表板", + "link": "打开 URL 链接" + }, + "loading-notifications": "正在加载通知...", + "management": "通知管理", + "mark-all-as-read": "全部标记为已读", + "mark-as-read": "标记为已读", + "message": "消息", + "message-required": "消息为必填项", + "message-max-length": "消息长度不得超过 {{ length }} 个字符", + "name": "名称", + "name-required": "名称为必填项", + "new-notification": "新通知", + "no-inbox-notification": "未找到通知", + "no-notification-request": "暂无通知请求", + "no-notification-templates": "未找到通知模板", + "no-notifications-yet": "暂无通知", + "no-recipients-notification": "暂无收件人通知", + "no-recipients-matching": "未找到与“{{entity}}”匹配的收件人。", + "no-recipients-text": "未找到收件人", + "no-rule": "未配置规则", + "no-rules-notification": "暂无规则通知", + "no-severity-found": "未找到严重程度", + "no-severity-matching": "未找到“{{severity}}”。", + "no-template-matching": "未找到与“{{template}}”匹配的资源。", + "create-new-template": "创建一个新模板!", + "not-found-slack-recipient": "未找到 Slack 收件人", + "notification": "通知", + "notification-center": "通知中心", + "notification-tap-action": "通知点击操作", + "notification-tap-action-hint": "如果未启用,将使用默认告警仪表板", + "notify": "通知", + "notify-again": "再次通知", + "notify-alarm-action": { + "acknowledged": "告警已确认", + "assigned": "告警已分配", + "cleared": "告警已清除", + "created": "告警已创建", + "severity-changed": "告警严重程度已变更", + "unassigned": "告警已取消分配" + }, + "notify-on": "通知条件", + "notify-on-comment-update": "评论更新时通知", + "notify-on-required": "通知条件为必填项", + "notify-on-unassign": "取消分配时通知", + "notify-only-user-comments": "仅通知用户评论", + "only-rule-chain-lifecycle-failures": "仅规则链生命周期失败", + "only-rule-node-lifecycle-failures": "仅规则节点生命周期失败", + "platform-users": "平台用户", + "ram-threshold": "RAM 阈值", + "rate-limits": "速率限制", + "rate-limits-hint": "如果该字段为空,触发器将应用于所有速率限制", + "recipient": "收件人", + "recipient-group": "收件人组", + "recipient-type": { + "affected-tenant-administrators": "受影响的租户管理员", + "affected-user": "受影响的用户", + "all-users": "所有用户", + "customer-users": "客户用户", + "system-administrators": "系统管理员", + "tenant-administrators": "租户管理员", + "user-filters": "用户过滤器", + "user-list": "用户列表", + "users-entity-owner": "实体所有者的用户" + }, + "recipients": "收件人", + "notification-recipient": "通知收件人", + "notification-recipient-required": "通知收件人为必填项。", + "notification-recipients": "通知 / 收件人", + "recipients-count": "{ count, plural, =1 {1 个收件人} other {# 个收件人} }", + "recipients-required": "收件人为必填项", + "refresh-allow-delivery-method": "刷新允许的发送方式", + "request-search": "搜索请求", + "request-status": { + "processing": "处理中", + "scheduled": "已计划", + "sent": "已发送" + }, + "review": "审核", + "rule": "规则", + "rule-chain-list-rule-hint": "如果该字段为空,触发器将应用于所有规则链", + "rule-engine-events-trigger-settings": "规则引擎事件触发器设置", + "rule-engine-filter": "规则引擎过滤器", + "rule-name": "规则名称", + "rule-name-required": "名称为必填项", + "rule-disable": "禁用通知规则", + "rule-enable": "启用通知规则", + "rule-node-filter": "规则节点过滤器", + "rules": "规则", + "notification-rules": "通知 / 规则", + "scheduler-later": "计划稍后发送", + "search-notification": "搜索通知", + "search-recipients": "搜索收件人", + "search-rules": "搜索规则", + "search-templates": "搜索模板", + "see-documentation": "查看文档", + "selected-notifications": "已选择 { count, plural, =1 {1 条通知} other {# 条通知} }", + "selected-recipients": "已选择 { count, plural, =1 {1 个收件人} other {# 个收件人} }", + "selected-requests": "已选择 { count, plural, =1 {1 个请求} other {# 个请求} }", + "selected-rules": "已选择 { count, plural, =1 {1 条规则} other {# 条规则} }", + "selected-template": "已选择 { count, plural, =1 {1 个模板} other {# 个模板} }", + "send-notification": "发送通知", + "sent": "已发送", + "setup": "设置", + "notification-sent": "通知 / 已发送", + "set-entity-from-notification": "将通知中的实体设置到仪表板状态", + "slack-chanel-type": "Slack 频道类型", + "slack-chanel-types": { + "direct": "私信", + "private-channel": "私有频道", + "public-channel": "公共频道" + }, + "start-from-scratch": "从头开始", + "status": "状态", + "stop-escalation-alarm-status-become": "当告警状态变为以下时停止升级:", + "storage-threshold": "存储阈值", + "subject": "主题", + "subject-required": "主题为必填项", + "subject-max-length": "主题长度不得超过 {{ length }} 个字符", + "template": "模板", + "template-name": "模板名称", + "template-required": "模板为必填项", + "template-type": { + "alarm": "告警", + "alarm-assignment": "告警分配", + "alarm-comment": "告警评论", + "api-usage-limit": "API 使用限制", + "device-activity": "设备活跃", + "entities-limit": "实体限制", + "entities-limit-increase-request": "实体限制提升请求", + "entity-action": "实体操作", + "general": "通用", + "rule-engine-lifecycle-event": "规则引擎生命周期事件", + "rule-node": "规则节点", + "new-platform-version": "新平台版本", + "rate-limits": "超出速率限制", + "edge-communication-failure": "Edge 通信故障", + "edge-connection": "Edge 连接", + "task-processing-failure": "任务处理失败", + "resources-shortage": "资源不足" + }, + "templates": "模板", + "notification-templates": "通知 / 模板", + "tenant-profiles-list-rule-hint": "如果该字段为空,触发器将应用于所有租户配置", + "tenants-list-rule-hint": "如果该字段为空,触发器将应用于所有租户", + "threshold": "阈值", + "theme-color": "主题颜色", + "time": "时间", + "track-rule-node-events": "跟踪规则节点事件", + "trigger": { + "alarm": "告警", + "alarm-assignment": "告警分配", + "alarm-comment": "告警评论", + "api-usage-limit": "API 使用限制", + "device-activity": "设备活跃", + "entities-limit": "实体限制", + "entity-action": "实体操作", + "rule-engine-lifecycle-event": "规则引擎生命周期事件", + "new-platform-version": "新平台版本", + "rate-limits": "超出速率限制", + "edge-connection": "Edge 连接", + "edge-communication-failure": "Edge 通信故障", + "task-processing-failure": "任务处理失败", + "resources-shortage": "资源不足", + "trigger": "触发器", + "trigger-required": "触发器为必填项" + }, + "type": "类型", + "unread": "未读", + "updated": "已更新", + "use-deprecated-webhook-connectors": "使用已弃用的 Webhook 连接器", + "use-old-api": "使用旧 API", + "use-template": "使用模板", + "view-all": "查看全部", + "warning": "警告", + "webhook-url": "Webhook URL", + "webhook-url-required": "Webhook URL 为必填项", + "workflow-url": "Workflow URL", + "workflow-url-required": "Workflow URL 为必填项", + "channel-name": "频道名称", + "channel-name-required": "频道名称为必填项", + "settings": { + "notification-settings": "通知设置", + "reset-all": "重置所有设置", + "reset-all-title": "确定要重置表单吗?", + "reset-all-text": "确认后,设置表单将重置为默认值并保存。", + "type": "类型", + "enable-all": "全部启用", + "disable-all": "全部禁用", + "delivery-not-configured": "发送方式未配置" + } + }, + "ota-update": { + "add": "添加软件包", + "assign-firmware": "已分配固件", + "assign-firmware-required": "已分配固件为必填项", + "assign-software": "已分配软件", + "assign-software-required": "已分配软件为必填项", + "auto-generate-checksum": "自动生成校验和", + "checksum": "校验和", + "checksum-hint": "如果校验和为空,将自动生成", + "checksum-algorithm": "校验和算法", + "checksum-copied-message": "软件包校验和已复制到剪贴板", + "change-firmware": "更改固件可能导致 { count, plural, =1 {1 个设备} other {# 个设备} } 更新。", + "change-software": "更改软件可能导致 { count, plural, =1 {1 个设备} other {# 个设备} } 更新。", + "change-ota-setting-title": "确定要更改 OTA 设置吗?", + "chose-compatible-device-profile": "上传的软件包仅对具有所选配置的设备可用。", + "chose-firmware-distributed-device": "选择要分发到设备的固件", + "chose-software-distributed-device": "选择要分发到设备的软件", + "content-type": "内容类型", + "copy-checksum": "复制校验和", + "copy-direct-url": "复制直接 URL", + "copyId": "复制软件包 Id", + "copied": "已复制!", + "delete": "删除软件包", + "delete-ota-update-text": "请注意,确认后该 OTA 更新将无法恢复。", + "delete-ota-update-title": "确定要删除 OTA 更新“{{title}}”吗?", + "delete-ota-updates-text": "请注意,确认后所有选中的 OTA 更新将被移除。", + "delete-ota-updates-title": "确定要删除 { count, plural, =1 {1 个 OTA 更新} other {# 个 OTA 更新} } 吗?", + "description": "描述", + "direct-url": "直接 URL", + "direct-url-copied-message": "软件包直接 URL 已复制到剪贴板", + "direct-url-required": "直接 URL 为必填项", + "download": "下载软件包", + "drop-file": "拖放软件包文件或点击选择要上传的文件。", + "drop-package-file-or": "拖放软件包文件或", + "file-name": "文件名", + "file-size": "文件大小", + "file-size-bytes": "文件大小(字节)", + "idCopiedMessage": "软件包 Id 已复制到剪贴板", + "no-firmware-matching": "未找到与“{{entity}}”匹配的兼容固件 OTA 更新软件包。", + "no-firmware-text": "暂无已配置的兼容固件 OTA 更新软件包。", + "no-packages-text": "未找到软件包", + "no-software-matching": "未找到与“{{entity}}”匹配的兼容软件 OTA 更新软件包。", + "no-software-text": "暂无已配置的兼容软件 OTA 更新软件包。", + "ota-update": "OTA 更新", + "ota-update-details": "OTA 更新详情", + "ota-updates": "OTA 更新", + "package-file": "软件包文件", + "package-type": "软件包类型", + "packages-repository": "软件包仓库", + "search": "搜索软件包", + "selected-package": "已选择 { count, plural, =1 {1 个软件包} other {# 个软件包} }", + "title": "标题", + "title-required": "标题为必填项。", + "title-max-length": "标题长度不得超过 256", + "types": { + "firmware": "固件", + "software": "软件" + }, + "upload-binary-file": "上传二进制文件", + "use-external-url": "使用外部 URL", + "version": "版本", + "version-required": "版本为必填项。", + "version-tag": "版本标签", + "version-tag-hint": "自定义标签应与设备上报的软件包版本一致。", + "version-max-length": "版本长度不得超过 256", + "warning-after-save-no-edit": "软件包上传后,将无法修改标题、版本、设备配置和软件包类型。" + }, + "position": { + "top": "顶部", + "bottom": "底部", + "left": "左侧", + "right": "右侧" + }, + "profile": { + "profile": "个人资料", + "last-login-time": "上次登录", + "change-password": "修改密码", + "current-password": "当前密码", + "copy-jwt-token": "复制 JWT Token", + "jwt-token": "JWT Token", + "token-valid-till": "Token 有效期至", + "tokenCopiedSuccessMessage": "JWT Token 已复制到剪贴板", + "tokenCopiedWarnMessage": "JWT Token 已过期!请刷新页面。" + }, + "profiles": { + "profiles": "配置" + }, + "security": { + "security": "安全", + "general-settings": "通用安全设置", + "access-token": "访问 Token", + "access-token-required": "访问 Token 为必填项", + "clientId": "Client ID", + "clientId-required": "Client ID 为必填项", + "username": "用户名", + "username-required": "用户名为必填项", + "ca-cert": "CA 证书", + "2fa": { + "2fa": "双因素认证", + "2fa-description": "双因素认证保护您的账户免受未授权访问。您只需在登录时输入安全码即可。", + "authenticate-with": "您可以通过以下方式进行认证:", + "disable-2fa-provider-text": "禁用 {{name}} 将降低您账户的安全性", + "disable-2fa-provider-title": "确定要禁用 {{name}} 吗?", + "get-new-code": "获取新验证码", + "main-2fa-method": "设为主要双因素认证方式", + "dialog": { + "activation-step-description-email": "下次登录时,系统将提示您输入发送到您 Email 地址的安全码。", + "activation-step-description-sms": "下次登录时,系统将提示您输入发送到该手机号码的安全码。", + "activation-step-description-totp": "下次登录时,您需要提供双因素认证码。", + "activation-step-label": "激活", + "backup-code-description": "请打印这些验证码,以便在需要登录账户时使用。每个备用验证码只能使用一次。", + "backup-code-warn": "离开此页面后,这些验证码将无法再次显示。请使用以下选项安全存储。", + "download-txt": "下载(txt)", + "email-step-description": "输入要用作认证器的 Email 地址。", + "email-step-label": "Email", + "enable-email-title": "启用 Email 认证器", + "enable-sms-title": "启用 SMS 认证器", + "enable-totp-title": "启用认证器应用", + "enter-verification-code": "在此输入 6 位验证码", + "get-backup-code-title": "获取备用验证码", + "next": "下一步", + "scan-qr-code": "使用验证应用扫描此二维码", + "send-code": "发送验证码", + "sms-step-description": "输入要用作认证器的手机号码。", + "sms-step-label": "手机号码", + "success": "成功!", + "totp-step-description-install": "您可以安装 Google Authenticator、Authy 或 Duo 等应用。", + "totp-step-description-open": "在手机上打开认证器应用。", + "totp-step-label": "获取应用", + "verification-code": "6 位验证码", + "verification-code-invalid": "验证码格式无效", + "verification-code-incorrect": "验证码不正确", + "verification-code-many-request": "验证码校验请求过多", + "verification-step-description": "输入我们刚发送到 {{address}} 的 6 位验证码", + "verification-step-label": "验证" + }, + "provider": { + "email": "Email", + "email-description": "使用发送到您 Email 地址的安全码进行认证。", + "email-hint": "认证码通过 Email 发送至 {{ info }}", + "sms": "SMS", + "sms-description": "使用手机进行认证。登录时我们将通过 SMS 短信向您发送安全码。", + "sms-hint": "认证码通过短信发送至 {{ info }}", + "totp": "认证器应用", + "totp-description": "使用手机上的 Google Authenticator、Authy 或 Duo 等应用进行认证。它将生成登录所需的安全码。", + "totp-hint": "认证器应用已为您的账户设置", + "backup_code": "备用验证码", + "backup-code-description": "这些可打印的一次性密码允许您在不方便使用手机时(例如旅行中)登录账户。", + "backup-code-hint": "目前有 {{ info }} 个一次性验证码可用" + } + }, + "password-requirement": { + "at-least": "至少:", + "character": "{ count, plural, =1 {1 个字符} other {# 个字符} }", + "digit": "{ count, plural, =1 {1 个数字} other {# 个数字} }", + "password-tooltip-min-length": "至少 {{minimumLength}} 个字符", + "password-tooltip-max-length": "最多 {{maximumLength}} 个字符", + "password-tooltip-uppercase": "{{minimumUppercaseLetters}} 个大写字符", + "password-tooltip-lowercase": "{{minimumLowercaseLetters}} 个小写字符", + "password-tooltip-digit": "{{minimumDigits}} 个数字", + "password-tooltip-special-characters": "{{minimumSpecialCharacters}} 个特殊字符", + "incorrect-password-try-again": "密码不正确,请重试", + "lowercase-letter": "{ count, plural, =1 {1 个小写字母} other {# 个小写字母} }", + "new-passwords-not-match": "新密码不匹配", + "password-should-not-contain-spaces": "密码不能包含空格", + "password-not-meet-requirements": "密码不符合要求", + "password-requirements": "密码要求", + "password-should-difference": "新密码应与当前密码不同", + "special-character": "{ count, plural, =1 {1 个特殊字符} other {# 个特殊字符} }", + "uppercase-letter": "{ count, plural, =1 {1 个大写字母} other {# 个大写字母} }", + "at-most": "最多:" + } + }, + "relation": { + "relations": "关联", + "direction": "方向", + "clear-relation-type": "清除关联类型", + "search-direction": { + "FROM": "从", + "TO": "到" + }, + "direction-type": { + "FROM": "从", + "TO": "到" + }, + "from-relations": "出站关联", + "to-relations": "入站关联", + "selected-relations": "已选择 { count, plural, =1 {1 条关联} other {# 条关联} }", + "type": "类型", + "to-entity-type": "目标实体类型", + "to-entity-name": "目标实体名称", + "from-entity-type": "源实体类型", + "from-entity-name": "源实体名称", + "to-entity": "目标实体", + "from-entity": "源实体", + "delete": "删除关联", + "relation-type": "关联类型", + "relation-type-required": "关联类型为必填项。", + "relation-type-max-length": "关联类型长度不得超过 256", + "any-relation-type": "任意类型", + "add": "添加关联", + "edit": "编辑关联", + "delete-to-relation-title": "确定要删除与实体“{{entityName}}”的关联吗?", + "delete-to-relation-text": "请注意,确认后实体“{{entityName}}”将与当前实体取消关联。", + "delete-to-relations-title": "确定要删除 { count, plural, =1 {1 条关联} other {# 条关联} } 吗?", + "delete-to-relations-text": "请注意,确认后所有选中的关联将被移除,相应实体将与当前实体取消关联。", + "delete-from-relation-title": "确定要删除来自实体“{{entityName}}”的关联吗?", + "delete-from-relation-text": "请注意,确认后当前实体将与实体“{{entityName}}”取消关联。", + "delete-from-relations-title": "确定要删除 { count, plural, =1 {1 条关联} other {# 条关联} } 吗?", + "delete-from-relations-text": "请注意,确认后所有选中的关联将被移除,当前实体将与相应实体取消关联。", + "remove-relation-filter": "移除关联过滤器", + "remove-filter": "移除过滤器", + "add-relation-filter": "添加关联过滤器", + "any-relation": "任意关联", + "relation-filters": "关联过滤器", + "relation-filter": "关联过滤器", + "additional-info": "附加信息(JSON)", + "invalid-additional-info": "无法解析附加信息 JSON。", + "no-relations-text": "未找到关联", + "not": "非", + "copy-type": "复制类型" + }, + "resource": { + "add": "添加资源", + "all-types": "全部", + "copyId": "复制资源 Id", + "delete": "删除资源", + "delete-resource-text": "请注意,确认后该资源将无法恢复。", + "delete-resource-title": "确定要删除资源“{{resourceTitle}}”吗?", + "delete-resources-action-title": "删除 { count, plural, =1 {1 个资源} other {# 个资源} }", + "delete-resources-text": "请注意,即使选中的资源正在设备配置中使用,也将被删除。", + "delete-resources-title": "确定要删除 { count, plural, =1 {1 个资源} other {# 个资源} } 吗?", + "download": "下载资源", + "drop-file": "拖放资源文件或点击选择要上传的文件。", + "drop-resource-file-or": "拖放资源文件或", + "empty": "资源为空", + "file-name": "文件名", + "idCopiedMessage": "资源 Id 已复制到剪贴板", + "no-resource-matching": "未找到与“{{widgetsBundle}}”匹配的资源。", + "no-resource-text": "未找到资源", + "open-widgets-bundle": "打开部件包", + "resource": "资源", + "resource-file": "资源文件", + "resource-files": "资源文件", + "resource-library-details": "资源详情", + "resource-type": "资源类型", + "resources-library": "资源库", + "search": "搜索资源", + "selected-resources": "已选择 { count, plural, =1 {1 个资源} other {# 个资源} }", + "system": "系统", + "title": "标题", + "title-required": "标题为必填项。", + "title-max-length": "标题长度不得超过 256", + "type": { + "jks": "JKS", + "js-module": "JS 模块", + "lwm2m-model": "LWM2M 模型", + "pkcs-12": "PKCS #12", + "general": "通用" + }, + "resource-sub-type": "子类型", + "sub-type": { + "image": "图片", + "scada-symbol": "Scada 符号", + "extension": "扩展", + "module": "模块" + }, + "resource-is-in-use": "资源正被其他实体使用", + "resources-are-in-use": "资源正被其他实体使用", + "resource-is-in-use-text": "资源 '{{title}}' 未被删除,因为它正被以下实体使用:", + "resources-are-in-use-text": "并非所有资源都已被删除,因为它们正被其他实体使用。
您可以通过点击相应资源行中的引用按钮查看引用的实体。
如果仍要删除这些资源,请在下方表格中选择它们并点击删除所选按钮。", + "delete-resource-in-use-text": "如果仍要删除该资源,请点击仍然删除按钮。" + }, + "javascript": { + "add": "添加 JavaScript 资源", + "delete": "删除 JavaScript 资源", + "delete-javascript-resource-text": "请注意,确认后该 JavaScript 资源将无法恢复。", + "delete-javascript-resource-title": "确定要删除 JavaScript 资源“{{resourceTitle}}”吗?", + "delete-javascript-resources-action-title": "删除 { count, plural, =1 {1 个 JavaScript 资源} other {# 个 JavaScript 资源} }", + "delete-javascript-resources-text": "请注意,即使选中的 JavaScript 资源正在 JavaScript 函数中使用,也将被删除。", + "delete-javascript-resources-title": "确定要删除 { count, plural, =1 {1 个 JavaScript 资源} other {# 个 JavaScript 资源} } 吗?", + "delete-javascript-resource-in-use-text": "如果仍要删除该 JavaScript 资源,请点击仍然删除按钮。", + "download": "下载 JavaScript 资源", + "upload-from-file": "从文件上传 JavaScript", + "resource-file": "JavaScript 资源文件", + "drop-file": "拖放 JavaScript 文件或点击选择要上传的文件。", + "drop-resource-file-or": "拖放 JavaScript 文件或", + "javascript-library": "JavaScript 库", + "javascript-type": "JavaScript 类型", + "javascript-resource-details": "JavaScript 资源详情", + "javascript-resource-is-in-use": "JavaScript 资源正被其他实体使用", + "javascript-resources-are-in-use": "JavaScript 资源正被其他实体使用", + "javascript-resource-is-in-use-text": "JavaScript 资源 '{{title}}' 未被删除,因为它正被以下实体使用:", + "javascript-resources-are-in-use-text": "并非所有 JavaScript 资源都已被删除,因为它们正被其他实体使用。
您可以通过点击相应资源行中的引用按钮查看引用的实体。
如果仍要删除这些 JavaScript 资源,请在下方表格中选择它们并点击删除所选按钮。", + "search": "搜索 JavaScript 资源", + "selected-javascript-resources": "已选择 { count, plural, =1 {1 个 JavaScript 资源} other {# 个 JavaScript 资源} }", + "no-javascript-resource-text": "未找到 JavaScript 资源", + "all-types": "全部", + "module-script": "模块脚本" + }, + "rpc": { + "error": { + "target-device-is-not-set": "目标设备未设置!", + "invalid-target-entity": "{{entityType}} 实体不支持 RPC 命令。", + "failed-to-resolve-target-device": "无法解析目标设备!", + "request-timeout": "请求超时", + "rpc-http-error": "错误:{{status}} - {{statusText}}" + } + }, + "rulechain": { + "rulechain": "规则链", + "rulechain-events": "规则链事件", + "rulechains": "规则链", + "root": "根", + "delete": "删除规则链", + "name": "名称", + "name-required": "名称为必填项。", + "name-max-length": "名称长度不得超过 256", + "description": "描述", + "add": "添加规则链", + "set-root": "设为根规则链", + "set-root-rulechain-title": "确定要将规则链“{{ruleChainName}}”设为根规则链吗?", + "set-root-rulechain-text": "确认后,该规则链将成为根规则链,并处理所有传入的传输消息。", + "delete-rulechain-title": "确定要删除规则链“{{ruleChainName}}”吗?", + "delete-rulechain-text": "请注意,确认后该规则链及所有相关数据将无法恢复。", + "delete-rulechains-title": "确定要删除 { count, plural, =1 {1 条规则链} other {# 条规则链} } 吗?", + "delete-rulechains-action-title": "删除 { count, plural, =1 {1 条规则链} other {# 条规则链} }", + "delete-rulechains-text": "请注意,确认后所有选中的规则链将被移除,所有相关数据将无法恢复。", + "add-rulechain-text": "添加新规则链", + "no-rulechains-text": "未找到规则链", + "rulechain-details": "规则链详情", + "details": "详情", + "events": "事件", + "system": "系统", + "import": "导入规则链", + "export": "导出规则链", + "export-failed-error": "无法导出规则链:{{error}}", + "create-new-rulechain": "创建新规则链", + "rulechain-file": "规则链文件", + "invalid-rulechain-file-error": "无法导入规则链:无效的规则链数据结构。", + "copyId": "复制规则链 Id", + "idCopiedMessage": "规则链 Id 已复制到剪贴板", + "select-rulechain": "选择规则链", + "no-rulechains-matching": "未找到与“{{entity}}”匹配的规则链。", + "rulechain-required": "规则链为必填项", + "management": "规则管理", + "debug-mode": "调试模式", + "search": "搜索规则链", + "selected-rulechains": "已选择 { count, plural, =1 {1 条规则链} other {# 条规则链} }", + "open-rulechain": "打开规则链", + "edge-template-root": "模板根", + "assign-to-edge": "分配到 Edge", + "edge-rulechain": "Edge 规则链", + "unassign-rulechain-from-edge-text": "确认后,该规则链将被取消分配,Edge 将无法访问。", + "unassign-rulechains-from-edge-title": "确定要取消分配 { count, plural, =1 {1 条规则链} other {# 条规则链} } 吗?", + "unassign-rulechains-from-edge-text": "确认后,所有选中的规则链将被取消分配,Edge 将无法访问。", + "assign-rulechain-to-edge-title": "将规则链分配到 Edge", + "assign-rulechain-to-edge-text": "请选择要分配到 Edge 的规则链", + "set-edge-template-root-rulechain": "设为 Edge 模板根规则链", + "set-edge-template-root-rulechain-title": "确定要将规则链“{{ruleChainName}}”设为 Edge 模板根规则链吗?", + "set-edge-template-root-rulechain-text": "确认后,该规则链将成为 Edge 模板根规则链,并作为新创建 Edge 的根规则链。", + "invalid-rulechain-type-error": "无法导入规则链:无效的规则链类型。期望类型为 {{expectedRuleChainType}}。", + "set-auto-assign-to-edge": "创建时自动将规则链分配到 Edge", + "set-auto-assign-to-edge-title": "确定要在创建时将 Edge 规则链“{{ruleChainName}}”分配到 Edge 吗?", + "set-auto-assign-to-edge-text": "确认后,该 Edge 规则链将在创建时自动分配到 Edge。", + "unset-auto-assign-to-edge": "创建时不自动将规则链分配到 Edge", + "unset-auto-assign-to-edge-title": "确定不在创建时将 Edge 规则链“{{ruleChainName}}”分配到 Edge 吗?", + "unset-auto-assign-to-edge-text": "确认后,该 Edge 规则链将不再在创建时自动分配到 Edge。", + "unassign-rulechain-title": "确定要取消分配规则链“{{ruleChainName}}”吗?", + "unassign-rulechains": "取消分配规则链" + }, + "rulenode": { + "rule-node-events": "规则节点事件", + "details": "详情", + "events": "事件", + "search": "搜索节点", + "open-node-library": "打开节点库", + "close-node-library": "关闭节点库", + "add": "添加规则节点", + "name": "名称", + "name-required": "名称为必填项。", + "name-max-length": "名称长度不得超过 256", + "type": "类型", + "rule-node-description": "规则节点描述", + "delete": "删除规则节点", + "select-all-objects": "选择所有节点和连接", + "deselect-all-objects": "取消选择所有节点和连接", + "delete-selected-objects": "删除所选节点和连接", + "delete-selected": "删除所选", + "create-nested-rulechain": "创建嵌套规则链", + "select-all": "全选", + "copy-selected": "复制所选", + "deselect-all": "取消全选", + "rulenode-details": "规则节点详情", + "debug-mode": "调试模式", + "singleton": "单例", + "configuration": "配置", + "link": "链接", + "link-details": "规则节点链接详情", + "add-link": "添加链接", + "link-label": "链接标签", + "link-label-required": "链接标签为必填项。", + "custom-link-label": "自定义链接标签", + "custom-link-label-required": "自定义链接标签为必填项。", + "link-labels": "链接标签", + "link-labels-required": "链接标签为必填项。", + "no-link-labels-found": "未找到链接标签", + "no-link-label-matching": "未找到“{{label}}”。", + "create-new-link-label": "创建一个新标签!", + "type-filter": "过滤", + "type-filter-details": "使用配置的条件过滤传入消息", + "type-enrichment": "数据补充", + "type-enrichment-details": "向消息元数据中添加附加信息", + "type-transformation": "转换", + "type-transformation-details": "更改消息负载和元数据", + "type-action": "操作", + "type-action-details": "执行特殊操作", + "type-external": "外部", + "type-external-details": "与外部系统交互", + "type-rule-chain": "规则链", + "type-rule-chain-details": "将传入消息转发到指定规则链", + "type-flow": "流程", + "type-flow-details": "组织消息流程", + "type-input": "输入", + "type-input-details": "规则链的逻辑输入,将传入消息转发到下一个关联的规则节点", + "type-unknown": "未知", + "type-unknown-details": "无法解析的规则节点", + "directive-is-not-loaded": "定义的配置指令“{{directiveName}}”不可用。", + "ui-resources-load-error": "加载配置 UI 资源失败。", + "invalid-target-rulechain": "无法解析目标规则链!", + "test-script-function": "测试脚本函数", + "script-lang-java-script": "JavaScript", + "script-lang-tbel": "TBEL", + "message": "消息", + "message-type": "消息类型", + "select-message-type": "选择消息类型", + "message-type-required": "消息类型为必填项", + "metadata": "元数据", + "metadata-required": "元数据条目不能为空。", + "output": "输出", + "test": "测试", + "help": "帮助", + "reset-debug-settings": "重置所有节点的调试设置", + "test-with-this-message": "{{test}} 使用此消息", + "queue-hint": "选择要将消息转发到的队列。默认使用“Main”队列。", + "queue-singleton-hint": "选择在多实例环境中用于消息转发的队列。默认使用“Main”队列。" + }, + "rule-node-config": { + "id": "Id", + "additional-info": "附加信息", + "advanced-settings": "高级设置", + "create-entity-if-not-exists": "如果实体不存在则创建新实体", + "create-entity-if-not-exists-hint": "如果启用,将使用指定参数创建新实体(除非该实体已存在)。已有实体将直接用于关联。", + "select-device-connectivity-event": "选择设备连接事件", + "entity-name-pattern": "名称模式", + "device-name-pattern": "设备名称", + "asset-name-pattern": "资产名称", + "entity-view-name-pattern": "实体视图名称", + "customer-title-pattern": "客户标题", + "dashboard-name-pattern": "仪表板标题", + "user-name-pattern": "用户 Email", + "edge-name-pattern": "Edge 名称", + "entity-name-pattern-required": "名称模式为必填项", + "entity-name-pattern-hint": "名称模式字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "copy-message-type": "复制消息类型", + "entity-type-pattern": "类型模式", + "entity-type-pattern-required": "类型模式为必填项", + "message-type-value": "消息类型值", + "message-type-value-required": "消息类型值为必填项", + "message-type-value-max-length": "消息类型值应少于 256 个字符", + "output-message-type": "输出消息类型", + "entity-cache-expiration": "实体缓存过期时间(秒)", + "entity-cache-expiration-hint": "指定存储已找到的实体记录的最大时间间隔。值为 0 表示记录永不过期。", + "entity-cache-expiration-required": "实体缓存过期时间为必填项。", + "entity-cache-expiration-range": "实体缓存过期时间应大于或等于 0。", + "customer-name-pattern": "客户标题", + "customer-name-pattern-required": "客户标题为必填项", + "customer-name-pattern-hint": "使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "create-customer-if-not-exists": "如果客户不存在则创建新客户", + "unassign-from-customer": "如果发起者是仪表板则从指定客户取消分配", + "unassign-from-customer-tooltip": "只有仪表板可以同时分配给多个客户。\n如果消息发起者是仪表板,您需要明确指定要取消分配的客户标题。", + "customer-cache-expiration": "客户缓存过期时间(秒)", + "customer-cache-expiration-hint": "指定存储已找到的客户记录的最大时间间隔。值为 0 表示记录永不过期。", + "customer-cache-expiration-required": "客户缓存过期时间为必填项。", + "customer-cache-expiration-range": "客户缓存过期时间应大于或等于 0。", + "interval-start": "间隔开始", + "interval-end": "间隔结束", + "time-unit": "时间单位", + "fetch-mode": "获取模式", + "order-by-timestamp": "按时间戳排序", + "limit": "限制", + "limit-hint": "最小限制值为 2,最大为 1000。如果您想获取单条记录,请选择获取模式“First”或“Last”。", + "limit-required": "限制为必填项。", + "limit-range": "限制应在 2 到 1000 的范围内。", + "time-unit-milliseconds": "毫秒", + "time-unit-seconds": "秒", + "time-unit-minutes": "分钟", + "time-unit-hours": "小时", + "time-unit-days": "天", + "time-value-range": "允许范围为 1 到 2147483647。", + "start-interval-value-required": "间隔开始为必填项。", + "end-interval-value-required": "间隔结束为必填项。", + "filter": "过滤器", + "switch": "切换", + "math-templatization-tooltip": "此字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "add-message-type": "添加消息类型", + "select-message-types-required": "至少应选择一种消息类型。", + "select-message-types": "选择消息类型", + "no-message-types-found": "未找到消息类型", + "no-message-type-matching": "未找到“{{messageType}}”。", + "create-new-message-type": "创建新的消息类型。", + "message-types-required": "消息类型为必填项。", + "client-attributes": "客户端属性", + "shared-attributes": "共享属性", + "server-attributes": "服务端属性", + "attributes-keys": "属性键", + "attributes-keys-required": "属性键为必填项", + "attributes-scope": "属性范围", + "attributes-scope-value": "属性范围值", + "attributes-scope-value-copy": "复制属性范围值", + "attributes-scope-hint": "使用 'scope' 元数据键为每条消息动态设置属性范围。如果提供了该值,将覆盖配置中设置的范围。", + "notify-device": "强制通知设备", + "send-attributes-updated-notification": "发送属性更新通知", + "send-attributes-updated-notification-hint": "将已更新属性的通知作为单独消息发送到规则引擎队列。", + "send-attributes-deleted-notification": "发送属性删除通知", + "send-attributes-deleted-notification-hint": "将已删除属性的通知作为单独消息发送到规则引擎队列。", + "update-attributes-only-on-value-change": "仅在值更改时保存属性", + "update-attributes-only-on-value-change-hint": "对每条传入消息都更新属性,无论其值是否已更改。这会增加 API 使用量并降低性能。", + "update-attributes-only-on-value-change-hint-enabled": "仅在属性值发生更改时才更新属性。如果值未更改,则不会更新属性时间戳,也不会发送属性更改通知。", + "fetch-credentials-to-metadata": "将凭据获取到元数据", + "notify-device-on-update-hint": "如果启用,将强制向设备发送共享属性更新通知。如果禁用,通知行为由传入消息元数据中的 'notifyDevice' 参数控制。要关闭通知,消息元数据必须包含值为 'false' 的 'notifyDevice' 参数。其他任何情况都将触发向设备发送通知。", + "notify-device-on-delete-hint": "如果启用,将强制向设备发送共享属性移除通知。如果禁用,通知行为由传入消息元数据中的 'notifyDevice' 参数控制。要开启通知,消息元数据必须包含值为 'true' 的 'notifyDevice' 参数。其他任何情况都不会触发向设备发送通知。", + "latest-timeseries": "最新时间序列数据键", + "timeseries-keys": "时间序列键", + "timeseries-keys-required": "至少应选择一个时间序列键。", + "add-timeseries-key": "添加时间序列键", + "add-message-field": "添加消息字段", + "relation-search-parameters": "关联搜索参数", + "relation-parameters": "关联参数", + "add-metadata-field": "添加元数据字段", + "data-keys": "消息字段名称", + "copy-from": "复制来源", + "data-to-metadata": "数据到元数据", + "metadata-to-data": "元数据到数据", + "use-regular-expression-hint": "使用正则表达式按模式复制键。\n\n提示与技巧:\n按“Enter”完成字段名称输入。\n按“Backspace”删除字段名称。支持多个字段名称。", + "interval": "间隔", + "interval-required": "间隔为必填项", + "interval-hint": "去重间隔(秒)。", + "interval-min-error": "最小允许值为 1", + "max-pending-msgs": "最大待处理消息数", + "max-pending-msgs-hint": "为每个唯一去重 ID 存储在内存中的最大消息数。", + "max-pending-msgs-required": "最大待处理消息数为必填项", + "max-pending-msgs-max-error": "最大允许值为 1000", + "max-pending-msgs-min-error": "最小允许值为 1", + "max-retries": "最大重试次数", + "max-retries-required": "最大重试次数为必填项", + "max-retries-hint": "将去重消息推入队列的最大重试次数。重试间隔为 10 秒", + "max-retries-max-error": "最大允许值为 100", + "max-retries-min-error": "最小允许值为 0", + "strategy": "策略", + "strategy-required": "策略为必填项", + "strategy-all-hint": "将去重期间内到达的所有消息作为单个 JSON 数组消息返回。其中每个元素表示包含 msg 和 metadata 内部属性的对象。", + "strategy-first-hint": "返回去重期间内到达的第一条消息。", + "strategy-last-hint": "返回去重期间内到达的最后一条消息。", + "first": "第一条", + "last": "最后一条", + "all": "全部", + "output-msg-type-hint": "去重结果的消息类型。", + "queue-name-hint": "去重结果将发布到的队列名称。", + "keys": "键", + "keys-required": "键为必填项", + "rename-keys-in": "在以下位置重命名键", + "data": "数据", + "message": "消息", + "metadata": "元数据", + "current-key-name": "当前键名称", + "key-name-required": "键名称为必填项", + "new-key-name": "新键名称", + "new-key-name-required": "新键名称为必填项", + "metadata-keys": "元数据字段名称", + "json-path-expression": "JSON 路径表达式", + "json-path-expression-required": "JSON 路径表达式为必填项", + "json-path-expression-hint": "JSONPath 指定 JSON 结构中某个元素或一组元素的路径。'$' 表示根对象或数组。", + "relations-query": "关联查询", + "device-relations-query": "设备关联查询", + "max-relation-level": "最大关联层级", + "max-relation-level-error": "值应大于 0 或不指定。", + "max-relation-level-invalid": "值应为整数。", + "relation-type": "关联类型", + "relation-type-pattern": "关联类型模式", + "relation-type-pattern-required": "关联类型模式为必填项", + "relation-types-list": "要传播的关联类型", + "relation-types-list-hint": "如果未选择传播关联类型,告警将不按关联类型过滤进行传播。", + "unlimited-level": "无限层级", + "latest-telemetry": "最新遥测", + "add-telemetry-key": "添加遥测键", + "delete-from": "删除来源", + "use-regular-expression-delete-hint": "使用正则表达式按模式删除键。\n\n提示与技巧:\n按“Enter”完成字段名称输入。\n按“Backspace”删除字段名称。\n支持多个字段名称。", + "fetch-into": "获取到", + "attr-mapping": "属性映射:", + "source-attribute": "源属性键", + "source-attribute-required": "源属性键为必填项。", + "source-telemetry": "源遥测键", + "source-telemetry-required": "源遥测键为必填项。", + "target-key": "目标键", + "target-key-required": "目标键为必填项。", + "attr-mapping-required": "至少应指定一个映射条目。", + "fields-mapping": "字段映射", + "fields-mapping-hint": "如果消息字段设置为 $entityId,消息发起者的 ID 将保存到指定的表列中。", + "relations-query-config-direction-suffix": "发起者", + "profile-name": "配置名称", + "fetch-circle-parameter-info-from-metadata-hint": "元数据字段 '{{perimeterKeyName}}' 应按以下格式定义:{\"latitude\":48.196, \"longitude\":24.6532, \"radius\":100.0, \"radiusUnit\":\"METER\"}", + "fetch-poligon-parameter-info-from-metadata-hint": "元数据字段 '{{perimeterKeyName}}' 应按以下格式定义:[[48.19736,24.65235],[48.19800,24.65060],...,[48.19849,24.65420]]", + "short-templatization-tooltip": "使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "fields-mapping-required": "至少应指定一个字段映射。", + "at-least-one-field-required": "至少一个输入字段必须提供值。", + "originator-fields-sv-map-hint": "目标键字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "sv-map-hint": "仅目标键字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "source-field": "源字段", + "source-field-required": "源字段为必填项。", + "originator-source": "发起者来源", + "new-originator": "新发起者", + "originator-customer": "客户", + "originator-tenant": "租户", + "originator-related": "关联实体", + "originator-alarm-originator": "告警发起者", + "originator-entity": "按名称模式匹配的实体", + "clone-message": "克隆消息", + "transform": "转换", + "default-ttl": "默认 TTL", + "default-ttl-required": "默认 TTL 为必填项。", + "default-ttl-hint": "规则节点将从消息元数据中获取 Time-to-Live(TTL)值。如果没有值,则使用配置中指定的 TTL。如果值设为 0,则应用租户配置中的 TTL。", + "default-ttl-zero-hint": "如果 TTL 值设为 0,则不会应用 TTL。", + "min-default-ttl-message": "最小 TTL 仅允许为 0。", + "generation-parameters": "生成参数", + "message-count": "生成消息限制(0 - 无限)", + "message-count-required": "生成消息限制为必填项。", + "min-message-count-message": "最小消息数仅允许为 0。", + "period-seconds": "周期(秒)", + "period-seconds-required": "周期为必填项。", + "generation-frequency-seconds": "生成频率(秒)", + "generation-frequency-required": "生成频率为必填项。", + "min-generation-frequency-message": "最小值仅允许为 60 秒。", + "script-lang-tbel": "TBEL", + "script-lang-js": "JS", + "use-metadata-period-in-seconds-patterns": "使用秒周期模式", + "use-metadata-period-in-seconds-patterns-hint": "如果选中,规则节点将使用消息元数据或数据中的秒周期间隔模式(假设间隔以秒为单位)。", + "period-in-seconds-pattern": "秒周期模式", + "period-in-seconds-pattern-required": "秒周期模式为必填项", + "min-period-seconds-message": "最小周期仅允许为 60 秒。", + "originator": "发起者", + "message-body": "消息体", + "message-metadata": "消息元数据", + "generate": "生成", + "current-rule-node": "当前规则节点", + "current-tenant": "当前租户", + "generator-function": "生成器函数", + "test-generator-function": "测试生成器函数", + "generator": "生成器", + "test-filter-function": "测试过滤器函数", + "test-switch-function": "测试切换函数", + "test-transformer-function": "测试转换器函数", + "transformer": "转换器", + "alarm-create-condition": "告警创建条件", + "test-condition-function": "测试条件函数", + "alarm-clear-condition": "告警清除条件", + "alarm-details-builder": "告警详情构建器", + "test-details-function": "测试详情函数", + "alarm-type": "告警类型", + "select-entity-types": "选择实体类型", + "alarm-type-required": "告警类型为必填项。", + "alarm-severity": "告警严重程度", + "alarm-severity-required": "告警严重程度为必填项", + "alarm-severity-pattern": "告警严重程度模式", + "alarm-status-filter": "告警状态过滤器", + "alarm-status-list-empty": "告警状态列表为空", + "no-alarm-status-matching": "未找到匹配的告警状态。", + "propagate": "将告警传播到关联实体", + "propagate-to-owner": "将告警传播到实体所有者(客户或租户)", + "propagate-to-tenant": "将告警传播到租户", + "condition": "条件", + "details": "详情", + "to-string": "转为字符串", + "test-to-string-function": "测试转为字符串函数", + "from-template": "发件人", + "from-template-required": "发件人为必填项", + "message-to-metadata": "消息到元数据", + "metadata-to-message": "元数据到消息", + "from-message": "来自消息", + "from-metadata": "来自元数据", + "to-template": "收件人", + "to-template-required": "收件人模板为必填项", + "mail-address-list-template-hint": "逗号分隔的地址列表,使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值", + "cc-template": "抄送", + "bcc-template": "密送", + "subject-template": "主题", + "subject-template-required": "主题模板为必填项", + "body-template": "正文", + "body-template-required": "正文模板为必填项", + "dynamic-mail-body-type": "动态邮件正文类型", + "mail-body-type": "邮件正文类型", + "body-type-template": "正文类型模板", + "reply-routing-configuration": "回复路由配置", + "rpc-reply-routing-configuration-hint": "这些配置参数指定用于标识服务、会话和请求的元数据键名称,以便发送回复。", + "reply-routing-configuration-hint": "这些配置参数指定用于标识服务和请求的元数据键名称,以便发送回复。", + "request-id-metadata-attribute": "请求 Id", + "service-id-metadata-attribute": "服务 Id", + "session-id-metadata-attribute": "会话 Id", + "timeout-sec": "超时时间(秒)", + "timeout-required": "超时时间为必填项", + "min-timeout-message": "最小超时值仅允许为 0。", + "endpoint-url-pattern": "Endpoint URL 模式", + "endpoint-url-pattern-required": "Endpoint URL 模式为必填项", + "request-method": "请求方法", + "use-simple-client-http-factory": "使用简单客户端 HTTP 工厂", + "ignore-request-body": "无请求体", + "parse-to-plain-text": "解析为纯文本", + "parse-to-plain-text-hint": "如果选中,请求体消息载荷将从 JSON 字符串转换为纯文本,例如 msg = \"Hello,\\t\"world\"\" 将解析为 Hello, \"world\"", + "read-timeout": "读取超时时间(毫秒)", + "read-timeout-hint": "值为 0 表示无限超时", + "max-parallel-requests-count": "最大并行请求数", + "max-parallel-requests-count-hint": "值为 0 表示不限制并行处理", + "max-response-size": "最大响应大小(KB)", + "max-response-size-hint": "解码或编码 HTTP 消息(如 JSON 或 XML 载荷)时分配用于缓冲数据的最大内存量", + "headers": "请求头", + "headers-hint": "在请求头/值字段中使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值", + "header": "请求头", + "header-required": "请求头为必填项", + "value": "值", + "value-required": "值为必填项", + "topic-pattern": "主题模式", + "key-pattern": "键模式", + "key-pattern-hint": "可选。如果指定了有效的分区号,则在发送记录时将使用该分区号。如果未指定分区,则使用键代替。如果两者都未指定,将以轮询方式分配分区。", + "topic-pattern-required": "主题模式为必填项", + "topic": "主题", + "topic-required": "主题为必填项", + "bootstrap-servers": "Bootstrap 服务器", + "bootstrap-servers-required": "Bootstrap 服务器值为必填项", + "other-properties": "其他属性", + "key": "键", + "key-required": "键为必填项", + "retries": "失败时自动重试次数", + "min-retries-message": "最小重试次数仅允许为 0。", + "batch-size-bytes": "生产者批量大小(字节)", + "min-batch-size-bytes-message": "最小批量大小仅允许为 0。", + "linger-ms": "本地缓冲时间(毫秒)", + "min-linger-ms-message": "最小值仅允许为 0 毫秒。", + "buffer-memory-bytes": "客户端缓冲区最大大小(字节)", + "min-buffer-memory-message": "最小缓冲区大小仅允许为 0。", + "memory-buffer-size-range": "内存缓冲区大小必须在 0 到 {{max}} KB 之间", + "acks": "确认数", + "topic-arn-pattern": "主题 ARN 模式", + "topic-arn-pattern-required": "主题 ARN 模式为必填项", + "aws-access-key-id": "AWS Access Key ID", + "aws-access-key-id-required": "AWS Access Key ID 为必填项", + "aws-secret-access-key": "AWS Secret Access Key", + "aws-secret-access-key-required": "AWS Secret Access Key 为必填项", + "aws-region": "AWS 区域", + "aws-region-required": "AWS 区域为必填项", + "exchange-name-pattern": "Exchange 名称模式", + "routing-key-pattern": "路由键模式", + "message-properties": "消息属性", + "host": "主机", + "host-required": "主机为必填项", + "port": "端口", + "port-required": "端口为必填项", + "port-range": "端口应在 1 到 65535 的范围内。", + "virtual-host": "虚拟主机", + "username": "用户名", + "password": "密码", + "automatic-recovery": "自动恢复", + "connection-timeout-ms": "连接超时时间(毫秒)", + "min-connection-timeout-ms-message": "最小值仅允许为 0 毫秒。", + "handshake-timeout-ms": "握手超时时间(毫秒)", + "min-handshake-timeout-ms-message": "最小值仅允许为 0 毫秒。", + "client-properties": "客户端属性", + "queue-url-pattern": "队列 URL 模式", + "queue-url-pattern-required": "队列 URL 模式为必填项", + "delay-seconds": "延迟(秒)", + "min-delay-seconds-message": "最小值仅允许为 0 秒。", + "max-delay-seconds-message": "最大值仅允许为 900 秒。", + "name": "名称", + "name-required": "名称为必填项", + "queue-type": "队列类型", + "sqs-queue-standard": "标准", + "sqs-queue-fifo": "FIFO", + "gcp-project-id": "GCP 项目 ID", + "gcp-project-id-required": "GCP 项目 ID 为必填项", + "gcp-service-account-key": "GCP 服务账号密钥文件", + "gcp-service-account-key-required": "GCP 服务账号密钥文件为必填项", + "pubsub-topic-name": "主题名称", + "pubsub-topic-name-required": "主题名称为必填项", + "message-attributes": "消息属性", + "message-attributes-hint": "在名称/值字段中使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值", + "connect-timeout": "连接超时时间(秒)", + "connect-timeout-required": "连接超时时间为必填项。", + "connect-timeout-range": "连接超时时间应在 1 到 200 的范围内。", + "client-id": "Client ID", + "client-id-hint": "可选。留空将自动生成 Client ID。指定 Client ID 时请谨慎。大多数 MQTT 代理不允许使用相同 Client ID 的多个连接。要连接到此类代理,您的 MQTT Client ID 必须唯一。当平台以微服务模式运行时,规则节点的副本会在每个微服务中启动。这将自动导致多个具有相同 ID 的 MQTT 客户端,并可能导致规则节点故障。为避免此类故障,请启用下方的“将 Service ID 作为后缀添加到 Client ID”选项。", + "append-client-id-suffix": "将 Service ID 作为后缀添加到 Client ID", + "client-id-suffix-hint": "可选。在明确指定“Client ID”时适用。如果选中,Service ID 将作为后缀添加到 Client ID。有助于避免平台以微服务模式运行时的故障。", + "device-id": "设备 ID", + "device-id-required": "设备 ID 为必填项。", + "clean-session": "清除会话", + "enable-ssl": "启用 SSL", + "credentials": "凭据", + "credentials-type": "凭据类型", + "credentials-type-required": "凭据类型为必填项。", + "credentials-anonymous": "匿名", + "credentials-basic": "基本", + "credentials-pem": "PEM", + "credentials-pem-hint": "至少需要服务器 CA 证书文件或客户端证书和客户端私钥文件对", + "credentials-sas": "共享访问签名", + "sas-key": "SAS 密钥", + "sas-key-required": "SAS 密钥为必填项。", + "hostname": "主机名", + "hostname-required": "主机名为必填项。", + "azure-ca-cert": "CA 证书文件", + "username-required": "用户名为必填项。", + "password-required": "密码为必填项。", + "ca-cert": "服务器 CA 证书文件", + "private-key": "客户端私钥文件", + "cert": "客户端证书文件", + "no-file": "未选择文件。", + "drop-file": "拖放文件或点击以选择要上传的文件。", + "private-key-password": "私钥密码", + "use-system-smtp-settings": "使用系统 SMTP 设置", + "use-metadata-dynamic-interval": "使用动态间隔", + "metadata-dynamic-interval-hint": "间隔开始和结束输入字段支持模板化。请注意,替换后的模板值应以毫秒为单位设置。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "use-metadata-interval-patterns-hint": "如果选中,规则节点将使用消息元数据或数据中的开始和结束间隔模式(假设间隔以毫秒为单位)。", + "use-message-alarm-data": "使用消息告警数据", + "overwrite-alarm-details": "覆盖告警详情", + "use-alarm-severity-pattern": "使用告警严重程度模式", + "check-all-keys": "检查所有指定字段是否存在", + "check-all-keys-hint": "如果选中,将检查所有指定的键是否存在于消息数据和元数据中。", + "check-relation-to-specific-entity": "检查与特定实体的关联", + "check-relation-to-specific-entity-tooltip": "如果启用,将检查与特定实体的关联是否存在;否则,检查与任意实体的关联是否存在。在两种情况下,关联查找都基于配置的方向和类型。", + "check-relation-hint": "根据方向和关联类型检查与特定实体或任意实体的关联是否存在。", + "delete-relation-with-specific-entity": "删除与特定实体的关联", + "delete-relation-with-specific-entity-hint": "如果启用,将仅删除与一个特定实体的关联。否则,将删除与所有匹配实体的关联。", + "delete-relation-hint": "根据方向和类型,从传入消息的发起者删除到指定实体或实体列表的关联。", + "remove-current-relations": "移除当前关联", + "remove-current-relations-hint": "根据方向和类型,移除传入消息发起者的当前关联。", + "change-originator-to-related-entity": "将发起者更改为关联实体", + "change-originator-to-related-entity-hint": "用于将提交的消息作为来自另一个实体的消息进行处理。", + "start-interval": "间隔开始", + "end-interval": "间隔结束", + "start-interval-required": "间隔开始为必填项。", + "end-interval-required": "间隔结束为必填项。", + "smtp-protocol": "协议", + "smtp-host": "SMTP 主机", + "smtp-host-required": "SMTP 主机为必填项。", + "smtp-port": "SMTP 端口", + "smtp-port-required": "必须提供 SMTP 端口。", + "smtp-port-range": "SMTP 端口应在 1 到 65535 的范围内。", + "timeout-msec": "超时时间(毫秒)", + "min-timeout-msec-message": "最小值仅允许为 0 毫秒。", + "enter-username": "输入用户名", + "enter-password": "输入密码", + "enable-tls": "启用 TLS", + "tls-version": "TLS 版本", + "enable-proxy": "启用代理", + "use-system-proxy-properties": "使用系统代理属性", + "proxy-host": "代理主机", + "proxy-host-required": "代理主机为必填项。", + "proxy-port": "代理端口", + "proxy-port-required": "代理端口为必填项。", + "proxy-port-range": "代理端口应在 1 到 65535 的范围内。", + "proxy-user": "代理用户", + "proxy-password": "代理密码", + "proxy-scheme": "代理方案", + "numbers-to-template": "电话号码收件人模板", + "numbers-to-template-required": "电话号码收件人模板为必填项", + "numbers-to-template-hint": "逗号分隔的电话号码,使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值", + "sms-message-template": "短信消息模板", + "sms-message-template-required": "短信消息模板为必填项", + "use-system-sms-settings": "使用系统短信提供商设置", + "min-period-0-seconds-message": "最小周期仅允许为 0 秒。", + "max-pending-messages": "最大待处理消息数", + "max-pending-messages-required": "最大待处理消息数为必填项。", + "max-pending-messages-range": "最大待处理消息数应在 1 到 100000 的范围内。", + "originator-types-filter": "发起者类型过滤器", + "interval-seconds": "间隔(秒)", + "interval-seconds-required": "间隔为必填项。", + "int-range": "值不得超过最大整数限制(2147483648)", + "min-interval-seconds-message": "最小间隔仅允许为 1 秒。", + "output-timeseries-key-prefix": "输出时间序列键前缀", + "output-timeseries-key-prefix-required": "输出时间序列键前缀为必填项。", + "separator-hint": "您应按“Enter”完成字段输入。", + "select-details": "选择详情", + "entity-details-id": "Id", + "entity-details-title": "标题", + "entity-details-country": "国家", + "entity-details-state": "省/州", + "entity-details-city": "城市", + "entity-details-zip": "邮编", + "entity-details-address": "地址", + "entity-details-address2": "地址2", + "entity-details-additional_info": "附加信息", + "entity-details-phone": "电话", + "entity-details-email": "Email", + "email-sender": "Email 发送者", + "fields-to-check": "要检查的字段", + "add-detail": "添加详情", + "check-all-keys-tooltip": "如果启用,将检查消息和元数据字段名称中列出的所有字段是否存在于传入消息及其元数据中。", + "fields-to-check-hint": "按“Enter”完成字段名称输入。支持多个字段名称。", + "entity-details-list-empty": "至少应选择一个详情。", + "alarm-status": "告警状态", + "alarm-required": "至少应选择一个告警状态。", + "no-entity-details-matching": "未找到匹配的实体详情。", + "custom-table-name": "自定义表名称", + "custom-table-name-required": "表名称为必填项", + "custom-table-hint": "该表必须在您的 Cassandra 集群中创建,且其名称必须以前缀 'cs_tb_' 开头,以避免将数据插入到通用 TB 表中。请在此处输入不带 'cs_tb_' 前缀的表名称。", + "message-field": "消息字段", + "message-field-required": "消息字段为必填项。", + "table-col": "表列", + "table-col-required": "表列为必填项。", + "latitude-field-name": "纬度字段名称", + "longitude-field-name": "经度字段名称", + "latitude-field-name-required": "纬度字段名称为必填项。", + "longitude-field-name-required": "经度字段名称为必填项。", + "fetch-perimeter-info-from-metadata": "从元数据获取边界信息", + "fetch-perimeter-info-from-metadata-tooltip": "如果边界类型设为“Polygon”,元数据字段 '{{perimeterKeyName}}' 的值将直接用作边界定义,不进行额外解析。否则,如果边界类型设为“Circle”,'{{perimeterKeyName}}' 元数据字段的值将被解析以提取 'latitude'、'longitude'、'radius'、'radiusUnit' 字段用于圆形边界定义。", + "perimeter-key-name": "边界键名称", + "perimeter-key-name-hint": "包含边界信息的元数据字段名称。", + "perimeter-key-name-required": "边界键名称为必填项。", + "perimeter-circle": "圆形", + "perimeter-polygon": "多边形", + "perimeter-type": "边界类型", + "circle-center-latitude": "中心纬度", + "circle-center-latitude-required": "中心纬度为必填项。", + "circle-center-longitude": "中心经度", + "circle-center-longitude-required": "中心经度为必填项。", + "range-unit-meter": "米", + "range-unit-kilometer": "千米", + "range-unit-foot": "英尺", + "range-unit-mile": "英里", + "range-unit-nautical-mile": "海里", + "range-units": "范围单位", + "range-units-required": "范围单位为必填项。", + "range": "范围", + "range-required": "范围为必填项。", + "polygon-definition": "多边形定义", + "polygon-definition-required": "多边形定义为必填项。", + "polygon-definition-hint": "使用以下格式手动定义多边形:[[lat1,lon1],[lat2,lon2], ... ,[latN,lonN]]。", + "min-inside-duration": "最小内部持续时间", + "min-inside-duration-value-required": "最小内部持续时间为必填项", + "min-inside-duration-time-unit": "最小内部持续时间单位", + "min-outside-duration": "最小外部持续时间", + "min-outside-duration-value-required": "最小外部持续时间为必填项", + "min-outside-duration-time-unit": "最小外部持续时间单位", + "tell-failure-if-absent": "报告失败", + "tell-failure-if-absent-hint": "如果至少一个选定的键不存在,出站消息将报告“Failure”。", + "get-latest-value-with-ts": "获取最新遥测值的时间戳", + "get-latest-value-with-ts-hint": "如果选中,最新遥测值还将包含时间戳,例如:\"temp\": \"{\"ts\":1574329385897, \"value\":42}\"", + "ignore-null-strings": "忽略空字符串", + "ignore-null-strings-hint": "如果选中,规则节点将忽略值为空的实体字段。", + "add-metadata-key-values-as-kafka-headers": "将消息元数据键值对添加到 Kafka 记录头", + "add-metadata-key-values-as-kafka-headers-hint": "如果选中,消息元数据中的键值对将作为具有预定义字符集编码的字节数组添加到出站记录头。", + "charset-encoding": "字符集编码", + "charset-encoding-required": "字符集编码为必填项。", + "charset-us-ascii": "US-ASCII", + "charset-iso-8859-1": "ISO-8859-1", + "charset-utf-8": "UTF-8", + "charset-utf-16be": "UTF-16BE", + "charset-utf-16le": "UTF-16LE", + "charset-utf-16": "UTF-16", + "select-queue-hint": "队列名称可从下拉列表中选择或添加自定义名称。", + "device-profile-node-hint": "如果您有持续时间或重复条件,此功能可确保告警状态评估的连续性。", + "persist-alarm-rules": "持久化告警规则的状态", + "persist-alarm-rules-hint": "如果启用,规则节点将把处理状态存储到数据库。", + "fetch-alarm-rules": "获取告警规则的状态", + "fetch-alarm-rules-hint": "如果启用,规则节点将在初始化时恢复处理状态,并确保即使在服务器重启后也能触发告警。否则,状态将在设备的第一条消息到达时恢复。", + "input-value-key": "输入值键", + "input-value-key-required": "输入值键为必填项。", + "output-value-key": "输出值键", + "output-value-key-required": "输出值键为必填项。", + "number-of-digits-after-floating-point": "浮点数后的位数", + "number-of-digits-after-floating-point-range": "浮点数后的位数应在 0 到 15 的范围内。", + "failure-if-delta-negative": "如果差值为负则报告失败", + "failure-if-delta-negative-tooltip": "如果差值为负,规则节点将强制消息处理失败。", + "use-caching": "使用缓存", + "use-caching-tooltip": "规则节点将缓存来自传入消息的“{{inputValueKey}}”值以提高性能。请注意,如果您在其他位置修改了“{{inputValueKey}}”值,缓存将不会更新。", + "add-time-difference-between-readings": "添加“{{inputValueKey}}”读数之间的时间差", + "add-time-difference-between-readings-tooltip": "如果启用,规则节点将把“{{periodValueKey}}”添加到出站消息中。", + "period-value-key": "周期值键", + "period-value-key-required": "周期值键为必填项。", + "general-pattern-hint": "使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值。", + "alarm-severity-pattern-hint": "使用 ${metadataKey} 获取元数据中的值,使用 $[messageKey] 获取消息体中的值。告警严重程度应为系统值(CRITICAL、MAJOR 等)", + "output-node-name-hint": "规则节点名称对应于出站消息的关联类型,用于将消息转发到调用者规则链中的其他规则节点。", + "use-server-ts": "使用服务器时间戳", + "use-server-ts-hint": "对缺少显式时间戳的时间序列数据使用服务器当前时间戳。这有助于在处理来自多个源的消息或消息乱序到达时保持正确的排序。", + "kv-map-pattern-hint": "所有输入字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "kv-map-single-pattern-hint": "输入字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "shared-scope": "共享范围", + "server-scope": "服务端范围", + "client-scope": "客户端范围", + "attribute-type": "属性", + "attribute-type-description": "从数据库获取属性值", + "attribute-type-result-description": "将结果作为实体属性存储到数据库", + "constant-type": "常量", + "constant-type-description": "定义常量值", + "time-series-type": "时间序列", + "time-series-type-description": "从数据库获取最新时间序列值", + "time-series-type-result-description": "将结果作为实体时间序列存储到数据库", + "message-body-type": "消息", + "message-body-type-description": "从传入消息获取参数值", + "message-body-type-result-description": "将结果添加到出站消息", + "message-metadata-type": "元数据", + "message-metadata-type-description": "从传入消息元数据获取参数值", + "message-metadata-result-description": "将结果添加到出站消息元数据", + "argument-tile": "参数", + "no-arguments-prompt": "未配置参数", + "result-title": "结果", + "functions-field-input": "函数", + "no-option-found": "未找到选项", + "argument-source-field-input": "来源", + "argument-source-field-input-required": "参数来源为必填项。", + "argument-key-field-input": "键", + "argument-key-field-input-required": "参数键为必填项。", + "constant-value-field-input": "常量值", + "constant-value-field-input-required": "常量值为必填项。", + "attribute-scope-field-input": "属性范围", + "attribute-scope-field-input-required": "属性范围为必填项。", + "default-value-field-input": "默认值", + "type-field-input": "类型", + "type-field-input-required": "类型为必填项。", + "key-field-input": "键", + "add-entity-type": "添加实体类型", + "add-device-profile": "添加设备配置", + "key-field-input-required": "键为必填项。", + "number-floating-point-field-input": "浮点数后的位数", + "number-floating-point-field-input-hint": "使用 0 将结果转换为整数", + "add-to-message-field-input": "添加到消息", + "add-to-metadata-field-input": "添加到元数据", + "custom-expression-field-input": "数学表达式", + "custom-expression-field-input-required": "数学表达式为必填项", + "custom-expression-field-input-hint": "指定要计算的数学表达式。默认表达式演示如何将华氏温度转换为摄氏温度", + "retained-message": "保留消息", + "attributes-mapping": "属性映射", + "latest-telemetry-mapping": "最新遥测映射", + "add-mapped-attribute-to": "将映射的属性添加到", + "add-mapped-latest-telemetry-to": "将映射的最新遥测添加到", + "add-mapped-fields-to": "将映射的字段添加到", + "add-selected-details-to": "将选定的详情添加到", + "clear-selected-types": "清除选定的类型", + "clear-selected-details": "清除选定的详情", + "clear-selected-fields": "清除选定的字段", + "clear-selected-keys": "清除选定的键", + "geofence-configuration": "地理围栏配置", + "coordinate-field-names": "坐标字段名称", + "coordinate-field-hint": "规则节点尝试从消息中检索指定的字段。如果不存在,将在元数据中查找。", + "presence-monitoring-strategy": "存在监测策略", + "presence-monitoring-strategy-on-first-message": "首条消息时", + "presence-monitoring-strategy-on-each-message": "每条消息时", + "presence-monitoring-strategy-on-first-message-hint": "在上一次存在状态“已进入”或“已离开”更新后,经过配置的最小持续时间,在第一条消息时报告存在状态“内部”或“外部”。", + "presence-monitoring-strategy-on-each-message-hint": "在存在状态“已进入”或“已离开”更新后,在每条消息时报告存在状态“内部”或“外部”。", + "fetch-credentials-to": "将凭据获取到", + "add-originator-attributes-to": "将发起者属性添加到", + "originator-attributes": "发起者属性", + "fetch-latest-telemetry-with-timestamp": "获取带时间戳的最新遥测", + "fetch-latest-telemetry-with-timestamp-tooltip": "如果选中,最新遥测值将带有时间戳添加到出站元数据,例如:\"{{latestTsKeyName}}\": \"{\"ts\":1574329385897, \"value\":42}\"", + "tell-failure": "如果任何属性缺失则报告失败", + "tell-failure-tooltip": "如果至少一个选定的键不存在,出站消息将报告“Failure”。", + "created-time": "创建时间", + "chip-help": "按 'Enter' 完成{{inputName}}输入。\n按 'Backspace' 删除{{inputName}}。\n支持多个值。", + "detail": "详情", + "field-name": "字段名称", + "device-profile": "设备配置", + "entity-type": "实体类型", + "message-type": "消息类型", + "timeseries-key": "时间序列键", + "type": "类型", + "first-name": "名", + "last-name": "姓", + "label": "标签", + "originator-fields-mapping": "发起者字段映射", + "add-mapped-originator-fields-to": "将映射的发起者字段添加到", + "fields": "字段", + "skip-empty-fields": "跳过空字段", + "skip-empty-fields-tooltip": "值为空的字段将不会添加到输出消息/输出元数据中。", + "fetch-interval": "获取间隔", + "fetch-strategy": "获取策略", + "fetch-timeseries-from-to": "获取从 {{startInterval}} {{startIntervalTimeUnit}} 前到 {{endInterval}} {{endIntervalTimeUnit}} 前的时间序列。", + "fetch-timeseries-from-to-invalid": "获取时间序列无效(“间隔开始”应小于“间隔结束”)。", + "use-metadata-dynamic-interval-tooltip": "如果选中,规则节点将基于消息和元数据模式使用动态间隔的开始和结束。", + "all-mode-hint": "如果选择了获取模式“All”,规则节点将使用可配置的查询参数从获取间隔中检索遥测。", + "first-mode-hint": "如果选择了获取模式“First”,规则节点将检索最接近获取间隔开始的遥测。", + "last-mode-hint": "如果选择了获取模式“Last”,规则节点将检索最接近获取间隔结束的遥测。", + "ascending": "升序", + "descending": "降序", + "min": "最小值", + "max": "最大值", + "average": "平均值", + "sum": "求和", + "count": "计数", + "none": "无", + "last-level-relation-tooltip": "如果选中,规则节点将仅在最大关联层级设定的层级上搜索关联实体。", + "last-level-device-relation-tooltip": "如果选中,规则节点将仅在最大关联层级设定的层级上搜索关联设备。", + "data-to-fetch": "要获取的数据", + "mapping-of-customers": "客户的映射", + "map-fields-required": "所有映射字段为必填项。", + "attributes": "属性", + "related-device-attributes": "关联设备属性", + "add-selected-attributes-to": "将选定的属性添加到", + "device-profiles": "设备配置", + "mapping-of-tenant": "租户的映射", + "add-attribute-key": "添加属性键", + "message-template": "消息模板", + "message-template-required": "消息模板为必填项", + "use-system-slack-settings": "使用系统 Slack 设置", + "slack-api-token": "Slack API Token", + "slack-api-token-required": "Slack API Token 为必填项", + "keys-mapping": "键映射", + "add-key": "添加键", + "recipients": "收件人", + "message-subject-and-content": "消息主题和内容", + "template-rules-hint": "两个输入字段均支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从消息元数据中提取值。", + "originator-customer-desc": "使用传入消息发起者的客户作为新发起者。", + "originator-tenant-desc": "使用当前租户作为新发起者。", + "originator-related-entity-desc": "使用关联实体作为新发起者。基于配置的关联类型和方向进行查找。", + "originator-alarm-originator-desc": "使用告警发起者作为新发起者。仅当传入消息发起者为告警实体时适用。", + "originator-entity-by-name-pattern-desc": "使用从数据库获取的实体作为新发起者。基于实体类型和指定的名称模式进行查找。", + "email-from-template-hint": "使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "recipients-block-main-hint": "逗号分隔的地址列表。所有输入字段支持模板化。使用 $[messageKey] 从消息中提取值,使用 ${metadataKey} 从元数据中提取值。", + "forward-msg-default-rule-chain": "将消息转发到发起者的默认规则链", + "forward-msg-default-rule-chain-tooltip": "如果启用,消息将转发到发起者的默认规则链;如果发起者在实体配置中未定义默认规则链,则使用配置中的规则链。", + "exclude-zero-deltas": "从出站消息中排除零差值", + "exclude-zero-deltas-hint": "如果启用,仅当“{{outputValueKey}}”输出键的值不为零时才将其添加到出站消息。", + "exclude-zero-deltas-time-difference-hint": "如果启用,仅当“{{outputValueKey}}”值不为零时,才将“{{outputValueKey}}”和“{{periodValueKey}}”输出键添加到出站消息。", + "search-direction-from": "从发起者到目标实体", + "search-direction-to": "从目标实体到发起者", + "del-relation-direction-from": "从发起者", + "del-relation-direction-to": "到发起者", + "target-entity": "目标实体", + "function-configuration": "函数配置", + "function-name": "函数名称", + "function-name-required": "函数名称为必填项。", + "qualifier": "限定符", + "qualifier-hint": "如果未指定限定符,将使用默认限定符 \"$LATEST\"。", + "aws-credentials": "AWS 凭据", + "connection-timeout": "连接超时时间", + "connection-timeout-required": "连接超时时间为必填项。", + "connection-timeout-min": "最小连接超时时间为 0。", + "connection-timeout-hint": "初始建立连接时在放弃并超时之前等待的时间(秒)。值为 0 表示无限,不推荐使用。", + "request-timeout": "请求超时时间", + "request-timeout-required": "请求超时时间为必填项", + "request-timeout-min": "最小请求超时时间为 0", + "request-timeout-hint": "等待请求完成后放弃并超时的时间(秒)。值为 0 表示无限,不推荐使用。", + "units": "单位", + "tell-failure-aws-lambda": "如果 AWS Lambda 函数执行引发异常则报告失败", + "tell-failure-aws-lambda-hint": "如果 AWS Lambda 函数执行引发异常,规则节点将强制消息处理失败。", + "basic-mode": "基本", + "advanced-mode": "高级", + "save-time-series": { + "processing-settings": "处理设置", + "processing-settings-hint": "定义传入消息的处理方式。基本处理设置允许您选择预配置的策略,而高级设置允许您为每个操作选择单独的处理策略。", + "advanced-settings-hint": "配置处理策略时请谨慎。某些组合可能导致意外行为。", + "strategy": "策略", + "deduplication-interval": "去重间隔", + "deduplication-interval-required": "去重间隔为必填项", + "deduplication-interval-min-max-range": "去重间隔应至少为 1 秒且最多为 1 天", + "strategy-type": { + "every-message": "每条消息", + "skip": "跳过", + "deduplicate": "去重", + "web-sockets-only": "仅 WebSockets" + }, + "time-series": "时间序列", + "latest": "最新值", + "web-sockets": "WebSockets", + "calculated-fields-and-alarm-rules": "计算字段和告警规则" + }, + "save-attribute": { + "processing-settings": "处理设置", + "processing-settings-hint": "定义传入消息的处理方式。基本处理设置允许您选择预配置的策略,而高级设置允许您为每个操作选择单独的处理策略。", + "advanced-settings-hint": "配置处理策略时请谨慎。某些组合可能导致意外行为。", + "strategy": "策略", + "deduplication-interval": "去重间隔", + "deduplication-interval-required": "去重间隔为必填项", + "deduplication-interval-min-max-range": "去重间隔应至少为 1 秒且最多为 1 天", + "scope": "范围", + "strategy-type": { + "every-message": "每条消息", + "skip": "跳过", + "deduplicate": "去重", + "web-sockets-only": "仅 WebSockets" + }, + "attributes": "属性" }, "key-val": { - "key": "键名", - "value": "数值", - "remove-entry": "删除条目", - "add-entry": "增加条目", - "no-data": "没有条目" - }, - "layout": { - "layout": "布局", - "layouts": "布局", - "manage": "布局管理", - "settings": "布局设置", - "color": "颜色", - "main": "主体", - "right": "右侧", - "left": "左侧", - "select": "选择目标布局", - "percentage-width": "百分比宽度(%)", - "fixed-width": "固定(px)", - "left-width": "左列(%)", - "right-width": "右列(%)", - "pick-fixed-side": "固定侧: ", - "layout-fixed-width": "固定宽度(px)", - "value-min-error": "值是必须大于{{min}}{{unit}}", - "value-max-error": "值是必须小于{{max}}{{unit}}", - "layout-fixed-width-required": "固定宽度必填", - "right-width-percentage-required": "右侧百分比宽度必填", - "left-width-percentage-required": "左侧百分比宽度必填", - "divider": "分隔线", - "right-side": "右侧布局", - "left-side": "左侧布局", - "add-new-breakpoint": "添加新的栅格", - "breakpoint": "栅格", - "breakpoints": "栅格", - "copy-from": "复制", - "size": "尺寸", - "delete-breakpoint-title": "确定要删除栅格'{{name}}'吗?", - "delete-breakpoint-text": "请注意确认后栅格将无法恢复并且将使用默认栅格。" - }, - "legend": { - "direction": "图例方向", - "position": "图例位置", - "show-values": "显示数值", - "min-option": "最小值", - "max-option": "最大值", - "average-option": "平均值", - "total-option": "总数", - "latest-option": "最新值", - "sort-legend": "在图例中排序数据键", - "show-max": "显示最大值", - "show-min": "显示最小值", - "show-avg": "显示平均值", - "show-total": "显示总数", - "show-latest": "显示最新值", - "settings": "设置图例", - "min": "最小值", - "max": "最大值", - "avg": "平均值", - "total": "总数", - "latest": "最新值", - "Min": "最小值", - "Max": "最大值", - "Avg": "平均值", - "Total": "总数", - "Latest": "最新值", - "comparison-time-ago": { - "previousInterval": "(历史间隔)", - "customInterval": "(自定义间隔)", - "days": "(一天前)", - "weeks": "(一周前)", - "months": "(一个月前)", - "years": "(一年前)" - }, - "column-title": "标题", - "label": "标签", - "value": "数值" - }, - "login": { - "login": "登录", - "request-password-reset": "请求密码重置", - "reset-password": "重置密码", - "create-password": "创建密码", - "two-factor-authentication": "双因素认证", - "passwords-mismatch-error": "输入的密码必须相同!", - "password-again": "再次输入密码", - "username": "用户名(电子邮件)", - "remember-me": "记住我", - "forgot-password": "忘记密码?", - "password-reset": "密码重置", - "expired-password-reset-message": "您的凭据已过期!请创建密码。", - "new-password": "新密码", - "new-password-again": "再次输入新密码", - "password-link-sent-message": "密码重置链接已成功发送!", - "email": "电子邮件", - "invalid-email-format": "email格式无效。", - "login-with": "使用{{name}}登录", - "or": "或", - "error": "登录错误", - "verify-your-identity": "身份验证", - "select-way-to-verify": "选择验证方式", - "resend-code": "重发验证码", - "resend-code-wait": "{ time, plural, =1 {1 秒} other {# 秒} }后重发验证码", - "try-another-way": "尝试其他方法", - "totp-auth-description": "请从验证APP中查看验证码。", - "totp-auth-placeholder": "验证码", - "sms-auth-description": "验证码已发送到手机号码{{contact}}。", - "sms-auth-placeholder": "SMS 验证码", - "email-auth-description": "验证码已发送到电子邮箱地址{{contact}}。", - "email-auth-placeholder": "电子邮件验证码", - "backup-code-auth-description": "请输入一个备份验证码。", - "backup-code-auth-placeholder": "备份验证码", - "activation-link-expired": "激活链接已过期", - "activation-link-expired-message": "您的激活链接已过期返回登录页面重新发送电子邮件。", - "reset-password-link-expired": "密码重置链接已过期", - "reset-password-link-expired-message": "重置密码链接已过期返回登录页面重新发送电子邮件。" + "key": "键", + "value": "值", + "see-examples": "查看示例。", + "remove-entry": "移除条目", + "remove-mapping-entry": "移除映射条目", + "add-mapping-entry": "添加映射", + "add-entry": "添加条目", + "copy-key-values-from": "复制键值来源", + "delete-key-values": "删除键值", + "delete-key-values-from": "删除键值来源", + "at-least-one-key-error": "至少应选择一个键。", + "unique-key-value-pair-error": "“{{keyText}}”必须与“{{valText}}”不同!" }, - "markdown": { - "edit": "编辑", - "preview": "预览", - "copy-code": "点击复制", - "copied": "复制完成" + "mail-body-types": { + "plain-text": "纯文本", + "html": "HTML", + "dynamic": "动态", + "use-body-type-template": "使用正文类型模板", + "plain-text-description": "简单的无格式文本,没有特殊样式或格式。", + "html-text-description": "允许您在邮件正文中使用 HTML 标签进行格式化、链接和图片。", + "dynamic-text-description": "允许基于模板化功能动态使用纯文本或 HTML 正文类型。", + "after-template-evaluation-hint": "模板评估后,值应为 true 表示 HTML,false 表示纯文本。" + }, + "ai": { + "ai-model": "AI 模型", + "model": "模型", + "ai-model-hint": "选择此规则节点用于处理请求的预配置 AI 模型,或使用“新建”来配置新模型。", + "prompt-settings": "提示词设置", + "prompt-settings-hint": "可选的系统提示词设置 AI 的一般角色和约束,而用户提示词定义要执行的具体任务。两个字段均支持模板化。", + "system-prompt": "系统提示词", + "system-prompt-max-length": "系统提示词不得超过 500000 个字符。", + "system-prompt-blank": "系统提示词不能为空。", + "user-prompt": "用户提示词", + "user-prompt-required": "用户提示词为必填项。", + "user-prompt-max-length": "用户提示词不得超过 500000 个字符。", + "user-prompt-blank": "用户提示词不能为空。", + "response-format": "响应格式", + "response-text": "文本", + "response-json": "JSON", + "response-json-schema": "JSON Schema", + "response-format-hint-TEXT": "允许模型生成任意文本,该文本可能是或不是有效的 JSON 对象。如果输出不是有效的 JSON 对象,将自动包装在 \"response\" 键下的 JSON 对象中。", + "response-format-hint-JSON": "要求模型生成有效的 JSON 响应。如果输出不是有效的 JSON 对象,将自动包装在 \"response\" 键下的 JSON 对象中。", + "response-format-hint-JSON_SCHEMA": "要求模型生成与提供的 schema 中定义的特定结构和数据类型匹配的 JSON。如果输出不是有效的 JSON 对象,将自动包装在 \"response\" 键下的 JSON 对象中。", + "response-json-schema-hint": "虽然可以输入任何有效的 JSON Schema,但此规则节点仅支持其功能的有限子集。详情请参阅节点文档。", + "response-json-schema-required": "JSON Schema 为必填项", + "advanced-settings": "高级设置", + "timeout": "超时时间", + "timeout-hint": "等待 AI 模型响应的最大时间,\n超过此时间请求将被终止。", + "timeout-required": "超时时间为必填项", + "timeout-validation": "必须为 1 秒到 10 分钟之间。", + "force-acknowledgement": "强制确认", + "force-acknowledgement-hint": "如果启用,传入消息将立即被确认。然后模型的响应将作为单独的新消息入队。", + "ai-resources": "AI 资源" + } + }, + "timezone": { + "timezone": "时区", + "select-timezone": "选择时区", + "no-timezones-matching": "未找到与“{{timezone}}”匹配的时区。", + "timezone-required": "时区为必填项。", + "browser-time": "浏览器时间" + }, + "queue": { + "queue-name": "队列", + "no-queues-found": "未找到队列。", + "no-queues-matching": "未找到与“{{queue}}”匹配的队列。", + "select-name": "选择队列名称", + "name": "名称", + "name-required": "队列名称为必填项!", + "name-unique": "队列名称不唯一!", + "name-pattern": "队列名称包含 ASCII 字母数字、“.”、“_”和“-”以外的字符!", + "queue-required": "队列为必填项!", + "topic-required": "队列主题为必填项!", + "poll-interval-required": "轮询间隔为必填项!", + "poll-interval-min-value": "轮询间隔不能小于 1", + "partitions-required": "分区为必填项!", + "partitions-min-value": "分区数不能小于 1", + "pack-processing-timeout-required": "处理超时为必填项", + "pack-processing-timeout-min-value": "处理超时值不能小于 1", + "batch-size-required": "批量大小为必填项!", + "batch-size-min-value": "批量大小不能小于 1", + "retries-required": "重试次数为必填项!", + "retries-min-value": "重试次数不能为负数", + "failure-percentage-required": "失败百分比为必填项!", + "failure-percentage-min-value": "失败百分比不能小于 0", + "failure-percentage-max-value": "失败百分比不能大于 100", + "pause-between-retries-required": "重试间隔为必填项!", + "pause-between-retries-min-value": "重试间隔不能小于 1", + "max-pause-between-retries-required": "最大重试间隔为必填项!", + "max-pause-between-retries-min-value": "最大重试间隔不能小于 1", + "submit-strategy-type-required": "提交策略类型为必填项!", + "processing-strategy-type-required": "处理策略类型为必填项!", + "queues": "队列", + "selected-queues": "{ count, plural, =1 {1 个队列} other {# 个队列} }已选择", + "delete-queue-title": "确定要删除队列“{{queueName}}”吗?", + "delete-queues-title": "确定要删除 { count, plural, =1 {1 个队列} other {# 个队列} }吗?", + "delete-queue-text": "请注意,确认后队列及所有相关数据将无法恢复。", + "delete-queues-text": "确认后所有选中的队列将被删除且不可访问。", + "search": "搜索队列", + "add": "添加队列", + "details": "队列详情", + "topic": "主题", + "submit-settings": "提交设置", + "submit-strategy": "策略类型 *", + "grouping-parameter": "分组参数", + "processing-settings": "重试处理设置", + "processing-strategy": "处理类型 *", + "retries-settings": "重试设置", + "polling-settings": "轮询设置", + "batch-processing": "批量处理", + "poll-interval": "轮询间隔", + "partitions": "分区", + "immediate-processing": "即时处理", + "consumer-per-partition": "为每个消费者发送消息轮询", + "consumer-per-partition-hint": "为每个分区启用独立的消费者", + "duplicate-msg-to-all-partitions": "将消息复制到所有分区", + "processing-timeout": "处理超时(毫秒)", + "batch-size": "批量大小", + "retries": "重试次数(0 — 无限)", + "failure-percentage": "跳过重试的失败消息百分比(%)", + "pause-between-retries": "重试间隔(秒)", + "max-pause-between-retries": "额外重试间隔(秒)", + "delete": "删除队列", + "copyId": "复制队列 Id", + "idCopiedMessage": "队列 Id 已复制到剪贴板", + "description": "描述", + "description-hint": "此文本将显示在队列描述中,替代所选策略", + "alt-description": "提交策略:{{submitStrategy}},处理策略:{{processingStrategy}}", + "custom-properties": "自定义属性", + "custom-properties-hint": "自定义队列(主题)创建属性,例如 'retention.ms:604800000;retention.bytes:1048576000'", + "strategies": { + "sequential-by-originator-label": "按来源顺序执行", + "sequential-by-originator-hint": "例如设备 A 的新消息在设备 A 的上一条消息被确认前不会提交", + "sequential-by-tenant-label": "按租户顺序执行", + "sequential-by-tenant-hint": "例如租户 A 的新消息在租户 A 的上一条消息被确认前不会提交", + "sequential-label": "顺序执行", + "sequential-hint": "新消息在上一条消息被确认前不会提交", + "burst-label": "突发", + "burst-hint": "所有消息按到达顺序提交到规则链", + "batch-label": "批量", + "batch-hint": "新批次在上一批次被确认前不会提交", + "skip-all-failures-label": "跳过所有失败", + "skip-all-failures-hint": "忽略所有失败", + "skip-all-failures-and-timeouts-label": "跳过所有失败和超时", + "skip-all-failures-and-timeouts-hint": "忽略所有失败和超时", + "retry-all-label": "全部重试", + "retry-all-hint": "重试处理包中的所有消息", + "retry-failed-label": "重试失败", + "retry-failed-hint": "重试处理包中所有失败的消息", + "retry-timeout-label": "重试超时", + "retry-timeout-hint": "重试处理包中所有超时的消息", + "retry-failed-and-timeout-label": "重试失败和超时", + "retry-failed-and-timeout-hint": "重试处理包中所有失败和超时的消息" + } + }, + "queue-statistics": { + "queue-statistics": "队列统计", + "no-queue-statistics-matching": "未找到与“{{entity}}”匹配的队列统计。", + "queue-statistics-required": "队列统计为必填项。", + "list-of-queue-statistics": "{ count, plural, =1 {1 条队列统计} other {# 条队列统计列表} }", + "selected-queue-statistics": "{ count, plural, =1 {1 条队列统计} other {# 条队列统计} }已选择", + "no-queue-statistics-text": "未找到队列统计", + "queue-statistics-starts-with": "名称以“{{prefix}}”开头的队列统计" + }, + "server-error": { + "general": "服务器通用错误", + "authentication": "认证错误", + "jwt-token-expired": "JWT Token 已过期", + "tenant-trial-expired": "租户试用已过期", + "credentials-expired": "凭证已过期", + "permission-denied": "权限被拒绝", + "invalid-arguments": "参数无效", + "bad-request-params": "请求参数错误", + "item-not-found": "未找到该项", + "too-many-requests": "请求过多", + "too-many-updates": "更新过多", + "entities-limit-exceeded": "实体数量超出限制" + }, + "tenant": { + "tenant": "租户", + "tenants": "租户", + "management": "租户管理", + "add": "添加租户", + "admins": "管理员", + "manage-tenant-admins": "管理租户管理员", + "delete": "删除租户", + "add-tenant-text": "添加新租户", + "no-tenants-text": "未找到租户", + "tenant-details": "租户详情", + "title-max-length": "标题不能超过 256 个字符", + "delete-tenant-title": "确定要删除租户“{{tenantTitle}}”吗?", + "delete-tenant-text": "请注意,确认后租户及所有相关数据将无法恢复。", + "delete-tenants-title": "确定要删除 { count, plural, =1 {1 个租户} other {# 个租户} }吗?", + "delete-tenants-action-title": "删除 { count, plural, =1 {1 个租户} other {# 个租户} }", + "delete-tenants-text": "请注意,确认后所有选中的租户将被移除,且所有相关数据将无法恢复。", + "title": "标题", + "title-required": "标题为必填项。", + "description": "描述", + "details": "详情", + "events": "事件", + "copyId": "复制租户 Id", + "idCopiedMessage": "租户 Id 已复制到剪贴板", + "select-tenant": "选择租户", + "no-tenants-matching": "未找到与“{{entity}}”匹配的租户。", + "tenant-required": "租户为必填项", + "search": "搜索租户", + "selected-tenants": "{ count, plural, =1 {1 个租户} other {# 个租户} }已选择", + "isolated-tb-rule-engine": "使用隔离的 ThingsBoard 规则引擎队列", + "isolated-tb-rule-engine-details": "每个租户将拥有专用的规则引擎队列" + }, + "tenant-profile": { + "tenant-profile": "租户配置", + "tenant-profiles": "租户配置", + "add": "添加租户配置", + "add-profile": "添加配置", + "debug": "调试", + "edit": "编辑租户配置", + "tenant-profile-details": "租户配置详情", + "no-tenant-profiles-text": "未找到租户配置", + "name-max-length": "名称不能超过 256 个字符", + "search": "搜索租户配置", + "selected-tenant-profiles": "{ count, plural, =1 {1 个租户配置} other {# 个租户配置} }已选择", + "no-tenant-profiles-matching": "未找到与“{{entity}}”匹配的租户配置。", + "tenant-profile-required": "租户配置为必填项", + "idCopiedMessage": "租户配置 Id 已复制到剪贴板", + "set-default": "设为默认租户配置", + "delete": "删除租户配置", + "copyId": "复制租户配置 Id", + "name": "名称", + "name-required": "名称为必填项。", + "data": "配置数据", + "profile-configuration": "配置", + "description": "描述", + "default": "默认", + "delete-tenant-profile-title": "确定要删除租户配置“{{tenantProfileName}}”吗?", + "delete-tenant-profile-text": "请注意,确认后租户配置及所有相关数据将无法恢复。", + "delete-tenant-profiles-title": "确定要删除 { count, plural, =1 {1 个租户配置} other {# 个租户配置} }吗?", + "delete-tenant-profiles-text": "请注意,确认后所有选中的租户配置将被移除,且所有相关数据将无法恢复。", + "set-default-tenant-profile-title": "确定要将租户配置“{{tenantProfileName}}”设为默认吗?", + "set-default-tenant-profile-text": "确认后,该租户配置将被标记为默认配置,并用于未指定配置的新租户。", + "no-tenant-profiles-found": "未找到租户配置。", + "create-new-tenant-profile": "创建新租户配置!", + "create-tenant-profile": "创建新租户配置", + "import": "导入租户配置", + "export": "导出租户配置", + "export-failed-error": "无法导出租户配置:{{error}}", + "tenant-profile-file": "租户配置文件", + "invalid-tenant-profile-file-error": "无法导入租户配置:租户配置数据结构无效。", + "advanced-settings": "高级设置", + "entities": "实体", + "rule-engine": "规则引擎", + "time-to-live": "生存时间", + "calculated-fields": "计算字段", + "alarms-and-notifications": "告警和通知", + "ota-files-in-bytes": "文件", + "ws-title": "WS", + "unlimited": "(0 — 无限)", + "maximum-devices": "设备最大数量", + "maximum-devices-required": "设备最大数量为必填项。", + "maximum-devices-range": "设备最大数量不能为负数", + "maximum-assets": "资产最大数量", + "maximum-assets-required": "资产最大数量为必填项。", + "maximum-assets-range": "资产最大数量不能为负数", + "maximum-customers": "客户最大数量", + "maximum-customers-required": "客户最大数量为必填项。", + "maximum-customers-range": "客户最大数量不能为负数", + "maximum-users": "用户最大数量", + "maximum-users-required": "用户最大数量为必填项。", + "maximum-users-range": "用户最大数量不能为负数", + "maximum-dashboards": "仪表板最大数量", + "maximum-dashboards-required": "仪表板最大数量为必填项。", + "maximum-dashboards-range": "仪表板最大数量不能为负数", + "maximum-edges": "Edge 最大数量", + "maximum-edges-required": "Edge 最大数量为必填项。", + "maximum-edges-range": "Edge 最大数量不能为负数", + "maximum-rule-chains": "规则链最大数量", + "maximum-rule-chains-required": "规则链最大数量为必填项。", + "maximum-rule-chains-range": "规则链最大数量不能为负数", + "maximum-resources-sum-data-size": "资源文件最大总大小(字节)", + "maximum-resources-sum-data-size-required": "资源文件最大总大小为必填项。", + "maximum-resources-sum-data-size-range": "资源文件最大总大小不能为负数", + "maximum-resource-size": "单个资源文件最大大小(字节)", + "maximum-resource-size-required": "单个资源文件最大大小为必填项", + "maximum-resource-size-range": "单个资源文件最大大小不能为负数", + "maximum-ota-packages-sum-data-size": "OTA 软件包文件最大总大小(字节)", + "maximum-ota-package-sum-data-size-required": "OTA 软件包文件最大总大小为必填项。", + "maximum-ota-package-sum-data-size-range": "OTA 软件包文件最大总大小不能为负数", + "maximum-debug-duration-min": "最大调试持续时间(分钟)", + "maximum-debug-duration-min-range": "最大调试持续时间不能为负数", + "rest-requests-for-tenant": "租户 REST 请求", + "transport-tenant-telemetry-msg-rate-limit": "传输层租户遥测消息", + "transport-tenant-telemetry-data-points-rate-limit": "传输层租户遥测数据点", + "transport-device-msg-rate-limit": "传输层设备消息", + "transport-device-telemetry-msg-rate-limit": "传输层设备遥测消息", + "transport-device-telemetry-data-points-rate-limit": "传输层设备遥测数据点", + "transport-gateway-msg-rate-limit": "传输层 Gateway 消息", + "transport-gateway-telemetry-msg-rate-limit": "传输层 Gateway 遥测消息", + "transport-gateway-telemetry-data-points-rate-limit": "传输层 Gateway 遥测数据点", + "transport-gateway-device-msg-rate-limit": "传输层 Gateway 设备消息", + "transport-gateway-device-telemetry-msg-rate-limit": "传输层 Gateway 设备遥测消息", + "transport-gateway-device-telemetry-data-points-rate-limit": "传输层 Gateway 设备遥测数据点", + "tenant-entity-export-rate-limit": "实体版本创建", + "tenant-entity-import-rate-limit": "实体版本加载", + "tenant-notification-request-rate-limit": "通知请求", + "tenant-notification-requests-per-rule-rate-limit": "每条通知规则的通知请求", + "max-calculated-fields": "每个实体的计算字段最大数量", + "max-calculated-fields-range": "每个实体的计算字段最大数量不能为负数", + "max-calculated-fields-required": "每个实体的计算字段最大数量为必填项", + "max-data-points-per-rolling-arg": "滚动参数中的最大数据点数量", + "max-data-points-per-rolling-arg-range": "滚动参数中的最大数据点数量不能为负数", + "max-data-points-per-rolling-arg-required": "滚动参数中的最大数据点数量为必填项", + "max-arguments-per-cf": "每个计算字段的最大参数数量", + "max-arguments-per-cf-range": "每个计算字段的最大参数数量不能为负数", + "max-arguments-per-cf-required": "每个计算字段的最大参数数量为必填项", + "max-related-level-per-argument": "“关联实体”参数的最大关联层级", + "max-related-level-per-argument-range": "“关联实体”参数的最大关联层级不能小于“1”", + "max-related-level-per-argument-required": "“关联实体”参数的最大关联层级为必填项", + "min-allowed-scheduled-update-interval": "“关联实体”参数的最小允许更新间隔(秒)", + "min-allowed-scheduled-update-interval-range": "最小允许更新间隔不能为负数", + "min-allowed-deduplication-interval": "最小允许去重间隔(秒)", + "min-allowed-deduplication-interval-range": "最小允许去重间隔不能为负数", + "min-allowed-deduplication-interval-required": "最小允许去重间隔为必填项", + "intermediate-aggregation-interval": "中间聚合间隔(秒)", + "intermediate-aggregation-interval-range": "中间聚合间隔不能小于“1”", + "intermediate-aggregation-interval-required": "中间聚合间隔为必填项", + "reevaluation-check-interval": "重新评估检查间隔(秒)", + "reevaluation-check-interval-range": "重新评估检查间隔不能小于“1”", + "reevaluation-check-interval-required": "重新评估检查间隔为必填项", + "alarms-reevaluation-interval": "告警重新评估间隔(秒)", + "alarms-reevaluation-interval-range": "告警重新评估间隔不能小于“1”", + "alarms-reevaluation-interval-required": "告警重新评估间隔为必填项", + "min-allowed-aggregation-interval": "最小允许聚合间隔(秒)", + "min-allowed-aggregation-interval-range": "最小允许聚合间隔不能为负数", + "min-allowed-aggregation-interval-required": "最小允许聚合间隔为必填项", + "min-allowed-scheduled-update-interval-required": "最小允许更新间隔为必填项", + "max-state-size": "状态最大大小(KB)", + "max-state-size-range": "状态最大大小(KB)不能为负数", + "max-state-size-required": "状态最大大小(KB)为必填项", + "max-value-argument-size": "单个值参数最大大小(KB)", + "max-value-argument-size-range": "单个值参数最大大小(KB)不能为负数", + "max-value-argument-size-required": "单个值参数最大大小(KB)为必填项", + "max-transport-messages": "传输消息最大数量", + "max-transport-messages-required": "传输消息最大数量为必填项。", + "max-transport-messages-range": "传输消息最大数量不能为负数", + "max-transport-data-points": "传输数据点最大数量 ", + "max-transport-data-points-required": "传输数据点最大数量 为必填项。", + "max-transport-data-points-range": "传输数据点最大数量 不能为负数", + "max-r-e-executions": "规则引擎执行最大次数", + "max-r-e-executions-required": "规则引擎执行最大次数为必填项。", + "max-r-e-executions-range": "规则引擎执行最大次数不能为负数", + "max-j-s-executions": "JavaScript 执行最大次数 ", + "max-j-s-executions-required": "JavaScript 执行最大次数 为必填项。", + "max-j-s-executions-range": "JavaScript 执行最大次数 不能为负数", + "max-tbel-executions": "TBEL 执行最大次数 ", + "max-tbel-executions-required": "TBEL 执行最大次数为必填项。", + "max-tbel-executions-range": "TBEL 执行最大次数不能为负数", + "max-d-p-storage-days": "数据点存储最大天数", + "max-d-p-storage-days-required": "数据点存储最大天数为必填项。", + "max-d-p-storage-days-range": "数据点存储最大天数不能为负数", + "default-storage-ttl-days": "默认存储 TTL 天数", + "default-storage-ttl-days-required": "默认存储 TTL 天数为必填项。", + "default-storage-ttl-days-range": "默认存储 TTL 天数不能为负数", + "alarms-ttl-days": "告警 TTL 天数", + "alarms-ttl-days-required": "告警 TTL 天数为必填项", + "alarms-ttl-days-days-range": "告警 TTL 天数不能为负数", + "rpc-ttl-days": "RPC TTL 天数", + "rpc-ttl-days-required": "RPC TTL 天数为必填项", + "rpc-ttl-days-days-range": "RPC TTL 天数不能为负数", + "queue-stats-ttl-days": "队列统计 TTL 天数", + "queue-stats-ttl-days-required": "队列统计 TTL 天数为必填项", + "queue-stats-ttl-days-range": "队列统计 TTL 天数不能为负数", + "rule-engine-exceptions-ttl-days": "规则引擎异常 TTL 天数", + "rule-engine-exceptions-ttl-days-required": "规则引擎异常 TTL 天数为必填项", + "rule-engine-exceptions-ttl-days-range": "规则引擎异常 TTL 天数不能为负数", + "max-rule-node-executions-per-message": "每条消息的规则节点执行最大次数", + "max-rule-node-executions-per-message-required": "每条消息的规则节点执行最大次数为必填项。", + "max-rule-node-executions-per-message-range": "每条消息的规则节点执行最大次数不能为负数", + "max-emails": "Email 发送最大数量", + "max-emails-required": "Email 发送最大数量为必填项。", + "max-emails-range": "Email 发送最大数量不能为负数", + "sms-enabled": "SMS 已启用", + "max-sms": "SMS 发送最大数量", + "max-sms-required": "SMS 发送最大数量为必填项。", + "max-sms-range": "SMS 发送最大数量不能为负数", + "max-created-alarms": "告警创建最大数量", + "max-created-alarms-required": "告警创建最大数量为必填项。", + "max-created-alarms-range": "告警创建最大数量不能为负数", + "no-queue": "未配置队列", + "add-queue": "添加队列", + "queues-with-count": "队列({{count}})", + "tenant-rest-limits": "租户 REST 请求", + "customer-rest-limits": "客户 REST 请求", + "incorrect-pattern-for-rate-limits": "格式为逗号分隔的容量和周期(秒)对,用冒号分隔,例如 100:1,2000:60", + "too-small-value-zero": "值必须大于 0", + "too-small-value-one": "值必须大于 1", + "queue-size-is-limited-by-system-configuration": "队列大小同时受系统配置限制。", + "cassandra-write-tenant-core-limits-configuration": "Rest API Cassandra 写入查询", + "cassandra-read-tenant-core-limits-configuration": "Rest API 和 WS 遥测 Cassandra 读取查询", + "cassandra-write-tenant-rule-engine-limits-configuration": "规则引擎遥测 Cassandra 写入查询", + "cassandra-read-tenant-rule-engine-limits-configuration": "规则引擎遥测 Cassandra 读取查询", + "ws-limit-max-sessions-per-tenant": "每个租户的最大会话数", + "ws-limit-max-sessions-per-customer": "每个客户的最大会话数", + "ws-limit-max-sessions-per-regular-user": "每个普通用户的最大会话数", + "ws-limit-max-sessions-per-public-user": "每个公共用户的最大会话数", + "ws-limit-queue-per-session": "每个会话的最大消息队列大小", + "ws-limit-max-subscriptions-per-tenant": "每个租户的最大订阅数", + "ws-limit-max-subscriptions-per-customer": "每个客户的最大订阅数", + "ws-limit-max-subscriptions-per-regular-user": "每个普通用户的最大订阅数", + "ws-limit-max-subscriptions-per-public-user": "每个公共用户的最大订阅数", + "ws-limit-updates-per-session": "每个会话的 WS 更新", + "relation-search-entity-limit": "关联搜索实体限制", + "relation-search-entity-limit-hint": "限制关联路径最后一级解析的实体数量。适用于“关联实体”参数和传播字段。", + "relation-search-entity-limit-required": "关联搜索实体限制", + "relation-search-entity-limit-range": "关联搜索实体限制不能小于“1”", + "rate-limits": { + "add-limit": "添加限制", + "and-also-less-than": "且小于", + "advanced-settings": "高级设置", + "edit-limit": "编辑限制", + "calculated-field-debug-event-rate-limit": "计算字段调试事件", + "edit-calculated-field-debug-event-rate-limit": "编辑计算字段调试事件速率限制", + "edit-transport-tenant-msg-title": "编辑传输层租户消息速率限制", + "edit-transport-tenant-telemetry-msg-title": "编辑传输层租户遥测消息速率限制", + "edit-transport-tenant-telemetry-data-points-title": "编辑传输层租户遥测数据点速率限制", + "edit-transport-device-msg-title": "编辑传输层设备消息速率限制", + "edit-transport-device-telemetry-msg-title": "编辑传输层设备遥测消息速率限制", + "edit-transport-device-telemetry-data-points-title": "编辑传输层设备遥测数据点速率限制", + "edit-transport-gateway-msg-title": "编辑传输层 Gateway 消息速率限制", + "edit-transport-gateway-telemetry-msg-title": "编辑传输层 Gateway 遥测消息速率限制", + "edit-transport-gateway-telemetry-data-points-title": "编辑传输层 Gateway 遥测数据点速率限制", + "edit-transport-gateway-device-msg-title": "编辑传输层 Gateway 设备消息速率限制", + "edit-transport-gateway-device-telemetry-msg-title": "编辑传输层 Gateway 设备遥测消息速率限制", + "edit-transport-gateway-device-telemetry-data-points-title": "编辑传输层 Gateway 设备遥测数据点速率限制", + "edit-tenant-rest-limits-title": "编辑租户 REST 请求速率限制", + "edit-customer-rest-limits-title": "编辑客户 REST 请求速率限制", + "edit-ws-limit-updates-per-session-title": "编辑每个会话的 WS 更新速率限制", + "edit-cassandra-write-tenant-core-limits-configuration": "编辑 Rest API Cassandra 写入查询", + "edit-cassandra-read-tenant-core-limits-configuration": "编辑 Rest API 和 WS 遥测 Cassandra 读取查询", + "edit-cassandra-write-tenant-rule-engine-limits-configuration": "编辑规则引擎遥测 Cassandra 写入查询", + "edit-cassandra-read-tenant-rule-engine-limits-configuration": "编辑规则引擎遥测 Cassandra 读取查询", + "edit-tenant-entity-export-rate-limit-title": "编辑实体版本创建速率限制", + "edit-tenant-entity-import-rate-limit-title": "编辑实体版本加载速率限制", + "edit-tenant-notification-request-rate-limit-title": "编辑通知请求速率限制", + "edit-tenant-notification-requests-per-rule-rate-limit-title": "编辑每条通知规则的通知请求速率限制", + "edit-edge-events-rate-limit": "编辑 Edge 事件速率限制", + "edit-edge-events-per-edge-rate-limit": "编辑每个 Edge 的 Edge 事件速率限制", + "edge-events-rate-limit": "Edge 事件", + "edge-events-per-edge-rate-limit": "每个 Edge 的 Edge 事件", + "edit-edge-uplink-messages-rate-limit": "编辑 Edge 上行消息速率限制", + "edit-edge-uplink-messages-per-edge-rate-limit": "编辑每个 Edge 的 Edge 上行消息速率限制", + "edge-uplink-messages-rate-limit": "Edge 上行消息", + "edge-uplink-messages-per-edge-rate-limit": "每个 Edge 的 Edge 上行消息", + "messages-per": "消息每", + "not-set": "未设置", + "number-of-messages": "消息数量", + "number-of-messages-required": "消息数量为必填项。", + "number-of-messages-min": "最小值为 1。", + "preview": "预览", + "per-seconds": "每秒", + "per-seconds-required": "时间速率为必填项。", + "per-seconds-min": "最小值为 1。", + "per-seconds-duplicate": "时间速率重复。每个时间间隔必须唯一。", + "rate-limits": "速率限制", + "remove-limit": "移除限制", + "transport-tenant-msg": "传输层租户消息", + "transport-tenant-telemetry-msg": "传输层租户遥测消息", + "transport-tenant-telemetry-data-points": "传输层租户遥测数据点", + "transport-device-msg": "传输层设备消息", + "transport-device-telemetry-msg": "传输层设备遥测消息", + "transport-device-telemetry-data-points": "传输层设备遥测数据点", + "transport-gateway-msg": "传输层 Gateway 消息", + "transport-gateway-telemetry-msg": "传输层 Gateway 遥测消息", + "transport-gateway-telemetry-data-points": "传输层 Gateway 遥测数据点", + "transport-gateway-device-msg": "传输层 Gateway 设备消息", + "transport-gateway-device-telemetry-msg": "传输层 Gateway 设备遥测消息", + "transport-gateway-device-telemetry-data-points": "传输层 Gateway 设备遥测数据点", + "sec": "秒" + } + }, + "timeinterval": { + "seconds-interval": "{ seconds, plural, =1 {1 秒} other {# 秒} }", + "minutes-interval": "{ minutes, plural, =1 {1 分钟} other {# 分钟} }", + "hours-interval": "{ hours, plural, =1 {1 小时} other {# 小时} }", + "days-interval": "{ days, plural, =1 {1 天} other {# 天} }", + "days": "天", + "hours": "小时", + "minutes": "分钟", + "seconds": "秒", + "advanced": "高级", + "custom": "自定义", + "predefined": { + "yesterday": "昨天", + "day-before-yesterday": "前天", + "this-day-last-week": "上周的今天", + "previous-week": "上一周(周日 - 周六)", + "previous-week-iso": "上一周(周一 - 周日)", + "previous-month": "上个月", + "previous-quarter": "上个季度", + "previous-half-year": "上半年", + "previous-year": "去年", + "current-hour": "当前小时", + "current-day": "当天", + "current-day-so-far": "当天至今", + "current-week": "本周(周日 - 周六)", + "current-week-iso": "本周(周一 - 周日)", + "current-week-so-far": "本周至今(周日 - 周六)", + "current-week-iso-so-far": "本周至今(周一 - 周日)", + "current-month": "本月", + "current-month-so-far": "本月至今", + "current-quarter": "本季度", + "current-quarter-so-far": "本季度至今", + "current-half-year": "本半年", + "current-half-year-so-far": "本半年至今", + "current-year": "本年", + "current-year-so-far": "本年至今" }, + "type": { + "week": "周(周日 - 周六)", + "week-iso": "周(周一 - 周日)", + "month": "月", + "quarter": "季度" + } + }, + "timeunit": { + "milliseconds": "毫秒", + "seconds": "秒", + "minutes": "分钟", + "hours": "小时", + "days": "天" + }, + "timewindow": { + "timewindow": "时间窗口", + "timewindow-settings": "时间窗口设置", + "years": "{ years, plural, =1 {1 年 } other {# 年 } }", + "years-short": "{{ years }}年", + "months": "{ months, plural, =1 {1 个月 } other {# 个月 } }", + "months-short": "{{ months }}月", + "weeks": "{ weeks, plural, =1 {1 周 } other {# 周 } }", + "weeks-short": "{{ weeks }}周", + "days": "{ days, plural, =1 {1 天 } other {# 天 } }", + "days-short": "{{ days }}天", + "hours": "{ hours, plural, =0 { 小时 } =1 {1 小时 } other {# 小时 } }", + "hr": "{{ hr }} 小时", + "hr-short": "{{ hr }}时", + "minutes": "{ minutes, plural, =0 { 分钟 } =1 {1 分钟 } other {# 分钟 } }", + "min": "{{ min }} 分钟", + "min-short": "{{ min }}分", + "seconds": "{ seconds, plural, =0 { 秒 } =1 {1 秒 } other {# 秒 } }", + "sec": "{{ sec }} 秒", + "sec-short": "{{ sec }}秒", + "short": { + "years": "{ years, plural, =1 {1 年 } other {# 年 } }", + "days": "{ days, plural, =1 {1 天 } other {# 天 } }", + "hours": "{ hours, plural, =1 {1 小时 } other {# 小时 } }", + "minutes": "{{minutes}} 分钟 ", + "seconds": "{{seconds}} 秒 " + }, + "realtime": "实时", + "history": "历史", + "last-prefix": "最近", + "period": "从 {{ startTime }} 到 {{ endTime }}", + "edit": "编辑时间窗口", + "date-range": "日期范围", + "for-all-time": "所有时间", + "last": "最近", + "time-period": "时间段", + "hide": "隐藏", + "interval": "间隔", + "just-now": "刚刚", + "just-now-lower": "刚刚", + "ago": "前", + "style": "时间窗口样式", + "icon": "图标", + "icon-position": "图标位置", + "icon-position-left": "左", + "icon-position-right": "右", + "font": "字体", + "color": "颜色", + "displayTypePrefix": "显示实时/历史前缀", + "preview": "预览", + "relative": "相对", + "range": "范围", + "hide-timewindow-section": "对终端用户隐藏时间窗口部分", + "hide-last-interval": "对终端用户隐藏最近间隔", + "hide-relative-interval": "对终端用户隐藏相对间隔", + "hide-fixed-interval": "对终端用户隐藏固定间隔", + "hide-aggregation": "对终端用户隐藏聚合", + "hide-group-interval": "对终端用户隐藏分组间隔", + "hide-max-values": "对终端用户隐藏最大值", + "hide-timezone": "对终端用户隐藏时区", + "disable-custom-interval": "禁用自定义间隔选择", + "edit-aggregation-functions-list": "编辑聚合函数列表", + "edit-aggregation-functions-list-hint": "可以指定可用选项列表。", + "allowed-aggregation-functions": "允许的聚合函数", + "edit-intervals-list": "编辑间隔列表", + "allowed-agg-intervals": "分组间隔", + "default-agg-interval": "默认分组间隔", + "edit-intervals-list-hint": "可以指定可用间隔选项列表。", + "edit-grouping-intervals-list-hint": "可以配置分组间隔列表和默认分组间隔。", + "all": "全部", + "save-current-settings-as-default": "将当前设置保存为默认时间窗口", + "hide-option-from-end-users": "对终端用户隐藏选项" + }, + "tooltip": { + "trigger": "触发器", + "trigger-point": "点", + "trigger-axis": "轴", + "label": "标签", + "value": "值", + "date": "日期", + "show-date-time-interval": "显示日期时间间隔", + "show-date-time-interval-hint": "根据数据聚合显示日期时间间隔。", + "hide-zero-tooltip-values": "隐藏零值", + "show-stack-total": "在堆叠模式下显示总值", + "background-color": "背景颜色", + "background-blur": "背景模糊" + }, + "unit": { + "set-unit-conversion": "设置单位转换", + "unit-settings": { + "unit-settings": "单位设置", + "source-unit": "源单位", + "source-unit-hint": "这是存储值的单位,即您要转换的源单位。请输入源数据使用的符号(例如 m、km、ft、in)。", + "target-metric-unit": "目标公制单位", + "target-metric-unit-hint": "选择要将源值转换为哪个公制单位(SI)(例如 cm、mm、km)。", + "target-imperial-unit": "目标英制单位", + "target-imperial-unit-hint": "选择要将源值转换为哪个英制单位(例如 in、ft、yd)。", + "target-hybrid-unit": "目标混合单位", + "target-hybrid-unit-hint": "选择要将源值转换为哪个混合单位(例如 cm、in、km)。混合单位结合了公制或英制单位。", + "enable-unit-conversion": "启用单位转换", + "enable-unit-conversion-hint": "开启以激活转换。关闭时,源值将保持不变。如果对应的度量组中只有一个单位(例如光通量、AQI),则此选项被禁用。" + }, + "unit-system": "单位制", + "unit-system-type": { + "AUTO": "自动", + "METRIC": "公制", + "IMPERIAL": "英制", + "HYBRID": "混合" + }, + "measures": { + "absorbed-dose-rate": "吸收剂量率", + "acceleration": "加速度", + "acidity": "酸度", + "air-quality-index": "空气质量指数", + "amount-of-substance": "物质的量", + "angle": "角度", + "angular-acceleration": "角加速度", + "area": "面积", + "area-density": "面密度", + "capacitance": "电容", + "catalytic-activity": "催化活性", + "catalytic-concentration": "催化浓度", + "charge": "电荷", + "current-density": "电流密度", + "data-transfer-rate": "数据传输速率", + "density": "密度", + "digital": "数字存储", + "dimension-ratio": "尺寸比", + "dynamic-viscosity": "动力粘度", + "earthquake-magnitude": "地震震级", + "electric-charge-density": "电荷密度", + "electric-current": "电流", + "electric-dipole-moment": "电偶极矩", + "electric-field-strength": "电场强度", + "electric-flux": "电通量", + "electric-permittivity": "介电常数", + "electric-polarizability": "电极化率", + "electrical-conductance": "电导", + "electrical-conductivity": "电导率", + "energy": "能量", + "energy-density": "能量密度", + "force": "力", + "frequency": "频率", + "fuel-efficiency": "燃油效率", + "heat-capacity": "热容", + "illuminance": "照度", + "inductance": "电感", + "kinematic-viscosity": "运动粘度", + "length": "长度", + "light-exposure": "曝光量", + "linear-charge-density": "线电荷密度", + "logarithmic-ratio": "对数比", + "luminous-efficacy": "发光效率", + "luminous-flux": "光通量", + "luminous-intensity": "发光强度", + "magnetic-field-gradient": "磁场梯度", + "magnetic-flux": "磁通量", + "magnetic-flux-density": "磁通量密度", + "magnetic-moment": "磁矩", + "magnetic-permeability": "磁导率", + "mass": "质量", + "mass-fraction": "质量分数", + "molar-concentration": "摩尔浓度", + "molar-energy": "摩尔能", + "molar-heat-capacity": "摩尔热容", + "molar-mass": "摩尔质量", + "number-concentration": "数浓度", + "parts-per-million": "百万分率", + "power": "功率", + "power-density": "功率密度", + "pressure": "压力", + "radiance": "辐射亮度", + "radiant-intensity": "辐射强度", + "radiation-dose": "辐射剂量", + "radioactive-decay": "放射性衰变", + "radioactivity": "放射性", + "radioactivity-concentration": "放射性浓度", + "reciprocal-length": "倒数长度", + "resistance": "电阻", + "reynolds-number": "雷诺数", + "signal-level": "信号电平", + "solid-angle": "立体角", + "specific-energy": "比能", + "specific-heat-capacity": "比热容", + "specific-humidity": "比湿", + "specific-volume": "比容", + "speed": "速度", + "surface-charge-density": "面电荷密度", + "surface-tension": "表面张力", + "temperature": "温度", + "thermal-conductivity": "热导率", + "time": "时间", + "torque": "扭矩", + "turbidity": "浊度", + "voltage": "电压", + "volume": "体积", + "volume-flow": "体积流量" + }, + "millimeter": "毫米", + "centimeter": "厘米", + "decimeter": "分米", + "angstrom": "埃", + "nanometer": "纳米", + "micrometer": "微米", + "meter": "米", + "kilometer": "千米", + "inch": "英寸", + "foot": "英尺", + "foot-us": "英尺(美国测量)", + "yard": "码", + "mile": "英里", + "nautical-mile": "海里", + "astronomical-unit": "天文单位", + "reciprocal-metre": "每米", + "meter-per-meter": "米每米", + "steradian": "球面度", + "thou": "密耳", + "barleycorn": "大麦粒", + "hand": "手宽", + "chain": "链", + "furlong": "弗隆", + "league": "里格", + "fathom": "英寻", + "cable": "链(海缆)", + "link": "链节", + "rod": "杆", + "nanogram": "纳克", + "microgram": "微克", + "milligram": "毫克", + "gram": "克", + "kilogram": "千克", + "tonne": "吨", + "ounce": "盎司", + "pound": "磅", + "stone": "英石", + "hundredweight-count": "英担", + "short-tons": "短吨", + "dalton": "道尔顿", + "grain": "格令", + "drachm": "打兰", + "quarter": "夸特", + "slug": "斯勒格", + "carat": "克拉", + "cubic-millimeter": "立方毫米", + "cubic-centimeter": "立方厘米", + "cubic-meter": "立方米", + "cubic-kilometer": "立方千米", + "microliter": "微升", + "milliliter": "毫升", + "liter": "升", + "hectoliter": "百升", + "cubic-inch": "立方英寸", + "cubic-foot": "立方英尺", + "cubic-yard": "立方码", + "fluid-ounce": "液量盎司", + "fluid-ounce-per-second": "液量盎司每秒", + "pint": "品脱", + "quart": "夸脱", + "gallon": "加仑", + "oil-barrels": "石油桶", + "cubic-meter-per-kilogram": "立方米每千克", + "gill": "及耳", + "hogshead": "霍格斯黑德", + "teaspoon": "茶匙", + "tablespoon": "汤匙", + "cup": "杯", + "celsius": "摄氏度", + "kelvin": "开尔文", + "rankine": "兰金", + "fahrenheit": "华氏度", + "percent": "百分比", + "meter-per-second": "米每秒", + "kilometer-per-hour": "千米每小时", + "foot-per-second": "英尺每秒", + "foot-per-minute": "英尺每分钟", + "mile-per-hour": "英里每小时", + "knot": "节", + "inch-per-second": "英寸每秒", + "inch-per-hour": "英寸每小时", + "millimeters-per-minute": "毫米每分钟", + "meter-per-minute": "米每分钟", + "kilometer-per-hour-squared": "千米每小时平方", + "foot-per-second-squared": "英尺每秒平方", + "pascal": "帕斯卡", + "kilopascal": "千帕", + "megapascal": "兆帕", + "gigapascal": "吉帕", + "millibar": "毫巴", + "bar": "巴", + "kilobar": "千巴", + "newton": "牛顿", + "newton-meter": "牛顿·米", + "foot-pounds": "英尺·磅", + "inch-pounds": "英寸·磅", + "newton-per-meter": "牛顿每米", + "atmospheres": "标准大气压", + "pounds-per-square-inch": "磅每平方英寸", + "kilopound-per-square-inch": "千磅每平方英寸", + "torr": "托", + "inches-of-mercury": "英寸汞柱", + "pascal-per-square-meter": "帕斯卡每平方米", + "pound-per-square-inch": "磅每平方英寸", + "newton-per-square-meter": "牛顿每平方米", + "kilogram-force-per-square-meter": "千克力每平方米", + "pascal-per-square-centimeter": "帕斯卡每平方厘米", + "ton-force-per-square-inch": "吨力每平方英寸", + "kilonewton-per-square-meter": "千牛顿每平方米", + "newton-per-square-millimeter": "牛顿每平方毫米", + "microjoule": "微焦", + "millijoule": "毫焦", + "joule": "焦耳", + "kilojoule": "千焦", + "megajoule": "兆焦", + "gigajoule": "吉焦", + "watt-hour": "瓦时", + "watt-minute": "瓦分", + "kilowatt-hour": "千瓦时", + "milliwatt-hour": "毫瓦时", + "megawatt-hour": "兆瓦时", + "gigawatt-hour": "吉瓦时", + "electron-volts": "电子伏特", + "joules-per-coulomb": "焦耳每库仑", + "british-thermal-unit": "英热单位", + "thousand-british-thermal-unit": "千英热单位", + "million-british-thermal-unit": "百万英热单位", + "foot-pound": "英尺·磅", + "calorie": "卡路里", + "small-calorie": "小卡", + "kilocalorie": "千卡", + "joule-per-kelvin": "焦耳每开尔文", + "joule-per-kilogram-kelvin": "焦耳每千克·开尔文", + "joule-per-kilogram": "焦耳每千克", + "watt-per-meter-kelvin": "瓦每米·开尔文", + "joule-per-cubic-meter": "焦耳每立方米", + "therm": "撒姆", + "electric-dipole-moment": "电偶极矩", + "magnetic-dipole-moment": "磁偶极矩", + "debye": "德拜", + "coulomb-per-square-meter-per-volt": "库仑每平方米每伏", + "milliwatt": "毫瓦", + "microwatt": "微瓦", + "watt": "瓦特", + "kilowatt": "千瓦", + "megawatt": "兆瓦", + "gigawatt": "吉瓦", + "metric-horsepower": "公制马力", + "milliwatt-per-square-centimeter": "毫瓦每平方厘米", + "watt-per-square-centimeter": "瓦每平方厘米", + "kilowatt-per-square-centimeter": "千瓦每平方厘米", + "milliwatt-per-square-meter": "毫瓦每平方米", + "watt-per-square-meter": "瓦每平方米", + "kilowatt-per-square-meter": "千瓦每平方米", + "watt-per-square-inch": "瓦每平方英寸", + "kilowatt-per-square-inch": "千瓦每平方英寸", + "horsepower": "马力", + "btu-per-hour": "英热单位每小时", + "btu-per-second": "英热单位每秒", + "btu-per-day": "英热单位每天", + "mbtu-per-hour": "千英热单位每小时", + "mbtu-per-second": "千英热单位每秒", + "mbtu-per-day": "千英热单位每天", + "mmbtu-per-hour": "百万英热单位每小时", + "mmbtu-per-second": "百万英热单位每秒", + "mmbtu-per-day": "百万英热单位每天", + "foot-pound-per-second": "英尺·磅每秒", + "coulomb": "库仑", + "millicoulomb": "毫库仑", + "microcoulomb": "微库仑", + "nanocoulomb": "纳库仑", + "picocoulomb": "皮库仑", + "coulomb-per-meter": "库仑每米", + "coulomb-per-cubic-meter": "库仑每立方米", + "coulomb-per-square-meter": "库仑每平方米", + "square-millimeter": "平方毫米", + "square-centimeter": "平方厘米", + "square-meter": "平方米", + "hectare": "公顷", + "square-kilometer": "平方千米", + "square-inch": "平方英寸", + "square-foot": "平方英尺", + "square-yard": "平方码", + "acre": "英亩", + "square-mile": "平方英里", + "are": "公亩", + "barn": "靶恩", + "circular-inch": "圆英寸", + "milliampere-hour": "毫安时", + "ampere-hours": "安时", + "kiloampere-hours": "千安时", + "nanoampere": "纳安", + "picoampere": "皮安", + "microampere": "微安", + "milliampere": "毫安", + "ampere": "安培", + "kiloampere": "千安", + "megaampere": "兆安", + "gigaampere": "吉安", + "microampere-per-square-centimeter": "微安每平方厘米", + "ampere-per-square-meter": "安培每平方米", + "ampere-per-meter": "安培每米", + "oersted": "奥斯特", + "bohr-magneton": "玻尔磁子", + "ampere-meter-squared": "安培·平方米", + "nanovolt": "纳伏", + "picovolt": "皮伏", + "millivolt": "毫伏", + "microvolt": "微伏", + "volt": "伏特", + "kilovolt": "千伏", + "megavolt": "兆伏", + "dbmV": "分贝伏", + "dbm": "分贝毫瓦", + "volt-meter": "伏特·米", + "kilovolt-meter": "千伏·米", + "megavolt-meter": "兆伏·米", + "microvolt-meter": "微伏·米", + "millivolt-meter": "毫伏·米", + "nanovolt-meter": "纳伏·米", + "ohm": "欧姆", + "microohm": "微欧", + "milliohm": "毫欧", + "kilohm": "千欧", + "megohm": "兆欧", + "gigohm": "吉欧", + "millihertz": "毫赫兹", + "hertz": "赫兹", + "kilohertz": "千赫兹", + "megahertz": "兆赫兹", + "gigahertz": "吉赫兹", + "terahertz": "太赫兹", + "rpm": "转每分钟", + "candela-per-square-meter": "坎德拉每平方米", + "candela": "坎德拉", + "lumen": "流明", + "lux": "勒克斯", + "foot-candle": "英尺烛光", + "lumen-per-square-meter": "流明每平方米", + "lux-second": "勒克斯·秒", + "lumen-second": "流明·秒", + "lumens-per-watt": "流明每瓦", + "mole": "摩尔", + "nanomole": "纳摩尔", + "micromole": "微摩尔", + "millimole": "毫摩尔", + "kilomole": "千摩尔", + "mole-per-cubic-meter": "摩尔每立方米", + "rssi": "接收信号强度指示", + "ppm": "百万分率", + "ppb": "十亿分率", + "micrograms-per-cubic-meter": "微克每立方米", + "aqi": "AQI", + "gram-per-cubic-meter": "克每立方米", + "gram-per-kilogram": "比湿", + "millimeters-per-second": "毫米每秒", + "neper": "奈培", + "bel": "贝尔", + "decibel": "分贝", + "meters-per-second-squared": "米每秒平方", + "becquerel": "贝可勒尔", + "curie": "居里", + "gray": "戈瑞", + "sievert": "希沃特", + "roentgen": "伦琴", + "cps": "计数每秒", + "rad": "拉德", + "rem": "雷姆", + "dps": "衰变每秒", + "rutherford": "卢瑟福", + "coulombs-per-kilogram": "库仑每千克", + "becquerels-per-cubic-meter": "贝可勒尔每立方米", + "curies-per-liter": "居里每升", + "becquerels-per-second": "贝可勒尔每秒", + "curies-per-second": "居里每秒", + "gy-per-second": "戈瑞每秒", + "watt-per-steradian": "瓦每球面度", + "watt-per-square-metre-steradian": "瓦每平方米·球面度", + "ph-level": "pH 值", + "turbidity": "浊度", + "mg-per-liter": "毫克每升", + "microsiemens-per-centimeter": "微西门子每厘米", + "millisiemens-per-meter": "毫西门子每米", + "siemens-per-meter": "西门子每米", + "kilogram-per-cubic-meter": "千克每立方米", + "gram-per-cubic-centimeter": "克每立方厘米", + "kilogram-per-square-meter": "千克每平方米", + "milligram-per-milliliter": "毫克每毫升", + "milligram-per-cubic-meter": "毫克每立方米", + "pound-per-cubic-foot": "磅每立方英尺", + "ounces-per-cubic-inch": "盎司每立方英寸", + "tons-per-cubic-yard": "吨每立方码", + "particle-density": "粒子密度", + "kilometers-per-liter": "千米每升", + "miles-per-gallon": "英里每加仑", + "liters-per-100-km": "升每百千米", + "gallons-per-mile": "加仑每英里", + "liters-per-hour": "升每小时", + "gallons-per-hour": "加仑每小时", + "beats-per-minute": "次每分钟", + "millimeters-of-mercury": "毫米汞柱", + "milligrams-per-deciliter": "毫克每分升", + "g-force": "重力加速度", + "kilonewton": "千牛顿", + "kilogram-force": "千克力", + "pound-force": "磅力", + "kilopound-force": "千磅力", + "dyne": "达因", + "poundal": "磅达", + "kip": "千磅力", + "gal": "伽", + "gravity": "重力", + "hectopascal": "百帕", + "atmosphere": "标准大气压", + "millibars": "毫巴", + "inch-of-mercury": "英寸汞柱", + "richter-scale": "里氏震级", + "nanosecond": "纳秒", + "microsecond": "微秒", + "millisecond": "毫秒", + "second": "秒", + "minute": "分钟", + "hour": "小时", + "day": "天", + "week": "周", + "month": "月", + "year": "年", + "cubic-foot-per-minute": "立方英尺每分钟", + "cubic-meters-per-hour": "立方米每小时", + "cubic-meters-per-second": "立方米每秒", + "liter-per-second": "升每秒", + "liter-per-minute": "升每分钟", + "gallons-per-minute": "加仑每分钟", + "cubic-foot-per-second": "立方英尺每秒", + "milliliters-per-minute": "毫升每分钟", + "cubic-decimeter-per-second": "立方分米每秒", + "bit": "比特", + "byte": "字节", + "kilobyte": "千字节", + "megabyte": "兆字节", + "gigabyte": "吉字节", + "terabyte": "太字节", + "petabyte": "拍字节", + "exabyte": "艾字节", + "zettabyte": "泽字节", + "yottabyte": "尧字节", + "bit-per-second": "比特每秒", + "kilobit-per-second": "千比特每秒", + "megabit-per-second": "兆比特每秒", + "gigabit-per-second": "吉比特每秒", + "terabit-per-second": "太比特每秒", + "byte-per-second": "字节每秒", + "kilobyte-per-second": "千字节每秒", + "megabyte-per-second": "兆字节每秒", + "gigabyte-per-second": "吉字节每秒", + "degree": "度", + "radian": "弧度", + "gradian": "百分度", + "arcminute": "角分", + "arcsecond": "角秒", + "milliradian": "毫弧度", + "revolution": "转", + "siemens": "西门子", + "millisiemens": "毫西门子", + "microsiemens": "微西门子", + "kilosiemens": "千西门子", + "megasiemens": "兆西门子", + "gigasiemens": "吉西门子", + "farad": "法拉", + "millifarad": "毫法拉", + "microfarad": "微法拉", + "nanofarad": "纳法拉", + "picofarad": "皮法拉", + "kilofarad": "千法拉", + "megafarad": "兆法拉", + "gigafarad": "吉法拉", + "terfarad": "太法拉", + "farad-per-meter": "法拉每米", + "tesla": "特斯拉", + "gauss": "高斯", + "kilogauss": "千高斯", + "millitesla": "毫特斯拉", + "microtesla": "微特斯拉", + "nanotesla": "纳特斯拉", + "kilotesla": "千特斯拉", + "megatesla": "兆特斯拉", + "millitesla-square-meters": "毫特斯拉·平方米", + "gamma": "伽马", + "lambda": "兰姆达", + "square-meter-per-second": "平方米每秒", + "square-centimeter-per-second": "平方厘米每秒", + "stoke": "斯托克斯", + "centistokes": "厘斯托克斯", + "square-foot-per-second": "平方英尺每秒", + "square-inch-per-second": "平方英寸每秒", + "pascal-second": "帕斯卡·秒", + "centipoise": "厘泊", + "poise": "泊", + "reynolds": "雷诺", + "pound-per-foot-hour": "磅每英尺·小时", + "newton-second-per-square-meter": "牛顿·秒每平方米", + "dyne-second-per-square-centimeter": "达因·秒每平方厘米", + "kilogram-per-meter-second": "千克每米·秒", + "tesla-square-meters": "特斯拉·平方米", + "maxwell": "麦克斯韦", + "tesla-per-meter": "特斯拉每米", + "gauss-per-centimeter": "高斯每厘米", + "weber": "韦伯", + "microweber": "微韦伯", + "milliweber": "毫韦伯", + "gauss-square-centimeter": "高斯·平方厘米", + "kilogauss-square-centimeter": "千高斯·平方厘米", + "henry": "亨利", + "millihenry": "毫亨利", + "microhenry": "微亨利", + "nanohenry": "纳亨利", + "henry-per-meter": "亨利每米", + "tesla-meter-per-ampere": "特斯拉·米每安培", + "gauss-per-oersted": "高斯每奥斯特", + "kilogram-per-mole": "千克每摩尔", + "gram-per-mole": "克每摩尔", + "milligram-per-mole": "毫克每摩尔", + "joule-per-mole": "焦耳每摩尔", + "joule-per-mole-kelvin": "焦耳每摩尔·开尔文", + "millivolts-per-meter": "毫伏每米", + "volts-per-meter": "伏特每米", + "kilovolts-per-meter": "千伏每米", + "radian-per-second": "弧度每秒", + "radian-per-second-squared": "弧度每秒平方", + "revolutions-per-minute-per-second": "角加速度", + "deg-per-second": "度每秒", + "rotation-per-minute": "转每分钟", + "degrees-brix": "白利度", + "katal": "开特", + "katal-per-cubic-metre": "开特每立方米", + "paris-inch": "巴黎英寸" + }, + "user": { + "user": "用户", + "users": "用户", + "customer-users": "客户用户", + "tenant-admins": "租户管理员", + "sys-admin": "系统管理员", + "tenant-admin": "租户管理员", + "customer": "客户", + "anonymous": "匿名", + "add": "添加用户", + "delete": "删除用户", + "add-user-text": "添加新用户", + "no-users-text": "未找到用户", + "user-details": "用户详情", + "delete-user-title": "确定要删除用户“{{userEmail}}”吗?", + "delete-user-text": "请注意,确认后用户及所有相关数据将无法恢复。", + "delete-users-title": "确定要删除 { count, plural, =1 {1 个用户} other {# 个用户} }吗?", + "delete-users-action-title": "删除 { count, plural, =1 {1 个用户} other {# 个用户} }", + "delete-users-text": "请注意,确认后所有选中的用户将被移除,且所有相关数据将无法恢复。", + "activation-email-sent-message": "激活 Email 已成功发送!", + "resend-activation": "重新发送激活", + "email": "Email", + "email-required": "Email 为必填项。", + "invalid-email-format": "Email 格式无效。", + "first-name": "名", + "last-name": "姓", + "description": "描述", + "default-dashboard": "默认仪表板", + "always-fullscreen": "始终全屏", + "select-user": "选择用户", + "no-users-matching": "未找到与“{{entity}}”匹配的用户。", + "user-required": "用户为必填项", + "activation-method": "激活方式", + "display-activation-link": "显示激活链接", + "send-activation-mail": "发送激活邮件", + "activation-link": "用户激活链接", + "activation-link-text": "要激活用户,请使用以下激活链接({{activationLinkTtl}}后过期):", + "copy-activation-link": "复制激活链接", + "activation-link-copied-message": "用户激活链接已复制到剪贴板", + "details": "详情", + "login-as-tenant-admin": "以租户管理员身份登录", + "login-as-customer-user": "以客户用户身份登录", + "search": "搜索用户", + "selected-users": "{ count, plural, =1 {1 个用户} other {# 个用户} }已选择", + "disable-account": "禁用用户账户", + "enable-account": "启用用户账户", + "enable-account-message": "用户账户已成功启用!", + "disable-account-message": "用户账户已成功禁用!", + "copyId": "复制用户 Id", + "idCopiedMessage": "用户 Id 已复制到剪贴板", + "user-list": "用户列表", + "user-list-required": "用户列表为必填项" + }, + "value": { + "type": "值类型", + "string": "字符串", + "string-value": "字符串值", + "string-value-required": "字符串值为必填项", + "integer": "整数", + "integer-value": "整数值", + "integer-value-required": "整数值为必填项", + "invalid-integer-value": "整数值无效", + "double": "双精度浮点数", + "double-value": "双精度浮点数值", + "double-value-required": "双精度浮点数值为必填项", + "boolean": "布尔值", + "boolean-value": "布尔值", + "false": "False", + "true": "True", + "long": "长整数", + "json": "JSON", + "json-value": "JSON 值", + "json-value-invalid": "JSON 值格式无效", + "json-value-required": "JSON 值为必填项。" + }, + "version-control": { + "version-control": "版本控制", + "management": "版本控制管理", + "search": "搜索版本", + "branch": "分支", + "default": "默认", + "select-branch": "选择分支", + "branch-required": "分支为必填项", + "create-entity-version": "创建实体版本", + "version-name": "版本名称", + "version-name-required": "版本名称为必填项", + "author": "作者", + "export-relations": "导出关联", + "export-attributes": "导出属性", + "export-credentials": "导出凭证", + "export-calculated-fields": "导出计算字段\n及告警规则", + "export-alarm-rules": "导出告警规则", + "entity-versions": "实体版本", + "versions": "版本", + "created-time": "创建时间", + "version-id": "版本 ID", + "no-entity-versions-text": "未找到实体版本", + "no-versions-text": "未找到版本", + "copy-full-version-id": "复制完整版本 ID", + "create-version": "创建版本", + "creating-version": "正在创建版本…请稍候", + "nothing-to-commit": "无更改可提交", + "restore-version": "恢复版本", + "restore-entity-from-version": "从版本“{{versionName}}”恢复实体", + "restoring-entity-version": "正在恢复实体版本…请稍候", + "load-relations": "加载关联", + "load-attributes": "加载属性", + "load-credentials": "加载凭证", + "load-calculated-fields": "加载计算字段和告警规则", + "load-alarm-rules": "加载告警规则", + "compare-with-current": "与当前版本比较", + "diff-entity-with-version": "与实体版本“{{versionName}}”比较差异", + "previous-difference": "上一个差异", + "next-difference": "下一个差异", + "current": "当前", + "differences": "{ count, plural, =1 {1 处差异} other {# 处差异} }", + "create-entities-version": "创建实体版本", + "default-sync-strategy": "默认同步策略", + "sync-strategy-merge": "合并", + "sync-strategy-overwrite": "覆盖", + "entities-to-export": "要导出的实体", + "entities-to-restore": "要恢复的实体", + "sync-strategy": "同步策略", + "all-entities": "所有实体", + "no-entities-to-export-prompt": "请指定要导出的实体", + "no-entities-to-restore-prompt": "请指定要恢复的实体", + "add-entity-type": "添加实体类型", + "remove-all": "全部移除", + "version-create-result": "{ added, plural, =0 {未添加实体} =1 {已添加 1 个实体} other {已添加 # 个实体} }。
{ modified, plural, =0 {未修改实体} =1 {已修改 1 个实体} other {已修改 # 个实体} }。
{ removed, plural, =0 {未移除实体} =1 {已移除 1 个实体} other {已移除 # 个实体} }。", + "remove-other-entities": "移除其他实体", + "find-existing-entity-by-name": "按名称查找现有实体", + "restore-entities-from-version": "从版本“{{versionName}}”恢复实体", + "restoring-entities-from-version": "正在恢复实体…请稍候", + "no-entities-restored": "未恢复任何实体", + "created": "{{created}} 已创建", + "updated": "{{updated}} 已更新", + "deleted": "{{deleted}} 已删除", + "remove-other-entities-confirm-text": "请注意!此操作将永久删除所有当前
不在您要恢复的版本中的所有实体。

请输入 \"remove other entities\" 以确认。", + "auto-commit-to-branch": "自动提交到 {{ branch }} 分支", + "default-create-entity-version-name": "{{entityName}} 更新", + "sync-strategy-merge-hint": "在仓库中创建或更新选中的实体。所有其他仓库实体不会被修改。", + "sync-strategy-overwrite-hint": "在仓库中创建或更新选中的实体。所有其他仓库实体将被删除。", + "device-credentials-conflict": "无法加载外部 ID 为 {{entityId}} 的设备,
因为数据库中另一个设备已存在相同的凭证。
请考虑在恢复表单中禁用加载凭证设置。", + "missing-referenced-entity": "无法加载外部 ID 为 {{sourceEntityId}} 的 {{sourceEntityTypeName}},
因为它引用了缺失的 ID 为 {{targetEntityId}} 的 {{targetEntityTypeName}}。", + "runtime-failed": "失败:{{message}}", + "auto-commit-settings-read-only-hint": "当仓库设置中启用了只读选项时,自动提交功能不可用。", + "rollback-on-error": "出错时回滚", + "rollback-on-error-hint": "如果您有大量实体需要恢复,请考虑禁用此选项以提高性能。\n 注意,如果在版本加载过程中发生错误,已持久化的实体(包括关联、属性等)将保持原样" + }, + "widget": { + "widget-library": "部件库", + "widget-bundle": "部件包", + "all-bundles": "所有包", + "select-widgets-bundle": "选择部件包", + "widgets": "部件", + "all-widgets": "所有部件", + "widget": "部件", + "select-widget": "选择部件", + "no-widgets-matching": "未找到匹配 '{{entity}}' 的部件。", + "no-widgets": "暂无部件", + "no-widgets-text": "未找到部件", + "management": "部件管理", + "editor": "部件编辑器", + "confirm-to-exit-editor-html": "您有未保存的部件设置。
确定要离开此页面吗?", + "widget-type-not-found": "加载部件配置时出现问题。
关联的\n 部件类型可能已被移除。", + "widget-type-load-error": "部件因以下错误未能加载:", + "remove": "移除部件", + "delete": "删除部件", + "edit": "编辑部件", + "remove-widget-title": "确定要移除部件 '{{widgetTitle}}' 吗?", + "remove-widget-text": "确认后,该部件及所有相关数据将无法恢复。", + "replace-reference-with-widget-copy": "将引用替换为部件副本", + "timeseries": "时间序列", + "search-data": "搜索数据", + "no-data-found": "未找到数据", + "latest": "最新值", + "rpc": "控制部件", + "alarm": "告警部件", + "static": "静态部件", + "timeseries-short": "序列", + "latest-short": "最新", + "rpc-short": "控制", + "alarm-short": "告警", + "static-short": "静态", + "select-widget-type": "选择部件类型", + "missing-widget-title-error": "必须指定部件标题!", + "widget-saved": "部件已保存", + "unable-to-save-widget-error": "无法保存部件!部件存在错误!", + "save": "保存部件", + "saveAs": "部件另存为", + "move": "移动部件", + "save-widget-as": "部件另存为", + "save-widget-as-text": "请输入新的部件标题", + "toggle-fullscreen": "切换全屏", + "run": "运行部件", + "widget-title": "部件标题", + "title": "标题", + "title-required": "部件标题为必填项。", + "title-max-length": "标题不能超过 256 个字符", + "system": "系统", + "type": "部件类型", + "resources": "资源", + "resource-url": "JavaScript/CSS URL", + "resource-is-extension": "是否为扩展", + "remove-resource": "移除资源", + "add-resource": "添加资源", + "html": "HTML", + "tidy": "整理", + "css": "CSS", + "settings-form": "设置表单", + "data-key-settings-form": "数据键设置表单", + "latest-data-key-settings-form": "最新数据键设置表单", + "widget-settings": "部件设置", + "description": "描述", + "tags": "标签", + "image-preview": "图片预览", + "settings-form-selector": "设置表单选择器", + "data-key-settings-form-selector": "数据键设置表单选择器", + "latest-data-key-settings-form-selector": "最新数据键设置表单选择器", + "all": "全部", + "actual": "当前", + "scada": "SCADA 符号", + "deprecated": "已弃用", + "has-basic-mode": "具有基础模式", + "basic-mode-form-selector": "基础模式表单选择器", + "basic-mode": "基础", + "advanced-mode": "高级", + "javascript": "Javascript", + "js": "JS", + "delete-widget-title": "确定要删除部件 '{{widgetName}}' 吗?", + "delete-widget-text": "确认后,该部件及所有相关数据将无法恢复。", + "delete-widgets-title": "确定要删除 { count, plural, =1 {1 个部件} other {# 个部件} } 吗?", + "delete-widgets-text": "请注意,确认后所有选中的部件将被移除,且所有相关数据将无法恢复。", + "delete-widget": "删除部件", + "widget-template-load-failed-error": "加载部件模板失败!", + "details": "详情", + "widget-details": "部件详情", + "add": "添加部件", + "add-existing-widget": "添加现有部件", + "add-new-widget": "添加新部件", + "search-widgets": "搜索部件", + "selected-widgets": "已选择 { count, plural, =1 {1 个部件} other {# 个部件} }", + "undo": "撤销部件更改", + "export": "导出部件", + "export-prompt": "嵌入部件图片和资源", + "export-widgets": "导出部件", + "export-widgets-prompt": "嵌入部件图片和资源", + "import": "导入部件", + "no-data": "部件无数据可显示", + "data-overflow": "部件显示 {{total}} 个实体中的 {{count}} 个", + "alarm-data-overflow": "部件显示 {{totalEntities}} 个实体中 {{allowedEntities}} 个(最大允许数)实体的告警", + "search": "搜索部件", + "filter": "部件过滤器类型", + "loading-widgets": "正在加载部件...", + "widget-template-error": "无效的部件 HTML 模板。", + "reference": "引用" + }, + "widget-action": { + "header-button": "部件标题栏按钮", + "do-nothing": "无操作", + "open-dashboard-state": "导航到新的仪表板状态", + "update-dashboard-state": "更新当前仪表板状态", + "open-dashboard": "导航到其他仪表板", + "custom": "自定义操作", + "custom-pretty": "自定义操作(带 HTML 模板)", + "custom-pretty-error-title": "自定义对话框错误", + "custom-pretty-template-error": "无效的自定义对话框模板。", + "custom-pretty-controller-error": "执行自定义对话框函数时发生错误。", + "mobile-action": "移动端操作", + "target-dashboard-state": "目标仪表板状态", + "target-dashboard-state-required": "目标仪表板状态为必填项", + "set-entity-from-widget": "从部件设置实体", + "target-dashboard": "目标仪表板", + "select-target-dashboard": "选择目标仪表板", + "target-dashboard-required": "目标仪表板为必填项。", + "open-right-layout": "打开右侧仪表板布局(移动端视图)", + "state-display-type": "仪表板状态显示选项", + "open-normal": "正常", + "open-in-separate-dialog": "在单独对话框中打开", + "open-in-popover": "在弹出框中打开", + "dialog-title": "对话框标题", + "dialog-hide-dashboard-toolbar": "在对话框中隐藏仪表板工具栏", + "dialog-width": "对话框宽度(相对于视口宽度的百分比)", + "dialog-height": "对话框高度(相对于视口高度的百分比)", + "dialog-size-range-error": "对话框大小百分比值应在 1 到 100 之间。", + "popover-preferred-placement": "首选弹出框位置", + "popover-placement-top": "顶部", + "popover-placement-topLeft": "左上", + "popover-placement-topRight": "右上", + "popover-placement-right": "右侧", + "popover-placement-rightTop": "右上", + "popover-placement-rightBottom": "右下", + "popover-placement-bottom": "底部", + "popover-placement-bottomLeft": "左下", + "popover-placement-bottomRight": "右下", + "popover-placement-left": "左侧", + "popover-placement-leftTop": "左上", + "popover-placement-leftBottom": "左下", + "popover-hide-on-click-outside": "点击外部时隐藏弹出框", + "popover-hide-dashboard-toolbar": "在弹出框中隐藏仪表板工具栏", + "popover-width": "弹出框宽度", + "popover-height": "弹出框高度", + "popover-style": "弹出框样式", + "open-new-browser-tab": "在新浏览器标签页中打开", + "open-URL": "打开 URL", + "URL": "URL", + "url-required": "URL 为必填项。", "mobile": { - "add-application": "添加应用", - "app-id": "应用ID", - "app-id-required": "应用ID必填", - "app-id-pattern": "应用ID格式无效", - "app-store-link": "应用商店链接", - "app-store-link-required": "应用商店链接必填", - "application-details": "应用详情", - "application-package": "应用包", - "application-secret": "应用密钥", - "application-secret-required": "应用密钥必填", - "application": "应用", - "applications": "应用列表", - "copy-app-id": "复制应用ID", - "copy-app-store-link": "复制应用商店链接", - "copy-application-package": "复制应用包", - "copy-application-secret": "复制应用密钥", - "copy-google-play-link": "复制Google Play链接", - "copy-sha256-certificate-fingerprints": "复制SHA256证书指纹", - "delete-application": "删除应用", - "delete-application-button-text": "我理解后果,删除应用", - "delete-application-text": "此操作无法撤销。删除应用将永久删除应用及其所有相关数据。
如果不想永久删除,可以暂时挂起应用。
若要删除应用,请输入\"{{phrase}}\"进行确认。", - "delete-application-title-short": "确定要删除应用 '{{name}}' 吗?", - "delete-application-text-short": "请注意:确认后应用及所有相关数据将无法恢复。", - "delete-application-phrase": "删除应用", - "delete-applications-bundle-text": "请注意:确认后移动应用包及所有相关数据将无法恢复。", - "delete-applications-bundle-title": "确定要删除移动应用包 '{{bundleName}}' 吗?", - "generate-application-secret": "生成应用密钥", - "google-play-link": "Google Play链接", - "google-play-link-required": "Google Play链接必填", - "latest-version": "最新版本", - "min-version": "最低版本", - "invalid-version-pattern": "版本格式无效。请使用格式:major.minor.patch(例如:1.0.0)。", - "mobile-center": "移动中心", - "mobile-package": "应用包", - "mobile-package-max-length": "应用包长度应少于256个字符", - "mobile-package-required": "应用包是必需的", - "mobile-package-pattern": "应用包格式无效", - "no-application": "未找到应用", - "no-bundles": "未找到移动包", - "platform-type": "平台类型", - "search-application": "搜索应用", - "search-bundles": "搜索移动包", - "set": "设置", - "sha256-certificate-fingerprints": "SHA256证书指纹", - "sha256-certificate-fingerprints-required": "SHA256证书指纹是必需的", - "sha256-certificate-fingerprints-pattern": "SHA256证书指纹格式无效", - "show-hidden-pages": "显示隐藏页面", - "status": "状态", - "status-type": { - "deprecated": "已弃用", - "draft": "草稿", - "published": "已发布", - "suspended": "已暂停" - }, - "store-information": "商店信息", - "version-information": "版本信息", - "min-version-release-notes": "最低版本发布说明", - "latest-version-release-notes": "最新版本发布说明", - "bundle": "捆绑包", - "bundles": "捆绑包列表", - "add-bundle": "添加捆绑包", - "title": "标题", - "title-required": "标题必填", - "oauth-clients": "OAuth2.0客户端", - "android-app": "Android应用", - "android-application": "Android应用程序", - "ios-app": "iOS应用", - "ios-application": "iOS应用程序", - "invalid-store-link": "无效的商店链接", - "enable-oauth": "启用OAuth2.0", - "enable-self-registration": "启用自助注册", - "edit-bundle": "编辑捆绑包", - "description": "描述", - "basic-settings": "基本设置", - "no-application-matching": "未找到与 '{{entity}}' 匹配的应用程序。", - "no-bundle-matching": "未找到与 '{{entity}}' 匹配的捆绑包。", - "application-required": "应用程序必填。", - "bundle-required": "捆绑包必填。", - "no-application-text": "未找到应用程序", - "no-bundle-text": "未找到捆绑包", - "layout": "布局", - "pages": "页面", - "hide-all-pages": "隐藏所有页面", - "reset-to-default-pages": "重置为默认页面", - "add-specific-page": "添加特定页面", - "visible": "可见", - "hidden": "隐藏", - "reset-to-page-default": "重置页面为默认", - "mobile-599": "移动端(最大 599px)", - "tablet-959": "平板(最大 959px)", - "max-element-number": "最大元素数量", - "page-name": "页面名称", - "page-name-required": "页面名称必填。", - "page-name-cannot-contain-only-spaces": "页面名称不能仅包含空格。", - "page-type": "页面类型", - "pages-types": { - "dashboard": "仪表盘", - "web-view": "网页视图", - "custom": "自定义" - }, - "url": "URL", - "invalid-url-format": "URL格式无效", - "path": "路径", - "invalid-path-format": "路径格式无效", - "custom-page": "自定义页面", - "edit-page": "编辑页面", - "edit-custom-page": "编辑自定义页面", - "delete-page": "删除页面", - "qr-code-widget": "二维码小部件", - "type-here": "在此输入", - "configuration-dialog": "配置对话框", - "configuration-app": "配置应用程序", - "configuration-step": { - "prepare-environment-title": "准备开发环境", - "prepare-environment-text": "Flutter ThingsBoard 移动应用程序需要 Flutter SDK。按照说明设置 Flutter SDK。", - "get-source-code-title": "获取应用程序源代码", - "get-source-code-text": "您可以通过从 GitHub 仓库克隆来获取 Flutter ThingsBoard 移动应用程序源代码:", - "configure-api-title": "配置 ThingsBoard API 端点", - "configure-api-text": "在编辑器/IDE 中打开 flutter_thingsboard_app 项目。编辑:", - "configure-api-hint": "将 thingsBoardApiEndpoint 常量的值设置为与您的 ThingsBoard 服务器实例的 API 端点匹配。请勿使用“localhost”或“127.0.0.1”主机名。", - "run-app-title": "运行应用程序", - "run-app-text": "按您的 IDE 中的说明运行应用程序。\n如果使用终端,请使用以下命令运行应用程序:", - "more-information": "详细信息请参阅我们的入门文档。", - "getting-started": "入门", - "configure-package-title": "配置应用程序包", - "configure-package-text": "您可以手动更改应用程序包,也可以使用第三方 CLI 工具。", - "configure-package-text-install": "要安装 Rename CLI 工具,请执行以下命令:", - "configure-package-run-commands": "在您的项目根目录下运行以下命令:" - } + "device-provision": "设备配网", + "action-type": "移动端操作类型", + "select-action-type": "选择移动端操作类型", + "action-type-required": "移动端操作类型为必填项", + "take-picture-from-gallery": "从相册选取图片", + "take-photo": "拍照", + "map-direction": "打开地图导航", + "map-location": "打开地图位置", + "scan-qr-code": "扫描二维码", + "make-phone-call": "拨打电话", + "get-location": "获取手机位置", + "take-screenshot": "截屏", + "handle-provision-success-function": "处理配网成功函数", + "get-location-function": "获取位置函数", + "process-launch-result-function": "处理启动结果函数", + "get-phone-number-function": "获取电话号码函数", + "process-image-function": "处理图片函数", + "process-qr-code-function": "处理二维码函数", + "process-location-function": "处理位置函数", + "handle-empty-result-function": "处理空结果函数", + "handle-error-function": "处理错误函数", + "handle-non-mobile-fallback-function": "处理非移动端回退函数", + "save-to-gallery": "保存到'图片库'", + "provision-type": "配网类型", + "auto": "自动", + "wi-fi": "Wi-Fi", + "ble": "BLE", + "soft-ap": "Soft AP" }, - "notification": { - "action-button": "操作按钮", - "action-type": "操作类型", - "active": "活动", - "add-notification-recipients-group": "添加通知收件人组", - "add-notification-template": "添加通知模板", - "add-recipient": "添加收件人", - "add-recipients": "添加收件人", - "add-rule": "添加规则", - "add-stage": "添加阶段", - "add-template": "添加模板", - "after": "之后", - "alarm-assignment-trigger-settings": "告警分配触发设置", - "alarm-comment-trigger-settings": "告警评论触发设置", - "alarm-trigger-settings": "告警触发设置", - "all": "所有", - "api-feature-hint": "如果字段为空,则触发器将应用于所有API功能", - "api-usage-trigger-settings": "API使用触发设置", - "new-platform-version-trigger-settings": "新平台版本触发设置", - "rate-limits-trigger-settings": "超出速率限制触发设置", - "task-processing-failure-trigger-settings": "任务处理故障触发设置", - "at-least-one-should-be-selected": "至少需要选择一个", - "basic-settings": "基本设置", - "button-text": "按钮文本", - "button-text-required": "按钮文本必填。", - "button-text-max-length": "按钮文本应小于或等于{{ length }}个字符。", - "compose": "组合", - "conversation": "对话", - "conversation-required": "对话必填。", - "copy-notification-template": "复制通知模板", - "copy-rule": "复制规则", - "copy-template": "复制模板", - "create-new": "创建", - "created": "已创建", - "customize-messages": "自定义消息", - "delete-notification-text": "请注意,在确认后,通知将无法恢复。", - "delete-notification-title": "您确定要删除该通知吗?", - "delete-notifications-text": "请注意,在确认后,通知将无法恢复。", - "delete-notifications-title": "确定要删除{ count, plural, =1 {1 个通知} other {# 个通知} }吗?", - "delete-recipient-text": "请注意,在确认后,收件人将无法恢复。", - "delete-recipient-title": "确定要删除收件人'{{recipientName}}' 吗?", - "delete-recipients-text": "请注意,在确认后,收件人将无法恢复。", - "delete-recipients-title": "确定要删除{ count, plural, =1 {1 个收件人} other {# 个收件人} }吗?", - "delete-request-text": "请注意,在确认后,请求将无法恢复。", - "delete-request-title": "确定要删除请求吗?", - "delete-requests-text": "请注意,在确认后,请求将无法恢复。", - "delete-requests-title": "确定要删除{ count, plural, =1 {1 个请求} other {# 个请求} }吗?", - "delete-rule-text": "请注意,在确认后,规则将无法恢复。", - "delete-rule-title": "确定要删除规则'{{ruleName}}' 吗?请注意,确认后无法恢复。", - "delete-rules-text": "请注意,在确认后,规则将无法恢复。", - "delete-rules-title": "确定要删除{ count, plural, =1 {1 条规则} other {# 条规则} }吗?", - "delete-template-text": "请注意,在确认后,模板将无法恢复。", - "delete-template-title": "确定要删除模板'{{templateName}}' 吗?", - "delete-templates-text": "请注意,在确认后,模板将无法恢复。", - "delete-templates-title": "确定要删除{ count, plural, =1 {1 个模板} other {# 个模板} }吗?", - "deleted": "已删除", - "delivery-method": { - "delivery-method": "推送方式", - "email": "Email", - "email-preview": "Email 通知预览", - "slack": "Slack", - "slack-preview": "Slack 通知预览", - "microsoft-teams": "微软Teams", - "microsoft-teams-preview": "微软Teams通知预览", - "sms": "SMS", - "sms-preview": "SMS通知预览", - "web": "Web", - "web-preview": "Web通知预览", - "mobile-app": "移动应用", - "mobile-app-preview": "移动应用通知预览" - }, - "delivery-method-not-configure-click": "单击进行推送方式设置。", - "delivery-method-not-configure-contact": "推送方式未设置,请与系统管理员联系。", - "delivery-methods": "推送方式", - "description": "描述", - "device-activity-trigger-settings": "设备活动触发设置", - "device-list-rule-hint": "如果该字段为空,则触发器将适用于所有设备。", - "device-profiles-list-rule-hint": "如果该字段为空,则触发器将适用于所有设备配置。", - "disabled": "禁用", - "edge-trigger-settings": "Edge触发设置", - "edge-list-rule-hint": "如果字段为空则将触发应用于所有Edge实例", - "edit-notification-recipients-group": "编辑通知收件人群组", - "edit-notification-template": "编辑通知模板", - "edit-rule": "编辑规则", - "edit-template": "编辑模板", - "enabled": "已启用", - "entities-limit-trigger-settings": "实体限制触发器设置", - "entity-action-trigger-settings": "实体动作触发器设置", - "entity-type": "实体类型", - "escalation-chain": "逐步升级链", - "failed-send": "发送失败", - "fails": "{ count, plural, =1 {1 个失败} other {# 个失败} }", - "filter": "筛选器", - "first-recipient": "第一收件人", - "inactive": "非活动", - "inbox": "收件箱", - "notification-inbox": "通知 / 收件箱", - "input-field-support-templatization": "输入字段支持模板化", - "input-fields-support-templatization": "输入字段支持模板化", - "link": "链接", - "link-required": "链接必填。", - "link-type": { - "dashboard": "打开仪表板", - "link": "打开URL链接" - }, - "loading-notifications": "加载通知中…", - "management": "通知管理", - "mark-all-as-read": "全部标记为已读", - "mark-as-read": "标记为已读", - "message": "信息", - "message-required": "信息必填。", - "message-max-length": "提示信息应该小于或等于 {{ length }}个字符", - "name": "名称", - "name-required": "名称必填", - "new-notification": "新通知", - "no-inbox-notification": "未找到通知", - "no-notification-request": "没有通知请求", - "no-notification-templates": "未找到通知模板", - "no-notifications-yet": "目前还没有通知", - "no-recipients-notification": "没有收件人的通知", - "no-recipients-matching": "未找到与 '{{entity}}' 匹配的收件人。", - "no-recipients-text": "未找到收件人", - "no-rule": "未配置规则", - "no-rules-notification": "没有规则通知", - "no-severity-found": "未找到严重性级别", - "no-severity-matching": "未找到'{{severity}}'。", - "no-template-matching": "未找到与'{{template}}' 匹配的资源。", - "not-found-slack-recipient": "未找到Slack的收件人", - "notification": "通知", - "notification-center": "通知中心", - "notification-tap-action": "单击通知操作", - "notification-tap-action-hint": "如果未启用将使用默认警报仪表板", - "notify": "通知", - "notify-again": "再次通知", - "notify-alarm-action": { - "acknowledged": "告警已确认", - "assigned": "告警已分配", - "cleared": "告警已清除", - "created": "告警已确认", - "severity-changed": "告警级别已更改", - "unassigned": "告警未分配" - }, - "notify-on": "通知条件", - "notify-on-comment-update": "在评论更新时通知", - "notify-on-required": "通知条件必填。", - "notify-on-unassign": "在取消分配时通知", - "notify-only-user-comments": "仅在用户评论时通知", - "only-rule-chain-lifecycle-failures": "仅在规则链生命周期故障时通知", - "only-rule-node-lifecycle-failures": "仅在规则节点生命周期故障时通知", - "platform-users": "平台用户", - "rate-limits": "速率限制", - "rate-limits-hint": "如果该字段为空,则触发器将应用于所有速率限制", - "recipient": "收件人", - "recipient-group": "收件人组", - "recipient-type": { - "affected-tenant-administrators": "受影响的租户管理员", - "affected-user": "受影响的用户", - "all-users": "所有用户", - "customer-users": "客户用户", - "system-administrators": "系统管理员", - "tenant-administrators": "租户管理员", - "user-filters": "用户筛选器", - "user-list": "用户列表", - "users-entity-owner": "实体所有者的用户" - }, - "recipients": "收件人", - "notification-recipient": "通知接收者", - "notification-recipient-required": "通知接收者必填。", - "notification-recipients": "通知/收件人", - "recipients-count": "{ count, plural, =1 {1 个收件人} other {# 个收件人} }", - "recipients-required": "收件人必填。", - "refresh-allow-delivery-method": "刷新允许推送方式", - "request-search": "请求搜索", - "request-status": { - "processing": "处理中", - "scheduled": "已安排", - "sent": "已发送" - }, - "review": "检查", - "rule": "规则", - "rule-chain-list-rule-hint": "如果字段为空,则触发器将应用于所有规则链。", - "rule-engine-events-trigger-settings": "规则引擎事件触发器设置", - "rule-engine-filter": "规则引擎筛选器", - "rule-name": "规则名称", - "rule-name-required": "名称必填。", - "rule-disable": "禁用通知规则", - "rule-enable": "启用通知规则", - "rule-node-filter": "规则节点筛选器", - "rules": "规则", - "notification-rules": "通知 / 规则", - "scheduler-later": "稍后安排", - "search-notification": "搜索通知", - "search-recipients": "搜索收件人", - "search-rules": "搜索规则", - "search-templates": "搜索模板", - "see-documentation": "查看文档", - "selected-notifications": "已选择{ count, plural, =1 {1 个通知} other {# 个通知} }", - "selected-recipients": "已选择{ count, plural, =1 {1 个收件人} other {# 个收件人} }", - "selected-requests": "已选择{ count, plural, =1 {1 个请求} other {# 个请求} }", - "selected-rules": "已选择{ count, plural, =1 {1 个规则} other {# 个规则} }", - "selected-template": "已选择{ count, plural, =1 {1 个模板} other {# 个模板} }", - "send-notification": "发送通知", - "sent": "已发送", - "setup": "步骤", - "notification-sent": "通知/已发送", - "set-entity-from-notification": "将通知中的实体设置为仪表盘状态", - "slack-chanel-type": "Slack频道类型", - "slack-chanel-types": { - "direct": "直接消息", - "private-channel": "私有频道", - "public-channel": "公开频道" - }, - "start-from-scratch": "从头开始", - "status": "状态", - "stop-escalation-alarm-status-become": "停止逐步升级,将告警状态设置为:", - "subject": "主题", - "subject-required": "主题必填。", - "subject-max-length": "主题应小于或等于{{ length }}个字符", - "template": "模板", - "template-name": "模板名称", - "template-required": "模板必填。", - "template-type": { - "alarm": "告警", - "alarm-assignment": "告警分配", - "alarm-comment": "告警评论", - "api-usage-limit": "API使用限制", - "device-activity": "设备活动", - "entities-limit": "实体限制", - "entity-action": "实体操作", - "general": "通用", - "rule-engine-lifecycle-event": "规则引擎生命周期事件", - "rule-node": "规则节点", - "new-platform-version": "新的平台版本", - "rate-limits": "超过速率限制", - "edge-communication-failure": "Edge通信故障", - "edge-connection": "Edge连接", - "task-processing-failure": "任务处理失败" - }, - "templates": "模板", - "notification-templates": "通知/模板", - "tenant-profiles-list-rule-hint": "如果字段为空,触发器将应用于所有租户配置", - "tenants-list-rule-hint": "如果字段为空,触发器将应用于所有租户", - "threshold": "阈值", - "theme-color": "主题颜色", - "time": "时间", - "track-rule-node-events": "追踪规则节点事件", - "trigger": { - "alarm": "告警", - "alarm-assignment": "告警分配", - "alarm-comment": "告警评论", - "api-usage-limit": "API使用限制", - "device-activity": "设备活动", - "entities-limit": "实体限制", - "entity-action": "实体操作", - "rule-engine-lifecycle-event": "规则引擎生命周期事件", - "new-platform-version": "新的平台版本", - "rate-limits": "超过速率限制", - "edge-connection": "Edge连接", - "edge-communication-failure": "Edge通信故障", - "task-processing-failure": "任务处理失败", - "trigger": "触发器", - "trigger-required": "触发器必填。" - }, - "type": "类型", - "unread": "未读", - "updated": "已更新", - "use-deprecated-webhook-connectors": "使用已弃用的Webhook连接器", - "use-old-api": "使用旧API", - "use-template": "使用模板", - "view-all": "查看全部", - "warning": "警告", - "webhook-url": "Webhook URL", - "webhook-url-required": "Webhook URL必填。", - "workflow-url": "Workflow URL", - "workflow-url-required": "Workflow URL必填。", - "channel-name": "频道名称", - "channel-name-required": "需要填写频道名称", - "settings": { - "notification-settings": "通知设置", - "reset-all": "重置全部设置", - "reset-all-title": "您确定要将表单重置吗?", - "reset-all-text": "确认后,设置表单将重置为默认值并保存。", - "type": "类型", - "enable-all": "全部启用", - "disable-all": "全部禁用", - "delivery-not-configured": "未配置传递方式" - } + "custom-action-function": "自定义操作函数", + "custom-pretty-function": "自定义操作(带 HTML 模板)函数", + "map-item-type": "地图项目类型", + "map-item": { + "marker": "标记", + "polygon": "多边形", + "rectangle": "矩形", + "circle": "圆形", + "polyline": "折线" }, - "ota-update": { - "add": "添加包", - "assign-firmware": "分配的固件", - "assign-firmware-required": "分配的固件必填", - "assign-software": "分配的软件", - "assign-software-required": "分配的软件必填", - "auto-generate-checksum": "自动生成校验和", - "checksum": "校验和", - "checksum-hint": "如果校验和为空,会自动生成", - "checksum-algorithm": "校验和算法", - "checksum-copied-message": "包校验和已复制到剪贴板", - "change-firmware": "固件的更改可能会导致{ count, plural, =1 {1 个设备} other {# 个设备} } 的更新。", - "change-software": "软件的更改可能会导致{ count, plural, =1 {1 个设备} other {# 个设备} } 的更新。", - "chose-compatible-device-profile": "上传的包仅适用于具有所选配置的设备。", - "chose-firmware-distributed-device": "选择将分发到设备的固件", - "chose-software-distributed-device": "选择将分发到设备的软件", - "content-type": "内容类型", - "copy-checksum": "复制校验和", - "copy-direct-url": "复制直接URL", - "copyId": "复制包ID", - "copied": "已复制!", - "delete": "删除包", - "delete-ota-update-text": "请注意:确认后,OTA升级将不可恢复。", - "delete-ota-update-title": "确定要删除 '{{title}}' OTA升级吗?", - "delete-ota-updates-text": "请注意:确认后,所有选中的OTA升级将被删除。", - "delete-ota-updates-title": "确定要删除{ count, plural, =1 {1 OTA升级} other {# OTA升级} }吗?", - "description": "说明", - "direct-url": "直接URL", - "direct-url-copied-message": "包直接URL已复制到剪贴板", - "direct-url-required": "直接URL必填", - "download": "下载包", - "drop-file": "拖放包文件或点击选择要上传的文件。", - "drop-package-file-or": "拖放一个包文件或者", - "file-name": "文件名", - "file-size": "文件大小", - "file-size-bytes": "文件大小(以字节为单位)", - "idCopiedMessage": "包ID已被复制到剪贴板", - "no-firmware-matching": "未找到与'{{entity}}'匹配的兼容固件OTA升级包。", - "no-firmware-text": "没有提供兼容的固件OTA升级包。", - "no-packages-text": "未找到包", - "no-software-matching": "未找到匹配 '{{entity}}' 的兼容软件OTA升级包。", - "no-software-text": "没有提供兼容的软件OTA升级包。", - "ota-update": "OTA 升级", - "ota-update-details": "OTA 升级详情", - "ota-updates": "OTA 升级", - "package-file": "包文件", - "package-type": "包类型", - "packages-repository": "包仓库", - "search": "搜索包", - "selected-package": "{ count, plural, =1 {1个包} other {#个包} }选中", - "title": "标题", - "title-required": "标题必填。", - "title-max-length": "标题长度应该小于256个字符。", - "types": { - "firmware": "固件", - "software": "软件" - }, - "upload-binary-file": "上传二进制文件", - "use-external-url": "使用外部URL", - "version": "版本", - "version-required": "版本必填。", - "version-tag": "版本标签", - "version-tag-hint": "自定义标签应与您设备报告的软件包版本相匹配。", - "version-max-length": "版本长度应该小于256个字符", - "warning-after-save-no-edit": "上传包后,您将无法修改标题、版本、设备配置和包类型。" - }, - "position": { - "top": "顶部", - "bottom": "底部", - "left": "左侧", - "right": "右侧" - }, - "profile": { - "profile": "属性", - "last-login-time": "最后登录", - "change-password": "更改密码", - "current-password": "当前密码", - "copy-jwt-token": "复制 JWT 令牌", - "jwt-token": "JWT 令牌", - "token-valid-till": "令牌有效期至", - "tokenCopiedSuccessMessage": "JWT 令牌已复制到剪贴板", - "tokenCopiedWarnMessage": "JWT 令牌已过期!请刷新页面。" - }, - "profiles": { - "profiles": "配置" - }, - "security": { - "security": "安全", - "general-settings": "通用安全设置", - "access-token": "访问令牌", - "access-token-required": "需要访问令牌", - "clientId": "客户端ID", - "clientId-required": "需要客户端ID", - "username": "用户名", - "username-required": "需要用户名", - "ca-cert": "CA证书", - "2fa": { - "2fa": "双因素认证", - "2fa-description": "双因素认证可保护您的帐户免受未经授权的访问。在登录时必须输入安全验证码。", - "authenticate-with": "可以使用以下身份验证:", - "disable-2fa-provider-text": "禁用{{ name }}会降低帐户的安全性", - "disable-2fa-provider-title": "确定要禁用 {{ name }}吗?", - "get-new-code": "获取新验证码", - "main-2fa-method": "用作主要的双因素认证方法", - "dialog": { - "activation-step-description-email": "下次登录时,系统将提示您输入电子邮件中的验证码。", - "activation-step-description-sms": "下次登录时,系统将提示您输入短信中的验证码。", - "activation-step-description-totp": "下次登录时,您需要提供一个双因素认证码。", - "activation-step-label": "激活", - "backup-code-description": "打印出验证码,以便在您需要时使用它们登录帐户。每个备份验证码可以使用一次。", - "backup-code-warn": "离开此页后,这些代码将无法再次显示。使用以下选项安全存放。", - "download-txt": "下载(txt)", - "email-step-description": "输入用作身份验证的电子邮件", - "email-step-label": "电子邮件", - "enable-email-title": "启用电子邮件验证", - "enable-sms-title": "启用SMS验证", - "enable-totp-title": "启用验证APP", - "enter-verification-code": "输入6位验证码", - "get-backup-code-title": "获取备份验证码", - "next": "下一步", - "scan-qr-code": "使用验证APP扫描二维码", - "send-code": "发送验证码", - "sms-step-description": "输入用作身份验证的手机号码", - "sms-step-label": "手机号码", - "success": "操作成功!", - "totp-step-description-install": "可以安装像Google Authenticator、Authy或Duo这样的应用程序。", - "totp-step-description-open": "在手机上打开验证APP。", - "totp-step-label": "获取APP", - "verification-code": "6位验证码", - "verification-code-invalid": "验证码格式无效", - "verification-code-incorrect": "验证码不正确", - "verification-code-many-request": "请求过多,请检查验证码", - "verification-step-description": "输入发送到{{ address }}的6位代码", - "verification-step-label": "验证" - }, - "provider": { - "email": "电子邮件", - "email-description": "使用您电子邮件中的验证码进行身份验证。", - "email-hint": "身份验证码通过电子邮件发送到{{ info }}", - "sms": "SMS", - "sms-description": "使用短信进行身份验证,当登录时系统会通过短信向您发送验证码。", - "sms-hint": "身份验证码通过短信发送到{{ info }}", - "totp": "验证APP", - "totp-description": "使用手机上的Google Authenticator、Authy或Duo等应用程序进行身份验证,它将生成用于登录的验证码。", - "totp-hint": "已为您的帐户设置验证APP", - "backup_code": "备份验证码", - "backup-code-description": "这些可打印的一次性密码允许您在离开手机时登录,比如正在旅行。", - "backup-code-hint": "{{ info }}个一次性代码此时处于激活状态" - } - }, - "password-requirement": { - "at-least": "至少:", - "character": "{ count, plural, =1 {1 位字符} other {# 位字符} }", - "digit": "{ count, plural, =1 {1 位数字} other {# 位数字} }", - "incorrect-password-try-again": "密码不正确再试一次。", - "lowercase-letter": "{ count, plural, =1 {1 位小写字母} other {# 位小写字母} }", - "new-passwords-not-match": "新密码不匹配", - "password-should-not-contain-spaces": "密码不应包含空格", - "password-not-meet-requirements": "密码不符合要求", - "password-requirements": "密码要求", - "password-should-difference": "新密码应与当前密码不同", - "special-character": "{ count, plural, =1 {1 位特殊字符} other {# 位特殊字符} }", - "uppercase-letter": "{ count, plural, =1 {1 位大写字母} other {# 位大写字母} }", - "at-most": "最多:" - } + "place-map-item": "放置地图项目", + "map-item-tooltip": { + "customize-map-item-tooltips": "自定义地图项目提示", + "place-marker": "放置标记", + "start-draw-rectangle": "开始绘制矩形", + "finish-draw-rectangle": "完成绘制矩形", + "start-draw-polygon": "开始绘制多边形", + "continue-draw-polygon": "继续绘制多边形", + "finish-draw-polygon": "完成绘制多边形", + "start-draw-circle": "开始绘制圆形", + "finish-draw-circle": "完成绘制圆形", + "start-draw-polyline": "开始绘制折线", + "finish-draw-polyline": "完成绘制折线" + } + }, + "widgets-bundle": { + "current": "当前包", + "widgets-bundles": "部件包", + "widgets-bundle-widgets": "部件包部件", + "add": "添加部件包", + "delete": "删除部件包", + "title": "标题", + "title-required": "标题为必填项。", + "title-max-length": "标题不能超过 256 个字符", + "description": "描述", + "image-preview": "图片预览", + "scada": "SCADA 部件包", + "order": "排序", + "add-widgets-bundle-text": "添加新部件包", + "no-widgets-bundles-text": "未找到部件包", + "empty": "部件包为空", + "details": "详情", + "widgets-bundle-details": "部件包详情", + "delete-widgets-bundle-title": "确定要删除部件包 '{{widgetsBundleTitle}}' 吗?", + "delete-widgets-bundle-text": "请注意,确认后该部件包及所有相关数据将无法恢复。", + "delete-widgets-bundles-title": "确定要删除 { count, plural, =1 {1 个部件包} other {# 个部件包} } 吗?", + "delete-widgets-bundles-action-title": "删除 { count, plural, =1 {1 个部件包} other {# 个部件包} }", + "delete-widgets-bundles-text": "请注意,确认后所有选中的部件包将被移除,且所有相关数据将无法恢复。", + "no-widgets-bundles-matching": "未找到匹配 '{{widgetsBundle}}' 的部件包。", + "widgets-bundle-required": "部件包为必填项。", + "system": "系统", + "import": "导入部件包", + "export": "导出部件包", + "export-widgets-bundle-widgets-prompt": "在导出数据中包含包内部件(否则仅导出引用的部件 FQN)", + "export-failed-error": "无法导出部件包:{{error}}", + "create-new-widgets-bundle": "创建新部件包", + "widgets-bundle-file": "部件包文件", + "invalid-widgets-bundle-file-error": "无法导入部件包:无效的部件包数据结构。", + "search": "搜索部件包", + "selected-widgets-bundles": "已选择 { count, plural, =1 {1 个部件包} other {# 个部件包} }", + "open-widgets-bundle": "打开部件包", + "loading-widgets-bundles": "正在加载部件包...", + "create-new": "创建新部件包" + }, + "widget-config": { + "data": "数据", + "settings": "设置", + "advanced": "高级", + "appearance": "外观", + "widget-card": "部件卡片", + "mobile": "移动端", + "title": "标题", + "title-tooltip": "标题提示", + "general-settings": "常规设置", + "display-title": "显示部件标题", + "card-title": "卡片标题", + "drop-shadow": "阴影", + "enable-fullscreen": "启用全屏", + "background-color": "背景颜色", + "text-color": "文本颜色", + "border-radius": "边框圆角", + "padding": "内边距", + "margin": "外边距", + "widget-style": "部件样式", + "widget-css": "部件 CSS", + "title-style": "标题样式", + "mobile-mode-settings": "移动端模式", + "order": "排序", + "height": "高度", + "mobile-hide": "在移动端模式下隐藏部件", + "desktop-hide": "在桌面端模式下隐藏部件", + "units": "显示在值旁边的特殊符号", + "units-by-default": "默认单位", + "decimals": "小数点后位数", + "decimals-by-default": "默认小数位数", + "default-data-key-parameter-hint": "此参数适用于所有部件值,除非被数据键配置覆盖", + "units-short": "单位", + "decimals-short": "小数位", + "decimals-suffix": "小数位", + "digits-suffix": "位数", + "timewindow": "时间窗口", + "use-dashboard-timewindow": "使用仪表板时间窗口", + "use-widget-timewindow": "使用部件时间窗口", + "display-timewindow": "显示时间窗口", + "legend": "图例", + "display-legend": "显示图例", + "datasources": "数据源", + "datasource": "数据源", + "maximum-datasources": "最多允许 { count, plural, =1 {1 个数据源。} other {# 个数据源} }", + "timeseries-key-error": "至少需要指定一个时间序列数据键", + "datasource-type": "类型", + "datasource-parameters": "参数", + "remove-datasource": "移除数据源", + "add-datasource": "添加数据源", + "target-device": "目标设备", + "alarm-source": "告警源", + "actions": "操作", + "action": "操作", + "add-action": "添加操作", + "search-actions": "搜索操作", + "no-actions-text": "未找到操作", + "action-source": "操作来源", + "select-action-source": "选择操作来源", + "action-source-required": "操作来源为必填项。", + "column-index": "列索引", + "select-column-index": "选择列索引", + "column-index-required": "列索引为必填项。", + "not-set": "未设置", + "action-name": "名称", + "action-name-required": "操作名称为必填项。", + "action-name-not-unique": "已存在同名的操作。\n操作名称在同一操作来源中必须唯一。", + "action-icon": "图标", + "header-button": { + "button-settings": "按钮设置", + "button-type": "按钮类型", + "button-type-basic": "基础", + "button-type-raised": "凸起", + "button-type-stroked": "描边", + "button-type-flat": "扁平", + "button-type-icon": "图标", + "button-type-mini-fab": "FAB", + "colors": "颜色", + "color": "颜色", + "background": "背景", + "border": "边框", + "advanced-button-style": "高级按钮样式", + "button-style": "按钮样式" }, - "relation": { - "relations": "关联", - "direction": "方向", - "clear-relation-type": "清除关系类型", - "search-direction": { - "FROM": "从", - "TO": "到" - }, - "direction-type": { - "FROM": "从", - "TO": "到" - }, - "from-relations": "向外的关联", - "to-relations": "向内的关联", - "selected-relations": "已选择{ count, plural, =1 {1 个关联} other {# 个关联} }", - "type": "类型", - "to-entity-type": "到实体类型", - "to-entity-name": "到实体名称", - "from-entity-type": "从实体类型", - "from-entity-name": "从实体类型", - "to-entity": "到实体", - "from-entity": "从实体", - "delete": "删除关联", - "relation-type": "关联类型", - "relation-type-required": "关联类型必填", - "relation-type-max-length": "关联类型长度应该小于256个字符。", - "any-relation-type": "任何类型", - "add": "添加关联", - "edit": "编辑关联", - "delete-to-relation-title": "确定要删除实体'{{entityName}}'的关联吗?", - "delete-to-relation-text": "确定删除后实体'{{entityName}}'将取消与当前实体的关联关系。", - "delete-to-relations-title": "确定要删除{ count, plural, =1 {1 个关联} other {# 个关联} }吗?", - "delete-to-relations-text": "确定删除所有选择的关联关系后,与当前实体对应的所有关联关系将被移除。", - "delete-from-relation-title": "确定要从实体'{{entityName}}' 删除关联吗?", - "delete-from-relation-text": "确定删除后当前实体将与实体 '{{entityName}}'取消关联", - "delete-from-relations-title": "确定删除{ count, plural, =1 {1 个关联} other {# 个关联} }吗?", - "delete-from-relations-text": "确定删除所有选择的关联关系后,当前实体将与对应的实体取消关联", - "remove-relation-filter": "移除关联筛选器", - "remove-filter": "移除过滤器", - "add-relation-filter": "添加关联筛选器", - "any-relation": "任意关联", - "relation-filters": "关联筛选器", - "additional-info": "附加信息 (JSON)", - "invalid-additional-info": "无法解析附加信息JSON。", - "no-relations-text": "未找到关联", - "not": "不是" - }, - "resource": { - "add": "添加资源", - "all-types": "全部", - "copyId": "复制资源ID", - "delete": "删除资源", - "delete-resource-text": "请注意:确认后,资源将不可恢复。", - "delete-resource-title": "确定要删除资源'{{resourceTitle}}'吗?", - "delete-resources-action-title": "删除{ count, plural, =1 {1 个资源} other {# 个资源} }", - "delete-resources-text": "请注意:确认后,所有选定的资源将被删除。", - "delete-resources-title": "确定要删除{ count, plural, =1 {1 个资源} other {# 个资源} }吗?", - "download": "下载资源", - "drop-file": "拖放资源文件或单击以选择要上传的文件。", - "drop-resource-file-or": "拖放一个资源文件或者", - "empty": "资源为空", - "file-name": "文件名称", - "idCopiedMessage": "资源ID已复制到剪贴板", - "no-resource-matching": "未找到与'{{widgetsBundle}}'匹配的资源。", - "no-resource-text": "未找到资源", - "open-widgets-bundle": "打开部件库", - "resource": "资源", - "resource-file": "资源文件", - "resource-files": "资源文件", - "resource-library-details": "资源库详情", - "resource-type": "资源类型", - "resources-library": "资源库", - "search": "查找资源", - "selected-resources": "已选择{ count, plural, =1 {1 个资源} other {# 个资源} }", - "system": "系统", - "title": "标题", - "title-required": "标题是必填项。", - "title-max-length": "标题长度应该小于256个字符。", - "type": { - "jks": "JKS", - "js-module": "JS 模块", - "lwm2m-model": "LWM2M 模型", - "pkcs-12": "PKCS #12" - }, - "resource-sub-type": "子类型", - "sub-type": { - "image": "图像", - "scada-symbol": "SCADA符号", - "extension": "扩展", - "module": "模块" - } + "show-hide-action-using-function": "使用函数显示/隐藏操作", + "show-action-function": "显示操作函数", + "action-type": "类型", + "action-type-required": "操作类型为必填项。", + "edit-action": "编辑操作", + "delete-action": "删除操作", + "delete-action-title": "删除部件操作", + "delete-action-text": "确定要删除名为 '{{actionName}}' 的部件操作吗?", + "title-icon": "标题图标", + "display-icon": "显示标题图标", + "card-icon": "卡片图标", + "icon": "图标", + "icon-color": "图标颜色", + "icon-size": "图标大小", + "advanced-settings": "高级设置", + "data-settings": "数据设置", + "limits": "限制", + "no-data-display-message": "“无数据可显示”替代消息", + "data-page-size": "每个数据源的最大实体数", + "settings-component-not-found": "未找到选择器 '{{selector}}' 对应的设置表单组件", + "preview": "预览", + "set": "设置", + "set-message": "设置消息", + "advanced-title-style": "高级标题样式", + "card-style": "卡片样式", + "text": "文本", + "background": "背景", + "advanced-widget-style": "高级部件样式", + "card-buttons": "卡片按钮", + "show-card-buttons": "显示卡片按钮", + "card-border-radius": "卡片边框圆角", + "card-padding": "卡片内边距", + "card-appearance": "卡片外观", + "color": "颜色", + "tooltip": "提示", + "units-required": "单位为必填项。", + "list-layout": "列表布局", + "layout": "布局", + "resize-options": "调整大小选项", + "resizable": "可调整大小", + "preserve-aspect-ratio": "保持宽高比" + }, + "widget-type": { + "import": "导入部件类型", + "export": "导出部件类型", + "export-failed-error": "无法导出部件:{{error}}", + "widget-file": "部件文件", + "invalid-widget-file-error": "无法导入部件:无效的部件数据结构。" + }, + "markdown": { + "edit": "编辑", + "preview": "预览", + "copy-code": "点击复制", + "copied": "已复制!" + }, + "widgets": { + "mobile-app-qr-code": { + "configuration-hint": "配置取决于平台主设置中的移动应用二维码部件", + "get-it-on-google-play": "在 Google Play 上获取", + "download-on-the-app-store": "在 App Store 上下载" }, - "javascript": { - "add": "添加JavaScript资源", - "delete": "删除JavaScript资源", - "delete-javascript-resource-text": "请注意:确认后该JavaScript资源将不可恢复。", - "delete-javascript-resource-title": "确定要删除JavaScript资源 '{{resourceTitle}}' 吗?", - "delete-javascript-resources-action-title": "删除JavaScript { count, plural, =1 {1 个资源} other {# 个资源} }", - "delete-javascript-resources-text": "请注意,即使这些JavaScript资源已被JavaScript函数使用,它们也将被删除。", - "delete-javascript-resources-title": "确定要删除JavaScript { count, plural, =1 {1 个资源} other {# 个资源} } 吗?", - "download": "下载JavaScript资源", - "upload-from-file": "从文件上传JavaScript", - "resource-file": "JavaScript资源文件", - "drop-file": "拖放一个JavaScript文件,或点击选择要上传的文件。", - "drop-resource-file-or": "拖放一个JavaScript文件或", - "javascript-library": "JavaScript库", - "javascript-type": "JavaScript类型", - "javascript-resource-details": "JavaScript资源详情", - "search": "搜索JavaScript资源", - "selected-javascript-resources": "{ count, plural, =1 {1 个 JavaScript 资源} other {# 个 JavaScript 资源} } 已选择", - "no-javascript-resource-text": "未找到JavaScript资源", - "all-types": "全部", - "module-script": "模块脚本" + "action-button": { + "behavior": "行为", + "on-click": "点击时", + "on-click-hint": "点击按钮时触发的操作", + "first-button-click": "第一按钮点击", + "first-button-click-hint": "按下第一按钮时的操作。", + "second-button-click": "第二按钮点击", + "second-button-click-hint": "按下第二按钮时的操作。", + "button-click-hint": "按下部件时的操作。" }, - "rpc": { - "error": { - "target-device-is-not-set": "目标设备未设置!", - "invalid-target-entity": "{{entityType}}实体不支持RPC命令。", - "failed-to-resolve-target-device": "无法解析目标设备!", - "request-timeout": "请求超时", - "rpc-http-error": "错误: {{status}}-{{statusText}}" - } + "command-button": { + "behavior": "行为", + "on-click": "点击时", + "on-click-hint": "点击按钮时执行的操作。" }, - "rulechain": { - "rulechain": "规则链", - "rulechain-events": "规则链事件", - "rulechains": "规则链库", - "root": "是否根链", - "delete": "删除规则链", - "name": "名称", - "name-required": "名称必填。", - "name-max-length": "名称长度应该小于256个字符。", - "description": "说明", - "add": "添加规则链", - "set-root": "设置为根规则链", - "set-root-rulechain-title": "确定要设置'{{ruleChainName}}'为根规则链吗?", - "set-root-rulechain-text": "确认后,规则链将变为根规格链,并将处理所有传入的传输消息。", - "delete-rulechain-title": " 确定要删除规则链'{{ruleChainName}}'吗?", - "delete-rulechain-text": "请注意,确认后,规则链和所有相关数据将不可恢复。", - "delete-rulechains-title": "确定要删除{count, plural, =1 { 1 个规则链} other {# 个规则链} }吗?", - "delete-rulechains-action-title": "删除{ count, plural, =1 {1 个规则链} other {# 个规则链} }", - "delete-rulechains-text": "请注意:确认后所有选定的规则链将被删除,所有相关的数据将不可恢复。", - "add-rulechain-text": "添加规则链", - "no-rulechains-text": "未找到规则链", - "rulechain-details": "规则链详情", - "details": "详情", - "events": "事件", - "system": "系统", - "import": "导入规则链", - "export": "导出规则链", - "export-failed-error": "无法导出规则链:{{error}}", - "create-new-rulechain": "创建规则链", - "rulechain-file": "规则链文件", - "invalid-rulechain-file-error": "不能导入规则链:无效的规则链数据格式。", - "copyId": "复制规则链ID", - "idCopiedMessage": "规则ID已经复制到粘贴板", - "select-rulechain": "选择规则链", - "no-rulechains-matching": "没有发现匹配'{{entity}}'的规则链。", - "rulechain-required": "规则链必填", - "management": "规则集管理", - "debug-mode": "调试模式", - "search": "查找规则链", - "selected-rulechains": "已选择{ count, plural, =1 {1 个规则链} other {# 个规则链} }", - "open-rulechain": "打开规则链", - "edge-template-root": "模版根链", - "assign-to-edge": "分配给边缘", - "edge-rulechain": "边缘规则链", - "unassign-rulechain-from-edge-text": "确认后规则链将会取消分配,边缘无法访问。", - "unassign-rulechains-from-edge-title": "确定要取消分配{ count, plural, =1 {1 个规则链} other {# 个规则链} }吗?", - "unassign-rulechains-from-edge-text": "确认后,选定的规则链将会取消分配,边缘无法访问。", - "assign-rulechain-to-edge-title": "分配规则链给边缘", - "assign-rulechain-to-edge-text": "请选择要分配给边缘的规则链", - "set-edge-template-root-rulechain": "设置为边缘模版根规则链", - "set-edge-template-root-rulechain-title": "确定将'{{ruleChainName}}' 设置为边缘模版根规则链吗?", - "set-edge-template-root-rulechain-text": "确认后,将会成为边缘模版根规则链,且它会成为新创建边缘的根规则链。", - "invalid-rulechain-type-error": "不能导入规则链:无效的规则链类型。期望类型为{{expectedRuleChainType}}。", - "set-auto-assign-to-edge": "将规则链分配给新创建的边缘", - "set-auto-assign-to-edge-title": "确定将规则链'{{ruleChainName}}'自动分配给新创建的边缘吗?", - "set-auto-assign-to-edge-text": "确认后,将自动分配规则链给新创建的边缘。", - "unset-auto-assign-to-edge": "不将规则链分配给边缘", - "unset-auto-assign-to-edge-title": "确定不再将规则链'{{ruleChainName}}'自动分配给新创建的边缘吗?", - "unset-auto-assign-to-edge-text": "确认后,将不再自动分配规则链给新创建的边缘。", - "unassign-rulechain-title": "确定要取消分配规则链'{{ruleChainName}}' 吗?", - "unassign-rulechains": "取消分配规则链" - }, - "rulenode": { - "rule-node-events": "规则节点事件", - "details": "详情", - "events": "事件", - "search": "查找节点", - "open-node-library": "打开节点库", - "close-node-library": "关闭节点库", - "add": "添加规则节点", - "name": "名称", - "name-required": "名称必填。", - "name-max-length": "名称长度应该小于256个字符。", - "type": "类型", - "rule-node-description": "规则节点描述", - "delete": "删除规则节点", - "select-all-objects": "选择所有节点和连接", - "deselect-all-objects": "取消选择所有节点和连接", - "delete-selected-objects": "删除选定的节点和连接", - "delete-selected": "删除选定", - "create-nested-rulechain": "创建嵌套规则链", - "select-all": "选择全部", - "copy-selected": "复制", - "deselect-all": "取消选择", - "rulenode-details": "规则节点详情", - "debug-mode": "调试模式", - "singleton": "单例", - "configuration": "配置", - "link": "链接", - "link-details": "规则节点链接详情", - "add-link": "添加链接", - "link-label": "链接标签", - "link-label-required": "链接标签必填", - "custom-link-label": "自定义链接标签", - "custom-link-label-required": "自定义链接标签必填", - "link-labels": "链接标签", - "link-labels-required": "链接标签必填。", - "no-link-labels-found": "未找到链接标签", - "no-link-label-matching": "未找到匹配'{{label}}'的链接标签。", - "create-new-link-label": "创建链接标签", - "type-filter": "筛选器", - "type-filter-details": "使用配置条件筛选传入消息", - "type-enrichment": "属性集", - "type-enrichment-details": "向消息元数据中添加附加信息", - "type-transformation": "变换", - "type-transformation-details": "更改消息Payload和元数据", - "type-action": "动作", - "type-action-details": "执行特别动作", - "type-external": "外部", - "type-external-details": "与外部系统交互", - "type-rule-chain": "规则链", - "type-rule-chain-details": "将传入消息转发到指定的规则链", - "type-flow": "流", - "type-flow-details": "流详情", - "type-input": "输入", - "type-input-details": "规则链的逻辑输入,将传入消息转发到下一个相关规则节点", - "type-unknown": "未知", - "type-unknown-details": "未解析的规则节点", - "directive-is-not-loaded": "定义的配置指令'{{directiveName}}'不可用。", - "ui-resources-load-error": "加载配置UI资源失败。", - "invalid-target-rulechain": "无法解析目标规则链!", - "test-script-function": "测试脚本功能", - "script-lang-java-script": "JavaScript", - "script-lang-tbel": "TBEL", - "message": "消息", - "message-type": "消息类型", - "select-message-type": "选择消息类型", - "message-type-required": "消息类型必填", - "metadata": "元数据", - "metadata-required": "元数据项不能为空。", - "output": "输出", - "test": "测试", - "help": "帮助", - "reset-debug-settings": "重置所有节点的调试设置", - "test-with-this-message": "使用此消息进行{{test}}测试", - "queue-hint": "选择一个队列将消息转发到另一个队列,默认情况下使用'Main'队列。", - "queue-singleton-hint": "选择一个队列以在多实体中转发消息,默认情况下使用'Main'队列。" - }, - "timezone": { - "timezone": "时区", - "select-timezone": "选择时区", - "no-timezones-matching": "未找到与'{{timezone}}'匹配的时区。", - "timezone-required": "时区必填。", - "browser-time": "浏览器时间" - }, - "queue": { - "queue-name": "队列", - "no-queues-found": "未找到队列", - "no-queues-matching": "未找到匹配'{{queue}}'的队列", - "select-name": "选择队列名称", - "name": "名称", - "name-required": "队列名称必填。", - "name-unique": "队列名称必须唯一。", - "name-pattern": "队列名称不能包含ASCII字母数字以外的字符,'.','_'和'-'等。", - "queue-required": "队列必填。", - "topic-required": "队列主题必填。", - "poll-interval-required": "轮询间隔必填。", - "poll-interval-min-value": "轮询间隔不能小于1", - "partitions-required": "分区必填。", - "partitions-min-value": "分区不能小于1", - "pack-processing-timeout-required": "处理超时时间必填。", - "pack-processing-timeout-min-value": "处理超时时间不能小于1", - "batch-size-required": "批量处理大小必填。", - "batch-size-min-value": "批量处理大小不能小于1", - "retries-required": "重试次数必填。", - "retries-min-value": "重试次数不能为负", - "failure-percentage-required": "失败百分比必填。", - "failure-percentage-min-value": "失败百分比值不能小于0", - "failure-percentage-max-value": "失败百分比值不能大于100", - "pause-between-retries-required": "重试间隔必填。", - "pause-between-retries-min-value": "重试间隔不能小于1", - "max-pause-between-retries-required": "最大重试间隔必填。", - "max-pause-between-retries-min-value": "最大重试间隔不能小于1", - "submit-strategy-type-required": "提交策略类型必填。", - "processing-strategy-type-required": "处理策略类型必填。", - "queues": "队列", - "selected-queues": "已选择{ count, plural, =1 {1 个队列} other {# 个队列} }", - "delete-queue-title": "确定要删除'{{queueName}}'队列吗?", - "delete-queues-title": "确定要删除{ count, plural, =1 {1 个队列} other {# 个队列} }吗?", - "delete-queue-text": "请注意:确认后队列和所有相关数据将不可恢复。", - "delete-queues-text": "确认后所有选定队列都将被删除并无法访问。", - "search": "搜索队列", - "add": "添加队列", - "details": "队列详情", - "topic": "主题", - "submit-settings": "提交设置", - "submit-strategy": "策略类型*", - "grouping-parameter": "分组参数", - "processing-settings": "重试处理设置", - "processing-strategy": "处理类型*", - "retries-settings": "重试设置", - "polling-settings": "轮询设置", - "batch-processing": "批量处理", - "poll-interval": "轮询间隔", - "partitions": "分区", - "immediate-processing": "即时处理", - "consumer-per-partition": "每个分区消费者单独轮询消息", - "consumer-per-partition-hint": "每个分区启用单独的消费者", - "duplicate-msg-to-all-partitions": "重复给所有分区的消息", - "processing-timeout": "处理超时(毫秒)", - "batch-size": "批量处理大小", - "retries": "重试次数 (0 – 无限制)", - "failure-percentage": "跳过重试的失败消息百分比", - "pause-between-retries": "重试间隔(秒)", - "max-pause-between-retries": "最大重试间隔(秒)", - "delete": "删除队列", - "copyId": "复制队列ID", - "idCopiedMessage": "队列ID已复制到剪贴板", - "description": "说明", - "description-hint": "此文本将显示在队列说明中,而不是所选策略中", - "alt-description": "提交策略:{{submitStrategy}},处理策略:{{processingStrategy}}", - "custom-properties": "自定义属性", - "custom-properties-hint": "自定义队列(主题)创建属性例如'retention.ms:604800000;retention.bytes:1048576000'", - "strategies": { - "sequential-by-originator-label": "按发起者顺序处理", - "sequential-by-originator-hint": "在确认设备A的前一条消息之前,不会提交设备A的新消息", - "sequential-by-tenant-label": "按租户顺序处理", - "sequential-by-tenant-hint": "在确认租户A的前一条消息之前,不会提交租户A的新消息", - "sequential-label": "顺序处理", - "sequential-hint": "在确认前一条消息之前,不会提交新消息", - "burst-label": "突发处理", - "burst-hint": "所有消息都按到达的顺序提交到规则链", - "batch-label": "批量处理", - "batch-hint": "在确认前一批消息之前,不会提交新批", - "skip-all-failures-label": "跳过所有失败", - "skip-all-failures-hint": "忽略所有失败", - "skip-all-failures-and-timeouts-label": "跳过所有失败和超时", - "skip-all-failures-and-timeouts-hint": "忽略所有失败和超时", - "retry-all-label": "全部重试", - "retry-all-hint": "重试处理包中的所有消息", - "retry-failed-label": "失败重试", - "retry-failed-hint": "重试处理包中的所有失败消息", - "retry-timeout-label": "超时重试", - "retry-timeout-hint": "重试处理包中的所有超时消息", - "retry-failed-and-timeout-label": "失败与超时重试", - "retry-failed-and-timeout-hint": "重试处理包中所有失败和超时的消息" - } + "power-button": { + "behavior": "行为", + "power-on": "电源“开”", + "power-on-hint": "执行组件开启操作。", + "power-off": "电源“关”", + "power-off-hint": "执行组件关闭操作。", + "on-label": "开", + "off-label": "关", + "layout": "布局", + "layout-default": "默认", + "layout-simplified": "简化", + "layout-outlined": "轮廓", + "layout-default-volume": "默认.音量", + "layout-simplified-volume": "简化.音量", + "layout-outlined-volume": "轮廓.音量", + "layout-default-icon": "默认.图标", + "layout-simplified-icon": "简化.图标", + "layout-outlined-icon": "轮廓.图标", + "main": "主色", + "background": "背景", + "button-icon-on": "按钮图标“开”", + "button-icon-off": "按钮图标“关”", + "power-on-colors": "电源“开”颜色", + "power-off-colors": "电源“关”颜色", + "disabled-colors": "禁用颜色", + "button": "按钮" }, - "queue-statistics": { - "queue-statistics": "队列统计", - "no-queue-statistics-matching": "未找到与'{{entity}}'匹配的队列统计信息。", - "queue-statistics-required": "队列统计必填。", - "list-of-queue-statistics": "{ count, plural, =1 {个队列统计} other {对列统计#列表} }", - "selected-queue-statistics": "选中{ count, plural, =1 {1个队列统计} other {#对列统计} }", - "no-queue-statistics-text": "找不到队列统计数据", - "queue-statistics-starts-with": "队列统计的名称以始于'{{prefix}}'" - }, - "server-error": { - "general": "一般服务器错误", - "authentication": "授权错误", - "jwt-token-expired": "JWT令牌已过期", - "tenant-trial-expired": "租户过期", - "credentials-expired": "凭据过期", - "permission-denied": "没有权限", - "invalid-arguments": "无效参数", - "bad-request-params": "请求无效", - "item-not-found": "找不到项目", - "too-many-requests": "请求过于频繁", - "too-many-updates": "更新过于频繁" - }, - "tenant": { - "tenant": "租户", - "tenants": "租户", - "management": "租户管理", - "add": "添加租户", - "admins": "管理员", - "manage-tenant-admins": "管理租户管理员", - "delete": "删除租户", - "add-tenant-text": "添加租户", - "no-tenants-text": "未找到租户", - "tenant-details": "租客详情", - "title-max-length": "标题长度应该小于256个字符。", - "delete-tenant-title": "确定要删除租户'{{tenantTitle}}'吗?", - "delete-tenant-text": "请注意:确认后租户和所有相关数据将不可恢复。", - "delete-tenants-title": "确定要删除{count,plural,=1 {1 个租户} other {# 个租户} }吗?", - "delete-tenants-action-title": "删除{ count, plural, =1 {1 个租户} other {# 个租户} }", - "delete-tenants-text": "请注意:确认后所有选定的租户将被删除,所有相关数据将不可恢复。", - "title": "标题", - "title-required": "标题必填。", - "description": "说明", - "details": "详情", - "events": "事件", - "copyId": "复制租户ID", - "idCopiedMessage": "租户ID已经复制到粘贴板", - "select-tenant": "选择租户", - "no-tenants-matching": "未找到匹配'{{entity}}'的租户", - "tenant-required": "租户必填", - "search": "查找租户", - "selected-tenants": "已选择{ count, plural, =1 {1 个租户} other {# 个租户} }", - "isolated-tb-rule-engine": "使用独立的规则引擎服务", - "isolated-tb-rule-engine-details": "每个独立租户需要单独的规则引擎微服务" - }, - "tenant-profile": { - "tenant-profile": "租户配置", - "tenant-profiles": "租户配置", - "add": "添加租户配置", - "add-profile": "添加配置", - "debug": "调试", - "edit": "编辑租户配置", - "tenant-profile-details": "租户配置详细信息", - "no-tenant-profiles-text": "未找到租户配置", - "name-max-length": "名称长度必须小于256个字符", - "search": "查找租户配置", - "selected-tenant-profiles": "已选择{ count, plural, =1 {1 个租户配置} other {# 个租户配置} }", - "no-tenant-profiles-matching": "未找到与'{{entity}}'匹配的租户配置。", - "tenant-profile-required": "租户配置必填", - "idCopiedMessage": "租户配置ID已复制到剪贴板", - "set-default": "设置该租户配置为默认", - "delete": "删除租户配置", - "copyId": "复制租户配置ID", - "name": "名称", - "name-required": "名称必填。", - "data": "配置数据", - "profile-configuration": "配置设置", - "description": "说明", - "default": "默认", - "delete-tenant-profile-title": "确定要删除租户配置'{{tenantProfileName}}'吗?", - "delete-tenant-profile-text": "请注意:确认后,租户配置和所有相关数据将不可恢复。", - "delete-tenant-profiles-title": "确定要删除{ count, plural, =1 {1 个租户配置} other {# 个租户配置} }吗?", - "delete-tenant-profiles-text": "请注意:确认后,所有选定的租户配置将被删除,所有相关数据将不可恢复。", - "set-default-tenant-profile-title": "确定要将租户配置'{{tenantProfileName}}'设为默认值吗?", - "set-default-tenant-profile-text": "确认后此租户配置将被标记为默认配置并将用于未指定配置的新租户。", - "no-tenant-profiles-found": "未找到租户配置。", - "create-new-tenant-profile": "创建租户配置", - "create-tenant-profile": "创建租户配置", - "import": "导入租户配置", - "export": "导出租户配置", - "export-failed-error": "无法导出租户配置:{{error}}", - "tenant-profile-file": "租户配置", - "invalid-tenant-profile-file-error": "无法导入租户配置:无效的租户配置数据结构。", - "advanced-settings": "高级设置", - "entities": "实体", - "rule-engine": "规则引擎", - "time-to-live": "TTL", - "alarms-and-notifications": "告警与通知", - "ota-files-in-bytes": "OTA文件(字节)", - "ws-title": "WS", - "unlimited": "(0 - 无限制)", - "maximum-devices": "最大设备数", - "maximum-devices-required": "最大设备数必填。", - "maximum-devices-range": "最大设备数不能为负数", - "maximum-assets": "最大资产数", - "maximum-assets-required": "最大资产数必填。", - "maximum-assets-range": "最大资产数不能为负数", - "maximum-customers": "最大客户数", - "maximum-customers-required": "最大客户数必填。", - "maximum-customers-range": "最大客户数不能为负数", - "maximum-users": "最大用户数", - "maximum-users-required": "最大用户数必填。", - "maximum-users-range": "最大用户数不能为负数", - "maximum-dashboards": "最大仪表板数", - "maximum-dashboards-required": "最大仪表板数必填。", - "maximum-dashboards-range": "最大仪表板数不能为负数", - "maximum-edges": "最大边数", - "maximum-edges-required": "需要最大边数。", - "maximum-edges-range": "边的最大数量不能为负数", - "maximum-rule-chains": "最大规则链数", - "maximum-rule-chains-required": "最大规则链数必填。", - "maximum-rule-chains-range": "最大规则链数不能为负数", - "maximum-resources-sum-data-size": "资源文件总大小", - "maximum-resources-sum-data-size-required": "资源文件总大小必填。", - "maximum-resources-sum-data-size-range": "资源文件总大小不能为负数", - "maximum-resource-size": "最大资源文件大小(字节)", - "maximum-resource-size-required": "最大资源文件大小是必需的", - "maximum-resource-size-range": "最大资源文件大小不能为负数", - "maximum-ota-packages-sum-data-size": "OTA包文件总大小", - "maximum-ota-package-sum-data-size-required": "OTA包文件总大小必填。", - "maximum-ota-package-sum-data-size-range": "OTA包文件总大小不能为负数", - "maximum-debug-duration-min": "最大调试时长(分钟)", - "maximum-debug-duration-min-range": "最大调试时长不能为负", - "rest-requests-for-tenant": "租户REST请求", - "transport-tenant-telemetry-msg-rate-limit": "租户遥测消息", - "transport-tenant-telemetry-data-points-rate-limit": "租户遥测数据点", - "transport-device-msg-rate-limit": "设备消息", - "transport-device-telemetry-msg-rate-limit": "设备遥测数据点", - "transport-device-telemetry-data-points-rate-limit": "设备遥测消息", - "transport-gateway-msg-rate-limit": "传输网关消息", - "transport-gateway-telemetry-msg-rate-limit": "传输网关遥测消息", - "transport-gateway-telemetry-data-points-rate-limit": "传输网关遥测数据点", - "transport-gateway-device-msg-rate-limit": "传输网关设备消息", - "transport-gateway-device-telemetry-msg-rate-limit": "传输网关设备遥测消息", - "transport-gateway-device-telemetry-data-points-rate-limit": "传输网关设备遥测数据点", - "tenant-entity-export-rate-limit": "实体版本创建", - "tenant-entity-import-rate-limit": "实体版本加载", - "tenant-notification-request-rate-limit": "通知请求", - "tenant-notification-requests-per-rule-rate-limit": "每个通知规则的通知请求", - "max-transport-messages": "最大传输消息数", - "max-transport-messages-required": "最大传输消息数必填。", - "max-transport-messages-range": "最大传输消息数不能为负数", - "max-transport-data-points": "最大传输数据点数", - "max-transport-data-points-required": "最大传输数据点数必填。", - "max-transport-data-points-range": "最大传输数据点数不能为负", - "max-r-e-executions": "最大规则引擎执行数", - "max-r-e-executions-required": "最大规则引擎执行数必填。", - "max-r-e-executions-range": "最大规则引擎执行数不能为负", - "max-j-s-executions": "最大JavaScript执行数", - "max-j-s-executions-required": "最大JavaScript执行数必填。", - "max-j-s-executions-range": "最大JavaScript执行数不能为负数", - "max-tbel-executions": "最大TBEL执行数", - "max-tbel-executions-required": "需要指定最大TBEL执行数。", - "max-tbel-executions-range": "最大TBEL执行数不能为负数。", - "max-d-p-storage-days": "最大存储点天", - "max-d-p-storage-days-required": "最大存储点天必填。", - "max-d-p-storage-days-range": "最大存储点天不能为负数", - "default-storage-ttl-days": "默认存储TTL天数", - "default-storage-ttl-days-required": "默认存储TTL天数必填。.", - "default-storage-ttl-days-range": "默认存储TTL天数不能为负数", - "alarms-ttl-days": "告警TTL天数", - "alarms-ttl-days-required": "告警TTL天数必填。", - "alarms-ttl-days-days-range": "告警TTL天数不能为负数", - "rpc-ttl-days": "RPC TTL天数", - "rpc-ttl-days-required": "RPC TTL天数必填。", - "rpc-ttl-days-days-range": "RPC TTL天数不能为负数", - "queue-stats-ttl-days": "队列统计信息TTL天数", - "queue-stats-ttl-days-required": "需要指定队列统计信息TTL天数", - "queue-stats-ttl-days-range": "队列统计信息TTL天数不能为负数", - "rule-engine-exceptions-ttl-days": "规则引擎异常TTL天数", - "rule-engine-exceptions-ttl-days-required": "需要指定规则引擎异常TTL天数", - "rule-engine-exceptions-ttl-days-range": "规则引擎异常TTL天数不能为负数", - "max-rule-node-executions-per-message": "每条消息的最大规则节点执行数", - "max-rule-node-executions-per-message-required": "每个消息的最大规则节点执行数必填。", - "max-rule-node-executions-per-message-range": "每条消息的最大规则节点执行数不能为负", - "max-emails": "最大电子邮件发送数", - "max-emails-required": "最大电子邮件发送数必填。", - "max-emails-range": "最大电子邮件发送数不能为负", - "sms-enabled": "启用短信", - "max-sms": "最大短信发送数", - "max-sms-required": "最大短信发送数必填。", - "max-sms-range": "最大短信发送数不能为负", - "max-created-alarms": "最大创建告警数", - "max-created-alarms-required": "最大创建告警数必填。", - "max-created-alarms-range": "最大创建告警数不能为负数", - "no-queue": "未配置队列", - "add-queue": "添加队列", - "queues-with-count": "队列 ({{count}})", - "tenant-rest-limits": "租户REST请求", - "customer-rest-limits": "客户REST请求", - "incorrect-pattern-for-rate-limits": "格式为以冒号分割容量与周期(秒)并以逗号分割配置对例如 100:1,2000:60", - "too-small-value-zero": "数值必须大于0", - "too-small-value-one": "数值必须大于1", - "queue-size-is-limited-by-system-configuration": "队列的大小也受系统配置的限制。", - "cassandra-tenant-limits-configuration": "租户Cassandra查询", - "ws-limit-max-sessions-per-tenant": "租户最大会话数", - "ws-limit-max-sessions-per-customer": "客户最大会话数", - "ws-limit-max-sessions-per-regular-user": "普通用户最大会话数", - "ws-limit-max-sessions-per-public-user": "公共用户最大会话数", - "ws-limit-queue-per-session": "会话最大消息队列大小", - "ws-limit-max-subscriptions-per-tenant": "租户最大订阅数", - "ws-limit-max-subscriptions-per-customer": "客户最大订阅数", - "ws-limit-max-subscriptions-per-regular-user": "普通用户最大订阅数", - "ws-limit-max-subscriptions-per-public-user": "公共用户最大订阅数", - "ws-limit-updates-per-session": "会话WS更新", - "rate-limits": { - "add-limit": "添加限制", - "advanced-settings": "高级设置", - "edit-limit": "编辑限制", - "edit-transport-tenant-msg-title": "编辑传输租户消息速率限制", - "edit-transport-tenant-telemetry-msg-title": "编辑传输租户遥测消息速率限制", - "edit-transport-tenant-telemetry-data-points-title": "编辑传输租户遥测数据点速率限制", - "edit-transport-device-msg-title": "编辑传输设备消息速率限制", - "edit-transport-device-telemetry-msg-title": "编辑传输设备遥测消息速率限制", - "edit-transport-device-telemetry-data-points-title": "编辑传输设备遥测数据点速率限制", - "edit-transport-gateway-msg-title": "编辑传输网关消息速率限制", - "edit-transport-gateway-telemetry-msg-title": "编辑传输网关遥测消息速率限制", - "edit-transport-gateway-telemetry-data-points-title": "编辑传输网关遥测数据点率限制", - "edit-transport-gateway-device-msg-title": "编辑传输网关设备消息速率限制", - "edit-transport-gateway-device-telemetry-msg-title": "编辑传输网关设备遥测消息速率限制", - "edit-transport-gateway-device-telemetry-data-points-title": "编辑传输网关设备遥测数据点速率限制", - "edit-tenant-rest-limits-title": "编辑租户费率限制的休息请求", - "edit-customer-rest-limits-title": "编辑客户REST请求速率限制", - "edit-ws-limit-updates-per-session-title": "编辑会话WS更新速率限制", - "edit-cassandra-tenant-limits-configuration-title": "编辑租户Cassandra查询速率限制", - "edit-tenant-entity-export-rate-limit-title": "编辑实体版本创建速率限制", - "edit-tenant-entity-import-rate-limit-title": "编辑实体版本加载速率限制", - "edit-tenant-notification-request-rate-limit-title": "编辑通知请求的速率限制", - "edit-tenant-notification-requests-per-rule-rate-limit-title": "编辑每个通知规则的通知请求速率限制", - "edit-edge-events-rate-limit": "编辑Edge事件速率限制", - "edit-edge-events-per-edge-rate-limit": "编辑每个Edge事件速率限制", - "edge-events-rate-limit": "Edge事件", - "edge-events-per-edge-rate-limit": "每个Edge事件", - "edit-edge-uplink-messages-rate-limit": "编辑Edge上行链路消息速率限制", - "edit-edge-uplink-messages-per-edge-rate-limit": "编辑Edge上行链路消息每个Edge速率限制", - "edge-uplink-messages-rate-limit": "Edge上行链路消息", - "edge-uplink-messages-per-edge-rate-limit": "每个Edge上行链路消息", - "messages-per": "每条消息", - "not-set": "未配置", - "number-of-messages": "消息数量", - "number-of-messages-required": "消息数量必填。", - "number-of-messages-min": "最小值为1。", - "preview": "预览", - "per-seconds": "每秒", - "per-seconds-required": "时间必填。", - "per-seconds-min": "最小值为1。", - "rate-limits": "速率限制", - "remove-limit": "删除限制", - "transport-tenant-msg": "传输租户消息", - "transport-tenant-telemetry-msg": "传输租户遥测消息", - "transport-tenant-telemetry-data-points": "传输租户遥测数据点", - "transport-device-msg": "传输设备消息", - "transport-device-telemetry-msg": "传输设备遥测消息", - "transport-device-telemetry-data-points": "传输设备遥测数据点", - "transport-gateway-msg": "传输网关消息", - "transport-gateway-telemetry-msg": "传输网关遥测消息", - "transport-gateway-telemetry-data-points": "传输网关遥测数据点", - "transport-gateway-device-msg": "传输网关设备消息", - "transport-gateway-device-telemetry-msg": "传输网关设备遥测消息", - "transport-gateway-device-telemetry-data-points": "传输网关设备遥测数据点", - "sec": "秒" - } + "toggle-button": { + "behavior": "行为", + "checked": "选中", + "unchecked": "未选中", + "check": "选中", + "check-hint": "执行选中组件的操作。", + "uncheck": "取消选中", + "uncheck-hint": "执行取消选中组件的操作。", + "auto-scale": "自动缩放", + "horizontal-fill": "水平填充", + "vertical-fill": "垂直填充", + "button-appearance": "按钮外观" }, - "timeinterval": { - "seconds-interval": "{ seconds, plural, =1 {1 秒} other {# 秒} }", - "minutes-interval": "{ minutes, plural, =1 {1 分} other {# 分} }", - "hours-interval": "{ hours, plural, =1 {1 小时} other {# 小时} }", - "days-interval": "{ days, plural, =1 {1 天} other {# 天} }", - "days": "天", - "hours": "小时", - "minutes": "分钟", - "seconds": "秒", - "advanced": "高级", - "custom": "自定义", - "predefined": { - "yesterday": "昨天", - "day-before-yesterday": "前天", - "this-day-last-week": "前一周的这一天", - "previous-week": "前一周(周日至周六)", - "previous-week-iso": "前一周(周一至周日)", - "previous-month": "前一个月", - "previous-quarter": "上季度", - "previous-half-year": "前半年", - "previous-year": "前一年", - "current-hour": "当前小时", - "current-day": "当前天", - "current-day-so-far": "当天到目前为止", - "current-week": "本周(周日至周六)", - "current-week-iso": "本周(周一至周日)", - "current-week-so-far": "本周到目前为止(周日至周六)", - "current-week-iso-so-far": "本周到目前为止(周一至周日)", - "current-month": "本月", - "current-month-so-far": "本月到目前为止", - "current-quarter": "当前季度", - "current-quarter-so-far": "当前季度到目前为止", - "current-half-year": "当前半年", - "current-half-year-so-far": "当前半年到目前为止", - "current-year": "本年", - "current-year-so-far": "本年到目前为止" - }, - "type": { - "week": "每周(星期日-星期六)", - "week-iso": "每周(星期一-星期日)", - "month": "月", - "quarter": "季度" - } + "segmented-button": { + "layout": "布局", + "layout-squared": "方形", + "layout-rounded": "圆角", + "card-border": "卡片边框", + "button-appearance": "按钮外观", + "first": "第一", + "second": "第二", + "color-styles": "颜色样式", + "selected": "已选择", + "unselected": "未选择" }, - "timeunit": { - "milliseconds": "毫秒", - "seconds": "秒", - "minutes": "分钟", - "hours": "小时", - "days": "天" - }, - "timewindow": { - "timewindow": "时间窗口", - "timewindow-settings": "设置时间窗口", - "years": "{ years, plural, =1 {1年 } other {#年 } }", - "years-short": "{{ years }}y", - "months": "{ months, plural, =1 {1月 } other {#月 } }", - "months-short": "{{ months }}M", - "weeks": "{ weeks, plural, =1 {1周 } other {#周 } }", - "weeks-short": "{{ weeks }}w", - "days": "{ days, plural, =1 {1天 } other {#天 } }", - "days-short": "{{ days }}d", - "hours": "{ hours, plural, =0 {-小时 } =1 {1小时 } other {#小时 } }", - "hr": "{{ hr }} 时", - "hr-short": "{{ hr }}h", - "minutes": "{ minutes, plural, =0 {-分 } =1 {1分 } other {#分 } }", - "min": "{{ min }} 分", - "min-short": "{{ min }}m", - "seconds": "{ seconds, plural, =0 {-秒 } =1 {1秒 } other {#秒 } }", - "sec": "{{ sec }} 秒", - "sec-short": "{{ sec }}s", - "short": { - "years": "{ years, plural, =1 {1 年} other {# 年} }", - "days": "{ days, plural, =1 {1 天 } other {# 天 } }", - "hours": "{ hours, plural, =1 {1 小时 } other {# 小时 } }", - "minutes": "{{minutes}}分 ", - "seconds": "{{seconds}}秒 " - }, - "realtime": "实时", - "history": "历史", - "last-prefix": "最后", - "period": "从{{ startTime }}到{{ endTime }}", - "edit": "编辑时间窗口", - "date-range": "日期范围", - "for-all-time": "所有时间", - "last": "最后", - "time-period": "时间段", - "hide": "隐藏", - "interval": "区间", - "just-now": "刚刚", - "just-now-lower": "刚刚", - "ago": "之前", - "style": "样式", - "icon": "图标", - "icon-position": "图标位置", - "icon-position-left": "左侧", - "icon-position-right": "右侧", - "font": "字体", - "color": "颜色", - "displayTypePrefix": "实时/历史", - "preview": "预览", - "relative": "相对", - "range": "范围", - "hide-timewindow-section": "隐藏用户的时间窗口", - "hide-last-interval": "隐藏用户的最后间隔", - "hide-relative-interval": "隐藏用户的相对间隔", - "hide-fixed-interval": "隐藏用户的固定间隔", - "hide-aggregation": "隐藏用户的聚合", - "hide-group-interval": "隐藏用户的分组间隔", - "hide-max-values": "隐藏用户的最大值", - "hide-timezone": "隐藏用户的时区", - "disable-custom-interval": "禁用自定义间隔选择", - "edit-aggregation-functions-list": "编辑聚合函数列表", - "edit-aggregation-functions-list-hint": "可以指定可用选项的列表。", - "allowed-aggregation-functions": "允许的聚合函数", - "edit-intervals-list": "编辑时间间隔列表", - "allowed-agg-intervals": "分组时间间隔", - "default-agg-interval": "默认分组时间间隔", - "edit-intervals-list-hint": "可以指定可用的时间间隔选项列表。", - "edit-grouping-intervals-list-hint": "可以配置分组时间间隔列表和默认分组时间间隔。", - "all": "全部" - }, - "tooltip": { - "trigger": "触发", - "trigger-point": "点", - "trigger-axis": "轴", + "button": { + "layout": "布局", + "outlined": "轮廓", + "filled": "填充", + "underlined": "下划线", + "basic": "基础", + "auto-scale": "自动缩放", + "label": "标签", + "icon": "图标", + "border-radius": "边框圆角", + "color-palette": "调色板", + "main": "主色", + "background": "背景", + "border": "边框", + "custom-styles": "自定义样式", + "clear-style": "清除样式", + "shadow": "阴影", + "enabled": "启用", + "disabled": "禁用", + "preview": "预览", + "copy-style-from": "从...复制样式" + }, + "value-stepper": { + "behavior": "行为", + "simplified": "简化", + "filled": "填充", + "outlined": "轮廓", + "volume": "音量", + "initial-state": "初始状态", + "initial-state-hint": "获取初始值的操作。", + "disabled-state": "禁用状态", + "disabled-state-hint": "配置组件被禁用的条件。", + "right-button-click": "右按钮点击", + "right-button-click-hint": "按下右按钮时的操作。", + "left-button-click": "左按钮点击", + "left-button-click-hint": "按下左按钮时的操作。", + "auto-scale": "自动缩放", + "value-range": "范围", + "min-range": "最小值", + "max-range": "最大值", + "value-increment-decrement-step": "值递增/递减步长", + "value": "值", + "value-box-background": "值框背景", + "border": "边框", + "button-appearance": "按钮外观", + "left": "左", + "right": "右", + "left-button": "左按钮", + "right-button": "右按钮", + "icon": "图标", + "color-palette": "调色板", + "main": "主色", + "background": "背景", + "button-icon-on": "按钮图标“开”", + "button-on-colors": "电源“开”颜色", + "disabled-colors": "禁用颜色" + }, + "button-state": { + "activated-state": "激活状态", + "activated-state-hint": "配置按钮处于激活状态的条件。", + "disabled-state": "禁用状态", + "disabled-state-hint": "配置按钮被禁用的条件。", + "selected-state": "选择按钮", + "selected-state-hint": "配置按钮被选中的条件。", + "enabled": "启用", + "hovered": "悬停", + "pressed": "按下", + "activated": "已激活", + "disabled": "已禁用", + "initial": "第一按钮", + "first": "第一", + "second": "第二" + }, + "background": { + "background": "背景", + "background-settings": "背景设置", + "background-type-image": "图片", + "background-type-color": "颜色", + "image-url": "图片 URL", + "overlay": "叠加层", + "enable-overlay": "启用叠加层", + "blur": "模糊", + "preview": "预览" + }, + "bar-chart": { + "bar-appearance": "柱状图外观", + "label-on-bar": "柱上标签", + "value-on-bar": "柱上数值", + "bar-chart-style": "柱状图样式", + "bar-axis": "柱状图轴" + }, + "polar-area-chart": { + "polar-axis": "极坐标轴", + "start-angle": "起始角度", + "polar-area-chart-style": "极坐标面积图样式" + }, + "battery-level": { + "layout": "布局", + "layout-vertical-solid": "垂直.实心", + "layout-horizontal-solid": "水平.实心", + "layout-vertical-divided": "垂直.分段", + "layout-horizontal-divided": "水平.分段", + "icon": "图标", + "value": "值", + "auto-scale": "自动缩放", + "battery-level-color": "电池电量颜色", + "battery-shape-color": "电池外形颜色", + "battery-level-card-style": "电池电量卡片样式", + "sections-count": "分段数量" + }, + "signal-strength": { + "value": "值", + "last-update": "最后更新", + "no-signal": "无信号", + "layout": "布局", + "layout-wifi": "Wi-Fi", + "layout-cellular-bar": "蜂窝信号条", + "icon": "图标", + "date": "日期", + "active-bars-color": "活跃信号条颜色", + "inactive-bars-color": "非活跃信号条颜色", + "signal-strength-card-style": "信号强度卡片样式", + "no-signal-rssi-value": "“无信号”RSSI 值" + }, + "status-widget": { + "behavior": "行为", + "layout": "布局", + "layout-default": "默认", + "layout-center": "居中", + "layout-icon": "图标", + "on": "开", + "off": "关", + "label": "标签", + "status": "状态", + "icon": "图标", + "color-palette": "调色板", + "disabled-color-palette": "禁用调色板", + "primary": "主要", + "primary-color-hint": "图标和标签的颜色", + "secondary": "次要", + "secondary-color-hint": "状态的颜色", + "background": "背景" + }, + "chart": { + "common-settings": "通用设置", + "enable-stacking-mode": "启用堆叠模式", + "selection": "时间范围选择", + "enable-selection-mode": "启用选择模式", + "line-shadow-size": "线条阴影大小", + "display-smooth-lines": "显示平滑(曲线)线条", + "default-bar-width": "非聚合数据的默认柱宽(毫秒)", + "bar-alignment": "柱对齐", + "bar-alignment-left": "左", + "bar-alignment-right": "右", + "bar-alignment-center": "居中", + "default-font": "默认字体", + "default-font-size": "默认字号", + "default-font-color": "默认字体颜色", + "thresholds-line-width": "所有阈值的默认线宽", + "tooltip-settings": "提示框设置", + "tooltip": "提示框", + "show-tooltip": "显示提示框", + "hover-individual-points": "悬停显示单个数据点", + "show-cumulative-values": "在堆叠模式下显示累计值", + "hide-zero-false-values": "在提示框中隐藏零值/假值", + "tooltip-value-format-function": "提示框值格式化函数", + "grid-settings": "网格设置", + "show-vertical-lines": "显示垂直线", + "show-horizontal-lines": "显示水平线", + "grid-outline-border-width": "网格轮廓/边框宽度(px)", + "primary-color": "主色", + "background-color": "背景颜色", + "ticks-color": "刻度颜色", + "xaxis-settings": "X 轴设置", + "axis-title": "轴标题", + "xaxis-tick-labels-settings": "X 轴刻度标签设置", + "show-tick-labels": "显示轴刻度标签", + "yaxis-settings": "Y 轴设置", + "min-scale-value": "刻度最小值", + "max-scale-value": "刻度最大值", + "yaxis-tick-labels-settings": "Y 轴刻度标签设置", + "tick-step-size": "刻度步长", + "number-of-decimals": "显示的小数位数", + "ticks-formatter-function": "刻度格式化函数", + "comparison-settings": "对比设置", + "enable-comparison": "启用对比", + "time-for-comparison": "对比周期", + "time-for-comparison-previous-interval": "上一区间(默认)", + "time-for-comparison-days": "一天前", + "time-for-comparison-weeks": "一周前", + "time-for-comparison-months": "一个月前", + "time-for-comparison-years": "一年前", + "time-for-comparison-custom-interval": "自定义区间", + "custom-interval-value": "自定义区间值(毫秒)", + "comparison-x-axis-settings": "对比 X 轴设置", + "axis-position": "轴位置", + "axis-position-top": "顶部(默认)", + "axis-position-bottom": "底部", + "custom-legend-settings": "自定义图例设置", + "enable-custom-legend": "启用自定义图例(这将允许您在键标签中使用属性/时间序列值)", + "key-name": "键名称", + "key-name-required": "键名称为必填项", + "key-type": "键类型", + "key-type-attribute": "属性", + "key-type-timeseries": "时间序列", + "label-keys-list": "标签中使用的键列表", + "no-label-keys": "未配置键", + "add-label-key": "添加新键", + "line-width": "线宽", + "color": "颜色", + "data-is-hidden-by-default": "数据默认隐藏", + "disable-data-hiding": "禁用数据隐藏", + "remove-from-legend": "从图例中移除数据键", + "exclude-from-stacking": "从堆叠中排除(在“堆叠”模式下可用)", + "line-settings": "线条设置", + "show-line": "显示线条", + "fill-line": "填充线条", + "fill-line-opacity": "填充不透明度", + "points-settings": "数据点设置", + "show-points": "显示数据点", + "points-line-width": "数据点线宽", + "points-radius": "数据点半径", + "point-shape": "数据点形状", + "point-shape-circle": "圆形", + "point-shape-cross": "十字形", + "point-shape-diamond": "菱形", + "point-shape-square": "方形", + "point-shape-triangle": "三角形", + "point-shape-custom": "自定义函数", + "point-shape-draw-function": "数据点形状绘制函数", + "show-separate-axis": "显示独立轴", + "axis-position-left": "左", + "axis-position-right": "右", + "thresholds": "阈值", + "no-thresholds": "未配置阈值", + "add-threshold": "添加阈值", + "show-values-for-comparison": "显示历史对比值", + "comparison-values-label": "历史值标签", + "comparison-line-color": "对比线颜色", + "threshold-settings": "阈值设置", + "use-as-threshold": "使用键值作为阈值", + "threshold-line-width": "阈值线宽", + "threshold-color": "阈值颜色", + "common-pie-settings": "通用饼图设置", + "radius": "半径", + "inner-radius": "内半径", + "tilt": "倾斜", + "common-pie-settings-range-error": "值应在 0 到 1 之间", + "stroke-settings": "描边设置", + "width-pixels": "宽度(像素)", + "show-labels": "显示标签", + "animation-settings": "动画设置", + "animated-pie": "启用饼图动画(实验性)", + "border-settings": "边框设置", + "border-width": "边框宽度", + "border-color": "边框颜色", + "legend-settings": "图例设置", + "display-legend": "显示图例", + "labels-font-color": "标签字体颜色", + "series": "系列", + "add-series": "添加系列", + "series-settings": "系列设置", + "remove-series": "移除系列", + "no-series": "未配置系列", + "no-series-error": "至少应指定一个系列", + "chart-appearance": "图表外观", + "vertical-grid-lines": "垂直网格线", + "horizontal-grid-lines": "水平网格线", + "chart-background": "图表背景", + "grid-lines-color": "网格线颜色", + "border": "边框", + "axis": "轴", + "vertical-axis": "垂直轴", + "ticks": "刻度", + "horizontal-axis": "水平轴", + "shape-empty-circle": "空心圆", + "shape-circle": "圆形", + "shape-rect": "矩形", + "shape-round-rect": "圆角矩形", + "shape-triangle": "三角形", + "shape-diamond": "菱形", + "shape-pin": "图钉", + "shape-arrow": "箭头", + "shape-none": "无", + "line-type-solid": "实线", + "line-type-dashed": "虚线", + "line-type-dotted": "点线", + "label-position-top": "顶部", + "label-position-bottom": "底部", + "label-position-outside": "外部", + "label-position-inside": "内部", + "fill": "填充", + "fill-type-none": "无", + "fill-type-solid": "实心", + "fill-type-opacity": "不透明度", + "fill-type-gradient": "渐变", + "background": "背景", + "opacity": "不透明度", + "gradient-stops": "渐变节点", + "gradient-start": "起始", + "gradient-end": "结束", + "animation": { + "animation": "动画", + "animation-threshold": "动画阈值", + "animation-duration": "动画持续时间", + "animation-easing": "动画缓动", + "animation-delay": "动画延迟", + "update-animation-duration": "更新动画持续时间", + "update-animation-easing": "更新动画缓动", + "update-animation-delay": "更新动画延迟" + }, + "chart-axis": { + "limit": "限制", + "source": "来源", + "key-value": "键 / 值", + "value-required": "值为必填项。", + "entity-key-required": "实体键为必填项。", + "key-required": "键为必填项。", + "scale-limits": "刻度限制", + "scale-appearance": "刻度外观", + "scale": "刻度", + "scale-min": "最小", + "scale-max": "最大", + "scale-auto": "自动" + }, + "bar": { + "show-border": "显示边框", + "border-width": "边框宽度", + "border-radius": "边框圆角", + "bar-width": "柱宽", "label": "标签", - "value": "数值", - "date": "日期", - "show-date-time-interval": "显示间隔", - "show-date-time-interval-hint": "根据数据聚合显示时间间隔。", - "background-color": "背景颜色", - "background-blur": "背景模糊" - }, - "unit": { - "millimeter": "毫米", - "centimeter": "厘米", - "angstrom": "埃米", - "nanometer": "纳米", - "micrometer": "千分尺", - "meter": "米", - "kilometer": "公里", - "inch": "英寸", - "foot": "英尺", - "yard": "码", - "mile": "英里", - "nautical-mile": "海里", - "astronomical-unit": "天文单位", - "reciprocal-metre": "倒数米", - "meter-per-meter": "每米", - "steradian": "球面度", - "thou": "毫英寸", - "barleycorn": "1/3英寸", - "hand": "4英寸", - "chain": "66英尺", - "furlong": "660英尺", - "league": "3英里", - "fathom": "6英尺", - "cable": "平方毫米", - "link": "Link", - "rod": "杆", - "nanogram": "纳克", - "microgram": "微克", - "milligram": "毫克", - "gram": "克", - "kilogram": "千克", - "tonne": "吨", - "ounce": "盎司", - "pound": "磅", - "stone": "英石", - "hundredweight-count": "数百分点", - "short-tons": "短吨", - "dalton": "道尔顿", - "grain": "格令", - "drachm": "打兰", - "quarter": "一刻钟", - "slug": "斯勒格", - "carat": "克拉", - "cubic-millimeter": "立方毫米", - "cubic-centimeter": "立方厘米", - "cubic-meter": "立方米", - "cubic-kilometer": "立方千米", - "microliter": "微升", - "milliliter": "毫升", - "liter": "公升", - "hectoliter": "百升", - "cubic-inch": "立方英寸", - "cubic-foot": "立方英尺", - "cubic-yard": "立方英里", - "fluid-ounce": "液体盎司", - "pint": "品脱", - "quart": "夸脱", - "gallon": "加仑", - "oil-barrels": "油桶", - "cubic-meter-per-kilogram": "每公斤立方米", - "gill": "吉尔", - "hogshead": "大桶", - "teaspoon": "茶匙", - "tablespoon": "汤匙", - "cup": "杯", - "celsius": "摄氏度", - "kelvin": "开尔文", - "rankine": "兰金", - "fahrenheit": "华氏度", - "percent": "百分比", - "meter-per-second": "米每秒", - "kilometer-per-hour": "千米每小时", - "foot-per-second": "英尺每秒", - "mile-per-hour": "英里每小时", - "knot": "节", - "millimeters-per-minute": "毫米每分钟", - "kilometer-per-hour-squared": "千米每小时平方", - "foot-per-second-squared": "英尺每秒平方", - "pascal": "帕斯卡", - "kilopascal": "千帕", - "megapascal": "兆帕", - "gigapascal": "吉帕", - "millibar": "毫巴", - "bar": "巴", - "kilobar": "千巴", - "newton": "牛顿", - "newton-meter": "牛顿·米", - "foot-pounds": "英尺磅", - "inch-pounds": "英寸磅", - "newton-per-meter": "牛顿每米", - "atmospheres": "大气压", - "pounds-per-square-inch": "磅每平方英寸", - "torr": "托", - "inches-of-mercury": "英寸汞柱", - "pascal-per-square-meter": "帕斯卡每平方米", - "pound-per-square-inch": "磅每平方英寸", - "newton-per-square-meter": "牛顿每平方米", - "kilogram-force-per-square-meter": "千克力每平方米", - "pascal-per-square-centimeter": "帕斯卡每平方厘米", - "ton-force-per-square-inch": "吨力每平方英寸", - "kilonewton-per-square-meter": "千牛顿每平方米", - "newton-per-square-millimeter": "牛顿每平方毫米", - "microjoule": "微焦耳", - "millijoule": "毫焦耳", - "joule": "焦耳", - "kilojoule": "千焦耳", - "megajoule": "兆焦耳", - "gigajoule": "吉焦耳", - "watt-hour": "瓦时", - "kilowatt-hour": "千瓦时", - "electron-volts": "电子伏特", - "joules-per-coulomb": "焦耳每库仑", - "british-thermal-unit": "英热单位", - "foot-pound": "英尺磅", - "calorie": "卡路里", - "small-calorie": "小卡路里", - "kilocalorie": "千卡路里", - "joule-per-kelvin": "焦耳每开尔文", - "joule-per-kilogram-kelvin": "焦耳每千克开尔文", - "joule-per-kilogram": "焦耳每千克", - "watt-per-meter-kelvin": "瓦特每米开尔文", - "joule-per-cubic-meter": "焦耳每立方米", - "therm": "热单位", - "electric-dipole-moment": "电偶极矩", - "magnetic-dipole-moment": "磁偶极矩", - "debye": "德拜", - "coulomb-per-square-meter-per-volt": "库仑每平方米每伏特", - "milliwatt": "毫瓦", - "microwatt": "微瓦", - "watt": "瓦特", - "kilowatt": "千瓦", - "megawatt": "兆瓦", - "gigawatt": "吉瓦", - "metric-horsepower": "公制马力", - "milliwatt-per-square-centimeter": "毫瓦每平方厘米", - "watt-per-square-centimeter": "瓦特每平方厘米", - "kilowatt-per-square-centimeter": "千瓦每平方厘米", - "milliwatt-per-square-meter": "毫瓦每平方米", - "watt-per-square-meter": "瓦特每平方米", - "kilowatt-per-square-meter": "千瓦每平方米", - "watt-per-square-inch": "瓦特每平方英寸", - "kilowatt-per-square-inch": "千瓦每平方英寸", - "horsepower": "马力", - "btu-per-hour": "英热单位/小时", - "coulomb": "库仑", - "millicoulomb": "毫库仑", - "microcoulomb": "微库仑", - "picocoulomb": "皮库仑", - "coulomb-per-meter": "库仑每米", - "coulomb-per-cubic-meter": "库仑每立方米", - "coulomb-per-square-meter": "库仑每平方米", - "square-millimeter": "平方毫米", - "square-centimeter": "平方厘米", - "square-meter": "平方米", - "hectare": "公顷", - "square-kilometer": "平方千米", - "square-inch": "平方英寸", - "square-foot": "平方英尺", - "square-yard": "平方码", - "acre": "英亩", - "square-mile": "平方英里", - "are": "亚尔", - "barn": "巴恩", - "circular-inch": "圆英寸", - "milliampere-hour": "毫安时", - "milliampere-hour-tags": "电流, 电流流动, 电荷, 电流容量, 电流流, 电流流动, 毫安时, 毫安小时, mAh", - "ampere-hours": "安时", - "ampere-hours-tags": "电流, 电流流动, 电荷, 电流容量, 电流流, 电流流动, 安培, 安时, Ah", - "kiloampere-hours": "千安时", - "kiloampere-hours-tags": "电流, 电流流动, 电荷, 电流容量, 电流流, 电流流动, 千安时, 千安小时, kAh", - "nanoampere": "纳安培", - "nanoampere-tags": "电流, 安培, 纳安培, nA", - "picoampere": "皮安培", - "picoampere-tags": "电流, 安培, 皮安培, pA", - "microampere": "微安培", - "microampere-tags": "电流, 微安培, 微安培, μA", - "milliampere": "毫安培", - "milliampere-tags": "电流, 毫安培, 毫安培, mA", - "ampere": "安培", - "ampere-tags": "电流, 电流流动, 电流流, 电流流动, 安培, 安培, 电流强度, A", - "kiloamperes": "千安培", - "kiloamperes-tags": "电流, 电流流动, 千安培, kA", - "microampere-per-square-centimeter": "每平方厘米微安培", - "microampere-per-square-centimeter-tags": "电流密度, 每平方厘米微安培, µA/cm²", - "ampere-per-square-meter": "每平方米安培", - "ampere-per-square-meter-tags": "电流密度, 单位面积电流, 每平方米安培, A/m²", - "ampere-per-meter": "每米安培", - "ampere-per-meter-tags": "磁场强度, 磁场强度, 每米安培, A/m", - "oersted": "奥斯特", - "oersted-tags": "磁场, 奥斯特, Oe", - "bohr-magneton": "玻尔磁子", - "bohr-magneton-tags": "原子物理学, 磁矩, 玻尔磁子, μB", - "ampere-meter-squared": "安培·米平方", - "ampere-meter-squared-tags": "磁矩, 偶极矩, 安培·米平方, A·m²", - "ampere-meter": "安培·米", - "ampere-meter-tags": "磁场, 电流环, 安培·米, A·m", - "nanovolt": "纳伏特", - "picovolt": "皮伏特", - "millivolts": "毫伏特", - "microvolts": "微伏特", - "volt": "伏特", - "kilovolts": "千伏特", - "dbmV": "dBmV", - "dbm": "dBm", - "volt-meter": "伏特·米", - "kilovolt-meter": "千伏特·米", - "megavolt-meter": "兆伏特·米", - "microvolt-meter": "微伏特·米", - "millivolt-meter": "毫伏特·米", - "nanovolt-meter": "纳伏特·米", - "ohm": "欧姆", - "microohm": "微欧姆", - "milliohm": "毫欧姆", - "kilohm": "千欧姆", - "megohm": "兆欧姆", - "gigohm": "吉欧姆", - "hertz": "赫兹", - "kilohertz": "千赫兹", - "megahertz": "兆赫兹", - "gigahertz": "吉赫兹", - "rpm": "每分钟转速", - "candela-per-square-meter": "每平方米坎德拉", - "candela": "坎德拉", - "lumen": "流明", - "lux": "勒克斯", - "foot-candle": "英尺烛光", - "lumen-per-square-meter": "每平方米流明", - "lux-second": "勒克斯秒", - "lumen-second": "流明秒", - "lumens-per-watt": "每瓦流明", - "absorbance": "吸光度", - "mole": "摩尔", - "nanomole": "纳摩尔", - "micromole": "微摩尔", - "millimole": "毫摩尔", - "kilomole": "千摩尔", - "mole-per-cubic-meter": "每立方米摩尔", - "rssi": "接收信号强度指示", - "ppm": "百万分之一", - "ppb": "十亿分之一", - "micrograms-per-cubic-meter": "每立方米微克", - "aqi": "空气质量指数", - "gram-per-cubic-meter": "每立方米克", - "gram-per-kilogram": "比湿", - "millimeters-per-second": "每秒毫米", - "neper": "奈普尔", - "bel": "贝尔", - "decibel": "分贝", - "meters-per-second-squared": "每秒平方米", - "becquerel": "贝克勒尔", - "curie": "居里", - "gray": "戈瑞", - "sievert": "希沃特", - "roentgen": "伦琴", - "cps": "每秒计数", - "rad": "辐射剂量", - "rem": "辐射当量剂量", - "dps": "每秒衰变次数", - "rutherford": "卢瑟福", - "coulombs-per-kilogram": "每千克库仑", - "becquerels-per-cubic-meter": "每立方米贝可勒尔", - "curies-per-liter": "每升居里", - "becquerels-per-second": "每秒贝可勒尔", - "curies-per-second": "每秒居里", - "gy-per-second": "每秒戈瑞", - "watt-per-steradian": "每斯特拉迪安瓦特", - "watt-per-square-metre-steradian": "每平方米斯特拉迪安瓦特", - "ph-level": "pH值", - "turbidity": "浑浊度", - "mg-per-liter": "每升毫克", - "microsiemens-per-centimeter": "每厘米微西门子", - "millisiemens-per-meter": "每米毫西门子", - "siemens-per-meter": "每米西门子", - "kilogram-per-cubic-meter": "每立方米千克", - "gram-per-cubic-centimeter": "每立方厘米克", - "kilogram-per-square-meter": "每平方米千克", - "milligram-per-milliliter": "每毫升毫克", - "milligram-per-cubic-meter": "每立方米毫克", - "pound-per-cubic-foot": "每立方英尺磅", - "ounces-per-cubic-inch": "每立方英寸盎司", - "tons-per-cubic-yard": "每立方码吨", - "particle-density": "粒子密度", - "kilometers-per-liter": "每升公里", - "miles-per-gallon": "每加仑英里", - "liters-per-100-km": "每百公里升数", - "gallons-per-mile": "每英里加仑", - "liters-per-hour": "每小时升数", - "gallons-per-hour": "每小时加仑", - "beats-per-minute": "每分钟节拍数", - "millimeters-of-mercury": "毫米汞柱", - "milligrams-per-deciliter": "每分升毫克", - "g-force": "重力加速度", - "kilonewton": "千牛顿", - "kilogram-force": "千克力", - "pound-force": "磅力", - "kilopound-force": "千磅力", - "dyne": "达因", - "poundal": "磅顿", - "kip": "千磅力", - "gal": "加尔", - "gravity": "重力", - "hectopascal": "百帕斯卡", - "atmosphere": "大气压", - "millibars": "毫巴", - "inch-of-mercury": "一英寸汞柱", - "richter-scale": "里氏震级", - "second": "秒", - "minute": "分", - "hour": "小时", - "day": "天", - "week": "周", - "month": "月", - "year": "年", - "cubic-foot-per-minute": "每分钟立方英尺", - "cubic-meters-per-hour": "每小时立方米", - "cubic-meters-per-second": "每秒立方米", - "liter-per-second": "每秒升", - "liter-per-minute": "每分钟升", - "gallons-per-minute": "每分钟加仑", - "cubic-foot-per-second": "每秒立方英尺", - "milliliters-per-minute": "每分钟毫升", - "bit": "比特", - "byte": "字节", - "kilobyte": "千字节", - "megabyte": "兆字节", - "gigabyte": "吉字节", - "terabyte": "太字节", - "petabyte": "拍字节", - "exabyte": "艾字节", - "zettabyte": "泽字节", - "yottabyte": "尧字节", - "bit-per-second": "每秒比特", - "kilobit-per-second": "每秒千比特", - "megabit-per-second": "每秒兆比特", - "gigabit-per-second": "每秒吉比特", - "terabit-per-second": "每秒太比特", - "byte-per-second": "每秒字节", - "kilobyte-per-second": "每秒千字节", - "megabyte-per-second": "每秒兆字节", - "gigabyte-per-second": "每秒吉字节", - "degree": "度", - "radian": "弧度", - "gradian": "梯度", - "mil": "千分之一英寸", - "revolution": "转", - "siemens": "西门子", - "millisiemens": "毫西门子", - "microsiemens": "微西门子", - "kilosiemens": "千西门子", - "megasiemens": "兆西门子", - "gigasiemens": "吉西门子", - "farad": "法拉", - "millifarad": "毫法拉", - "microfarad": "微法拉", - "nanofarad": "纳法拉", - "picofarad": "皮法拉", - "kilofarad": "千法拉", - "megafarad": "兆法拉", - "gigafarad": "吉法拉", - "terfarad": "太法拉", - "farad-per-meter": "每米法拉", - "tesla": "特斯拉", - "gauss": "高斯", - "kilogauss": "千高斯", - "millitesla": "毫特斯拉", - "microtesla": "微特斯拉", - "nanotesla": "纳特斯拉", - "kilotesla": "千特斯拉", - "megatesla": "兆特斯拉", - "millitesla-square-meters": "毫特斯拉平方米", - "gamma": "伽马", - "lambda": "波长", - "square-meter-per-second": "每秒平方米", - "square-centimeter-per-second": "每秒平方厘米", - "stoke": "斯托克", - "centistokes": "厘斯托克", - "square-foot-per-second": "每秒平方英尺", - "square-inch-per-second": "每秒平方英寸", - "pascal-second": "帕斯卡秒", - "centipoise": "厘泊", - "poise": "泊", - "reynolds": "雷诺数", - "pound-per-foot-hour": "每英尺每小时磅", - "newton-second-per-square-meter": "每平方米牛顿秒", - "dyne-second-per-square-centimeter": "每平方厘米达因秒", - "kilogram-per-meter-second": "千克每米秒", - "tesla-square-meters": "特斯拉平方米", - "maxwell": "麦克斯韦", - "tesla-per-meter": "每米特斯拉", - "gauss-per-centimeter": "每厘米高斯", - "weber": "韦伯", - "microweber": "微韦伯", - "milliweber": "毫韦伯", - "gauss-square-centimeter": "高斯平方厘米", - "kilogauss-square-centimeter": "千高斯平方厘米", - "henry": "亨利", - "millihenry": "毫亨利", - "microhenry": "微亨利", - "nanohenry": "纳亨利", - "henry-per-meter": "每米亨利", - "tesla-meter-per-ampere": "每安培米特斯拉", - "gauss-per-oersted": "每欧斯特高斯", - "kilogram-per-mole": "每摩尔千克", - "gram-per-mole": "每摩尔克", - "milligram-per-mole": "每摩尔毫克", - "joule-per-mole": "每摩尔焦耳", - "joule-per-mole-kelvin": "每摩尔每开尔文焦耳", - "millivolts-per-meter": "每米毫伏", - "volts-per-meter": "每米伏特", - "kilovolts-per-meter": "每米千伏", - "radian-per-second": "每秒弧度", - "radian-per-second-squared": "每秒平方弧度", - "revolutions-per-minute-per-second": "角加速度", - "revolutions-per-minute-per-second-squared": "角加速度", - "deg-per-second": "度/秒", - "degrees-brix": "布里克度", - "katal": "卡塔尔", - "katal-per-cubic-metre": "每立方米卡塔尔" - }, - "user": { - "user": "用户", - "users": "用户", - "customer-users": "客户用户", - "tenant-admins": "租户管理员", - "sys-admin": "系统管理员", - "tenant-admin": "租户管理员", - "customer": "客户", - "anonymous": "匿名", - "add": "添加用户", - "delete": "删除用户", - "add-user-text": "添加用户", - "no-users-text": "未找到用户", - "user-details": "用户详细信息", - "delete-user-title": "确定要删除用户'{{userEmail}}' 吗?", - "delete-user-text": "请注意:确认后,用户和所有相关数据将不可恢复。", - "delete-users-title": "确定要删除{ count, plural, =1 {1 个用户} other {# 个用户} } 吗?", - "delete-users-action-title": "删除{ count, plural, =1 {1 个用户} other {# 个用户} }", - "delete-users-text": "请注意:确认后所有选定的用户将被删除,所有相关数据将不可恢复。", - "activation-email-sent-message": "激活电子邮件已成功发送!", - "resend-activation": "重新发送激活", - "email": "电子邮件", - "email-required": "电子邮件必填。", - "invalid-email-format": "无效的邮件格式。", - "first-name": "名字", - "last-name": "姓氏", - "description": "说明", - "default-dashboard": "默认面板", - "always-fullscreen": "始终全屏", - "select-user": "选择用户", - "no-users-matching": "未找到匹配'{{entity}}'的用户。", - "user-required": "用户必填", - "activation-method": "激活方式", - "display-activation-link": "显示激活链接", - "send-activation-mail": "发送激活邮件", - "activation-link": "用户激活链接", - "activation-link-text": "使用该链接激活激活用户:", - "copy-activation-link": "复制用户激活链接", - "activation-link-copied-message": "用户激活链接已经复制到粘贴板", - "details": "详情", - "login-as-tenant-admin": "以租户管理员身份登录", - "login-as-customer-user": "以客户用户身份登录", - "search": "查找用户", - "selected-users": "已选择{ count, plural, =1 {1 个用户} other {# 个用户} }", - "disable-account": "禁用用户帐户", - "enable-account": "启用用户帐户", - "enable-account-message": "已成功启用用户帐户!", - "disable-account-message": "已成功禁用用户帐户!", - "copyId": "复制用户ID", - "idCopiedMessage": "用户ID已复制到剪贴板", - "user-list": "用户列表", - "user-list-required": "用户列表必填。" - }, - "value": { - "type": "值类型", - "string": "字符串", - "string-value": "字符串值", - "string-value-required": "字符串值必填", - "integer": "整数", - "integer-value": "整数值", - "integer-value-required": "整数值必填", - "invalid-integer-value": "整数值无效", - "double": "双精度小数", - "double-value": "双精度小数值", - "double-value-required": "需要双精度值", - "boolean": "布尔值", - "boolean-value": "布尔值", - "false": "假", - "true": "真", - "long": "长整数", - "json": "JSON", - "json-value": "JSON值", - "json-value-invalid": "JSON值的格式无效", - "json-value-required": "JSON值必填。" - }, - "version-control": { - "version-control": "版本控制", - "management": "版本控制管理", - "search": "搜索版本", - "branch": "分支", - "default": "默认", - "select-branch": "选择分支", - "branch-required": "分支必填", - "create-entity-version": "创建实体版本", - "version-name": "版本名称", - "version-name-required": "版本名称必填", - "author": "作者", - "export-relations": "导出关联", - "export-attributes": "导出属性", - "export-credentials": "导出凭据", - "entity-versions": "实体版本", - "versions": "版本", - "created-time": "创建时间", - "version-id": "版本ID", - "no-entity-versions-text": "未找到实体版本", - "no-versions-text": "未找到版本", - "copy-full-version-id": "复制完整版本ID", - "create-version": "创建版本", - "creating-version": "请稍候,正在创建版本...", - "nothing-to-commit": "没有要提交的更改", - "restore-version": "还原版本", - "restore-entity-from-version": "从版本'{{versionName}}'还原实体", - "restoring-entity-version": "请稍候正在还原实体版本...", - "load-relations": "加载关联", - "load-attributes": "加载属性", - "load-credentials": "加载凭据", - "compare-with-current": "与当前比较", - "diff-entity-with-version": "与实体版本'{{versionName}}'不同", - "previous-difference": "上一个差异", - "next-difference": "下一个差异", - "current": "当前", - "differences": "{ count, plural, =1 {1 个差异} other {# 个差异} }", - "create-entities-version": "创建实体版本", - "default-sync-strategy": "默认同步策略", - "sync-strategy-merge": "合并", - "sync-strategy-overwrite": "覆盖", - "entities-to-export": "导出的实体", - "entities-to-restore": "还原的实体", - "sync-strategy": "同步策略", - "all-entities": "所有实体", - "no-entities-to-export-prompt": "请指定要导出的实体", - "no-entities-to-restore-prompt": "请指定要还原的实体", - "add-entity-type": "添加实体类型", - "remove-all": "全部删除", - "version-create-result": "{ added, plural, =0 {没有实体} =1 {1 个实体} other {# 个实体} } 被添加。
{ modified, plural, =0 {没有实体} =1 {1 个实体} other {# 个实体} }被修改。
{ removed, plural, =0 {没有实体} =1 {1 个实体} other {# 个实体} }被删除。", - "remove-other-entities": "删除其他实体", - "find-existing-entity-by-name": "按名称查找现有实体", - "restore-entities-from-version": "从版本'{{versionName}}'还原实体", - "restoring-entities-from-version": "请稍候正在还原实体...", - "no-entities-restored": "未还原任何实体", - "created": "{{created}}创建", - "updated": "{{updated}}更新", - "deleted": "{{deleted}}删除", - "remove-other-entities-confirm-text": "请注意!在还原版本中不存在的当前实体
将被永久删除。

请输入\"remove other entities\"进行确认。", - "auto-commit-to-branch": "自动提交到{{ branch }}分支", - "default-create-entity-version-name": "{{entityName}}更新", - "sync-strategy-merge-hint": "创建或更新选定的实体,仓库其他实体均不修改。", - "sync-strategy-overwrite-hint": "创建或更新选定的实体,仓库其他实体将被删除。", - "device-credentials-conflict": "无法加载外部ID为{{entityId}} 的设备
因为数据库中已存在相同的凭据。
请考虑禁用还原表单中的 加载凭据 设置。", - "missing-referenced-entity": "无法加载外部ID为{{sourceEntityId}} 的 {{sourceEntityTypeName}}
因为它引用了缺失的 {{targetEntityTypeName}} (ID:{{targetEntityId}}).", - "runtime-failed": "失败:{{message}}", - "auto-commit-settings-read-only-hint": "在仓库设置中启用只读选项后,'自动提交'功能将无法正常工作。", - "rollback-on-error": "回滚错误", - "rollback-on-error-hint": "如果您有大量实体需要恢复请考虑禁用此选项以提高性能。\n请注意如果在版本加载过程中发生错误,已经持久化的实体(具有关系、属性等)将不发生修改。" - }, - "widget": { - "widget-library": "部件库", - "widget-bundle": "部件包", - "all-bundles": "所有包", - "select-widgets-bundle": "选择部件包", - "widgets": "部件", - "all-widgets": "所有部件", - "widget": "部件", - "select-widget": "选择部件", - "no-widgets-matching": "未找到与'{{entity}}匹配的部件。", - "no-widgets": "暂无部件", - "no-widgets-text": "未找到部件", - "management": "管理部件", - "editor": "部件编辑器", - "confirm-to-exit-editor-html": "有未保存的部件设置
确定要离开此页面吗?", - "widget-type-not-found": "加载部件配置出错
可能关联的部件已经删除了。", - "widget-type-load-error": "由于以下错误未加载部件:", - "remove": "删除部件", - "delete": "删除部件", - "edit": "编辑部件", - "remove-widget-title": "确定要删除'{{widgetTitle}}'部件吗?", - "remove-widget-text": "确认后所有相关数据将变得不可恢复。", - "replace-reference-with-widget-copy": "基于部件复制替换引用", - "timeseries": "时间序列", - "search-data": "查找数据", - "no-data-found": "未找到数据", - "latest": "最新值", - "rpc": "控件部件", - "alarm": "告警部件", - "static": "静态部件", - "timeseries-short": "时序", - "latest-short": "最新", - "rpc-short": "控制", - "alarm-short": "告警", - "static-short": "静态", - "select-widget-type": "选择窗口部件类型", - "missing-widget-title-error": "部件标题必须指定!", - "widget-saved": "部件已保存", - "unable-to-save-widget-error": "控件有错误无法保存部件!", - "save": "保存部件", - "saveAs": "部件另存为", - "move": "移动部件", - "save-widget-as": "部件另存为", - "save-widget-as-text": "请输入部件标题", - "toggle-fullscreen": "全屏", - "run": "运行", - "widget-title": "部件标题", - "title": "部件标题", - "title-required": "部件标题必填。", - "title-max-length": "标题必须小于256个字符", - "system": "系统", - "type": "类型", - "resources": "资源", - "resource-url": "JavaScript/CSS", - "resource-is-extension": "是否扩展", - "remove-resource": "删除资源", - "add-resource": "添加资源", - "html": "HTML", - "tidy": "整理", - "css": "CSS", - "settings-form": "设置表单", - "data-key-settings-form": "数据键设置表单", - "latest-data-key-settings-form": "最新数据键设置表单", - "widget-settings": "设置", - "description": "描述", - "tags": "标签", - "image-preview": "图片预览", - "settings-form-selector": "设置表单选择器", - "data-key-settings-form-selector": "数据键设置表单选择器", - "latest-data-key-settings-form-selector": "最新值数据键设置表单选择器", - "all": "全部", - "actual": "实际", - "scada": "组态", - "deprecated": "弃用", - "has-basic-mode": "基础模式", - "basic-mode-form-selector": "表单选择器", - "basic-mode": "基础", - "advanced-mode": "高级", - "javascript": "JavaScript", - "js": "JS", - "delete-widget-title": "确定要删除部件'{{widgetName}}'吗?", - "delete-widget-text": "确认后部件及其所有相关数据将无法恢复。", - "delete-widgets-title": "确定要删除{ count, plural, =1 {1个部件} other {#个部件} }吗?", - "delete-widgets-text": "请谨慎操作确认后,所有选定的部件将被删除并且所有相关数据将无法恢复。", - "delete-widget": "删除部件", - "widget-template-load-failed-error": "无法加载部件模板!", - "details": "详情", - "widget-details": "部件详情", - "add": "添加", - "add-existing-widget": "添加现有部件", - "add-new-widget": "添加新部件", - "search-widgets": "搜索部件", - "selected-widgets": "已选择{ count, plural, =1 {1 个部件} other {# 个部件} }", - "undo": "撤销", - "export": "导出", - "export-prompt": "嵌入部件图片和资源", - "export-widgets": "导出部件", - "export-widgets-prompt": "嵌入部件图片和资源", - "import": "导入部件", - "no-data": "部件上没有要显示的数据", - "data-overflow": "部件显示{{count}}条实体中的{{total}}条。", - "alarm-data-overflow": "部件显示了{{allowedEntities}}(最大允许)实体中的告警,总共有{{totalEntities}}个实体", - "search": "搜索", - "filter": "过滤", - "loading-widgets": "加载部件...", - "widget-template-error": "无效HTML模板部件。", - "reference": "引用" + "label-hint": "在柱上方显示标签。", + "series-label-hint": "在柱上方显示带数值的标签。", + "label-background": "标签背景" + } }, - "widget-action": { - "header-button": "顶部按钮", - "do-nothing": "无任何动作", - "open-dashboard-state": "切换仪表板状态", - "update-dashboard-state": "更新仪表板状态", - "open-dashboard": "打开仪表板", - "custom": "自定义动作", - "custom-pretty": "自定义动作(HTML模板)", - "custom-pretty-error-title": "自定义对话框错误", - "custom-pretty-template-error": "无效的自定义对话框模板。", - "custom-pretty-controller-error": "自定义对话框函数错误。", - "mobile-action": "移动端动作", - "target-dashboard-state": "目标仪表板状态", - "target-dashboard-state-required": "目标仪表板状态必填", - "set-entity-from-widget": "从部件中设置实体", - "target-dashboard": "目标仪表板", - "select-target-dashboard": "选择目标仪表板", - "target-dashboard-required": "必须选择目标仪表板。", - "open-right-layout": "打开右侧布局 (移动端视图)", - "state-display-type": "显示仪表板状态选项", - "open-normal": "普通", - "open-in-separate-dialog": "在单独的对话框中打开", - "open-in-popover": "在弹出框中打开", - "dialog-title": "对话框标题", - "dialog-hide-dashboard-toolbar": "在对话框中隐藏仪表板工具栏", - "dialog-width": "对话框宽度相对于视口宽度的百分比", - "dialog-height": "对话框高度相对于视口高度的百分比", - "dialog-size-range-error": "对话框的大小百分比值应该介于1到100之间。", - "popover-preferred-placement": "首选的弹出框位置", - "popover-placement-top": "顶部", - "popover-placement-topLeft": "上左", - "popover-placement-topRight": "上右", - "popover-placement-right": "右侧", - "popover-placement-rightTop": "右上", - "popover-placement-rightBottom": "右下", - "popover-placement-bottom": "底部", - "popover-placement-bottomLeft": "下左", - "popover-placement-bottomRight": "下右", - "popover-placement-left": "左侧", - "popover-placement-leftTop": "左上", - "popover-placement-leftBottom": "左下", - "popover-hide-on-click-outside": "在点击弹出框外部时隐藏弹出框", - "popover-hide-dashboard-toolbar": "在弹出框中隐藏仪表板工具栏", - "popover-width": "宽度", - "popover-height": "高度", - "popover-style": "样式", - "open-new-browser-tab": "在选项卡中打开", - "open-URL": "打开URL", - "URL": "URL", - "url-required": "URL必填。", - "mobile": { - "action-type": "移动端动作类型", - "select-action-type": "选择移动端操作类型", - "action-type-required": "移动端动作类型必填。", - "take-picture-from-gallery": "从图库中获取照片", - "take-photo": "拍照", - "map-direction": "打开地图导航", - "map-location": "打开地图位置", - "scan-qr-code": "扫描二维码", - "make-phone-call": "拨打电话", - "get-location": "获取手机位置", - "take-screenshot": "截屏" - }, - "custom-action-function": "自定义动作函数", - "custom-pretty-function": "自定义动作(HTML模板)" - }, - "widgets-bundle": { - "current": "当前组", - "widgets-bundles": "部件包", - "widgets-bundle-widgets": "包部件", - "add": "添加部件包", - "delete": "删除部件包", - "title": "标题", - "title-required": "标题必填。", - "title-max-length": "标题长度应该小于256个字符。", - "description": "描述", - "image-preview": "图片预览", - "scada": "SCADA部件包", - "order": "其他", - "add-widgets-bundle-text": "添加部件包", - "no-widgets-bundles-text": "未找到部件包", - "empty": "部件包是空的", - "details": "详情", - "widgets-bundle-details": "部件包详细信息", - "delete-widgets-bundle-title": "确定要删除部件包'{{widgetsBundleTitle}}'吗?", - "delete-widgets-bundle-text": "请注意:确认后部件包和所有相关数据将不可恢复。", - "delete-widgets-bundles-title": "确定要删除{ count, plural, =1 {1 个部件包} other {# 个部件包} }吗?", - "delete-widgets-bundles-action-title": "删除{ count, plural, =1 {1 个部件包} other {# 个部件包} }", - "delete-widgets-bundles-text": "请注意:确认后,所有选定的部件包将被删除,所有相关数据将不可恢复。", - "no-widgets-bundles-matching": "未找到与'{{widgetsBundle}}'匹配的部件包。", - "widgets-bundle-required": "部件包必填。", - "system": "系统", - "import": "导入部件包", - "export": "导出部件包", - "export-widgets-bundle-widgets-prompt": "在导出的数据中包含包部件(否则只会导出引用的部件完全限定名)", - "export-failed-error": "无法导出部件包: {{error}}", - "create-new-widgets-bundle": "创建部件包", - "widgets-bundle-file": "部件包文件", - "invalid-widgets-bundle-file-error": "无法导入部件包:无效的部件包数据结构。", - "search": "查找部件包", - "selected-widgets-bundles": "已选择{ count, plural, =1 {1 个部件包} other {# 个部件包} }", - "open-widgets-bundle": "打开部件包", - "loading-widgets-bundles": "加载部件包...", - "create-new": "创建新部件包" - }, - "widget-config": { - "data": "数据", - "settings": "设置", - "advanced": "高级", - "appearance": "外观", - "widget-card": "卡片", - "mobile": "移动设备", - "title": "标题", - "title-tooltip": "标题提示文字", - "general-settings": "常规设置", - "display-title": "显示", - "card-title": "标题", - "drop-shadow": "投影", - "enable-fullscreen": "启用全屏", - "background-color": "背景颜色", - "text-color": "文字颜色", - "border-radius": "边框半径", - "padding": "填充", - "margin": "边缘", - "widget-style": "部件样式", - "widget-css": "部件CSS", - "title-style": "标题样式", - "mobile-mode-settings": "移动端设置", - "order": "顺序", - "height": "高度", - "mobile-hide": "在移动端隐藏部件", - "desktop-hide": "在桌面端隐藏部件", - "units": "特殊符号展示值", - "units-by-default": "默认单位", - "decimals": "小数位数", - "decimals-by-default": "默认小数位数", - "default-data-key-parameter-hint": "该参数适用于所有部件值,除非被数据键配置覆盖。", - "units-short": "单位", - "decimals-short": "小数", - "decimals-suffix": "位小数", - "timewindow": "时间窗口", - "use-dashboard-timewindow": "使用仪表板时间窗口", - "use-widget-timewindow": "使用部件时间窗口", - "display-timewindow": "显示时间窗口", - "legend": "图例", - "display-legend": "显示图例", - "datasources": "数据源", - "datasource": "数据源", - "maximum-datasources": "最大允许{ count, plural, =1 {1 个数据源。} other {# 个数据源。} }", - "timeseries-key-error": "需要至少指定一个timeseries数据键", - "datasource-type": "类型", - "datasource-parameters": "参数", - "remove-datasource": "移除数据源", - "add-datasource": "添加数据源", - "target-device": "目标设备", - "alarm-source": "告警源", - "actions": "动作", - "action": "动作", - "add-action": "添加动作", - "search-actions": "搜索动作", - "no-actions-text": "找不到动作", - "action-source": "动作源", - "select-action-source": "选择动作源", - "action-source-required": "动作源必填", - "column-index": "序号", - "select-column-index": "选择序号", - "column-index-required": "序号必填。", - "not-set": "未设置", - "action-name": "名称", - "action-name-required": "动作名称必填。", - "action-name-not-unique": "动作名称已经存在。", - "action-icon": "图标", - "show-hide-action-using-function": "使用函数显示/隐藏动作", - "show-action-function": "显示动作函数", - "action-type": "类型", - "action-type-required": "类型必填", - "edit-action": "编辑动作", - "delete-action": "删除动作", - "delete-action-title": "删除部件动作", - "delete-action-text": "确定要删除部件动作'{{actionName}}'吗?", - "title-icon": "标题图标", - "display-icon": "显示标题图标", - "card-icon": "图标", + "color": { + "color-settings": "颜色设置", + "color-type-constant": "常量", + "color-type-gradient": "渐变", + "color-type-range": "范围", + "color-type-function": "函数", + "color": "颜色", + "value-range": "值范围", + "from": "从", + "to": "到", + "color-function": "颜色函数", + "copy-color-settings-from": "从...复制颜色设置", + "copy-from": "从...复制", + "settings-type": "设置类型", + "basic-mode": "基础", + "advanced-mode": "高级", + "entity-alias": "实体别名", + "entity-attribute": "实体属性", + "gradient-color": "渐变颜色", + "gradient-color-min": "颜色", + "gradient-start": "渐变起始颜色", + "gradient-start-min": "起始", + "gradient-end": "渐变结束颜色", + "gradient-end-min": "结束", + "start-value": "起始值", + "end-value": "结束值", + "gradient-type": "渐变类型" + }, + "dashboard-state": { + "dashboard-state-settings": "仪表板状态设置", + "dashboard-state": "仪表板状态 ID", + "autofill-state-layout": "默认自动填充状态布局高度", + "default-margin": "默认部件间距", + "default-background-color": "默认背景颜色", + "sync-parent-state-params": "与父仪表板同步状态参数" + }, + "date-range-navigator": { + "date-range-picker-settings": "日期范围选择器设置", + "hide-date-range-picker": "隐藏日期范围选择器", + "picker-one-panel": "日期范围选择器单面板", + "picker-auto-confirm": "日期范围选择器自动确认", + "picker-show-template": "日期范围选择器显示模板", + "first-day-of-week": "每周第一天", + "interval-settings": "区间设置", + "hide-interval": "隐藏区间", + "initial-interval": "初始区间", + "interval-hour": "小时", + "interval-day": "天", + "interval-week": "周", + "interval-two-weeks": "2 周", + "interval-month": "月", + "interval-three-months": "3 个月", + "interval-six-months": "6 个月", + "step-settings": "步长设置", + "hide-step-size": "隐藏步长", + "initial-step-size": "初始步长", + "hide-labels": "隐藏标签", + "use-session-storage": "使用会话存储", + "localizationMap": { + "Sun": "日", + "Mon": "一", + "Tue": "二", + "Wed": "三", + "Thu": "四", + "Fri": "五", + "Sat": "六", + "Jan": "1月", + "Feb": "2月", + "Mar": "3月", + "Apr": "4月", + "May": "5月", + "Jun": "6月", + "Jul": "7月", + "Aug": "8月", + "Sep": "9月", + "Oct": "10月", + "Nov": "11月", + "Dec": "12月", + "January": "一月", + "February": "二月", + "March": "三月", + "April": "四月", + "June": "六月", + "July": "七月", + "August": "八月", + "September": "九月", + "October": "十月", + "November": "十一月", + "December": "十二月", + "Custom Date Range": "自定义日期范围", + "Date Range Template": "日期范围模板", + "Today": "今天", + "Yesterday": "昨天", + "This Week": "本周", + "Last Week": "上周", + "This Month": "本月", + "Last Month": "上月", + "Year": "年", + "This Year": "今年", + "Last Year": "去年", + "Date picker": "日期选择器", + "Hour": "小时", + "Day": "天", + "Week": "周", + "2 weeks": "2 周", + "Month": "月", + "3 months": "3 个月", + "6 months": "6 个月", + "Custom interval": "自定义区间", + "Interval": "区间", + "Step size": "步长", + "Ok": "确定" + } + }, + "doughnut": { + "doughnut-appearance": "环形图外观", + "layout": "布局", + "layout-default": "默认", + "layout-with-total": "显示总计", + "central-total-value": "中心总计值", + "doughnut-card-style": "环形图卡片样式" + }, + "entities-hierarchy": { + "hierarchy-data-settings": "层级数据设置", + "relations-query-function": "节点关联查询函数", + "has-children-function": "节点有子节点函数", + "node-state-settings": "节点状态设置", + "node-opened-function": "默认节点展开函数", + "node-disabled-function": "节点禁用函数", + "display-settings": "显示设置", + "node-icon-function": "节点图标函数", + "node-text-function": "节点文本函数", + "sort-settings": "排序设置", + "nodes-sort-function": "节点排序函数" + }, + "edge": { + "display-default-title": "显示默认标题" + }, + "gateway": { + "general-settings": "常规设置", + "widget-title": "部件标题", + "default-archive-file-name": "默认归档文件名", + "device-type-for-new-gateway": "新 Gateway 的设备类型", + "messages-settings": "消息设置", + "save-config-success-message": "Gateway 配置保存成功的文本消息", + "device-name-exists-message": "输入的设备名称已存在时的文本消息", + "gateway-title": "Gateway 表单", + "read-only": "只读", + "events-title": "Gateway 事件表单标题", + "events-filter": "事件过滤器", + "event-key-contains": "事件键包含...", + "show-connector": "为连接器显示", + "connector-state-param-key": "连接器状态参数键", + "message": "消息", + "level": "级别", + "created-time": "创建时间" + }, + "gauge": { + "default-color": "默认颜色", + "radial-gauge-settings": "径向仪表设置", + "ticks-settings": "刻度设置", + "min-value": "最小值", + "max-value": "最大值", + "min-value-short": "最小", + "max-value-short": "最大", + "start-ticks-angle": "起始刻度角度", + "ticks-angle": "刻度角度", + "major-ticks": "主刻度", + "major-ticks-count": "主刻度数量", + "major-ticks-color": "主刻度颜色", + "minor-ticks": "次刻度", + "minor-ticks-count": "次刻度数量", + "minor-ticks-color": "次刻度颜色", + "tick-numbers-font": "刻度数字字体", + "unit-title-settings": "单位标题设置", + "show-unit-title": "单位标题", + "unit-title": "单位标题", + "title-font": "标题文本字体", + "units-settings": "单位设置", + "units-font": "单位文本字体", + "value-box-settings": "值框设置", + "show-value-box": "显示值框", + "value-box": "值框", + "value-int": "值整数部分的位数", + "value-text": "值文本", + "value-text-shadow": "值文本阴影", + "value-font": "值文本字体", + "rect-stroke-color-start": "矩形描边颜色 - 起始渐变", + "rect-stroke-color-end": "矩形描边颜色 - 结束渐变", + "background-color": "背景颜色", + "shadow-color": "阴影颜色", + "value-box-rect-stroke-color": "值框矩形描边颜色", + "value-box-rect-stroke-color-end": "值框矩形描边颜色 - 结束渐变", + "value-box-background-color": "值框背景颜色", + "value-box-shadow-color": "值框阴影颜色", + "plate-settings": "表盘设置", + "show-plate-border": "表盘边框", + "plate-color": "表盘颜色", + "needle-settings": "指针设置", + "needle-circle-size": "指针圆圈大小", + "needle-color": "指针颜色", + "needle-color-start": "指针颜色 - 起始渐变", + "needle-color-end": "指针颜色 - 结束渐变", + "needle-color-shadow-up": "指针上半部分阴影颜色", + "needle-color-shadow-down": "投影", + "highlights-settings": "高亮设置", + "highlights-width": "高亮宽度", + "highlights": "高亮", + "highlight-from": "起始", + "highlight-to": "结束", + "highlight-color": "颜色", + "no-highlights": "未配置高亮", + "add-highlight": "添加高亮", + "animation-settings": "动画设置", + "enable-animation": "动画", + "animation-duration-rule": "动画持续时间和规则", + "animation-duration": "动画持续时间", + "animation-rule": "动画规则", + "animation-linear": "线性", + "animation-quad": "二次", + "animation-quint": "五次", + "animation-cycle": "循环", + "animation-bounce": "弹跳", + "animation-elastic": "弹性", + "animation-dequad": "反二次", + "animation-dequint": "反五次", + "animation-decycle": "反循环", + "animation-debounce": "反弹跳", + "animation-delastic": "反弹性", + "linear-gauge-settings": "线性仪表设置", + "bar-stroke": "条形描边", + "bar-stroke-width": "条形描边宽度", + "bar-stroke-color": "条形描边颜色", + "bar-background-color": "条形背景颜色 - 起始渐变", + "bar-background-color-end": "条形背景颜色 - 结束渐变", + "progress-bar-color": "进度条颜色", + "progress-bar": "进度条", + "progress-bar-color-start": "进度条颜色 - 起始渐变", + "progress-bar-color-end": "进度条颜色 - 结束渐变", + "major-ticks-names": "主刻度名称", + "show-stroke-ticks": "显示刻度描边", + "major-ticks-font": "主刻度字体", + "border-color": "边框颜色", + "border-width": "边框宽度", + "needle-circle": "指针圆圈", + "needle-circle-color": "指针圆圈颜色", + "animation-target": "动画目标", + "animation-target-needle": "指针", + "animation-target-plate": "表盘", + "common-settings": "通用仪表设置", + "gauge-type": "仪表类型", + "gauge-type-arc": "弧形", + "gauge-type-donut": "环形", + "gauge-type-horizontal-bar": "水平条形", + "gauge-type-vertical-bar": "垂直条形", + "donut-start-angle": "起始角度(度)", + "bar-settings": "仪表条形设置", + "relative-bar-width": "相对条形宽度", + "neon-glow-brightness": "霓虹发光效果亮度(0-100)", + "neon-glow-brightness-hint": "0 - 禁用效果 ", + "stripes-thickness": "条纹厚度", + "stripes-thickness-hint": "0 - 无条纹", + "rounded-line-cap": "圆角线帽", + "bar-color-settings": "条形颜色设置", + "use-precise-level-color-values": "使用精确颜色级别", + "bar-colors": "条形颜色,从低到高", + "color": "颜色", + "no-bar-colors": "未配置条形颜色", + "add-bar-color": "添加条形颜色", + "from": "起始", + "to": "结束", + "fixed-level-colors": "使用边界值的条形颜色", + "gauge-title-settings": "仪表标题设置", + "show-gauge-title": "显示仪表标题", + "gauge-title": "仪表标题", + "gauge-title-font": "仪表标题字体", + "unit-title-and-timestamp-settings": "单位标题和时间戳设置", + "show-timestamp": "时间戳", + "timestamp-format": "时间戳格式", + "label-font": "值下方标签的字体", + "value-settings": "值设置", + "show-value": "显示值文本", + "min-max-settings": "最小/最大标签设置", + "show-min-max": "显示最小值和最大值", + "min-max-font": "最小和最大标签的字体", + "show-ticks": "显示刻度", + "tick-width": "刻度宽度", + "tick-color": "刻度颜色", + "tick-values": "刻度值", + "no-tick-values": "未配置刻度值", + "add-tick-value": "添加刻度值", + "gauge-appearance": "仪表外观", + "units-title": "单位标题", + "value": "值", + "ticks": "刻度", + "arrow-and-scale-color": "箭头和刻度默认颜色", + "scale-settings": "刻度设置", + "scale": "刻度", + "scale-color": "刻度颜色", + "compass-appearance": "罗盘外观", + "label": "标签", + "labels": "标签", + "label-style": "标签样式", + "simple-gauge-type": "类型", + "gauge-bar-background": "仪表条形背景", + "bar-color": "条形颜色", + "min-and-max-value": "最小值和最大值", + "min-and-max-label": "最小和最大标签", + "font": "字体", + "tick-width-and-color": "刻度宽度和颜色", + "min-max-validation-text": "最大值必须大于最小值" + }, + "gpio": { + "pin": "引脚", + "label": "标签", + "row": "行", + "column": "列", + "color": "颜色", + "panel-settings": "面板设置", + "background-color": "背景颜色", + "gpio-switches": "GPIO 开关", + "no-gpio-switches": "未配置 GPIO 开关", + "add-gpio-switch": "添加 GPIO 开关", + "gpio-status-request": "GPIO 状态请求", + "method-name": "方法名称", + "method-body": "方法体", + "gpio-status-change-request": "GPIO 状态更改请求", + "parse-gpio-status-function": "解析 GPIO 状态函数", + "gpio-leds": "GPIO LED", + "no-gpio-leds": "未配置 GPIO LED", + "add-gpio-led": "添加 GPIO LED" + }, + "html-card": { + "html": "HTML", + "css": "CSS" + }, + "input-widgets": { + "attribute-not-allowed": "属性参数不能在此部件中使用", + "blocked-location": "地理定位已在您的浏览器中被阻止", + "claim-device": "认领设备", + "claim-failed": "认领设备失败!", + "claim-not-found": "未找到设备!", + "claim-successful": "设备认领成功!", + "date": "日期", + "device-name": "设备名称", + "device-name-required": "设备名称为必填项", + "discard-changes": "放弃更改", + "entity-attribute-required": "实体属性为必填项", + "entity-coordinate-required": "纬度和经度两个字段均为必填项", + "entity-timeseries-required": "实体时间序列为必填项", + "get-location": "获取当前位置", + "invalid-date": "无效日期", + "latitude": "纬度", + "longitude": "经度", + "min-value-error": "最小值为 {{value}}", + "max-value-error": "最大值为 {{value}}", + "not-allowed-entity": "所选实体不能拥有共享属性", + "no-attribute-selected": "未选择属性", + "no-datakey-selected": "未选择数据键", + "no-coordinate-specified": "未指定纬度/经度的数据键", + "no-entity-selected": "未选择实体", + "no-image": "无图片", + "no-support-geolocation": "您的浏览器不支持地理定位", + "no-support-web-camera": "您的浏览器不支持摄像头", + "enable-https-use-widget": "请启用 HTTPS 以使用此部件", + "no-found-your-camera": "找不到您的摄像头", + "no-permission-camera": "用户拒绝了权限 / 此站点没有使用摄像头的权限", + "no-timeseries-selected": "未选择时间序列", + "secret-key": "密钥", + "secret-key-required": "密钥为必填项", + "switch-attribute-value": "切换实体属性值", + "switch-camera": "切换摄像头", + "switch-timeseries-value": "切换实体时间序列值", + "take-photo": "拍照", + "time": "时间", + "timeseries-not-allowed": "时间序列参数不能在此部件中使用", + "update-failed": "更新失败", + "update-successful": "更新成功", + "update-attribute": "更新属性", + "update-timeseries": "更新时间序列", + "value": "值", + "general-settings": "常规设置", + "widget-title": "部件标题", + "claim-button-label": "认领按钮标签", + "show-secret-key-field": "显示“密钥”输入字段", + "labels-settings": "标签设置", + "show-labels": "显示标签", + "device-name-label": "设备名称输入字段的标签", + "secret-key-label": "密钥输入字段的标签", + "messages-settings": "消息设置", + "claim-device-success-message": "设备认领成功的文本消息", + "claim-device-not-found-message": "未找到设备时的文本消息", + "claim-device-failed-message": "设备认领失败的文本消息", + "claim-device-name-required-message": "“设备名称必填”错误消息", + "claim-device-secret-key-required-message": "“密钥必填”错误消息", + "show-label": "显示标签", + "label": "标签", + "required": "必填", + "required-error-message": "“必填”错误消息", + "show-result-message": "显示结果消息", + "integer-field-settings": "整数字段设置", + "min-value": "最小值", + "max-value": "最大值", + "double-field-settings": "双精度字段设置", + "text-field-settings": "文本字段设置", + "min-length": "最小长度", + "max-length": "最大长度", + "checkbox-settings": "复选框设置", + "true-label": "选中时标签", + "false-label": "未选中时标签", + "image-input-settings": "图片输入设置", + "display-preview": "显示预览", + "display-clear-button": "显示清除按钮", + "display-apply-button": "显示应用按钮", + "display-discard-button": "显示放弃按钮", + "datetime-field-settings": "日期/时间字段设置", + "display-time-input": "显示时间输入", + "latitude-key-name": "纬度键名称", + "longitude-key-name": "经度键名称", + "show-get-location-button": "显示“获取当前位置”按钮", + "use-high-accuracy": "使用高精度", + "location-fields-settings": "位置字段设置", + "latitude-label": "纬度标签", + "longitude-label": "经度标签", + "input-fields-alignment": "输入字段对齐", + "input-fields-alignment-column": "列(默认)", + "input-fields-alignment-row": "行", + "layout": "布局", + "row-gap": "行间距(像素)", + "column-gap": "列间距(像素)", + "latitude-field-required": "纬度字段为必填项", + "longitude-field-required": "经度字段为必填项", + "attribute-settings": "属性设置", + "widget-mode": "部件模式", + "widget-mode-update-attribute": "更新属性", + "widget-mode-update-timeseries": "更新时间序列", + "attribute-scope": "属性范围", + "attribute-scope-server": "服务端属性", + "attribute-scope-shared": "共享属性", + "value-required": "值为必填项", + "image-settings": "图片输出设置", + "image-format": "图片格式", + "image-format-jpeg": "JPEG", + "image-format-png": "PNG", + "image-format-webp": "WEBP", + "image-quality": "图片质量", + "max-image-width": "最大宽度", + "max-image-height": "最大高度", + "action-buttons": "操作按钮", + "show-action-buttons": "显示操作按钮", + "update-all-values": "更新所有值,而不仅仅是已修改的值", + "save-button-label": "“保存”按钮标签", + "reset-button-label": "“撤销”按钮标签", + "group-settings": "分组设置", + "show-group-title": "显示与不同实体相关的字段组标题", + "group-title": "组标题", + "fields-alignment": "字段对齐", + "fields-alignment-row": "行(默认)", + "fields-alignment-column": "列", + "fields-in-row": "每行字段数量", + "option-value": "值(输入‘null’以创建空选项)", + "option-label": "标签", + "hide-input-field": "隐藏输入字段", + "datakey-type": "数据键类型", + "datakey-type-server": "服务端属性(默认)", + "datakey-type-shared": "共享属性", + "datakey-type-timeseries": "时间序列", + "datakey-value-type": "数据键值类型", + "datakey-value-type-string": "字符串", + "datakey-value-type-double": "双精度", + "datakey-value-type-integer": "整数", + "datakey-value-type-json": "JSON", + "datakey-value-type-boolean-checkbox": "布尔值(复选框)", + "datakey-value-type-boolean-switch": "布尔值(开关)", + "datakey-value-type-date-time": "日期和时间", + "datakey-value-type-date": "日期", + "datakey-value-type-time": "时间", + "datakey-value-type-select": "下拉选择", + "datakey-value-type-radio": "单选按钮", + "datakey-value-type-color": "颜色", + "value-is-required": "值为必填项", + "ability-to-edit-attribute": "属性编辑能力", + "ability-to-edit-attribute-editable": "可编辑(默认)", + "ability-to-edit-attribute-disabled": "禁用", + "ability-to-edit-attribute-readonly": "只读", + "disable-on-datakey-name": "在另一个数据键值为 false 时禁用(指定数据键名称)", + "field-appearance": "字段外观", + "appearance-fill": "填充", + "appearance-outline": "轮廓", + "subscript-sizing": "下标大小", + "subscript-sizing-fixed": "固定", + "subscript-sizing-dynamic": "动态", + "slide-toggle-settings": "滑动开关设置", + "slide-toggle-label-position": "滑动开关标签位置", + "slide-toggle-label-position-after": "之后", + "slide-toggle-label-position-before": "之前", + "select-options": "下拉选项", + "no-select-options": "未配置下拉选项", + "add-select-option": "添加下拉选项", + "numeric-field-settings": "数值字段设置", + "step-interval": "值之间的步进间隔", + "error-messages": "错误消息", + "min-value-error-message": "“最小值”错误消息", + "max-value-error-message": "“最大值”错误消息", + "invalid-date-error-message": "“无效日期”错误消息", + "invalid-JSON-error-message": "“无效 JSON”错误消息", + "icon-settings": "图标设置", + "dialog-editor-settings": "对话框编辑器设置", + "use-custom-icon": "使用自定义图标", + "input-cell-icon": "输入单元格前显示的图标", + "value-conversion-settings": "值转换设置", + "get-value-settings": "获取值设置", + "use-get-value-function": "使用 getValue 函数", + "get-value-function": "getValue 函数", + "set-value-settings": "设置值设置", + "use-set-value-function": "使用 setValue 函数", + "set-value-function": "setValue 函数", + "json-invalid": "JSON 值格式无效", + "title": "标题", + "cancel-button-label": "“取消”按钮标签", + "radio-button-settings": "单选按钮设置", + "color": "颜色", + "columns": "列", + "radio-options": "单选选项", + "no-radio-options": "未配置单选选项", + "add-radio-option": "添加单选选项", + "radio-label-position": "标签位置", + "radio-label-position-before": "之前", + "radio-label-position-after": "之后", + "save-image": "保存图片", + "save-to-gallery": "自动将拍摄的图片存储到图片库", + "public-image": "使图片对所有未授权用户可用" + }, + "invalid-qr-code-text": "二维码的输入文本无效。输入应为字符串类型", + "qr-code": { + "use-qr-code-text-function": "使用二维码文本函数", + "qr-code-text-pattern": "二维码文本模式(例如 '${entityName} | ${keyName} - some text.')", + "qr-code-text-pattern-hint": "二维码文本模式使用实体别名中实体中第一个找到的键的值。", + "qr-code-text-pattern-required": "二维码文本模式为必填项。", + "qr-code-text-function": "二维码文本函数" + }, + "label-widget": { + "label-pattern": "模式", + "label-pattern-hint": "提示:例如 'Text ${keyName} units.' 或 ${#<key index>} units'", + "label-pattern-required": "模式为必填项", + "label-position": "位置(相对于背景的百分比)", + "x-pos": "X", + "y-pos": "Y", + "background-color": "背景颜色", + "font-settings": "字体设置", + "background-image": "背景图片", + "labels": "标签", + "no-labels": "未配置标签", + "add-label": "添加标签" + }, + "navigation": { + "title": "标题", + "navigation-path": "导航路径", + "filter-type": "过滤类型", + "filter-type-all": "所有项目", + "filter-type-include": "包含项目", + "filter-type-exclude": "排除项目", + "items": "项目", + "enter-urls-to-filter": "输入要过滤的 URL..." + }, + "persistent-table": { + "rpc-id": "RPC ID", + "message-type": "消息类型", + "method": "方法", + "params": "参数", + "created-time": "创建时间", + "expiration-time": "过期时间", + "retries": "重试次数", + "status": "状态", + "filter": "过滤器", + "refresh": "刷新", + "add": "添加 RPC 请求", + "details": "详情", + "delete": "删除", + "delete-request-title": "删除持久化 RPC 请求", + "delete-request-text": "您确定要删除该请求吗?", + "details-title": "详情 RPC ID:", + "additional-info": "附加信息", + "response": "响应", + "any-status": "任意状态", + "rpc-status-list": "RPC 状态列表", + "no-request-prompt": "无可显示的请求", + "send-request": "发送请求", + "add-title": "创建持久化 RPC 请求", + "method-error": "方法为必填项。", + "timeout-error": "最小超时值为 5000(5 秒)。", + "white-space-error": "不允许使用空格。", + "rpc-status": { + "QUEUED": "已排队", + "SENT": "已发送", + "DELIVERED": "已送达", + "SUCCESSFUL": "成功", + "TIMEOUT": "超时", + "EXPIRED": "已过期", + "FAILED": "失败" + }, + "rpc-search-status-all": "全部", + "message-types": { + "false": "双向", + "true": "单向" + }, + "general-settings": "常规设置", + "enable-filter": "启用过滤器", + "enable-sticky-header": "滚动时显示表头", + "enable-sticky-action": "滚动时显示操作列", + "display-request-details": "显示请求详情", + "allow-send-request": "允许发送 RPC 请求", + "allow-delete-request": "允许删除请求", + "columns-settings": "列设置", + "display-columns": "要显示的列", + "column": "列", + "no-columns-found": "未找到列", + "no-columns-matching": "未找到“{{column}}”。" + }, + "range-chart": { + "chart": "图表", + "data-zoom": "数据缩放", + "range-chart-appearance": "范围图表外观", + "range-colors": "范围颜色", + "out-of-range-color": "超出范围颜色", + "show-range-thresholds": "显示范围阈值", + "range-thresholds-settings": "范围阈值设置", + "fill-area": "填充区域", + "fill-area-opacity": "填充区域不透明度", + "range-chart-style": "范围图表样式" + }, + "knob": { + "behavior": "行为", + "initial-value": "初始值", + "initial-value-hint": "获取旋钮初始值的操作。", + "on-value-change": "值变更时", + "on-value-change-hint": "旋钮值变更时触发的操作。", + "range": "范围", + "min": "最小", + "max": "最大", + "value": "值", + "fallback-initial-value": "备用初始值" + }, + "rpc": { + "value-settings": "值设置", + "initial-value": "初始值", + "retrieve-value-settings": "获取开/关值设置", + "retrieve-value-method": "使用方法获取值", + "retrieve-value-method-none": "不获取", + "retrieve-value-method-rpc": "调用 RPC 获取值方法", + "retrieve-value-method-attribute": "订阅属性", + "retrieve-value-method-timeseries": "订阅时间序列", + "attribute-value-key": "属性键", + "timeseries-value-key": "时间序列键", + "get-value-method": "RPC 获取值方法", + "parse-value-function": "解析值函数", + "update-value-settings": "更新值设置", + "set-value-method": "RPC 设置值方法", + "convert-value-function": "转换值函数", + "rpc-settings": "RPC 设置", + "request-timeout": "RPC 请求超时(毫秒)", + "persistent-rpc-settings": "持久化 RPC 设置", + "request-persistent": "RPC 请求持久化", + "persistent-polling-interval": "获取持久化 RPC 命令响应的轮询间隔(毫秒)", + "common-settings": "通用设置", + "switch-title": "开关标题", + "show-on-off-labels": "显示开/关标签", + "slide-toggle-label": "滑动开关标签", + "label-position": "标签位置", + "label-position-before": "之前", + "label-position-after": "之后", + "slider-color": "滑块颜色", + "slider-color-primary": "主要", + "slider-color-accent": "强调", + "slider-color-warn": "警告", + "button-style": "按钮样式", + "button-raised": "凸起按钮", + "button-primary": "主要颜色", + "button-background-color": "按钮背景颜色", + "button-text-color": "按钮文本颜色", + "widget-title": "部件标题", + "button-label": "按钮标签", + "device-attribute-scope": "设备属性范围", + "server-attribute": "服务端属性", + "shared-attribute": "共享属性", + "device-attribute-parameters": "设备属性参数", + "is-one-way-command": "单向命令", + "rpc-method": "RPC 方法", + "rpc-method-params": "RPC 方法参数", + "show-rpc-error": "显示 RPC 命令执行错误", + "led-title": "LED 标题", + "led-color": "LED 颜色", + "check-status-settings": "检查状态设置", + "perform-rpc-status-check": "执行 RPC 设备状态检查", + "retrieve-led-status-value-method": "使用方法获取 LED 状态值", + "led-status-value-attribute": "包含 LED 状态值的设备属性", + "led-status-value-timeseries": "包含 LED 状态值的设备时间序列", + "check-status-method": "RPC 检查设备状态方法", + "parse-led-status-value-function": "解析 LED 状态值函数", + "knob-title": "旋钮标题" + }, + "maps": { + "map-type": { + "type": "地图类型", + "map": "地图", + "image": "图片" + }, + "image": { + "image-source": "图片来源", + "image-source-image": "图片", + "image-source-entity-key": "实体键", + "source-entity-alias": "来源实体别名", + "image-url-key": "图片 URL 键", + "image-url-key-required": "图片 URL 键为必填项" + }, + "control": { + "map-controls": "地图控件", + "position": "位置", + "position-topleft": "左上", + "position-topright": "右上", + "position-bottomleft": "左下", + "position-bottomright": "右下", + "zoom-actions": "缩放操作", + "zoom-scroll": "滚动", + "zoom-double-click": "双击", + "zoom-control-buttons": "控制按钮", + "scale": "比例尺", + "scale-metric": "公制", + "scale-imperial": "英制", + "switch-to-drag-mode-using-button": "使用按钮切换到拖拽模式" + }, + "timeline": { + "control-panel": "时间线控制面板", + "time-step": "时间步长", + "speed-options": "速度选项", + "timestamp": "时间戳", + "snap-to-real-location": "对齐到真实位置", + "location-snap-filter-function": "位置对齐过滤函数", + "no-trips-data-available": "无可用的行程数据" + }, + "map-action": { + "map-action-buttons": "地图操作按钮", + "label": "标签", "icon": "图标", - "icon-color": "图标颜色", - "icon-size": "图标大小", - "advanced-settings": "高级设置", - "data-settings": "数据设置", - "limits": "限制", - "no-data-display-message": "没有数据时显示文本", - "data-page-size": "每个数据源的最大实体数", - "settings-component-not-found": "未找到设置的表单组件选择器'{{selector}}'", - "preview": "预览", - "set": "设置", - "set-message": "设置显示文本", - "advanced-title-style": "高级标题样式", - "card-style": "样式", - "text": "文本", - "background": "背景", - "advanced-widget-style": "高级部件样式", - "card-buttons": "按钮", - "show-card-buttons": "全屏按钮", - "card-border-radius": "边框半径", - "card-padding": "内边边距", - "card-appearance": "外观", "color": "颜色", - "tooltip": "文字提示", - "units-required": "单位必填", - "list-layout": "列表", - "layout": "布局", - "resize-options": "调整大小", - "resizable": "可调整大小", - "preserve-aspect-ratio": "保留纵横比例" - }, - "widget-type": { - "import": "导入部件", - "export": "导出部件", - "export-failed-error": "无法导出部件:{{error}}", - "widget-file": "部件文件", - "invalid-widget-file-error": "无法导入部件:无效的部件数据结构。" - }, - "widgets": { - "mobile-app-qr-code": { - "configuration-hint": "配置取决于平台中设置的移动应用二维码部件", - "get-it-on-google-play": "在Google Play上下载", - "download-on-the-app-store": "在App Store上下载" - }, - "action-button": { - "behavior": "行为", - "on-click": "单击", - "on-click-hint": "单击按钮时触发动作", - "first-button-click": "第一次按钮单击", - "first-button-click-hint": "按下第一个按钮时触发的动作", - "second-button-click": "第二次按钮单击", - "second-button-click-hint": "按下第二个按钮时触发的动作", - "button-click-hint": "按下部件时触发的动作" - }, - "command-button": { - "behavior": "行为", - "on-click": "单击", - "on-click-hint": "单击按钮时触发动作" - }, - "power-button": { - "behavior": "行为", - "power-on": "打开", - "power-on-hint": "打开电源执行的动作。", - "power-off": "关闭", - "power-off-hint": "关闭电源执行的动作。", - "on-label": "打开", - "off-label": "关闭", - "layout": "布局", - "layout-default": "默认", - "layout-simplified": "精简", - "layout-outlined": "默认", - "layout-default-volume": "默认", - "layout-simplified-volume": "精简", - "layout-outlined-volume": "默认", - "layout-default-icon": "默认.Icon", - "layout-simplified-icon": "精简.Icon", - "layout-outlined-icon": "默认.Icon", - "main": "前景色", - "background": "背景色", - "power-on-colors": "打开颜色", - "power-off-colors": "关闭颜色", - "disabled-colors": "禁用颜色", - "button": "按钮" - }, - "toggle-button": { - "behavior": "行为", - "checked": "打开", - "unchecked": "关闭", - "check": "打开状态", - "check-hint": "打开组件执行的动作。", - "uncheck": "关闭状态", - "uncheck-hint": "关闭组件执行的动作。", - "auto-scale": "自动", - "horizontal-fill": "水平填充", - "vertical-fill": "垂直填充", - "button-appearance": "外观" - }, - "segmented-button": { - "layout": "布局", - "layout-squared": "方形", - "layout-rounded": "圆角", - "card-border": "卡片边框", - "button-appearance": "按钮外观", - "first": "第一个", - "second": "第二个", - "color-styles": "颜色样式", - "selected": "已选中", - "unselected": "未选中" - }, - "button": { - "layout": "布局", - "outlined": "默认", - "filled": "填充", - "underlined": "强调", - "basic": "基础", - "auto-scale": "自动", - "label": "标签", - "icon": "图标", - "border-radius": "圆角", - "color-palette": "调色板", - "main": "前景色", - "background": "背景色", - "border": "边框", - "custom-styles": "自定义样式", - "clear-style": "清除样式", - "shadow": "阴影", - "enabled": "启用", - "disabled": "禁用", - "preview": "预览", - "copy-style-from": "复制" - }, - "button-state": { - "activated-state": "激活状态", - "activated-state-hint": "按钮在激活状态的触发条件。", - "disabled-state": "禁用状态", - "disabled-state-hint": "按钮在禁用状态的触发条件。", - "selected-state": "选中状态", - "selected-state-hint": "配置按钮选中状态的触发条件。", - "enabled": "启用", - "hovered": "悬停", - "pressed": "按压", - "activated": "激活", - "disabled": "禁用", - "initial": "初始状态", - "first": "第一个", - "second": "第二个" - }, - "background": { - "background": "背景颜色", - "background-settings": "背景设置", - "background-type-image": "上传图像", - "background-type-color": "纯色背景", - "image-url": "图像URL", - "overlay": "覆盖", - "enable-overlay": "启用覆盖", - "blur": "模糊", - "preview": "预览" - }, - "bar-chart": { - "bar-appearance": "外观", - "label-on-bar": "标签", - "value-on-bar": "数值", - "bar-chart-style": "样式", - "bar-axis": "轴" - }, - "polar-area-chart": { - "polar-axis": "极轴", - "start-angle": "角度", - "polar-area-chart-style": "图表样式" - }, - "battery-level": { - "layout": "布局", - "layout-vertical-solid": "垂直.实心", - "layout-horizontal-solid": "水平.实心", - "layout-vertical-divided": "垂直.分割", - "layout-horizontal-divided": "水平.分割", - "icon": "图标", - "value": "数值", - "auto-scale": "自动缩放", - "battery-level-color": "电量颜色", - "battery-shape-color": "形状颜色", - "battery-level-card-style": "电量卡片样式", - "sections-count": "分段数量" - }, - "signal-strength": { - "value": "数值", - "last-update": "上次更新", - "no-signal": "无信号", - "layout": "布局", - "layout-wifi": "Wi-Fi", - "layout-cellular-bar": "蜂窝网络", - "icon": "图标", - "date": "日期", - "active-bars-color": "有信号颜色", - "inactive-bars-color": "无信号颜色", - "signal-strength-card-style": "信号强度样式", - "no-signal-rssi-value": "无信号RSSI值" - }, - "status-widget": { - "behavior": "行为", - "layout": "布局", - "layout-default": "默认", - "layout-center": "居中", - "layout-icon": "图标", - "on": "打开", - "off": "关闭", - "label": "标签", - "status": "状态", - "icon": "图标", - "color-palette": "启用时调色板", - "disabled-color-palette": "禁用时调色板", - "primary": "基本", - "primary-color-hint": "图标和标签的颜色", - "secondary": "次要", - "secondary-color-hint": "状态颜色", - "background": "背景" - }, - "chart": { - "common-settings": "通用设置", - "enable-stacking-mode": "启用堆叠模式", - "selection": "时间范围选择", - "enable-selection-mode": "启用选择模式", - "line-shadow-size": "线条阴影大小", - "display-smooth-lines": "显示平滑(曲线)线条", - "default-bar-width": "非聚合数据的默认条形图宽度(毫秒)", - "bar-alignment": "Bar 对齐方式", - "bar-alignment-left": "左", - "bar-alignment-right": "右", - "bar-alignment-center": "居中", - "default-font": "默认字体", - "default-font-size": "默认字体大小", - "default-font-color": "默认字体颜色", - "thresholds-line-width": "所有阈值的默认线条宽度", - "tooltip-settings": "文字提示设置", - "tooltip": "文字提示", - "show-tooltip": "显示文字提示", - "hover-individual-points": "悬停在单个数据点上", - "show-cumulative-values": "在堆叠模式下显示累积值", - "hide-zero-false-values": "文字提示隐藏 zero/false", - "tooltip-value-format-function": "文字提示值的格式化函数", - "grid-settings": "网格线设置", - "show-vertical-lines": "显示垂直线条", - "show-horizontal-lines": "显示水平线条", - "grid-outline-border-width": "网格轮廓/边框宽度(px)", - "primary-color": "主要颜色", - "background-color": "背景颜色", - "ticks-color": "刻度颜色", - "xaxis-settings": "X轴设置", - "axis-title": "轴标题", - "xaxis-tick-labels-settings": "X轴刻度标签设置", - "show-tick-labels": "显示轴刻度标签", - "yaxis-settings": "Y轴设置", - "min-scale-value": "刻度上的最小值", - "max-scale-value": "刻度上的最大值", - "yaxis-tick-labels-settings": "Y轴刻度标签设置", - "tick-step-size": "刻度之间的步长", - "number-of-decimals": "要显示的小数位数", - "ticks-formatter-function": "刻度格式化函数", - "comparison-settings": "比较设置", - "enable-comparison": "启用比较", - "time-for-comparison": "比较期间", - "time-for-comparison-previous-interval": "上一个时间段(默认)", - "time-for-comparison-days": "一天前", - "time-for-comparison-weeks": "一周前", - "time-for-comparison-months": "一月前", - "time-for-comparison-years": "一年前", - "time-for-comparison-custom-interval": "自定义时间间隔", - "custom-interval-value": "自定义时间间隔值 (毫秒)", - "comparison-x-axis-settings": "比较X轴设置", - "axis-position": "轴位置", - "axis-position-top": "顶部 (默认)", - "axis-position-bottom": "底部", - "custom-legend-settings": "自定义设置图例", - "enable-custom-legend": "启用自定义图例 (这将允许您在键标签中使用属性/时间序列值)", - "key-name": "键名", - "key-name-required": "键名是必需的", - "key-type": "键类型", - "key-type-attribute": "属性", - "key-type-timeseries": "时间序列", - "label-keys-list": "要在标签中使用的键列表", - "no-label-keys": "未配置键", - "add-label-key": "添加新键", - "line-width": "线宽", - "color": "颜色", - "data-is-hidden-by-default": "默认情况下隐藏数据", - "disable-data-hiding": "禁用数据隐藏", - "remove-from-legend": "从图例中移除数据键", - "exclude-from-stacking": "从堆叠中排除(仅适用于'堆叠'模式)", - "line-settings": "线设置", - "show-line": "显示线条", - "fill-line": "填充线条", - "fill-line-opacity": "填充透明度", - "points-settings": "点设置", - "show-points": "显示点", - "points-line-width": "点的线宽", - "points-radius": "点的半径", - "point-shape": "形状", - "point-shape-circle": "圆", - "point-shape-cross": "十字", - "point-shape-diamond": "菱形", - "point-shape-square": "矩形", - "point-shape-triangle": "三角形", - "point-shape-custom": "自定义函数", - "point-shape-draw-function": "形状绘制函数", - "show-separate-axis": "显示分离的轴", - "axis-position-left": "左侧", - "axis-position-right": "右侧", - "thresholds": "阈值", - "no-thresholds": "未配置阈值", - "add-threshold": "添加新阈值", - "show-values-for-comparison": "显示历史比较值", - "comparison-values-label": "历史值标签", - "comparison-line-color": "对比线颜色", - "threshold-settings": "阈值设置", - "use-as-threshold": "使用遥测作为阈值", - "threshold-line-width": "阈值线宽", - "threshold-color": "阈值颜色", - "common-pie-settings": "常用饼图设置", - "radius": "半径", - "inner-radius": "内部半径", - "tilt": "倾斜", - "common-pie-settings-range-error": "值应在0到1的范围内", - "stroke-settings": "描边设置", - "width-pixels": "宽度(px)", - "show-labels": "显示标签", - "animation-settings": "动画设置", - "animated-pie": "启用饼图动画", - "border-settings": "边框设置", - "border-width": "边框宽度", - "border-color": "边框颜色", - "legend-settings": "设置图例", - "display-legend": "显示图例", - "labels-font-color": "标签字体颜色", - "series": "时间序列", - "add-series": "添加", - "series-settings": "设置", - "remove-series": "删除", - "no-series": "没有设置时间序列", - "no-series-error": "至少指定一个时间序列", - "chart-appearance": "图表外观", - "vertical-grid-lines": "垂直网格线", - "horizontal-grid-lines": "水平网格线", - "chart-background": "图表背景", - "grid-lines-color": "网格线颜色", - "border": "边框", - "axis": "轴", - "vertical-axis": "垂直轴", - "ticks": "刻度", - "horizontal-axis": "水平轴", - "shape-empty-circle": "空心圆形", - "shape-circle": "圆形", - "shape-rect": "长方形", - "shape-round-rect": "矩形", - "shape-triangle": "三角形", - "shape-diamond": "菱形", - "shape-pin": "Pin", - "shape-arrow": "箭头", - "shape-none": "无", - "line-type-solid": "实线", - "line-type-dashed": "虚线", - "line-type-dotted": "点画线", - "label-position-top": "上", - "label-position-bottom": "下", - "label-position-outside": "外部", - "label-position-inside": "内部", - "fill": "填充", - "fill-type-none": "无", - "fill-type-solid": "实线", - "fill-type-opacity": "透明度", - "fill-type-gradient": "渐变色", - "background": "背景色", - "opacity": "透明度", - "gradient-stops": "渐变值", - "gradient-start": "起始点", - "gradient-end": "结束点", - "animation": { - "animation": "动画", - "animation-threshold": "动画阈值", - "animation-duration": "动画时长", - "animation-easing": "动画方式", - "animation-delay": "动画延迟", - "update-animation-duration": "更新时长", - "update-animation-easing": "更新方式", - "update-animation-delay": "更新延迟" - }, - "chart-axis": { - "scale": "比例", - "scale-min": "最小", - "scale-max": "最大", - "scale-auto": "自动" - }, - "bar": { - "show-border": "显示边框", - "border-width": "边框宽度", - "border-radius": "边框圆角", - "bar-width": "宽度", - "label": "标签", - "label-hint": "在条形图上显示标签。", - "series-label-hint": "显示具有数值的标签。", - "label-background": "标签背景" - } - }, - "color": { - "color-settings": "颜色设置", - "color-type-constant": "固定值", - "color-type-gradient": "渐变色", - "color-type-range": "数值范围", - "color-type-function": "函数", - "color": "颜色", - "value-range": "数值范围", - "from": "从", - "to": "到", - "color-function": "颜色函数", - "copy-color-settings-from": "复制其他颜色设置", - "copy-from": "复制", - "settings-type": "类型设置", - "basic-mode": "基础", - "advanced-mode": "高级", - "entity-alias": "实体别名", - "entity-attribute": "实体属性", - "gradient-color": "渐变色", - "gradient-color-min": "颜色", - "gradient-start": "起始点", - "gradient-start-min": "起始", - "gradient-end": "结束点", - "gradient-end-min": "结束", - "start-value": "起始值", - "end-value": "结束植", - "gradient-type": "渐变类型" - }, - "dashboard-state": { - "dashboard-state-settings": "仪表板状态设置", - "dashboard-state": "仪表板状态 ID", - "autofill-state-layout": "自动填充状态布局默认高度", - "default-margin": "默认部件边距", - "default-background-color": "默认背景颜色", - "sync-parent-state-params": "与父仪表板同步状态参数" - }, - "date-range-navigator": { - "date-range-picker-settings": "日期范围选择设置", - "hide-date-range-picker": "隐藏日期范围选择", - "picker-one-panel": "日期范围选择板", - "picker-auto-confirm": "自动确认日期范围选择", - "picker-show-template": "日期范围选择显示模板", - "first-day-of-week": "一周的第一天", - "interval-settings": "间隔设置", - "hide-interval": "隐藏间隔", - "initial-interval": "初始间隔", - "interval-hour": "小时", - "interval-day": "天", - "interval-week": "周", - "interval-two-weeks": "2周", - "interval-month": "月", - "interval-three-months": "3个月", - "interval-six-months": "6个月", - "step-settings": "步长设置", - "hide-step-size": "隐藏步长", - "initial-step-size": "初始步长", - "hide-labels": "隐藏标签", - "use-session-storage": "使用会话存储", - "localizationMap": { - "Sun": "周日", - "Mon": "周一", - "Tue": "周二", - "Wed": "周三", - "Thu": "周四", - "Fri": "周五", - "Sat": "周六", - "Jan": "1月", - "Feb": "2月", - "Mar": "3月", - "Apr": "4月", - "May": "5月", - "Jun": "6月", - "Jul": "7月", - "Aug": "8月", - "Sep": "9月", - "Oct": "10月", - "Nov": "11月", - "Dec": "12月", - "January": "一月", - "February": "二月", - "March": "三月", - "April": "四月", - "June": "六月", - "July": "七月", - "August": "八月", - "September": "九月", - "October": "十月", - "November": "十一月", - "December": "十二月", - "Custom Date Range": "自定义日期范围", - "Date Range Template": "日期范围模板", - "Today": "今天", - "Yesterday": "昨天", - "This Week": "本星期", - "Last Week": "上星期", - "This Month": "本月", - "Last Month": "上月", - "Year": "年", - "This Year": "今年", - "Last Year": "去年", - "Date picker": "日期选择", - "Hour": "小时", - "Day": "天", - "Week": "周", - "2 weeks": "2周", - "Month": "月", - "3 months": "3个月", - "6 months": "6个月", - "Custom interval": "自定义间隔", - "Interval": "间隔", - "Step size": "步长", - "Ok": "确定" - } - }, - "doughnut": { - "doughnut-appearance": "外观", - "layout": "布局", - "layout-default": "默认", - "layout-with-total": "总计", - "central-total-value": "中心数值", - "doughnut-card-style": "样式" - }, - "entities-hierarchy": { - "hierarchy-data-settings": "层次数据设置", - "relations-query-function": "关系查询函数", - "has-children-function": "是否子级函数", - "node-state-settings": "状态设置", - "node-opened-function": "展开函数", - "node-disabled-function": "禁用函数", - "display-settings": "显示设置", - "node-icon-function": "图标函数", - "node-text-function": "文本函数", - "sort-settings": "排序设置", - "nodes-sort-function": "排序函数" - }, - "edge": { - "display-default-title": "显示默认标题" - }, - "gateway": { - "general-settings": "基础设置", - "widget-title": "部件标题", - "default-archive-file-name": "默认文件名", - "device-type-for-new-gateway": "新网关的设备类型", - "messages-settings": "消息设置", - "save-config-success-message": "配置保存成功消息", - "device-name-exists-message": "设备名称已经存在消消息", - "gateway-title": "网关标题", - "read-only": "只读", - "events-title": "事件标题", - "events-filter": "事件过滤", - "event-key-contains": "事件键包含...", - "show-connector": "显示连接器", - "connector-state-param-key": "连接器状态参数键", - "message": "消息", - "level": "层级", - "created-time": "创建时间" - }, - "gauge": { - "default-color": "默认颜色", - "radial-gauge-settings": "量规设置", - "ticks-settings": "刻度设置", - "min-value": "最小值", - "max-value": "最大值", - "min-value-short": "最小值", - "max-value-short": "最大值", - "start-ticks-angle": "起始角度", - "ticks-angle": "结束角度", - "major-ticks": "主刻度", - "major-ticks-count": "主刻度数量", - "major-ticks-color": "主刻度颜色", - "minor-ticks": "次刻度", - "minor-ticks-count": "次刻度数量", - "minor-ticks-color": "次刻度颜色", - "tick-numbers-font": "刻度数字字体", - "unit-title-settings": "单位设置", - "show-unit-title": "显示单位", - "unit-title": "单位", - "title-font": "字体", - "units-settings": "单位设置", - "units-font": "字体", - "value-box-settings": "数值框设置", - "show-value-box": "显示数值框", - "value-box": "数值框", - "value-int": "整数位数", - "value-text": "数值文本", - "value-text-shadow": "文本阴影", - "value-font": "数值字体", - "rect-stroke-color-start": "边框色渐变起始", - "rect-stroke-color-end": "边框色渐变结束", - "background-color": "背景颜色", - "shadow-color": "阴影颜色", - "value-box-rect-stroke-color": "数值框描边", - "value-box-rect-stroke-color-end": "数值框描边色渐变结束", - "value-box-background-color": "数值框背景颜色", - "value-box-shadow-color": "数值框阴影颜色", - "plate-settings": "背景设置", - "show-plate-border": "显示背景边框", - "plate-color": "背景颜色", - "needle-settings": "指针设置", - "needle-circle-size": "针座尺寸", - "needle-color": "指针颜色", - "needle-color-start": "指针颜色渐变起始", - "needle-color-end": "指针颜色渐变结束", - "needle-color-shadow-up": "指针上半部分阴影颜色", - "needle-color-shadow-down": "指针下半部分阴影颜色", - "highlights-settings": "高亮设置", - "highlights-width": "高亮宽度", - "highlights": "高亮", - "highlight-from": "起始值", - "highlight-to": "终止值", - "highlight-color": "颜色", - "no-highlights": "未配置高亮", - "add-highlight": "添加高亮", - "animation-settings": "动画设置", - "enable-animation": "启用动画", - "animation-duration-rule": "动画保持规则", - "animation-duration": "动画保持时间", - "animation-rule": "动画规则", - "animation-linear": "线性", - "animation-quad": "二次方", - "animation-quint": "五次方", - "animation-cycle": "循环", - "animation-bounce": "弹跳", - "animation-elastic": "弹性", - "animation-dequad": "二次方反向", - "animation-dequint": "五次方反向", - "animation-decycle": "循环反向", - "animation-debounce": "弹跳反向", - "animation-delastic": "弹性反向", - "linear-gauge-settings": "线性量规设置", - "bar-stroke": "柱状图描边", - "bar-stroke-width": "条形图描边宽度", - "bar-stroke-color": "条形图描边颜色", - "bar-background-color": "量规条形图背景颜色", - "bar-background-color-end": "条形图背景颜色-渐变结束", - "progress-bar-color": "进度条颜色", - "progress-bar": "进度条", - "progress-bar-color-start": "进度条颜色-渐变起始", - "progress-bar-color-end": "进度条颜色-渐变结束", - "major-ticks-names": "主刻度名称", - "show-stroke-ticks": "显示刻度描边", - "major-ticks-font": "主刻度字体", - "border-color": "边框颜色", - "border-width": "边框宽度", - "needle-circle": "指针圆圈", - "needle-circle-color": "指针圆圈颜色", - "animation-target": "动画目标", - "animation-target-needle": "指针", - "animation-target-plate": "背景板", - "common-settings": "通用量规设置", - "gauge-type": "量规类型", - "gauge-type-arc": "弧形", - "gauge-type-donut": "圆圈", - "gauge-type-horizontal-bar": "水平条形", - "gauge-type-vertical-bar": "垂直条形", - "donut-start-angle": "角度", - "bar-settings": "条形图设置", - "relative-bar-width": "条形宽度", - "neon-glow-brightness": "霓虹灯光效亮度(0-100)0-禁用效果", - "neon-glow-brightness-hint": "0-禁用效果", - "stripes-thickness": "条纹的厚度", - "stripes-thickness-hint": "0-无条纹", - "rounded-line-cap": "圆角线帽", - "bar-color-settings": "颜色设置", - "use-precise-level-color-values": "使用精确的颜色级别", - "bar-colors": "条形图颜色,从低到高", - "color": "颜色", - "no-bar-colors": "未配置条形图颜色", - "add-bar-color": "添加条形图颜色", - "from": "起始值", - "to": "终止值", - "fixed-level-colors": "使用边界值的条形图颜色", - "gauge-title-settings": "量规标题设置", - "show-gauge-title": "显示量规标题", - "gauge-title": "量规标题", - "gauge-title-font": "量规标题字体", - "unit-title-and-timestamp-settings": "单位标题和时间戳设置", - "show-timestamp": "显示值时间戳", - "timestamp-format": "时间格式", - "label-font": "显示在数值下方的标签字体", - "value-settings": "数值设置", - "show-value": "显示数值文本", - "min-max-settings": "最小/最大标签设置", - "show-min-max": "显示最小和最大值", - "min-max-font": "最小和最大标签字体", - "show-ticks": "显示刻度", - "tick-width": "宽度", - "tick-color": "颜色", - "tick-values": "刻度值", - "no-tick-values": "未配置刻度值", - "add-tick-value": "添加刻度值", - "gauge-appearance": "仪表盘外观", - "units-title": "单位标题", - "value": "数值", - "ticks": "刻度", - "arrow-and-scale-color": "默认颜色", - "scale-settings": "设置", - "scale": "尺寸", - "scale-color": "颜色", - "compass-appearance": "外观", - "label": "标签", - "labels": "标签", - "label-style": "标签样式", - "simple-gauge-type": "类型", - "gauge-bar-background": "量规背景", - "bar-color": "颜色", - "min-and-max-value": "最小和最大值", - "min-and-max-label": "最小和最大标签", - "font": "字体", - "tick-width-and-color": "刻度宽度和颜色", - "min-max-validation-text": "最大值必须大于最小值" - }, - "gpio": { - "pin": "引脚", - "label": "标签", - "row": "行", - "column": "列", - "color": "颜色", - "panel-settings": "面板设置", - "background-color": "背景颜色", - "gpio-switches": "GPIO开关", - "no-gpio-switches": "未配置GPIO开关", - "add-gpio-switch": "添加GPIO开关", - "gpio-status-request": "GPIO状态请求", - "method-name": "方法名称", - "method-body": "方法内容", - "gpio-status-change-request": "GPIO状态变更请求", - "parse-gpio-status-function": "解析GPIO状态函数", - "gpio-leds": "GPIO LED", - "no-gpio-leds": "未配置GPIO LED", - "add-gpio-led": "添加GPIO LED" - }, - "html-card": { - "html": "HTML", - "css": "CSS" - }, - "input-widgets": { - "attribute-not-allowed": "属性参数不能在此部件中使用", - "blocked-location": "在浏览器中阻止地理位置", - "claim-device": "声明设备", - "claim-failed": "声明设备失败!", - "claim-not-found": "未找到设备!", - "claim-successful": "设备已成功申领!", - "date": "日期", - "device-name": "设备名称", - "device-name-required": "设备名称必填", - "discard-changes": "放弃更改", - "entity-attribute-required": "实体属性必填", - "entity-coordinate-required": "纬度和经度两个字段都是必需的", - "entity-timeseries-required": "实体时间序列必填", - "get-location": "获取当前位置", - "invalid-date": "无效日期", - "latitude": "纬度", - "longitude": "经度", - "min-value-error": "最小值是{{value}}", - "max-value-error": "最大值是{{value}}", - "not-allowed-entity": "所选实体不能具有共享属性", - "no-attribute-selected": "未选择任何属性", - "no-datakey-selected": "未选择数据键", - "no-coordinate-specified": "未指定纬度/经度的数据键", - "no-entity-selected": "未选择实体", - "no-image": "没有图像", - "no-support-geolocation": "您的浏览器不支持地理定位", - "no-support-web-camera": "您的浏览器不支持摄像头", - "enable-https-use-widget": "请启用HTTPS以使用此部件", - "no-found-your-camera": "未找到摄像机", - "no-permission-camera": "权限被用户拒绝/此站点无权使用摄像机", - "no-timeseries-selected": "未选择时间序列值", - "secret-key": "密钥", - "secret-key-required": "密钥必填", - "switch-attribute-value": "切换实体属性值", - "switch-camera": "切换摄像机", - "switch-timeseries-value": "切换实体时间序列值", - "take-photo": "拍照", - "time": "时间", - "timeseries-not-allowed": "时间序列参数不能用于此部件", - "update-failed": "更新失败", - "update-successful": "更新成功", - "update-attribute": "更新属性", - "update-timeseries": "更新时间序列", - "value": "数值", - "general-settings": "通用设置", - "widget-title": "部件标题", - "claim-button-label": "认领按钮标签", - "show-secret-key-field": "显示'秘钥'输入字段", - "labels-settings": "标签设置", - "show-labels": "显示标签", - "device-name-label": "设备名称输入字段标签", - "secret-key-label": "秘钥输入字段标签", - "messages-settings": "消息设置", - "claim-device-success-message": "设备认领成功文本消息", - "claim-device-not-found-message": "设备未找到文本消息", - "claim-device-failed-message": "设备认领失败文本消息", - "claim-device-name-required-message": "'设备名称必填'错误消息", - "claim-device-secret-key-required-message": "'秘钥必填'错误消息", - "show-label": "显示标签", - "label": "标签", - "required": "必填", - "required-error-message": "'必填'错误消息", - "show-result-message": "显示结果消息", - "integer-field-settings": "整数字段设置", - "min-value": "最小值", - "max-value": "最大值", - "double-field-settings": "双精度字段设置", - "text-field-settings": "文本字段设置", - "min-length": "最小长度", - "max-length": "最大长度", - "checkbox-settings": "复选框设置", - "true-label": "选中标签", - "false-label": "未选中标签", - "image-input-settings": "图像输入设置", - "display-preview": "显示预览", - "display-clear-button": "显示清除按钮", - "display-apply-button": "显示应用按钮", - "display-discard-button": "显示放弃按钮", - "datetime-field-settings": "日期/时间字段设置", - "display-time-input": "显示时间输入", - "latitude-key-name": "纬度键名称", - "longitude-key-name": "经度键名称", - "show-get-location-button": "显示'获取当前位置'按钮", - "use-high-accuracy": "使用高精度", - "location-fields-settings": "位置字段设置", - "latitude-label": "纬度标签", - "longitude-label": "经度标签", - "input-fields-alignment": "输入字段对齐方式", - "input-fields-alignment-column": "列(默认)", - "input-fields-alignment-row": "行", - "layout": "布局", - "row-gap": "行之间的间距(像素)", - "column-gap": "列之间的间距(像素)", - "latitude-field-required": "纬度字段必填", - "longitude-field-required": "经度字段必填", - "attribute-settings": "属性设置", - "widget-mode": "部件模式", - "widget-mode-update-attribute": "更新属性", - "widget-mode-update-timeseries": "更新时间序列", - "attribute-scope": "属性范围", - "attribute-scope-server": "服务器属性", - "attribute-scope-shared": "共享属性", - "value-required": "值必填", - "image-settings": "图像设置", - "image-format": "图像格式", - "image-format-jpeg": "JPEG", - "image-format-png": "PNG", - "image-format-webp": "WEBP", - "image-quality": "使用有损压缩的图像质量,比如JPEG和WEBP", - "max-image-width": "最大图像宽度", - "max-image-height": "最大图像高度", - "action-buttons": "操作按钮", - "show-action-buttons": "显示操作按钮", - "update-all-values": "更新所有值,而不仅仅是修改过的值", - "save-button-label": "'保存'按钮标签", - "reset-button-label": "'撤销'按钮标签", - "group-settings": "分组设置", - "show-group-title": "显示字段组的标题,与不同实体相关联", - "group-title": "分组标题", - "fields-alignment": "字段对齐方式", - "fields-alignment-row": "行(默认)", - "fields-alignment-column": "列", - "fields-in-row": "每行字段数", - "option-value": "值(创建空选项时填写'null')", - "option-label": "标签", - "hide-input-field": "隐藏输入字段", - "datakey-type": "数据键类型", - "datakey-type-server": "服务器属性(默认)", - "datakey-type-shared": "共享属性", - "datakey-type-timeseries": "时间序列", - "datakey-value-type": "数据键值类型", - "datakey-value-type-string": "字符串", - "datakey-value-type-double": "双精度", - "datakey-value-type-integer": "整数", - "datakey-value-type-json": "JSON", - "datakey-value-type-boolean-checkbox": "布尔值(复选框)", - "datakey-value-type-boolean-switch": "布尔值(开关)", - "datakey-value-type-date-time": "日期和时间", - "datakey-value-type-date": "日期", - "datakey-value-type-time": "时间", - "datakey-value-type-select": "选择", - "datakey-value-type-color": "颜色", - "value-is-required": "值必填", - "ability-to-edit-attribute": "编辑属性的能力", - "ability-to-edit-attribute-editable": "可编辑(默认)", - "ability-to-edit-attribute-disabled": "禁用", - "ability-to-edit-attribute-readonly": "只读", - "disable-on-datakey-name": "在另一个数据键的值为假时禁用(指定数据键名称)", - "field-appearance": "字段外观", - "appearance-fill": "填充", - "appearance-outline": "轮廓", - "subscript-sizing": "下标大小", - "subscript-sizing-fixed": "固定", - "subscript-sizing-dynamic": "动态", - "slide-toggle-settings": "滑动开关设置", - "slide-toggle-label-position": "滑动开关标签位置", - "slide-toggle-label-position-after": "之后", - "slide-toggle-label-position-before": "之前", - "select-options": "选择选项", - "no-select-options": "没有配置选择选项", - "add-select-option": "添加选择选项", - "numeric-field-settings": "数值字段设置", - "step-interval": "值之间的步长间隔", - "error-messages": "错误消息", - "min-value-error-message": "'最小值'错误消息", - "max-value-error-message": "'最大值'错误消息", - "invalid-date-error-message": "'无效日期'错误消息", - "invalid-JSON-error-message": "'无效JSON'错误消息", - "icon-settings": "图标设置", - "dialog-editor-settings": "对话框编辑器设置", - "use-custom-icon": "使用自定义图标", - "input-cell-icon": "输入单元格前显示的图标", - "value-conversion-settings": "值转换设置", - "get-value-settings": "获取值设置", - "use-get-value-function": "使用getValue函数", - "get-value-function": "getValue函数", - "set-value-settings": "设置值设置", - "use-set-value-function": "使用setValue函数", - "set-value-function": "setValue函数", - "json-invalid": "JSON值格式无效", - "title": "标题", - "cancel-button-label": "'取消'按钮标签" - }, - "invalid-qr-code-text": "无效的二维码输入文本,输入应为字符串类型。", - "qr-code": { - "use-qr-code-text-function": "使用二维码文本函数", - "qr-code-text-pattern": "二维码文本模式(例如'${entityName} | ${keyName} - some text.')", - "qr-code-text-pattern-hint": "二维码文本模式将使用实体别名中的实体中找到的第一个键的值。", - "qr-code-text-pattern-required": "必须提供二维码文本模式。", - "qr-code-text-function": "二维码文本函数" - }, - "label-widget": { - "label-pattern": "模式", - "label-pattern-hint": "提示:例如文本${keyName}单位或${#<key index>}单位'", - "label-pattern-required": "必须提供模式", - "label-position": "位置(相对于背景的百分比)", - "x-pos": "X", - "y-pos": "Y", - "background-color": "背景颜色", - "font-settings": "字体设置", - "background-image": "背景图像", - "labels": "标签", - "no-labels": "无配置的标签", - "add-label": "添加标签" - }, - "navigation": { - "title": "标题", - "navigation-path": "导航路径", - "filter-type": "筛选类型", - "filter-type-all": "所有项目", - "filter-type-include": "包含项目", - "filter-type-exclude": "排除项目", - "items": "项目", - "enter-urls-to-filter": "输入要筛选的URL..." - }, - "persistent-table": { - "rpc-id": "RPC ID", - "message-type": "消息类型", - "method": "方法", - "params": "参数", - "created-time": "创建时间", - "expiration-time": "过期时间", - "retries": "重试次数", - "status": "状态", - "filter": "筛选", - "refresh": "刷新", - "add": "添加RPC请求", - "details": "详情", - "delete": "删除", - "delete-request-title": "删除持久化RPC请求", - "delete-request-text": "确定要删除请求吗?", - "details-title": "详情 RPC ID: ", - "additional-info": "附加信息", - "response": "响应", - "any-status": "任何状态", - "rpc-status-list": "RPC状态列表", - "no-request-prompt": "没有要显示的请求", - "send-request": "发送请求", - "add-title": "创建持久化RPC请求", - "method-error": "必须提供方法。", - "timeout-error": "最小超时值为5000(5秒)。", - "white-space-error": "不允许使用空格。", - "rpc-status": { - "QUEUED": "已排队", - "SENT": "已发送", - "DELIVERED": "已送达", - "SUCCESSFUL": "成功", - "TIMEOUT": "超时", - "EXPIRED": "已过期", - "FAILED": "失败" - }, - "rpc-search-status-all": "所有", - "message-types": { - "false": "双向", - "true": "单向" - }, - "general-settings": "常规设置", - "enable-filter": "启用筛选", - "enable-sticky-header": "滚动时显示标题栏", - "enable-sticky-action": "滚动时显示操作列", - "display-request-details": "显示请求详情", - "allow-send-request": "允许发送RPC请求", - "allow-delete-request": "允许删除请求", - "columns-settings": "列设置", - "display-columns": "要显示的列", - "column": "列", - "no-columns-found": "找不到列", - "no-columns-matching": "未找到'{{column}}'。" + "action": "操作", + "add-button": "添加按钮", + "no-action-buttons-configured": "未配置操作按钮", + "remove-action-button": "移除操作按钮", + "map-action-button": "地图操作按钮", + "button-requires": "按钮需要标签或图标" + }, + "common": { + "common-map-settings": "通用地图设置", + "fit-map-bounds": "调整地图范围以覆盖所有标记", + "default-map-center-position": "默认地图中心位置", + "default-map-zoom-level": "默认地图缩放级别", + "entities-limit": "加载实体数量限制" + }, + "layer": { + "label": "标签", + "layer": "图层", + "layers": "图层", + "map-layers": "地图图层", + "add-layer": "添加图层", + "layer-settings": "图层设置", + "remove-layer": "移除图层", + "no-layers": "未配置图层", + "roadmap": "路线图", + "satellite": "卫星图", + "hybrid": "混合图", + "reference": { + "reference-layer": "参考图层", + "no-layer": "无图层", + "openstreetmap-hybrid": "OpenStreetMap 混合", + "world-edition-hybrid": "World Edition 混合", + "enhanced-contrast-hybrid": "增强对比度混合" }, - "range-chart": { - "chart": "图表", - "data-zoom": "数据缩放", - "range-chart-appearance": "图表范围外观", - "range-colors": "范围颜色", - "out-of-range-color": "超出范围颜色", - "show-range-thresholds": "显示范围阈值", - "range-thresholds-settings": "范围阈值设置", - "fill-area": "填充区域", - "fill-area-opacity": "填充区域透明度", - "range-chart-style": "范围图样式" + "provider": { + "provider": "提供商", + "openstreet": { + "title": "OpenStreet", + "mapnik": "Mapnik", + "hot": "HOT", + "esri-street": "WorldStreetMap", + "esri-topo": "WorldTopoMap", + "esri-imagery": "WorldImagery", + "cartodb-positron": "Positron", + "cartodb-dark-matter": "DarkMatter" + }, + "google": { + "title": "Google", + "roadmap": "路线图", + "satellite": "卫星图", + "hybrid": "混合图", + "terrain": "地形图" + }, + "here": { + "title": "HERE", + "normal-day": "日间标准", + "normal-night": "夜间标准", + "hybrid-day": "日间混合", + "terrain-day": "日间地形" + }, + "tencent": { + "title": "腾讯", + "normal": "标准", + "satellite": "卫星图", + "terrain": "地形图" + }, + "custom": { + "title": "自定义", + "tile-url": "瓦片 URL" + } }, - "rpc": { - "value-settings": "值设置", - "initial-value": "初始值", - "retrieve-value-settings": "获取开/关值设置", - "retrieve-value-method": "使用方法获取值", - "retrieve-value-method-none": "不获取", - "retrieve-value-method-rpc": "调用RPC获取值方法", - "retrieve-value-method-attribute": "订阅属性获取值", - "retrieve-value-method-timeseries": "订阅时间序列获取值", - "attribute-value-key": "属性键", - "timeseries-value-key": "时间序列键", - "get-value-method": "RPC获取值方法", - "parse-value-function": "解析值的函数", - "update-value-settings": "更新值设置", - "set-value-method": "RPC设置值方法", - "convert-value-function": "转换值的函数", - "rpc-settings": "RPC设置", - "request-timeout": "RPC请求超时时间(毫秒)", - "persistent-rpc-settings": "持久化RPC设置", - "request-persistent": "持久化RPC请求", - "persistent-polling-interval": "持久化RPC命令响应的轮询间隔(毫秒)", - "common-settings": "常见设置", - "switch-title": "开关标题", - "show-on-off-labels": "显示开/关标签", - "slide-toggle-label": "滑动开关标签", - "label-position": "标签位置", - "label-position-before": "之前", - "label-position-after": "之后", - "slider-color": "滑块颜色", - "slider-color-primary": "主要", - "slider-color-accent": "强调", - "slider-color-warn": "警告", - "button-style": "按钮样式", - "button-raised": "凸起按钮", - "button-primary": "主要颜色", - "button-background-color": "按钮背景颜色", - "button-text-color": "按钮文字颜色", - "widget-title": "部件标题", - "button-label": "按钮标签", - "device-attribute-scope": "设备属性范围", - "server-attribute": "服务器属性", - "shared-attribute": "共享属性", - "device-attribute-parameters": "设备属性参数", - "is-one-way-command": "是单向命令", - "rpc-method": "RPC方法", - "rpc-method-params": "RPC方法参数", - "show-rpc-error": "显示RPC命令执行错误", - "led-title": "LED标题", - "led-color": "LED颜色", - "check-status-settings": "检查状态设置", - "perform-rpc-status-check": "执行RPC设备状态检查", - "retrieve-led-status-value-method": "使用方法获取LED状态值", - "led-status-value-attribute": "包含LED状态值的设备属性", - "led-status-value-timeseries": "包含LED状态值的设备时间序列", - "check-status-method": "RPC检查设备状态方法", - "parse-led-status-value-function": "解析LED状态值的函数", - "knob-title": "旋钮标题", - "min-value": "最小值", - "max-value": "最大值" + "credentials": { + "credentials": "凭据", + "api-key": "API 密钥" + } + }, + "overlays": { + "overlays": "覆盖层", + "overlays-hint": "配置地图实体的数据源、外观、行为、编辑选项和分组", + "trips": "行程", + "markers": "标记", + "polygons": "多边形", + "circles": "圆形", + "polylines": "折线" + }, + "data-layer": { + "source": "来源", + "filter": "过滤器", + "additional-data-keys": "附加数据键", + "additional-datasources": "附加数据源", + "additional-datasources-hint": "用于访问地图上未显示的实体的属性或遥测的数据源,可在地图覆盖层函数中使用。", + "more-datasources": "更多数据源", + "data-keys": "数据键", + "add-datasource": "添加数据源", + "no-datasources": "未配置数据源", + "remove-datasource": "移除数据源", + "behavior": "行为", + "on-click": "点击时", + "on-click-hint": "用户点击地图项目时触发的操作。", + "groups": "分组", + "groups-hint": "分配给覆盖层的分组名称列表,用于切换其在地图上的可见性。", + "color": "颜色", + "color-settings": "颜色设置", + "color-type-constant": "常量", + "color-type-range": "范围", + "color-type-function": "函数", + "color-range-source-key": "颜色范围来源键", + "color-range-source-key-required": "颜色范围来源键为必填项", + "color-range": "颜色范围", + "color-function": "颜色函数", + "label": "标签", + "tooltip": "提示框", + "pattern-type-pattern": "模式", + "pattern-type-function": "函数", + "label-pattern": "标签(模式示例:'${entityName}','${entityName}: (Text ${keyName} units.)')", + "label-function": "标签函数", + "tooltip-pattern": "提示框(例如 'Text ${keyName} units.' 或 Link text)", + "tooltip-function": "提示框函数", + "tooltip-trigger": "提示框触发方式", + "tooltip-trigger-click": "点击时显示提示框", + "tooltip-trigger-hover": "悬停时显示提示框", + "auto-close-tooltips": "自动关闭提示框", + "tooltip-offset": "提示框偏移", + "tooltip-offset-horizontal": "水平", + "tooltip-offset-vertical": "垂直", + "tooltip-tag-actions": "标签操作", + "add-tooltip-tag-action": "添加标签操作", + "edit-tooltip-tag-action": "编辑标签操作", + "remove-tooltip-tag-action": "移除标签操作", + "action-add": "添加", + "action-edit": "编辑", + "action-move": "移动", + "action-remove": "移除", + "edit-instruments": "工具", + "persist-location-attribute-scope": "持久化位置的属性范围", + "enable-snapping": "启用对齐到其他顶点以精确绘制", + "enable-snapping-hint": "自动将新点与现有形状对齐,使绘制更轻松、更准确。", + "drag-drop-mode": "拖放模式", + "trip": { + "no-trips": "未配置行程", + "add-trip": "添加行程", + "trip-configuration": "行程配置", + "remove-trip": "移除行程" }, - "maps": { - "select-entity": "选择实体", - "select-entity-hint": "提示:选择后在地图上点击以设置位置", - "tooltips": { - "placeMarker": "点击以放置'{{entityName}}'实体", - "firstVertex": "'{{entityName}}' 的多边形:点击以放置第一个点", - "firstVertex-cut": "点击以放置第一个点", - "continueLine": "'{{entityName}}' 的多边形:点击以继续绘制", - "continueLine-cut": "点击以继续绘制", - "finishLine": "点击任意现有标记以完成", - "finishPoly": "'{{entityName}}'的多边形:点击第一个标记以完成并保存", - "finishPoly-cut": "点击第一个标记以完成并保存", - "finishRect": "'{{entityName}}' 的多边形:点击以完成并保存", - "startCircle": "'{{entityName}}' 的圆:点击以放置圆心", - "finishCircle": "'{{entityName}}' 的圆:点击以完成圆形", - "placeCircleMarker": "点击以放置圆形标记" - }, - "actions": { - "finish": "完成", - "cancel": "取消", - "removeLastVertex": "移除最后一个点" - }, - "buttonTitles": { - "drawMarkerButton": "放置实体", - "drawPolyButton": "创建多边形", - "drawLineButton": "创建折线", - "drawCircleButton": "创建圆形", - "drawRectButton": "创建矩形", - "editButton": "编辑模式", - "dragButton": "拖放模式", - "cutButton": "切割多边形区域", - "deleteButton": "移除", - "drawCircleMarkerButton": "创建圆形标记", - "rotateButton": "旋转多边形" - }, - "map-provider-settings": "地图提供商设置", - "map-provider": "地图提供商", - "map-provider-google": "谷歌地图", - "map-provider-openstreet": "OpenStreet地图", - "map-provider-here": "HERE地图", - "map-provider-image": "图像地图", - "map-provider-tencent": "腾讯地图", - "openstreet-provider": "OpenStreet地图提供商", - "openstreet-provider-mapnik": "OpenStreetMap.Mapnik(默认)", - "openstreet-provider-hot": "OpenStreetMap.HOT", - "openstreet-provider-esri-street": "Esri.WorldStreetMap", - "openstreet-provider-esri-topo": "Esri.WorldTopoMap", - "openstreet-provider-esri-imagery": "Esri.WorldImagery", - "openstreet-provider-cartodb-positron": "CartoDB.Positron", - "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", - "use-custom-provider": "使用自定义提供商", - "custom-provider-tile-url": "自定义提供商瓦片URL", - "google-maps-api-key": "谷歌地图API密钥", - "default-map-type": "默认地图类型", - "google-map-type-roadmap": "路线图", - "google-map-type-satelite": "卫星图", - "google-map-type-hybrid": "混合图", - "google-map-type-terrain": "地形图", - "map-layer": "地图图层", - "here-map-normal-day": "HERE.normalDay(默认)", - "here-map-normal-night": "HERE.normalNight", - "here-map-hybrid-day": "HERE.hybridDay", - "here-map-terrain-day": "HERE.terrainDay", - "credentials": "凭证", - "here-app-id": "HERE应用ID", - "here-app-code": "HERE应用代码", - "here-api-key": "HERE API密钥", - "here-use-new-version-api-3": "使用API版本3", - "tencent-maps-api-key": "腾讯地图API密钥", - "tencent-map-type-roadmap": "路线图", - "tencent-map-type-satelite": "卫星图", - "tencent-map-type-hybrid": "混合图", - "image-map-background": "图像地图背景", - "image-map-background-from-entity-attribute": "从实体属性获取图像地图背景", - "image-url-source-entity-alias": "图像URL源实体别名", - "image-url-source-entity-attribute": "图像URL源实体属性", - "common-map-settings": "常见地图设置", - "x-pos-key-name": "X位置键名", - "y-pos-key-name": "Y位置键名", - "latitude-key-name": "纬度键名", - "longitude-key-name": "经度键名", - "default-map-zoom-level": "默认地图缩放级别(0-20)", - "default-map-center-position": "默认地图中心位置(0,0)", - "disable-scroll-zooming": "禁用滚动缩放", - "disable-double-click-zooming": "禁用双击缩放", - "disable-zoom-control-buttons": "禁用缩放控制按钮", - "fit-map-bounds": "适应地图边界以覆盖所有标记", - "use-default-map-center-position": "使用默认地图中心位置", - "entities-limit": "加载实体的限制", - "markers-settings": "标记设置", - "marker-offset-x": "标记X偏移相对于位置乘以标记宽度", - "marker-offset-y": "标记Y偏移相对于位置乘以标记高度", - "position-function": "位置转换函数,应返回0到1之间的x,y坐标", - "draggable-marker": "可拖动的标记", - "label": "标签", - "show-label": "显示标签", - "use-label-function": "使用标签函数", - "label-pattern": "标签 (模式示例:'${entityName}', '${entityName}: (Text ${keyName} units.)' )", - "label-function": "标签函数", - "tooltip": "文字提示", - "show-tooltip": "显示文字提示", - "show-tooltip-action": "显示文字提示的操作", - "show-tooltip-action-click": "点击时显示文字提示(默认)", - "show-tooltip-action-hover": "悬停时显示文字提示", - "auto-close-tooltips": "自动关闭文字提示", - "use-tooltip-function": "使用文字提示函数", - "tooltip-pattern": "文字提示 (例如 'Text ${keyName} units.' or Link text')", - "tooltip-function": "文字提示函数", - "tooltip-offset-x": "文字提示X偏移相对于标记锚点乘以标记宽度", - "tooltip-offset-y": "文字提示Y偏移相对于标记锚点乘以标记高度", - "color": "颜色", - "use-color-function": "使用颜色函数", - "color-function": "颜色函数", - "marker-image": "标记图像", - "use-marker-image-function": "使用标记图像函数", - "custom-marker-image": "自定义标记图像", - "custom-marker-image-size": "自定义标记图像大小(px)", - "marker-image-function": "标记图像函数", - "marker-images": "标记图像", - "polygon-settings": "多边形设置", - "show-polygon": "显示多边形", - "polygon-key-name": "多边形键名", - "enable-polygon-edit": "启用多边形编辑", - "polygon-label": "多边形标签", - "show-polygon-label": "显示多边形标签", - "use-polygon-label-function": "使用多边形标签函数", - "polygon-label-pattern": "多边形标签 (模板示例:'${entityName}', '${entityName}: (Text ${keyName} units.)' )", - "polygon-label-function": "多边形标签函数", - "polygon-tooltip": "多边形文字提示", - "show-polygon-tooltip": "显示多边形文字提示", - "auto-close-polygon-tooltips": "自动关闭多边形文字提示", - "use-polygon-tooltip-function": "使用多边形文字提示函数", - "polygon-tooltip-pattern": "文字提示(例如'文本${keyName}单位'或连接文本')", - "polygon-tooltip-function": "多边形文字提示函数", - "polygon-color": "多边形颜色", - "polygon-opacity": "多边形不透明度", - "use-polygon-color-function": "使用多边形颜色函数", - "polygon-color-function": "多边形颜色函数", - "polygon-stroke": "多边形描边", - "stroke-color": "描边颜色", - "stroke-opacity": "描边不透明度", - "stroke-weight": "描边宽度", - "use-polygon-stroke-color-function": "使用多边形描边颜色函数", - "polygon-stroke-color-function": "多边形描边颜色函数", - "circle-settings": "圆设置", - "show-circle": "显示圆", - "circle-key-name": "圆键名", - "enable-circle-edit": "启用圆编辑", - "circle-label": "圆标签", - "show-circle-label": "显示圆标签", - "use-circle-label-function": "使用圆标签函数", - "circle-label-pattern": "圆标签 (模板示例:'${entityName}', '${entityName}: (Text ${keyName} units.)' )", - "circle-label-function": "圆标签函数", - "circle-tooltip": "圆文字提示", - "show-circle-tooltip": "显示圆文字提示", - "auto-close-circle-tooltips": "自动关闭圆文字提示", - "use-circle-tooltip-function": "使用圆文字提示函数", - "circle-tooltip-pattern": "文字提示(例如'文本${keyName}单位'或连接文本')", - "circle-tooltip-function": "圆文字提示函数", - "circle-fill-color": "圆填充颜色", - "circle-fill-color-opacity": "圆填充颜色不透明度", - "use-circle-fill-color-function": "使用圆填充颜色函数", - "circle-fill-color-function": "圆填充颜色函数", - "circle-stroke": "圆边框", - "use-circle-stroke-color-function": "使用圆边框颜色函数", - "circle-stroke-color-function": "圆边框颜色函数", - "markers-clustering-settings": "标记聚类设置", - "use-map-markers-clustering": "使用地图标记聚类", - "zoom-on-cluster-click": "点击集群时进行缩放", - "max-cluster-zoom": "当标记可以成为集群的一部分时的最大缩放级别(0-18)", - "max-cluster-radius-pixels": "簇所覆盖的最大半径(px)", - "cluster-zoom-animation": "缩放时显示标记动画", - "show-markers-bounds-on-cluster-mouse-over": "鼠标悬停在集群上时显示标记范围", - "spiderfy-max-zoom-level": "以最大缩放级别展开簇(以查看所有集群标记)", + "marker": { + "marker": "标记", + "latitude-key": "纬度键", + "longitude-key": "经度键", + "x-pos-key": "X 位置键", + "y-pos-key": "Y 位置键", + "latitude-key-required": "纬度键为必填项", + "longitude-key-required": "经度键为必填项", + "x-pos-key-required": "X 位置键为必填项", + "y-pos-key-required": "Y 位置键为必填项", + "no-markers": "未配置标记", + "add-marker": "添加标记", + "marker-configuration": "标记配置", + "remove-marker": "移除标记", + "marker-type": "标记类型", + "marker-type-shape": "形状", + "marker-type-icon": "图标", + "marker-type-image": "图片", + "shape": "形状", + "icon": "图标", + "image": "图片", + "marker-shapes": "标记形状", + "marker-icon": "标记图标", + "marker-appearance": "标记外观", + "marker-image": "标记图片", + "marker-image-type-image": "图片", + "marker-image-type-function": "函数", + "custom-marker-image-size": "自定义标记图片大小", + "marker-image-function": "标记图片函数", + "marker-images": "标记图片", + "marker-offset": "标记偏移", + "offset-horizontal": "水平", + "offset-vertical": "垂直", + "rotate-marker": "旋转标记", + "offset-angle": "偏移角度", + "position-conversion": "位置转换", + "position-conversion-function": "位置转换函数,应返回 x、y 坐标,每个值为 0 到 1 之间的双精度数", + "clustering": { + "use-map-markers-clustering": "使用地图标记聚合", + "zoom-on-cluster-click": "点击聚合时缩放", + "max-zoom": "标记可成为聚合一部分的最大缩放级别(0 - 18)", + "max-radius": "聚合覆盖的最大半径", + "zoom-animation": "缩放时标记的动画", + "bounds-on-cluster-mouse-over": "鼠标悬停聚合时的标记边界", + "spiderfy-max-zoom-level": "在最大缩放级别时展开(以查看所有聚合标记)", "load-optimization": "加载优化", - "cluster-chunked-loading": "使用块添加标记,以避免页面冻结", - "cluster-markers-lazy-load": "使用延迟加载添加标记", - "editor-settings": "编辑器设置", - "enable-snapping": "启用精确绘制时的吸附到其他顶点", - "init-draggable-mode": "以可拖动模式初始化地图", - "hide-all-edit-buttons": "隐藏所有编辑控制按钮", - "hide-draw-buttons": "隐藏绘制按钮", - "hide-edit-buttons": "隐藏编辑按钮", - "hide-remove-button": "隐藏删除按钮", - "route-map-settings": "路线地图设置", - "trip-animation-settings": "行程动画设置", - "normalization-step": "数据标准化步骤(毫秒)", - "tooltip-background-color": "文字提示背景颜色", - "tooltip-font-color": "文字提示字体颜色", - "tooltip-opacity": "文字提示不透明度(0-1)", - "auto-close-tooltip": "自动关闭文字提示", - "rotation-angle": "设置标记的附加旋转角度(度)", - "path-settings": "路径设置", - "path-color": "路径颜色", - "use-path-color-function": "使用路径颜色函数", - "path-color-function": "路径颜色函数", - "path-decorator": "路径装饰器", - "use-path-decorator": "使用路径装饰器", - "decorator-symbol": "装饰符号", - "decorator-symbol-arrow-head": "箭头", - "decorator-symbol-dash": "短划线", - "decorator-symbol-size": "装饰符号大小(px)", - "use-path-decorator-custom-color": "使用路径装饰器自定义颜色", - "decorator-custom-color": "装饰符号自定义颜色", - "decorator-offset": "装饰符号偏移量", - "end-decorator-offset": "结束装饰符号偏移量", - "decorator-repeat": "装饰符号重复", - "points-settings": "点设置", - "show-points": "显示点", - "point-color": "点颜色", - "point-size": "点大小(px)", - "use-point-color-function": "使用点颜色函数", - "point-color-function": "点颜色函数", - "use-point-as-anchor": "使用点作为锚点", - "point-as-anchor-function": "点作为锚点函数", - "independent-point-tooltip": "独立点文字提示", - "clustering-markers": "标记聚类", - "use-icon-create-function": "使用标记颜色函数", + "chunked-load": "使用分块添加标记以避免页面冻结", + "lazy-load": "使用懒加载添加标记", + "use-cluster-marker-color-function": "使用聚合标记颜色函数", "marker-color-function": "标记颜色函数" + }, + "edit": "编辑标记", + "remove-marker-for": "移除“{{entityName}}”的标记", + "place-marker": "放置标记", + "place-marker-hint": "点击以放置标记", + "place-marker-hint-with-entity": "点击以放置“{{entityName}}”实体" }, - "markdown": { - "use-markdown-text-function": "使用Markdown/HTML值函数", - "markdown-text-function": "Markdown/HTML值函数", - "markdown-text-pattern": "Markdown/HTML模板(使用变量的markdown或HTML,例如 '${entityName}或${keyName}。')", - "apply-default-markdown-style": "应用默认Markdown样式", - "markdown-css": "Markdown/HTML CSS" - }, - "simple-card": { - "label": "标签", - "label-position": "标签位置", - "label-position-left": "左侧", - "label-position-top": "顶部" - }, - "single-switch": { - "behavior": "行为", - "layout": "布局", - "layout-right": "居右", - "layout-left": "居左", - "layout-centered": "居中", - "auto-scale": "自动", - "label": "标签", - "icon": "图标", - "switch-color": "开关颜色", - "on": "打开", - "off": "关闭", - "disabled": "禁用", - "tumbler-color": "缩略图颜色", - "on-label": "打开标签", - "off-label": "闭关标签", - "switch": "开关" - }, - "slider": { - "behavior": "行为", - "initial-value": "初始值", - "initial-value-hint": "获取滑块的初始值。", - "on-value-change": "设置值", - "on-value-change-hint": "更改滑块数值时触发操作。", - "layout": "布局", - "layout-default": "默认", - "layout-extended": "扩展", - "layout-simplified": "精简", - "auto-scale": "自动", - "icon": "图标", - "value": "数值", - "range": "范围", - "min": "最小值", - "max": "最大值", - "range-ticks": "刻度范围", - "tick-marks": "刻度线", - "colors": "颜色", - "main": "前景色", - "background": "背景色", - "left-icon": "左边图标", - "right-icon": "右边图标", - "slider": "滑块" + "path": { + "path": "路径", + "path-decorator": "路径装饰器", + "decorator-symbol": "装饰器符号", + "decorator-symbol-arrow-head": "箭头", + "decorator-symbol-dash": "虚线", + "decorator-arrangement": "装饰器排列", + "decorator-offset": "起始", + "decorator-end-offset": "结束", + "decorator-repeat": "重复" }, - "value-card": { - "layout": "布局", - "layout-square": "正方形", - "layout-vertical": "垂直", - "layout-centered": "居中", - "layout-simplified": "简化", - "layout-horizontal": "水平", - "layout-horizontal-reversed": "水平翻转", - "label": "标签", - "icon": "图标", - "value": "数值", - "date": "日期", - "value-card-style": "数值卡片样式", - "auto-scale": "自动缩放" + "points": { + "points": "点", + "point-tooltip": "点提示框" }, - "label-card": { - "auto-scale": "自动", - "label": "标签", - "icon": "图标", - "label-card-style": "样式" + "shape": { + "fill": "填充", + "fill-type-color": "颜色", + "fill-type-stripe": "条纹", + "fill-type-image": "图片", + "color": "颜色", + "stripe": "条纹", + "image": "图片", + "stroke": "描边", + "fill-image": "填充图片", + "fill-image-type-image": "图片", + "fill-image-type-function": "函数", + "preserve-aspect-ratio": "保持宽高比", + "opacity": "不透明度", + "angle": "旋转角度", + "scale": "缩放", + "fill-image-function": "形状填充图片函数", + "fill-images": "形状填充图片", + "stripe-pattern": "条纹图案", + "first-stripe": "第一条纹", + "second-stripe": "第二条纹" }, - "label-value-card": { - "value": "数值", - "label-value-card-style": "标签数值样式" + "polygon": { + "polygon-key": "多边形键", + "polygon-key-required": "多边形键为必填项", + "no-polygons": "未配置多边形", + "add-polygon": "添加多边形", + "polygon-configuration": "多边形配置", + "remove-polygon": "移除多边形", + "edit": "编辑多边形", + "remove-polygon-for": "移除“{{entityName}}”的多边形", + "cut": "裁剪多边形区域", + "rotate": "旋转多边形", + "draw-rectangle": "绘制矩形", + "draw-polygon": "绘制多边形", + "polygon-place-first-point-cut-hint": "点击以放置第一个点", + "continue-polygon-cut-hint": "点击以继续绘制", + "finish-polygon-cut-hint": "点击第一个标记以完成并保存", + "polygon-place-first-point-hint": "多边形:点击以放置第一个点", + "polygon-place-first-point-hint-with-entity": "“{{entityName}}”的多边形:点击以放置第一个点", + "continue-polygon-hint": "多边形:点击以继续绘制", + "continue-polygon-hint-with-entity": "“{{entityName}}”的多边形:点击以继续绘制", + "finish-polygon-hint": "多边形:点击第一个标记以完成绘制", + "finish-polygon-hint-with-entity": "“{{entityName}}”的多边形:点击第一个标记以完成并保存", + "rectangle-place-first-point-hint": "矩形:点击以放置第一个点", + "rectangle-place-first-point-hint-with-entity": "“{{entityName}}”的矩形:点击以放置第一个点", + "finish-rectangle-hint": "矩形:点击以完成绘制", + "finish-rectangle-hint-with-entity": "“{{entityName}}”的矩形:点击以完成并保存" }, - "liquid-level-card": { - "layout-simple": "简单", - "layout-percentage": "百分比", - "layout-absolute": "绝对值", - "layout": "布局", - "background-overlay": "值背景叠加", - "total-volume": "总体积", - "total-volume-units": "总体积单位", - "tank": "容器", - "shape": "形状", - "datasource-units": "数据源单位", - "widget-units": "部件单位", - "decimals": "小数位数", - "liquid": "液体", - "liquid-color": "液体颜色", - "value": "数值", - "value-font": "值字体", - "level": "水位", - "last-update": "最后更新", - "shape-by-attribute": "按属性名称设置储罐形状", - "tooltip-background": "背景颜色", - "background-blur": "背景模糊", - "tank-color": "容器颜色", - "static": "静态", - "see-examples": "查看示例", - "attribute": "属性", - "shape-type": "类型", - "v-oval": "垂直椭圆", - "v-cylinder": "垂直圆柱体", - "v-capsule": "垂直胶囊", - "rectangle": "矩形", - "h-oval": "水平椭圆", - "h-ellipse": "水平椭圆", - "h-dish-ends": "水平盘形", - "h-cylinder": "水平圆柱体", - "h-capsule": "水平胶囊", - "h-elliptical_2_1": "水平2:1椭圆", - "icon": "图标", - "title": "标题", - "units": "单位", - "color-and-font": "颜色和字体", - "shape-attribute-name": "属性名称", - "total-volume-required": "需要总体积。", - "attribute-name-required": "需要属性名称。", - "attribute-key-not-set": "未设置属性'{{attributeName}}'键", - "attribute-key-invalid": "属性'{{attributeName}}'键无效" + "circle": { + "circle-key": "圆形键", + "circle-key-required": "圆形键为必填项", + "no-circles": "未配置圆形", + "add-circle": "添加圆形", + "circle-configuration": "圆形配置", + "remove-circle": "移除圆形", + "edit": "编辑圆形", + "remove-circle-for": "移除“{{entityName}}”的圆形", + "draw-circle": "绘制圆形", + "place-circle-center-hint-with-entity": "“{{entityName}}”的圆形:点击以放置圆心", + "place-circle-center-hint": "圆形:点击以放置圆心", + "finish-circle-hint-with-entity": "“{{entityName}}”的圆形:点击以完成并保存", + "finish-circle-hint": "圆形:点击以完成绘制" }, - "aggregated-value-card": { - "subtitle": "副标题", - "chart": "图表", - "values": "数值", - "value-appearance": "数值外观", - "position": "位置", - "position-center": "中心", - "position-right-top": "右上", - "position-right-bottom": "右下", - "position-left-top": "左上", - "position-left-bottom": "左下", - "font": "字体", - "color": "颜色", - "arrow": "箭头", - "display-up-down-arrow": "显示向上/向下箭头", - "add-value": "添加数值", - "remove-value": "移除数值", - "no-values": "未配置数值", - "aggregation": "聚合", - "aggregated-value-card-style": "聚合数值卡片样式", - "auto-scale": "自动缩放" + "polyline": { + "polyline-key": "折线键", + "polyline-key-required": "折线键为必填项", + "no-polylines": "未配置折线", + "add-polylines": "添加折线", + "polyline-configuration": "折线配置", + "remove-polyline": "移除折线", + "edit": "编辑折线", + "cut": "裁剪折线区域", + "rotate": "旋转折线", + "remove-polyline-for": "移除“{{entityName}}”的折线", + "draw-polyline": "绘制折线", + "polyline-place-first-point-hint-with-entity": "“{{entityName}}”的折线:点击以放置第一个点", + "polyline-place-first-point-hint": "折线:点击以放置第一个点", + "finish-polyline-hint-with-entity": "“{{entityName}}”的折线:点击以完成绘制", + "finish-polyline-hint": "折线:点击以完成绘制", + "polyline-place-first-point-cut-hint": "点击以放置第一个点", + "finish-polyline-cut-hint": "点击第一个标记以完成并保存" }, - "value-chart-card": { - "layout": "布局", - "layout-left": "左侧", - "layout-right": "右侧", - "auto-scale": "自动缩放", - "icon": "图标", - "value": "数值", - "chart": "图表", - "value-chart-card-style": "数值图表卡片样式" - }, - "progress-bar": { - "layout": "布局", - "layout-default": "默认", - "layout-simplified": "简化", - "auto-scale": "自动缩放", - "icon": "图标", - "value": "数值", - "range": "范围", - "min": "最小值", - "max": "最大值", - "range-ticks": "刻度范围", - "bar": "条形", - "bar-color": "条形颜色", - "bar-background": "条形背景色", - "progress-bar-card-style": "条形卡片样式" - }, - "notification": { - "max-notification-display": "显示的最大通知数量", - "counter": "计数提示", - "counter-hint": "如果启用部件标题将显示通知计数", - "icon": "图标", - "counter-value": "数值", - "counter-color": "颜色", - "notification-button": "通知按钮", - "button-view-all": "查看全部", - "button-filter": "过滤", - "type-filter": "类型", - "button-mark-read": "全部已读", - "notification-types": "通知类型", - "notification-type": "通知类型", - "search-type": "查询类型", - "any-type": "任意类型" - }, - "alarm-count": { - "alarm-count-card-style": "告警告计数卡片样式" - }, - "entity-count": { - "entity-count-card-style": "实体计数卡片样式" - }, - "count": { - "layout": "布局", - "layout-column": "列", - "layout-row": "行", - "label": "标签", - "icon": "图标", - "icon-background": "图标背景", - "value": "数值", - "chevron": "箭头", - "auto-scale": "自动缩放" - }, - "table": { - "common-table-settings": "常规设置", - "enable-search": "启用搜索", - "enable-sticky-header": "显示表头", - "enable-sticky-action": "显示操作列", - "hidden-cell-button-display-mode": "隐藏单元格按钮操作显示模式", - "show-empty-space-hidden-action": "显示空白区域而不是隐藏单元格按钮操作", - "dont-reserve-space-hidden-action": "不为隐藏的操作按钮预留空间", - "display-timestamp": "显示时间", - "display-pagination": "显示分页", - "default-page-size": "默认大小", - "use-entity-label-tab-name": "在选项卡名称中使用实体标签", - "hide-empty-lines": "隐藏空行", - "row-style": "行样式", - "use-row-style-function": "使用行样式函数", - "row-style-function": "行样式函数", - "cell-style": "单元格样式", - "use-cell-style-function": "使用单元格样式函数", - "cell-style-function": "单元格样式函数", - "cell-content": "单元格内容", - "use-cell-content-function": "使用单元格内容函数", - "cell-content-function": "单元格内容函数", - "show-latest-data-column": "显示最新数据列", - "latest-data-column-order": "最新数据列顺序", - "entities-table-title": "实体表标题", - "enable-select-column-display": "启用选择要显示的列", - "display-entity-name": "显示实体名称列", - "entity-name-column-title": "实体名称列标题", - "display-entity-label": "显示实体标签列", - "entity-label-column-title": "实体标签列标题", - "display-entity-type": "显示实体类型列", - "default-sort-order": "默认排序顺序", - "custom-title": "自定义表头标题", - "column-width": "列宽度(px或%)", - "default-column-visibility": "默认显示", - "column-visibility-visible": "显示", - "column-visibility-hidden": "隐藏", - "column-visibility-hidden-mobile": "在移动模式下隐藏", - "column-selection-to-display": "'显示列'中的列选择", - "column-selection-to-display-enabled": "启用", - "column-selection-to-display-disabled": "禁用", - "alarms-table-title": "告警表格标题", - "enable-alarms-selection": "启用告警选择", - "enable-alarms-search": "启用告警搜索", - "enable-alarm-filter": "启用告警过滤", - "display-alarm-details": "显示告警详细信息", - "allow-alarms-ack": "允许确认告警", - "allow-alarms-clear": "允许清除告警", - "display-alarm-activity": "显示活动告警", - "allow-alarms-assign": "允许分配告警", - "columns": "列", - "column-settings": "列设置", - "remove-column": "移除列", - "add-column": "添加列", - "no-columns": "没有配置列", - "columns-to-display": "显示的列", - "table-header": "表头", - "header-buttons": "表头按钮", - "table-buttons": "表格按钮", - "pagination": "分页", - "rows": "行", - "timeseries-column-error": "必须指定一个时间序序列", - "alarm-column-error": "必须指定一个告警数据列", - "table-tabs": "标签", - "show-cell-actions-menu-mobile": "在移动模式下显示单元格操作下拉菜单" - }, - "latest-chart": { - "total": "总数", - "auto-scale": "自动", - "clockwise-layout": "顺时针方向", - "sort-series": "标签排序", - "tooltip-value-type-absolute": "绝对", - "tooltip-value-type-percentage": "百分比" - }, - "pie-chart": { - "pie-chart-appearance": "外观", - "label": "标签", - "border": "标签", - "radius": "圆角", - "pie-chart-card-style": "样式" - }, - "radar-chart": { - "radar-appearance": "外观", - "shape": "形状", - "shape-polygon": "多边形", - "shape-circle": "圆形", - "color": "颜色", - "line": "线条", - "points": "点", - "points-label": "点标签", - "radar-axis": "雷达轴", - "axis-label": "轴标签", - "ticks-label": "刻度标签", - "radar-chart-style": "样式" - }, - "time-series-chart": { - "chart": "图表", - "chart-style": "样式", - "data-zoom": "数据缩放", - "stack-mode": "堆叠模式", - "stack-mode-hint": "图表上的堆栈具有同一单元的序列将放置在一起。", - "axes": "坐标", - "y-axes": "Y轴", - "line-type": "线条类型", - "line-width": "线条宽度", - "type-line": "线条", - "type-bar": "条形", - "type-point": "点", - "no-aggregation-bar-width-strategy": "非聚合数据的条形宽度", - "no-aggregation-bar-width-strategy-group": "分组", - "no-aggregation-bar-width-strategy-separate": "分隔", - "bar-group-width": "条形分组宽度", - "bar-width": "条形宽度", - "bar-width-relative": "百分比", - "bar-width-absolute": "绝对(ms)", - "comparison": { - "comparison": "数据比较", - "comparison-hint": "仅与历史数据一起比较!", - "show": "显示", - "settings": "比较设置", - "show-values-for-comparison": "显示历史数据进行比较", - "comparison-values-label": "数据键标签", - "comparison-values-label-auto": "自动", - "comparison-data-color": "颜色" - }, - "threshold": { - "thresholds": "阈值", - "source": "源", - "key-value": "键/值", - "no-thresholds": "未配置阈值", - "add-threshold": "添加", - "type-constant": "常量", - "type-latest-key": "键", - "type-entity": "实体", - "threshold-settings": "设置阈值", - "remove-threshold": "移除阈值", - "threshold-value-required": "阈值必填。", - "key-required": "键必填。", - "entity-key-required": "实体必填。", - "line-appearance": "线条外观", - "line-color": "线条颜色", - "start-symbol": "起始", - "end-symbol": "结束", - "symbol-size": "尺寸", - "label": "标签", - "label-position-start": "起始", - "label-position-middle": "中间", - "label-position-end": "结束", - "label-position-inside-start": "起始", - "label-position-inside-start-top": "起始上", - "label-position-inside-start-bottom": "起始下", - "label-position-inside-middle": "中间", - "label-position-inside-middle-top": "中间上", - "label-position-inside-middle-bottom": "中间下", - "label-position-inside-end": "结束", - "label-position-inside-end-top": "结束上", - "label-position-inside-end-bottom": "结束下", - "label-background": "标签背景" - }, - "state": { - "states": "状态", - "label": "标签", - "ticks-value": "刻度", - "source": "源", - "value-range": "值/范围", - "no-states": "未配置状态", - "add-state": "添加", - "type-constant": "常量", - "type-range": "范围", - "from": "从", - "to": "到", - "remove-state": "移除" - }, - "grid": { - "grid": "网格", - "background-color": "背景颜色", - "border": "边框" - }, - "axis": { - "axes": "坐标", - "x-axis": "X轴", - "y-axis": "Y轴", - "y-axis-settings": "Y轴设置", - "comparison-x-axis-settings": "比较X轴设置", - "remove-y-axis": "移除Y轴", - "id": "编号", - "label": "标签", - "position": "位置", - "position-left": "左", - "position-right": "右", - "position-top": "上", - "position-bottom": "下", - "tick-labels": "刻度标签", - "ticks-formatter-function": "刻度格式化函数", - "ticks-generator-function": "刻度生成函数", - "show-ticks": "显示刻度", - "show-line": "显示线条", - "show-split-lines": "显示分隔线", - "show-split-lines-x-axis-hint": "启用则显示垂直线。", - "show-split-lines-y-axis-hint": "启用则显示水平线。", - "ticks-interval": "刻度间隔", - "ticks-interval-hint": "强制设置轴的分段间隔。", - "split-number": "分隔号码", - "split-number-hint": "轴分成的多少段数量。", - "min": "最小值", - "max": "最大值", - "show": "显示", - "add-y-axis": "添加" - }, - "series": { - "legend-settings": "设置图例", - "show-in-legend": "显示图例", - "show-in-legend-hint": "在图例中显示系列名称和数据。", - "hidden-by-default": "默认情况下隐藏", - "hidden-by-default-hint": "默认情况下使系列隐藏在图例中。", - "series-type": "序列类型", - "type": "类型", - "type-line": "线条类型", - "type-bar": "条形", - "line": { - "line": "线条", - "show-line": "显示线条", - "step-line": "斜线", - "step-type-start": "起始", - "step-type-middle": "中间", - "step-type-end": "结束", - "smooth-line": "流畅" - }, - "point": { - "points": "点", - "show-points": "显示", - "point-label": "标签", - "point-label-hint": "显示具有超过序列点的值的标签。", - "point-label-background": "背景", - "point-shape": "形状", - "point-size": "尺寸" - } - } - }, - "wind-speed-direction": { - "layout": "布局", - "layout-default": "默认", - "layout-advanced": "高级", - "layout-simplified": "简化", - "values": "数值", - "wind-direction": "风向", - "center-value": "中心数值", - "icon": "图标", - "arrow": "箭头", - "ticks": "刻度", - "labels-type": "标签类型", - "directional-names": "方向名称", - "degrees": "度数", - "major-ticks": "主要刻度", - "minor-ticks": "次要刻度", - "wind-speed-direction-card-style": "风速和风向卡片样式", - "ticks-color": "刻度颜色", - "ticks-labels-type": "刻度标签类型", - "arrow-color": "箭头颜色" - }, - "value-source": { - "value-source": "值来源", - "predefined-value": "预定义值", - "entity-attribute": "从实体属性中获取的值", - "value": "数值", - "value-required": "数值必填。", - "key-required": "键必填。", - "entity-key-required": "实体必填。", - "source-entity-alias": "源实体别名", - "source-entity-attribute": "源实体属性", - "type-constant": "常量", - "type-latest-key": "键", - "type-entity": "实体" - }, - "rpc-state": { - "initial-state": "初始状态", - "initial-state-hint": "获取组件的初始状态(开/关)。", - "disabled-state": "禁用状态", - "disabled-state-hint": "配置禁用条件。", - "turn-on": "打开", - "turn-on-hint": "当滑块切换至“开”时触发的操作", - "turn-off": "关闭", - "turn-off-hint": "当滑块切换至“关闭”时触发的操作", - "on": "打开", - "off": "关闭", - "disabled": "禁用" - }, - "value-action": { - "do-nothing": "无任何动作", - "execute-rpc": "执行RPC", - "get-attribute": "获取属性", - "set-attribute": "设置属性", - "get-time-series": "获取遥测", - "get-alarm-status": "获取告警状态", - "get-dashboard-state": "获取仪表板状态", - "add-time-series": "添加遥测", - "execute-rpc-text": "执行RPC方法'{{methodName}}'", - "get-time-series-text": "使用遥测'{{key}}'", - "get-attribute-text": "使用属性'{{key}}'", - "get-alarm-status-text": "使用告警状态", - "get-dashboard-state-text": "使用仪表板状态", - "when-dashboard-state-is-text": "当仪表板状态为'{{state}}'", - "when-dashboard-state-function-is-text": "当(仪表板状态)是'{{state}}'", - "set-attribute-to-value-text": "设置'{{key}}'属性值:{{value}}", - "add-time-series-value-text": "添加'{{key}}'遥测值:{{value}}", - "set-attribute-text": "设置'{{key}}'属性", - "add-time-series-text": "添加'{{key}}'遥测", - "action": "动作", - "value": "数值", - "init-value-hint": "在设备发送数据之前将值设置。", - "method": "方法", - "method-name-required": "方法名称必填。", - "request-timeout-ms": "RPC请求超时(ms)", - "request-timeout-required": "RPC请求超时必填。", - "min-request-timeout-error": "请求超时值应更大或等于5000(5秒)。", - "request-persistent": "RPC请求持久化", - "persistent-polling-interval": "持久化的轮训间隔(ms)", - "persistent-polling-interval-hint": "轮询获取持久化RPC的命令响应", - "persistent-polling-interval-required": "轮询获取持久化必填。", - "min-persistent-polling-interval-error": "持续的轮询间隔值应>=1000 ms(1秒)。", - "attribute-scope": "作用域", - "attribute-key": "键名称", - "attribute-key-required": "键名称必填。", - "time-series-key": "键名称", - "time-series-key-required": "键名称必填。", - "action-result-converter": "动作转换", - "converter-none": "无", - "converter-function": "函数", - "converter-constant": "常量", - "converter-value": "数值", - "parse-value-function": "解析函数", - "state-when-result-is": "'{{state}}'结果是", - "parameters": "参数", - "convert-value-function": "转换函数", - "error": { - "target-entity-is-not-set": "目标实体未设置!", - "failed-to-perform-action": "无法执行{{ actionLabel }}操作。", - "invalid-attribute-scope": " {{entityType}}实体不支持{{scope}}属性作用域。" - } - }, - "widget-font": { - "font-settings": "字体设置", - "font-family": "字体", - "size": "大小", - "relative-font-size": "相对字体大小(百分比)", - "font-style": "样式", - "font-style-normal": "正常", - "font-style-italic": "斜体", - "font-style-oblique": "倾斜", - "font-weight": "粗细", - "font-weight-normal": "正常", - "font-weight-bold": "粗体", - "font-weight-bolder": "更粗", - "font-weight-lighter": "更轻", - "color": "颜色", - "shadow-color": "阴影颜色", - "preview": "预览", - "line-height": "行高", - "auto": "自动" - }, - "home": { - "no-data-available": "暂无数据" - }, - "system-info": { - "cpu": "CPU", - "ram": "内存", - "disk": "磁盘", - "cpu-warning-text": "CPU使用率过高为了避免系统故障,请优化系统性能。", - "cpu-critical-text": "CPU使用率严重过高为了避免系统故障,请优化系统性能。", - "ram-warning-text": "内存储备不足为了避免系统故障,请优化系统性能或增加内存大小。", - "ram-critical-text": "内存储备严重不足为了避免系统故障,请优化系统性能或增加内存大小。", - "disk-warning-text": "磁盘空间不足为了避免数据丢失,请释放或扩展磁盘空间。", - "disk-critical-text": "磁盘空间严重不足为了避免数据丢失,请释放或扩展磁盘空间。" - }, - "cluster-info": { - "service-id": "服务ID", - "service-type": "服务类型", - "no-data": "无数据" - }, - "transport-messages": { - "title": "传输消息", - "info": "来自设备的所有消息" + "select-entity": "选择实体", + "select-entity-hint": "提示:选择后点击地图以设置位置" + }, + "select-entity": "选择实体", + "select-entity-hint": "提示:选择后点击地图以设置位置", + "tooltips": { + "placeMarker": "点击以放置“{{entityName}}”实体", + "firstVertex": "“{{entityName}}”的多边形:点击以放置第一个点", + "firstVertex-cut": "点击以放置第一个点", + "continueLine": "“{{entityName}}”的多边形:点击以继续绘制", + "continueLine-cut": "点击以继续绘制", + "finishLine": "点击任意现有标记以完成", + "finishPoly": "“{{entityName}}”的多边形:点击第一个标记以完成并保存", + "finishPoly-cut": "点击第一个标记以完成并保存", + "finishRect": "“{{entityName}}”的多边形:点击以完成并保存", + "startCircle": "“{{entityName}}”的圆形:点击以放置圆心", + "finishCircle": "“{{entityName}}”的圆形:点击以完成圆形", + "placeCircleMarker": "点击以放置圆形标记" + }, + "actions": { + "finish": "完成", + "cancel": "取消", + "removeLastVertex": "移除最后一个点" + }, + "buttonTitles": { + "drawMarkerButton": "放置实体", + "drawPolyButton": "创建多边形", + "drawLineButton": "创建折线", + "drawCircleButton": "创建圆形", + "drawRectButton": "创建矩形", + "editButton": "编辑模式", + "dragButton": "拖放模式", + "cutButton": "裁剪多边形区域", + "deleteButton": "移除", + "drawCircleMarkerButton": "创建圆形标记", + "rotateButton": "旋转多边形" + }, + "map-provider-settings": "地图提供商设置", + "map-provider": "地图提供商", + "map-provider-google": "Google 地图", + "map-provider-openstreet": "OpenStreet 地图", + "map-provider-here": "HERE 地图", + "map-provider-image": "图片地图", + "map-provider-tencent": "腾讯地图", + "openstreet-provider": "OpenStreet 地图提供商", + "openstreet-provider-mapnik": "OpenStreetMap.Mapnik(默认)", + "openstreet-provider-hot": "OpenStreetMap.HOT", + "openstreet-provider-esri-street": "Esri.WorldStreetMap", + "openstreet-provider-esri-topo": "Esri.WorldTopoMap", + "openstreet-provider-esri-imagery": "Esri.WorldImagery", + "openstreet-provider-cartodb-positron": "CartoDB.Positron", + "openstreet-provider-cartodb-dark-matter": "CartoDB.DarkMatter", + "use-custom-provider": "使用自定义提供商", + "custom-provider-tile-url": "自定义提供商瓦片 URL", + "google-maps-api-key": "Google Maps API 密钥", + "default-map-type": "默认地图类型", + "google-map-type-roadmap": "路线图", + "google-map-type-satelite": "卫星图", + "google-map-type-hybrid": "混合图", + "google-map-type-terrain": "地形图", + "map-layer": "地图图层", + "here-map-normal-day": "HERE.normalDay(默认)", + "here-map-normal-night": "HERE.normalNight", + "here-map-hybrid-day": "HERE.hybridDay", + "here-map-terrain-day": "HERE.terrainDay", + "credentials": "凭据", + "here-app-id": "HERE 应用 ID", + "here-app-code": "HERE 应用代码", + "here-api-key": "HERE API 密钥", + "here-use-new-version-api-3": "使用 API 版本 3", + "tencent-maps-api-key": "腾讯地图 API 密钥", + "tencent-map-type-roadmap": "路线图", + "tencent-map-type-satelite": "卫星图", + "tencent-map-type-hybrid": "混合图", + "image-map-background": "图片地图背景", + "image-map-background-from-entity-attribute": "从实体属性获取图片地图背景", + "image-url-source-entity-alias": "图片 URL 来源实体别名", + "image-url-source-entity-attribute": "图片 URL 来源实体属性", + "common-map-settings": "通用地图设置", + "x-pos-key-name": "X 位置键名称", + "y-pos-key-name": "Y 位置键名称", + "latitude-key-name": "纬度键名称", + "longitude-key-name": "经度键名称", + "default-map-zoom-level": "默认地图缩放级别(0 - 20)", + "default-map-center-position": "默认地图中心位置(0,0)", + "disable-scroll-zooming": "禁用滚动缩放", + "disable-double-click-zooming": "禁用双击缩放", + "disable-zoom-control-buttons": "禁用缩放控制按钮", + "fit-map-bounds": "调整地图范围以覆盖所有标记", + "use-default-map-center-position": "使用默认地图中心位置", + "entities-limit": "加载实体数量限制", + "markers-settings": "标记设置", + "marker-offset-x": "标记 X 偏移(相对于位置乘以标记宽度)", + "marker-offset-y": "标记 Y 偏移(相对于位置乘以标记高度)", + "position-function": "位置转换函数,应返回 x、y 坐标,每个值为 0 到 1 之间的双精度数", + "draggable-marker": "可拖动标记", + "label": "标签", + "show-label": "显示标签", + "use-label-function": "使用标签函数", + "label-pattern": "标签(模式示例:'${entityName}','${entityName}: (Text ${keyName} units.)')", + "label-function": "标签函数", + "tooltip": "提示框", + "show-tooltip": "显示提示框", + "show-tooltip-action": "显示提示框的操作", + "show-tooltip-action-click": "点击时显示提示框(默认)", + "show-tooltip-action-hover": "悬停时显示提示框", + "auto-close-tooltips": "自动关闭提示框", + "use-tooltip-function": "使用提示框函数", + "tooltip-pattern": "提示框(例如 'Text ${keyName} units.' 或 Link text')", + "tooltip-function": "提示框函数", + "tooltip-offset-x": "提示框 X 偏移(相对于标记锚点乘以标记宽度)", + "tooltip-offset-y": "提示框 Y 偏移(相对于标记锚点乘以标记高度)", + "color": "颜色", + "use-color-function": "使用颜色函数", + "color-function": "颜色函数", + "marker-image": "标记图片", + "use-marker-image-function": "使用标记图片函数", + "custom-marker-image": "自定义标记图片", + "custom-marker-image-size": "自定义标记图片大小(像素)", + "marker-image-function": "标记图片函数", + "marker-images": "标记图片", + "polygon-settings": "多边形设置", + "show-polygon": "显示多边形", + "polygon-key-name": "多边形键名称", + "enable-polygon-edit": "启用多边形编辑", + "polygon-label": "多边形标签", + "show-polygon-label": "显示多边形标签", + "use-polygon-label-function": "使用多边形标签函数", + "polygon-label-pattern": "多边形标签(模式示例:'${entityName}','${entityName}: (Text ${keyName} units.)')", + "polygon-label-function": "多边形标签函数", + "polygon-tooltip": "多边形提示框", + "show-polygon-tooltip": "显示多边形提示框", + "auto-close-polygon-tooltips": "自动关闭多边形提示框", + "use-polygon-tooltip-function": "使用多边形提示框函数", + "polygon-tooltip-pattern": "提示框(例如 'Text ${keyName} units.' 或 Link text')", + "polygon-tooltip-function": "多边形提示框函数", + "polygon-color": "多边形颜色", + "polygon-opacity": "多边形不透明度", + "use-polygon-color-function": "使用多边形颜色函数", + "polygon-color-function": "多边形颜色函数", + "polygon-stroke": "多边形描边", + "stroke-color": "描边颜色", + "stroke-opacity": "描边不透明度", + "stroke-weight": "描边粗细", + "use-polygon-stroke-color-function": "使用多边形描边颜色函数", + "polygon-stroke-color-function": "多边形描边颜色函数", + "circle-settings": "圆形设置", + "show-circle": "显示圆形", + "circle-key-name": "圆形键名称", + "enable-circle-edit": "启用圆形编辑", + "circle-label": "圆形标签", + "show-circle-label": "显示圆形标签", + "use-circle-label-function": "使用圆形标签函数", + "circle-label-pattern": "圆形标签(模式示例:'${entityName}','${entityName}: (Text ${keyName} units.)')", + "circle-label-function": "圆形标签函数", + "circle-tooltip": "圆形提示框", + "show-circle-tooltip": "显示圆形提示框", + "auto-close-circle-tooltips": "自动关闭圆形提示框", + "use-circle-tooltip-function": "使用圆形提示框函数", + "circle-tooltip-pattern": "提示框(例如 'Text ${keyName} units.' 或 Link text')", + "circle-tooltip-function": "圆形提示框函数", + "circle-fill-color": "圆形填充颜色", + "circle-fill-color-opacity": "圆形填充颜色不透明度", + "use-circle-fill-color-function": "使用圆形填充颜色函数", + "circle-fill-color-function": "圆形填充颜色函数", + "circle-stroke": "圆形描边", + "use-circle-stroke-color-function": "使用圆形描边颜色函数", + "circle-stroke-color-function": "圆形描边颜色函数", + "markers-clustering-settings": "标记聚合设置", + "use-map-markers-clustering": "使用地图标记聚合", + "zoom-on-cluster-click": "点击聚合时缩放", + "max-cluster-zoom": "标记可成为聚合一部分的最大缩放级别(0 - 18)", + "max-cluster-radius-pixels": "聚合覆盖的最大半径(像素)", + "cluster-zoom-animation": "缩放时显示标记动画", + "show-markers-bounds-on-cluster-mouse-over": "鼠标悬停聚合时显示标记边界", + "spiderfy-max-zoom-level": "在最大缩放级别时展开(以查看所有聚合标记)", + "load-optimization": "加载优化", + "cluster-chunked-loading": "使用分块添加标记以避免页面冻结", + "cluster-markers-lazy-load": "使用懒加载添加标记", + "editor-settings": "编辑器设置", + "enable-snapping": "启用对齐到其他顶点以精确绘制", + "init-draggable-mode": "以拖拽模式初始化地图", + "hide-all-edit-buttons": "隐藏所有编辑控制按钮", + "hide-draw-buttons": "隐藏绘制按钮", + "hide-edit-buttons": "隐藏编辑按钮", + "hide-remove-button": "隐藏移除按钮", + "route-map-settings": "路线地图设置", + "trip-animation-settings": "行程动画设置", + "normalization-step": "数据标准化步长(毫秒)", + "tooltip-background-color": "提示框背景颜色", + "tooltip-font-color": "提示框字体颜色", + "tooltip-opacity": "提示框不透明度(0-1)", + "auto-close-tooltip": "自动关闭提示框", + "rotation-angle": "设置标记的附加旋转角度(度)", + "path-settings": "路径设置", + "path-color": "路径颜色", + "use-path-color-function": "使用路径颜色函数", + "path-color-function": "路径颜色函数", + "path-decorator": "路径装饰器", + "use-path-decorator": "使用路径装饰器", + "decorator-symbol": "装饰器符号", + "decorator-symbol-arrow-head": "箭头", + "decorator-symbol-dash": "虚线", + "decorator-symbol-size": "装饰器符号大小(像素)", + "use-path-decorator-custom-color": "使用路径装饰器自定义颜色", + "decorator-custom-color": "装饰器自定义颜色", + "decorator-offset": "装饰器偏移", + "end-decorator-offset": "结束装饰器偏移", + "decorator-repeat": "装饰器重复", + "points-settings": "点设置", + "show-points": "显示点", + "point-color": "点颜色", + "point-size": "点大小(像素)", + "use-point-color-function": "使用点颜色函数", + "point-color-function": "点颜色函数", + "use-point-as-anchor": "使用点作为锚点", + "point-as-anchor-function": "点作为锚点函数", + "independent-point-tooltip": "独立点提示框", + "clustering-markers": "标记聚合", + "use-icon-create-function": "使用标记颜色函数", + "marker-color-function": "标记颜色函数" + }, + "markdown": { + "use-markdown-text-function": "使用 Markdown/HTML 值函数", + "markdown-text-function": "Markdown/HTML 值函数", + "markdown-text-pattern": "Markdown/HTML 模式(Markdown 或带变量的 HTML,例如 '${entityName} or ${keyName} - some text.')", + "apply-default-markdown-style": "应用默认 Markdown 样式", + "markdown-css": "Markdown/HTML CSS" + }, + "simple-card": { + "label": "标签", + "label-position": "标签位置", + "label-position-left": "左侧", + "label-position-top": "顶部" + }, + "single-switch": { + "behavior": "行为", + "layout": "布局", + "layout-right": "右侧", + "layout-left": "左侧", + "layout-centered": "居中", + "auto-scale": "自动缩放", + "label": "标签", + "icon": "图标", + "switch-color": "开关颜色", + "on": "开", + "off": "关", + "disabled": "禁用", + "tumbler-color": "拨动器颜色", + "on-label": "开启标签", + "off-label": "关闭标签", + "switch": "开关" + }, + "slider": { + "behavior": "行为", + "initial-value": "初始值", + "initial-value-hint": "获取滑块初始值的操作。", + "on-value-change": "值变更时", + "on-value-change-hint": "滑块值变更时触发的操作。", + "layout": "布局", + "layout-default": "默认", + "layout-extended": "扩展", + "layout-simplified": "简化", + "auto-scale": "自动缩放", + "icon": "图标", + "value": "值", + "range": "范围", + "min": "最小", + "max": "最大", + "range-ticks": "范围刻度", + "tick-marks": "刻度标记", + "colors": "颜色", + "main": "主要", + "background": "背景", + "left-icon": "左侧图标", + "right-icon": "右侧图标", + "slider": "滑块" + }, + "value-card": { + "layout": "布局", + "layout-square": "方形", + "layout-vertical": "垂直", + "layout-centered": "居中", + "layout-simplified": "简化", + "layout-horizontal": "水平", + "layout-horizontal-reversed": "水平反向", + "label": "标签", + "icon": "图标", + "value": "值", + "date": "日期", + "value-card-style": "值卡片样式", + "auto-scale": "自动缩放" + }, + "label-card": { + "auto-scale": "自动缩放", + "label": "标签", + "icon": "图标", + "label-card-style": "标签卡片样式" + }, + "label-value-card": { + "value": "值", + "label-value-card-style": "标签和值卡片样式" + }, + "liquid-level-card": { + "layout-simple": "简单", + "layout-percentage": "百分比", + "layout-absolute": "绝对值", + "layout": "布局", + "background-overlay": "值背景覆盖", + "total-volume": "总容量", + "total-volume-units": "总容量单位", + "tank": "容器", + "shape": "形状", + "datasource-units": "源单位", + "widget-units": "部件单位", + "decimals": "小数位数", + "liquid": "液体", + "liquid-color": "液体颜色", + "value": "值", + "value-font": "值字体", + "level": "液位", + "last-update": "最后更新", + "shape-by-attribute": "通过属性名称设置容器形状", + "tooltip-background": "背景颜色", + "background-blur": "背景模糊", + "tank-color": "容器颜色", + "static": "静态", + "see-examples": "查看示例", + "attribute": "属性", + "shape-type": "类型", + "v-oval": "垂直椭圆", + "v-cylinder": "垂直圆柱", + "v-capsule": "垂直胶囊", + "rectangle": "矩形", + "h-oval": "水平椭圆", + "h-ellipse": "水平椭圆形", + "h-dish-ends": "水平碟形封头", + "h-cylinder": "水平圆柱", + "h-capsule": "水平胶囊", + "h-elliptical_2_1": "水平 2:1 椭圆形", + "icon": "卡片图标", + "title": "卡片标题", + "units": "单位", + "color-and-font": "颜色和字体", + "shape-attribute-name": "属性名称", + "total-volume-required": "总容量为必填项。", + "attribute-name-required": "属性名称为必填项。", + "attribute-key-not-set": "属性“{{attributeName}}”键未设置", + "attribute-key-invalid": "属性“{{attributeName}}”键无效" + }, + "aggregated-value-card": { + "subtitle": "副标题", + "chart": "图表", + "values": "值", + "value-appearance": "值外观", + "position": "位置", + "position-center": "居中", + "position-right-top": "右上", + "position-right-bottom": "右下", + "position-left-top": "左上", + "position-left-bottom": "左下", + "font": "字体", + "color": "颜色", + "arrow": "箭头", + "display-up-down-arrow": "显示上/下箭头", + "add-value": "添加值", + "remove-value": "移除值", + "no-values": "未配置值", + "aggregation": "聚合", + "aggregated-value-card-style": "聚合值卡片样式", + "auto-scale": "自动缩放" + }, + "value-chart-card": { + "layout": "布局", + "layout-left": "左", + "layout-right": "右", + "auto-scale": "自动缩放", + "icon": "图标", + "value": "值", + "chart": "图表", + "value-chart-card-style": "值图表卡片样式" + }, + "progress-bar": { + "layout": "布局", + "layout-default": "默认", + "layout-simplified": "简化", + "auto-scale": "自动缩放", + "icon": "图标", + "value": "值", + "range": "范围", + "min": "最小值", + "max": "最大值", + "range-ticks": "范围刻度", + "bar": "条形", + "bar-color": "条形颜色", + "bar-background": "条形背景", + "progress-bar-card-style": "进度条卡片样式" + }, + "notification": { + "max-notification-display": "最大通知显示数量", + "counter": "计数器", + "counter-hint": "当启用“部件标题”时将显示计数器", + "icon": "图标", + "counter-value": "值", + "counter-color": "颜色", + "notification-button": "通知按钮", + "button-view-all": "查看全部", + "button-filter": "筛选", + "type-filter": "类型筛选", + "button-mark-read": "全部标记为已读", + "notification-types": "通知类型", + "notification-type": "通知类型", + "search-type": "搜索类型", + "any-type": "任意类型" + }, + "alarm-count": { + "alarm-count-card-style": "告警计数卡片样式" + }, + "entity-count": { + "entity-count-card-style": "实体计数卡片样式" + }, + "count": { + "layout": "布局", + "layout-column": "列", + "layout-row": "行", + "label": "标签", + "icon": "图标", + "icon-background": "图标背景", + "value": "值", + "chevron": "箭头指示", + "auto-scale": "自动缩放" + }, + "table": { + "common-table-settings": "通用表格设置", + "enable-search": "启用搜索", + "enable-sticky-header": "始终显示表头", + "enable-sticky-action": "始终显示操作列", + "hidden-cell-button-display-mode": "隐藏单元格按钮操作显示模式", + "show-empty-space-hidden-action": "显示空白代替隐藏的单元格按钮操作", + "dont-reserve-space-hidden-action": "不为隐藏的操作按钮预留空间", + "display-timestamp": "时间戳", + "timestamp-column-name": "时间戳", + "display-pagination": "显示分页", + "default-page-size": "默认页面大小", + "page-step-settings": "分页步长设置", + "page-step-count": "步数", + "page-step-increment": "步长增量", + "page-step-count-format-message": "应为 1 到 100 之间的整数值。", + "page-step-increment-format-message": "应为大于或等于 1 的整数值。", + "use-entity-label-tab-name": "在标签页名称中使用实体标签", + "hide-empty-lines": "隐藏空行", + "row-style": "行样式", + "use-row-style-function": "使用行样式函数", + "row-style-function": "行样式函数", + "cell-style": "单元格样式", + "use-cell-style-function": "使用单元格样式函数", + "cell-style-function": "单元格样式函数", + "cell-content": "单元格内容", + "use-cell-content-function": "使用单元格内容函数", + "cell-content-function": "单元格内容函数", + "show-latest-data-column": "显示最新数据列", + "latest-data-column-order": "最新数据列顺序", + "entities-table-title": "实体表格标题", + "enable-select-column-display": "启用选择显示列", + "display-entity-name": "显示实体名称列", + "entity-name-column-title": "实体名称列标题", + "display-entity-label": "显示实体标签列", + "entity-label-column-title": "实体标签列标题", + "display-entity-type": "显示实体类型列", + "default-sort-order": "默认排序", + "custom-title": "自定义表头标题", + "column-width": "列宽(px 或 %)", + "default-column-visibility": "默认列可见性", + "column-visibility-visible": "可见", + "column-visibility-hidden": "隐藏", + "column-visibility-hidden-mobile": "在移动模式下隐藏", + "column-selection-to-display": "在“显示列”中的列选择", + "column-selection-to-display-enabled": "启用", + "column-selection-to-display-disabled": "禁用", + "alarms-table-title": "告警表格标题", + "enable-alarms-selection": "启用告警选择", + "enable-alarms-search": "启用告警搜索", + "enable-alarm-filter": "启用告警筛选", + "display-alarm-details": "显示告警详情", + "allow-alarms-ack": "允许告警确认", + "allow-alarms-clear": "允许告警清除", + "display-alarm-activity": "显示告警活动", + "allow-alarms-assign": "允许告警分配", + "columns": "列", + "column-settings": "列设置", + "remove-column": "移除列", + "add-column": "添加列", + "no-columns": "未配置列", + "columns-to-display": "显示列", + "table-header": "表头", + "header-buttons": "表头按钮", + "table-buttons": "表格按钮", + "pagination": "分页", + "rows": "行", + "timeseries-column-error": "至少应指定一个时间序列列", + "alarm-column-error": "至少应指定一个告警列", + "table-tabs": "表格标签页", + "show-cell-actions-menu-mobile": "在移动模式下显示单元格操作下拉菜单", + "disable-sorting": "禁用排序", + "sort-by": "标签页排序方式", + "sort-timestamp-option": "创建时间" + }, + "latest-chart": { + "total": "总计", + "auto-scale": "自动缩放", + "clockwise-layout": "顺时针布局", + "sort-series": "按标签排序系列", + "tooltip-value-type-absolute": "绝对值", + "tooltip-value-type-percentage": "百分比" + }, + "pie-chart": { + "pie-chart-appearance": "饼图外观", + "label": "标签", + "border": "边框", + "radius": "半径", + "pie-chart-card-style": "饼图卡片样式" + }, + "radar-chart": { + "radar-appearance": "雷达图外观", + "shape": "形状", + "shape-polygon": "多边形", + "shape-circle": "圆形", + "color": "颜色", + "line": "线条", + "points": "数据点", + "points-label": "数据点标签", + "radar-axis": "雷达轴", + "axis-label": "轴标签", + "ticks-label": "刻度标签", + "radar-chart-style": "雷达图样式", + "max-axes-scaling": "最大轴缩放", + "max-axes-scaling-hint": "选择每个雷达轴是有独立的最大值(独立)还是共享基于部件数据集的所有轴中的最大值(共用)。", + "separate": "独立", + "common": "共用" + }, + "time-series-chart": { + "chart": "图表", + "chart-style": "图表样式", + "data-zoom": "数据缩放", + "stack-mode": "堆叠模式", + "stack-mode-hint": "在图表上堆叠系列。具有相同单位的系列将叠放在一起。", + "axes": "轴", + "y-axes": "Y 轴", + "line-type": "线条类型", + "line-width": "线条宽度", + "type-line": "折线", + "type-bar": "柱状", + "type-point": "散点", + "no-aggregation-bar-width-strategy": "非聚合数据的柱宽策略", + "no-aggregation-bar-width-strategy-group": "分组", + "no-aggregation-bar-width-strategy-separate": "独立", + "bar-group-width": "柱组宽度", + "bar-width": "柱宽度", + "bar-width-relative": "时间窗口百分比", + "bar-width-absolute": "绝对值(毫秒)", + "comparison": { + "comparison": "对比", + "comparison-hint": "对比仅适用于历史数据!", + "show": "显示", + "settings": "对比设置", + "show-values-for-comparison": "显示用于对比的历史数据", + "comparison-values-label": "对比数据键标签", + "comparison-values-label-auto": "自动", + "comparison-data-color": "对比数据颜色" + }, + "threshold": { + "thresholds": "阈值", + "source": "来源", + "key-value": "键 / 值", + "no-thresholds": "未配置阈值", + "add-threshold": "添加阈值", + "type-constant": "常量", + "type-latest-key": "键", + "type-entity": "实体", + "threshold-settings": "阈值设置", + "remove-threshold": "移除阈值", + "threshold-value-required": "阈值为必填项。", + "key-required": "键为必填项。", + "entity-key-required": "实体键为必填项。", + "line-appearance": "线条外观", + "line-color": "线条颜色", + "start-symbol": "起始符号", + "end-symbol": "结束符号", + "symbol-size": "大小", + "label": "标签", + "label-position-start": "起始", + "label-position-middle": "中间", + "label-position-end": "末尾", + "label-position-inside-start": "内部起始", + "label-position-inside-start-top": "内部起始上方", + "label-position-inside-start-bottom": "内部起始下方", + "label-position-inside-middle": "内部中间", + "label-position-inside-middle-top": "内部中间上方", + "label-position-inside-middle-bottom": "内部中间下方", + "label-position-inside-end": "内部末尾", + "label-position-inside-end-top": "内部末尾上方", + "label-position-inside-end-bottom": "内部末尾下方", + "label-background": "标签背景" + }, + "state": { + "states": "状态", + "label": "标签", + "ticks-value": "刻度值", + "source": "来源", + "value-range": "值 / 范围", + "no-states": "未配置状态", + "add-state": "添加状态", + "type-constant": "常量", + "type-range": "范围", + "from": "从", + "to": "到", + "remove-state": "移除状态" + }, + "grid": { + "grid": "网格", + "background-color": "背景颜色", + "border": "边框" + }, + "axis": { + "axes": "轴", + "x-axis": "X 轴", + "y-axis": "Y 轴", + "y-axis-settings": "Y 轴设置", + "comparison-x-axis-settings": "对比 X 轴设置", + "remove-y-axis": "移除 Y 轴", + "id": "Id", + "label": "标签", + "position": "位置", + "position-left": "左", + "position-right": "右", + "position-top": "上", + "position-bottom": "下", + "tick-labels": "刻度标签", + "ticks-formatter-function": "刻度格式化函数", + "ticks-generator-function": "刻度生成器函数", + "show-ticks": "显示刻度", + "show-line": "显示线条", + "show-split-lines": "显示分隔线", + "show-split-lines-x-axis-hint": "启用后,图表上将显示垂直线。", + "show-split-lines-y-axis-hint": "启用后,图表上将显示水平线。", + "ticks-interval": "刻度间隔", + "ticks-interval-hint": "强制设置轴的分段间隔。", + "split-number": "分段数", + "split-number-hint": "轴被分割的段数。", + "min": "最小值", + "max": "最大值", + "show": "显示", + "add-y-axis": "添加 Y 轴" + }, + "series": { + "legend-settings": "图例设置", + "show-in-legend": "在图例中显示", + "show-in-legend-hint": "在图例中显示系列名称和数据。", + "hidden-by-default": "默认隐藏", + "hidden-by-default-hint": "默认在图例中隐藏系列。", + "series-type": "系列类型", + "type": "类型", + "type-line": "折线", + "type-bar": "柱状", + "line": { + "line": "线条", + "show-line": "显示线条", + "step-line": "阶梯线", + "step-type-start": "起始", + "step-type-middle": "中间", + "step-type-end": "末尾", + "smooth-line": "平滑线" }, - "activity": { - "title": "活动" + "point": { + "points": "数据点", + "show-points": "显示数据点", + "point-label": "数据点标签", + "point-label-hint": "在系列数据点上方显示带有值的标签。", + "point-label-background": "数据点标签背景", + "point-shape": "数据点形状", + "point-size": "数据点大小" + } + } + }, + "wind-speed-direction": { + "layout": "布局", + "layout-default": "默认", + "layout-advanced": "高级", + "layout-simplified": "简化", + "values": "值", + "wind-direction": "风向", + "center-value": "中心值", + "icon": "图标", + "arrow": "箭头", + "ticks": "刻度", + "labels-type": "标签类型", + "directional-names": "方向名称", + "degrees": "度数", + "major-ticks": "主刻度", + "minor-ticks": "次刻度", + "wind-speed-direction-card-style": "风速和风向卡片样式", + "ticks-color": "刻度颜色", + "ticks-labels-type": "刻度标签类型", + "arrow-color": "箭头颜色" + }, + "value-source": { + "value-source": "值来源", + "predefined-value": "常量", + "entity-attribute": "实体属性", + "value": "值", + "value-required": "值为必填项。", + "key-required": "键为必填项。", + "entity-key-required": "实体键为必填项。", + "source-entity-alias": "源实体别名", + "source-entity-attribute": "源实体属性", + "type-constant": "常量", + "type-latest-key": "键", + "type-entity": "实体" + }, + "rpc-state": { + "initial-state": "初始状态", + "initial-state-hint": "获取组件初始状态(开/关)的操作。", + "disabled-state": "禁用状态", + "disabled-state-hint": "配置组件被禁用的条件。", + "turn-on": "开启", + "turn-on-hint": "当滑块切换为“开”时触发的操作", + "turn-off": "关闭", + "turn-off-hint": "当滑块切换为“关”时触发的操作", + "on": "开", + "off": "关", + "disabled": "已禁用" + }, + "value-action": { + "do-nothing": "不执行操作", + "execute-rpc": "执行 RPC", + "get-attribute": "获取属性", + "set-attribute": "设置属性", + "get-time-series": "获取时间序列", + "get-alarm-status": "获取告警状态", + "get-dashboard-state": "获取仪表板状态 ID", + "get-dashboard-state-object": "获取仪表板状态对象", + "add-time-series": "添加时间序列", + "execute-rpc-text": "执行 RPC 方法“{{methodName}}”", + "get-time-series-text": "使用时间序列“{{key}}”", + "get-attribute-text": "使用属性“{{key}}”", + "get-alarm-status-text": "使用告警状态", + "get-dashboard-state-text": "使用仪表板状态", + "get-dashboard-state-object-text": "使用仪表板状态对象", + "when-dashboard-state-is-text": "当仪表板状态 ID 为“{{state}}”时", + "when-dashboard-state-function-is-text": "当 f(仪表板状态 ID) 为“{{state}}”时", + "when-dashboard-state-object-function-is-text": "当 f(仪表板状态对象) 为“{{state}}”时", + "set-attribute-to-value-text": "将属性“{{key}}”设置为:{{value}}", + "add-time-series-value-text": "添加时间序列“{{key}}”值:{{value}}", + "set-attribute-text": "设置属性“{{key}}”", + "add-time-series-text": "添加时间序列“{{key}}”", + "action": "操作", + "value": "值", + "init-value-hint": "在设备发送数据之前将设置的值。", + "method": "方法", + "method-name-required": "方法名称为必填项。", + "request-timeout-ms": "RPC 请求超时(毫秒)", + "request-timeout-required": "请求超时为必填项。", + "min-request-timeout-error": "请求超时值应大于或等于 5000 毫秒(5 秒)。", + "request-persistent": "RPC 请求持久化", + "persistent-polling-interval": "持久化轮询间隔(毫秒)", + "persistent-polling-interval-hint": "获取持久化 RPC 命令响应的轮询间隔(毫秒)", + "persistent-polling-interval-required": "持久化轮询间隔为必填项。", + "min-persistent-polling-interval-error": "持久化轮询间隔值应大于或等于 1000 毫秒(1 秒)。", + "attribute-scope": "属性范围", + "attribute-key": "属性键", + "attribute-key-required": "属性键为必填项。", + "time-series-key": "时间序列键", + "time-series-key-required": "时间序列键为必填项。", + "action-result-converter": "操作结果转换器", + "converter-none": "无", + "converter-function": "函数", + "converter-constant": "常量", + "converter-value": "值", + "parse-value-function": "解析值函数", + "state-when-result-is": "当结果为“{{state}}”时", + "parameters": "参数", + "convert-value-function": "转换值函数", + "error": { + "target-entity-is-not-set": "未设置目标实体!", + "failed-to-perform-action": "执行 {{ actionLabel }} 操作失败。", + "invalid-attribute-scope": "{{entityType}} 实体不支持 {{scope}} 属性范围。" + } + }, + "widget-font": { + "font-settings": "字体设置", + "font-family": "字体族", + "size": "大小", + "relative-font-size": "相对字体大小(百分比)", + "font-style": "样式", + "font-style-normal": "常规", + "font-style-italic": "斜体", + "font-style-oblique": "倾斜", + "font-weight": "字重", + "font-weight-normal": "常规", + "font-weight-bold": "粗体", + "font-weight-bolder": "更粗", + "font-weight-lighter": "更细", + "color": "颜色", + "shadow-color": "阴影颜色", + "preview": "预览", + "line-height": "行高", + "auto": "自动" + }, + "home": { + "no-data-available": "暂无数据" + }, + "system-info": { + "cpu": "CPU", + "ram": "RAM", + "disk": "磁盘", + "cpu-warning-text": "CPU 使用率偏高。为避免系统故障,请优化系统性能。", + "cpu-critical-text": "CPU 使用率严重偏高。为避免系统故障,请优化系统性能。", + "ram-warning-text": "RAM 剩余空间不足。为避免系统故障,请优化系统性能或增加 RAM 容量。", + "ram-critical-text": "RAM 剩余空间严重不足。为避免系统故障,请优化系统性能或增加 RAM 容量。", + "disk-warning-text": "磁盘空间不足。为避免数据丢失,请释放或扩展磁盘空间。", + "disk-critical-text": "磁盘空间严重不足。为避免数据丢失,请释放或扩展磁盘空间。" + }, + "cluster-info": { + "service-id": "服务 ID", + "service-type": "服务类型", + "no-data": "暂无数据" + }, + "transport-messages": { + "title": "传输消息", + "info": "来自设备的所有消息" + }, + "activity": { + "title": "活动" + }, + "documentation": { + "title": "文档", + "add-link": "添加链接", + "add-link-title": "添加文档链接", + "name": "名称", + "name-required": "名称为必填项。", + "link": "链接", + "link-required": "链接为必填项。", + "columns": "列数" + }, + "quick-links": { + "title": "快速链接", + "add-link": "添加链接", + "add-link-title": "添加快速链接", + "quick-link": "快速链接", + "quick-link-required": "快速链接为必填项。", + "no-links-matching": "未找到与“{{name}}”匹配的链接。", + "columns": "列数" + }, + "recent-dashboards": { + "title": "仪表板", + "last": "最近查看", + "starred": "已收藏", + "name": "名称", + "last-viewed": "最近查看", + "no-last-viewed-dashboards": "暂无最近查看的仪表板" + }, + "configured-features": { + "title": "已配置功能", + "info": "需要配置的功能状态", + "email-feature": "Email", + "sms-feature": "短信", + "slack-feature": "Slack", + "oauth2-feature": "OAuth 2", + "2fa-feature": "双因素认证", + "feature-configured": "功能已配置。\n点击进行设置", + "feature-not-configured": "功能未配置。\n点击进行设置" + }, + "version-info": { + "title": "版本", + "contact-us": "联系我们", + "current-version": "当前版本", + "current": "当前", + "available-version": "可用版本", + "available": "可用", + "upgrade": "升级", + "version-is-up-to-date": "版本已是最新" + }, + "usage-info": { + "title": "使用情况", + "entities": "实体", + "api-calls": "API 调用" + }, + "functions": { + "title": "功能", + "pe-feature-tooltip": "仅适用于 ThingsBoard\nProfessional Edition", + "switch-to-pe": "切换到 PE", + "alarms": "告警", + "dashboards": "仪表板", + "entities-and-relations": "实体与关联", + "profiles": "配置", + "advanced-features": "高级功能", + "notification-center": "通知中心", + "api-usage": "API 使用情况", + "customers": "客户", + "customers-hierarchy": "客户层级", + "roles-and-permissions": "角色与权限", + "groups": "分组", + "integrations": "集成", + "solution-templates": "解决方案模板", + "scheduler": "调度器", + "white-labeling": "白标" + }, + "devices": { + "view-docs": "查看文档", + "inactive": "不活跃", + "active": "活跃", + "total": "总计" + }, + "alarms": { + "critical": "严重", + "assigned-to-me": "分配给我", + "total": "总计" + }, + "getting-started": { + "get-started": "开始使用", + "finish": "完成", + "done-welcome-title": "欢迎加入", + "done-welcome-text": "您做得非常棒!", + "sys-admin": { + "step1": { + "title": "创建租户和租户管理员", + "content": "

租户是拥有或管理设备和资产的个人或组织。租户可以拥有多个租户管理员用户、客户、设备和资产。

租户管理员可以在租户账户内创建和管理设备、资产、客户和仪表板。

请参考以下文档了解操作方法:

", + "how-to-create-tenant": "如何创建租户和租户管理员" }, - "documentation": { - "title": "文档", - "add-link": "添加链接", - "add-link-title": "添加文档链接", - "name": "名称", - "name-required": "名称是必填项。", - "link": "链接", - "link-required": "链接是必填项。", - "columns": "列" + "step2": { + "title": "配置功能:邮件服务器", + "content": "

邮件服务器配置对于用户激活、密码恢复和告警通知发送至关重要。

请参考以下文档了解操作方法:

", + "how-to-configure-mail-server": "如何配置邮件服务器" }, - "quick-links": { - "title": "快速链接", - "add-link": "添加链接", - "add-link-title": "添加快速链接", - "quick-link": "快速链接", - "quick-link-required": "快速链接是必填项。", - "no-links-matching": "找不到与'{{name}}'匹配的链接。", - "columns": "列" + "step3": { + "title": "配置功能:短信提供商", + "content": "

配置短信提供商,以便通过短信通知客户有关告警的信息。

请参考以下文档了解操作方法:

", + "how-to-configure-sms-provider": "如何配置短信提供商" }, - "recent-dashboards": { - "title": "仪表盘", - "last": "最近查看", - "starred": "已收藏", - "name": "名称", - "last-viewed": "最近查看", - "no-last-viewed-dashboards": "暂无最近查看的仪表盘" + "step4": { + "title": "配置功能:白标", + "content": "

无需编写代码,无需重启服务,即可轻松自定义您公司或产品的徽标和配色方案。

请参考以下文档了解操作方法:

" }, - "configured-features": { - "title": "配置的功能", - "info": "需要配置的功能状态", - "email-feature": "电子邮件", - "sms-feature": "短信", - "slack-feature": "Slack", - "oauth2-feature": "OAuth2.0", - "2fa-feature": "两步验证", - "feature-configured": "功能已配置", - "feature-not-configured": "功能未配置" + "step5": { + "title": "配置功能:双因素认证", + "content": "

通过双因素认证提高平台账户的安全性。

请参考以下文档了解操作方法:

" }, - "version-info": { - "title": "版本", - "contact-us": "联系我们", - "current-version": "当前版本", - "current": "当前", - "available-version": "可用版本", - "available": "可用", - "upgrade": "升级", - "version-is-up-to-date": "已经是最新版本" + "step6": { + "title": "配置功能:OAuth 2", + "content": "

通过 OAuth 2.0 单点登录功能简化租户和客户用户的登录流程。

请参考以下文档了解操作方法:

" + } + }, + "tenant-admin": { + "step1": { + "title": "创建设备", + "content": "

让我们通过界面将您的第一个设备接入平台。请参考以下文档了解操作方法:

", + "how-to-create-device": "如何创建设备" }, - "usage-info": { - "title": "使用情况", - "entities": "实体", - "api-calls": "API调用" + "step2": { + "title": "连接设备", + "content-before": "

要连接设备,您需要获取设备凭据。本指南建议使用默认自动生成的凭据,即访问令牌。

  • 前往设备列表
  • 点击设备行以打开设备详情
  • 点击“复制访问令牌”按钮

使用简单命令通过 HTTP 发布数据。请将 $ACCESS_TOKEN 替换为您的设备访问令牌:

", + "ubuntu": { + "install-curl": "为 Ubuntu 安装 cURL:" + }, + "macos": { + "install-curl": "为 MacOS 安装 cURL:" + }, + "windows": { + "install-curl": "从 Windows 10 b17063 开始,cURL 已默认可用。" + }, + "replace-access-token": "将 $ACCESS_TOKEN 替换为您的设备令牌:", + "content-after": "

您也可以使用其他协议,例如 MQTT、CoAP 等。

请参考以下文档了解操作方法:

", + "how-to-connect-device": "如何连接设备" }, - "functions": { - "title": "功能", - "pe-feature-tooltip": "仅适用于ThingsBoard专业版", - "switch-to-pe": "切换专业版", - "alarms": "告警", - "dashboards": "仪表盘", - "entities-and-relations": "实体和关系", - "profiles": "配置", - "advanced-features": "高级功能", - "notification-center": "通知中心", - "api-usage": "API使用情况", - "customers": "客户", - "customers-hierarchy": "客户层级", - "roles-and-permissions": "角色和权限", - "groups": "群组", - "integrations": "集成", - "solution-templates": "解决方案模板", - "scheduler": "调度器", - "white-labeling": "白标" + "step3": { + "title": "创建仪表板", + "content": "

创建仪表板以可视化来自资产、设备等实体的数据。

请参考以下文档了解操作方法:

", + "how-to-create-dashboard": "如何创建仪表板" }, - "devices": { - "view-docs": "查看文档", - "inactive": "非活动", - "active": "活动", - "total": "总数" + "step4": { + "title": "配置告警规则", + "alarm-rules": "告警规则", + "content": "

让我们在温度达到 25°C 时触发告警。请参考以下文档了解操作方法:

", + "how-to-configure-alarm-rules": "如何配置告警规则" }, - "alarms": { - "critical": "严重", - "assigned-to-me": "分配给我", - "total": "总数" + "step5": { + "title": "创建告警", + "content-before": "

要触发告警,请发送 26°C 或更高的遥测值。

", + "replace-access-token": "将 $ACCESS_TOKEN 替换为您的设备令牌:", + "content-after": "

请参考以下文档了解操作方法:

", + "how-to-create-alarm": "如何创建告警" }, - "getting-started": { - "get-started": "开始", - "finish": "完成", - "done-welcome-title": "欢迎加入我们", - "done-welcome-text": "你做得很好!", - "sys-admin": { - "step1": { - "title": "创建租户和租户管理员", - "content": "

租户是拥有或生产设备和资产的个人或组织,租户可以有多个租户管理员用户、客户、设备和资产。

租户管理员可以在租户账户中创建和管理设备、资产、客户和仪表板。

请按照文档进行操作:

", - "how-to-create-tenant": "如何创建租户和租户管理员" - }, - "step2": { - "title": "配置功能:邮件服务", - "content": "

邮件服务配置对于用户激活、密码恢复和报警通知的发送非常重要。

请按照文档进行操作:

", - "how-to-configure-mail-server": "如何配置邮件服务器" - }, - "step3": { - "title": "配置功能:短信提供商", - "content": "

配置短信提供商,通过短信向客户发送报警通知。

请按照文档进行操作:

", - "how-to-configure-sms-provider": "如何配置短信提供商" - }, - "step4": { - "title": "配置功能:双因素认证", - "content": "

通过双因素认证提高平台账户的安全性。

请按照文档进行操作:

", - "how-to-configure-2fa": "如何配置双因素认证" - }, - "step5": { - "title": "配置功能:OAuth 2", - "content": "

通过OAuth 2.0的单点登录功能,简化租户和客户用户的登录过程。

请按照文档进行操作:

", - "how-to-configure-oauth2": "如何配置OAuth 2" - }, - "step6": { - "title": "配置功能:Slack", - "content": "

根据设置的通知规则,通过Slack向租户和客户用户发送通知。

请按照文档进行操作:

", - "how-to-configure-notifications": "如何配置Slack" - } - }, - "tenant-admin": { - "step1": { - "title": "创建设备", - "content": "

让我们通过用户界面将您的第一个设备添加到平台。请按照文档进行操作:

", - "how-to-create-device": "如何创建设备" - }, - "step2": { - "title": "连接设备", - "content-before": "

要连接设备,您需要获取设备凭据。我们建议在本指南中使用默认生成的凭据,即访问令牌。

  • 转到设备表
  • 点击设备行以打开设备详情
  • 点击“复制访问令牌”按钮

使用简单的命令通过HTTP发布数据。不要忘记将$ACCESS_TOKEN替换为您的设备访问令牌:

", - "ubuntu": { - "install-curl": "Ubuntu上安装cURL:" - }, - "macos": { - "install-curl": "MacOS上安装cURL:" - }, - "windows": { - "install-curl": "从Windows 10 b17063开始,cURL已默认可用。" - }, - "replace-access-token": "将$ACCESS_TOKEN替换为您的设备令牌:", - "content-after": "

您还可以使用其他协议如MQTT、CoAP等。

请按照文档进行操作:

", - "how-to-connect-device": "如何连接设备" - }, - "step3": { - "title": "创建仪表板", - "content": "

创建一个仪表板,用于可视化来自实体(如资产、设备等)的数据。

请按照文档进行操作:

", - "how-to-create-dashboard": "如何创建仪表板" - }, - "step4": { - "title": "配置报警规则", - "alarm-rules": "报警规则", - "content": "

当温度达到25°C时,让我们触发一个报警。请按照文档进行操作:

", - "how-to-configure-alarm-rules": "如何配置报警规则" - }, - "step5": { - "title": "创建报警", - "content-before": "

要触发报警,请发送一个新的温度值,该值为26℃或更高。

", - "replace-access-token": "将$ACCESS_TOKEN替换为您的设备令牌:", - "content-after": "

请按照文档进行操作:

", - "how-to-create-alarm": "如何创建报警" - }, - "step6": { - "title": "创建客户并分配仪表板", - "content": "

通过创建终端用户仪表板,客户用户只能查看自己的设备,另一个客户的数据将被隐藏。

请按照文档进行操作:

", - "how-to-create-customer-and-assign-dashboard": "如何创建客户并分配仪表板" - } - } + "step6": { + "title": "创建客户并共享仪表板", + "content": "

通过创建终端用户仪表板,客户用户只能查看自己的设备,其他客户的数据将被隐藏。

请参考以下文档了解操作方法:

" } + } }, - "color": { - "color": "颜色" - }, - "icon": { - "icon": "图标", - "icons": "图标", - "select-icon": "选择图标", - "material-icons": "素材图标", - "show-all": "显示所有图标", - "search-icon": "搜索图标", - "no-icons-found": "没有找到符合'{{iconSearch}}'的图标" - }, - "phone-input": { - "phone-input-label": "手机号码", - "phone-input-required": "手机号码必填", - "phone-input-validation": "手机号码无效或不存在", - "phone-input-pattern": "无效的手机号码应为E.164格式例如:{{phoneNumber}}", - "phone-input-hint": "E.164格式手机号码例如:{{phoneNumber}}" - }, - "custom": { - "widget-action": { - "action-cell-button": "动作单元格按钮", - "row-click": "点击行时", - "cell-click": "点击单元格时", - "polygon-click": "点击多边形时", - "marker-click": "点击标记时", - "circle-click": "点击圆圈时", - "tooltip-tag-action": "文字提示标签操作", - "node-selected": "选择节点时", - "element-click": "点击HTML元素时", - "pie-slice-click": "点击切片时", - "row-double-click": "双击行时", - "cell-double-click": "双击单元格时", - "card-click": "点击卡片时", - "click": "点击时" - } - }, - "paginator": { - "items-per-page": "每页数量:", - "first-page-label": "首页", - "last-page-label": "尾页", - "next-page-label": "下一页", - "previous-page-label": "上一页", - "items-per-page-separator": "至" - }, - "language": { - "language": "语言" + "api-usage": { + "api-usage": "API 使用情况", + "label": "标签", + "state-name": "状态名称", + "status": "状态", + "status-required": "状态为必填项。", + "limit": "最大限制", + "limit-required": "最大限制为必填项。", + "current-number": "当前数量", + "current-number-required": "当前数量为必填项。", + "add-key": "添加键", + "no-key": "无键", + "delete-key": "删除键", + "target-dashboard-state": "目标仪表板状态", + "go-to-main-state": "前往默认视图" + } + }, + "icon": { + "icon": "图标", + "icons": "图标", + "custom": "自定义", + "select-icon": "选择图标", + "material-icons": "Material 图标", + "show-all": "显示全部图标", + "search-icon": "搜索图标", + "no-icons-found": "未找到与“{{iconSearch}}”匹配的图标" + }, + "phone-input": { + "phone-input-label": "电话号码", + "phone-input-required": "电话号码为必填项", + "phone-input-validation": "电话号码无效或不存在", + "phone-input-pattern": "电话号码无效。应为 E.164 格式,例如 {{phoneNumber}}", + "phone-input-hint": "E.164 格式的电话号码,例如 {{phoneNumber}}" + }, + "custom": { + "widget-action": { + "action-cell-button": "操作单元格按钮", + "row-click": "单击行时", + "cell-click": "单击单元格时", + "polygon-click": "单击多边形时", + "marker-click": "单击标记时", + "circle-click": "单击圆形时", + "tooltip-tag-action": "提示标签操作", + "node-selected": "选中节点时", + "element-click": "单击 HTML 元素时", + "pie-slice-click": "单击扇区时", + "row-double-click": "双击行时", + "cell-double-click": "双击单元格时", + "card-click": "单击卡片时", + "click": "单击时" } -} + }, + "paginator": { + "items-per-page": "每页条目数:", + "first-page-label": "第一页", + "last-page-label": "最后一页", + "next-page-label": "下一页", + "previous-page-label": "上一页", + "items-per-page-separator": "/" + }, + "language": { + "auto": "自动", + "language": "语言" + } +} \ No newline at end of file diff --git a/ui-ngx/src/form.scss b/ui-ngx/src/form.scss index 1a6ee74f80..bb5a76b6e9 100644 --- a/ui-ngx/src/form.scss +++ b/ui-ngx/src/form.scss @@ -108,7 +108,7 @@ } } &.disabled { - color: var(--mdc-outlined-text-field-disabled-input-text-color); + color: var(--mat-form-field-outlined-disabled-input-text-color); } .mat-expansion-panel { &.tb-settings { @@ -136,7 +136,7 @@ > .mat-expansion-panel-header-description { align-items: center; } - > .mat-expansion-panel-content { + .mat-expansion-panel-content { > .mat-expansion-panel-body { display: flex; flex-direction: column; @@ -164,9 +164,9 @@ } .tb-form-panel-disabled { - color: var(--mdc-outlined-text-field-disabled-input-text-color); + color: var(--mat-form-field-outlined-disabled-input-text-color); .tb-form-panel { - color: var(--mdc-outlined-text-field-disabled-input-text-color); + color: var(--mat-form-field-outlined-disabled-input-text-color); } } @@ -191,10 +191,10 @@ } .tb-form-hint { padding: 12px 16px; - font-size: var(--mdc-typography-caption-font-size, 12px); - line-height: var(--mdc-typography-caption-line-height, 20px); - font-weight: var(--mdc-typography-caption-font-weight, 400); - letter-spacing: var(--mdc-typography-caption-letter-spacing, 0.0333333333em); + font-size: var(--mat-typography-caption-font-size, 12px); + line-height: var(--mat-typography-caption-line-height, 20px); + font-weight: var(--mat-typography-caption-font-weight, 400); + letter-spacing: var(--mat-typography-caption-letter-spacing, 0.0333333333em); color: rgba(0, 0, 0, 0.6); white-space: normal; border-radius: 6px; @@ -845,8 +845,8 @@ } .tb-form-panel.outlined { - --mdc-outlined-text-field-outline-color: rgba(0,0,0,0.12); - --mdc-outlined-text-field-container-shape: 6px; + --mat-form-field-outlined-outline-color: rgba(0,0,0,0.12); + --mat-form-field-outlined-container-shape: 6px; --mat-form-field-trailing-icon-color: rgba(0, 0, 0, 0.56); box-shadow: none; @@ -875,8 +875,8 @@ height: 56px; margin-bottom: 22px; &.disabled { - border-color: var(--mdc-outlined-text-field-disabled-outline-color); - color: var(--mdc-outlined-text-field-disabled-input-text-color); + border-color: var(--mat-form-field-outlined-disabled-outline-color); + color: var(--mat-form-field-outlined-disabled-input-text-color); } } } diff --git a/ui-ngx/src/styles.scss b/ui-ngx/src/styles.scss index cdac88d00b..93422abfde 100644 --- a/ui-ngx/src/styles.scss +++ b/ui-ngx/src/styles.scss @@ -224,10 +224,10 @@ div { .tb-hint { padding: 0 16px 16px; - font-size: var(--mdc-typography-caption-font-size, 12px); - line-height: var(--mdc-typography-caption-line-height, 20px); - font-weight: var(--mdc-typography-caption-font-weight, 400); - letter-spacing: var(--mdc-typography-caption-letter-spacing, 0.0333333333em); + font-size: var(--mat-typography-caption-font-size, 12px); + line-height: var(--mat-typography-caption-line-height, 20px); + font-weight: var(--mat-typography-caption-font-weight, 400); + letter-spacing: var(--mat-typography-caption-letter-spacing, 0.0333333333em); color: rgba(0, 0, 0, 0.6); white-space: normal; } @@ -637,8 +637,8 @@ pre.tb-highlight { } .tb-autofilled:has(input:-webkit-autofill){ - --mdc-outlined-text-field-label-text-color: var(--mdc-outlined-text-field-input-text-color); - --mdc-outlined-text-field-hover-label-text-color: var(--mdc-outlined-text-field-input-text-color); + --mat-form-field-outlined-label-text-color: var(--mat-form-field-outlined-input-text-color); + --mat-form-field-outlined-hover-label-text-color: var(--mat-form-field-outlined-input-text-color); .mat-mdc-form-field-flex { &:before { display: block; @@ -663,10 +663,10 @@ pre.tb-highlight { &:-webkit-autofill:focus, &:-webkit-autofill:active { -webkit-background-clip: text !important; - -webkit-text-fill-color: var(--mdc-outlined-text-field-input-text-color); + -webkit-text-fill-color: var(--mat-form-field-outlined-input-text-color); } &::spelling-error { - color: var(--mdc-outlined-text-field-input-text-color); + color: var(--mat-form-field-outlined-input-text-color); text-decoration: none; } } @@ -1329,7 +1329,7 @@ pre.tb-highlight { } &.active:not(:disabled) { - --mdc-outlined-button-outline-color: $primary; + --mat-button-outlined-outline-color: $primary; } &:disabled { diff --git a/ui-ngx/src/theme-overwrites.scss b/ui-ngx/src/theme-overwrites.scss index 483511197f..fcf7faa5cb 100644 --- a/ui-ngx/src/theme-overwrites.scss +++ b/ui-ngx/src/theme-overwrites.scss @@ -63,12 +63,17 @@ @mixin _mat-button-overwrites($primary, $config-or-theme) { .mat-toolbar { .mat-mdc-outlined-button { - --mdc-outlined-button-outline-color: inherit; + --mat-button-outlined-outline-color: inherit; &:not(:disabled) { border-color: inherit; } } } + .mat-mdc-button-base { + .mat-icon { + min-height: auto; + } + } } @mixin _mat-icon-overwrites() { diff --git a/ui-ngx/src/theme.scss b/ui-ngx/src/theme.scss index 9b2c005280..cb6e47a637 100644 --- a/ui-ngx/src/theme.scss +++ b/ui-ngx/src/theme.scss @@ -15,11 +15,13 @@ */ @use '@angular/material' as mat; @use 'theme-overwrites' as overwrites; +@use '@angular/material/core/tokens/m2-utils'; @import '../theme/datepicker-theme'; @import './scss/constants'; @include mat.all-component-typographies(); -@include mat.core(); +@include mat.elevation-classes(); +@include mat.app-background(); $tb-mat-indigo: ( 50: #e8eaf6, @@ -57,10 +59,6 @@ $tb-mat-indigo: ( $tb-primary: mat.m2-define-palette($tb-mat-indigo); $tb-accent: mat.m2-define-palette(mat.$m2-deep-orange-palette); -$background: (background: map_get(mat.$m2-grey-palette, 200)); - -$tb-theme-background: map_merge(mat.$m2-light-theme-background-palette, $background); - $tb-mat-theme: mat.m2-define-light-theme(( color: ( primary: $tb-primary, @@ -72,10 +70,9 @@ $tb-mat-theme: mat.m2-define-light-theme(( density: 0 )); -$color: map_get($tb-mat-theme, color); -$color: map_merge($color, (background: $tb-theme-background)); -$tb-mat-theme: map_merge($tb-mat-theme, (color: $color)); -$tb-theme: map_merge($tb-mat-theme, $color); +$tb-theme-system: m2-utils.get-system($tb-mat-theme); +$tb-theme-system: map_merge($tb-theme-system, ( background: map_get(mat.$m2-grey-palette, 200) )); +$tb-theme: map_merge($tb-mat-theme, (_mat-system: $tb-theme-system)); $primary: mat.m2-get-color-from-palette($tb-primary); $accent: mat.m2-get-color-from-palette($tb-accent); @@ -115,24 +112,6 @@ $tb-dark-mat-indigo: ( $tb-dark-primary: mat.m2-define-palette($tb-dark-mat-indigo); -$tb-dark-theme-background: ( - status-bar: black, - app-bar: map_get($tb-dark-mat-indigo, 900), - background: map_get($tb-dark-mat-indigo, 800), - hover: rgba(white, 0.04), - card: map_get($tb-dark-mat-indigo, 800), - dialog: map_get($tb-dark-mat-indigo, 800), - disabled-button: rgba(white, 0.12), - raised-button: map-get($tb-dark-mat-indigo, 50), - focused-button: rgba(white, 0.12), - selected-button: map_get($tb-dark-mat-indigo, 900), - selected-disabled-button: map_get($tb-dark-mat-indigo, 800), - disabled-button-toggle: black, - unselected-chip: map_get($tb-dark-mat-indigo, 700), - disabled-list-option: black, - tooltip: map_get(mat.$m2-grey-palette, 700), -); - $tb-dark-theme: mat.m2-define-dark-theme(( color: ( primary: $tb-dark-primary, @@ -142,10 +121,9 @@ $tb-dark-theme: mat.m2-define-dark-theme(( density: 0 )); -$color: map_get($tb-dark-theme, color); -$color: map_merge($color, (background: $tb-dark-theme-background)); -$tb-dark-theme: map_merge($tb-dark-theme, (color: $color)); -$tb-dark-theme: map_merge($tb-dark-theme, $color); +$tb-dark-theme-system: m2-utils.get-system($tb-dark-theme); +$tb-dark-theme-system: map_merge($tb-dark-theme-system, ( surface: map_get($tb-dark-mat-indigo, 800) )); +$tb-dark-theme: map_merge($tb-dark-theme, (_mat-system: $tb-dark-theme-system)); @mixin mat-fab-toolbar-theme($theme) { $primary: map-get($theme, primary); diff --git a/ui-ngx/structure.md b/ui-ngx/structure.md new file mode 100644 index 0000000000..23d85b5f56 --- /dev/null +++ b/ui-ngx/structure.md @@ -0,0 +1,746 @@ +# ThingsBoard CE Frontend — Navigation & Testing Guide + +## 1. Quick Reference + +| Key | Value | +|-----|-------| +| Angular | 20 | +| Angular Material | 20 | +| Component prefix | `tb` | +| TypeScript | 5.9 | +| RxJS | 7.8 | + +**Path Aliases** (from `tsconfig.json`): + +| Alias | Maps to | +|-------|---------| +| `@app/*` | `src/app/*` | +| `@env/*` | `src/environments/*` | +| `@core/*` | `src/app/core/*` | +| `@modules/*` | `src/app/modules/*` | +| `@home/*` | `src/app/modules/home/*` | +| `@shared/*` | `src/app/shared/*` | + +**Key Directories:** + +``` +src/app/ +├── core/ +│ ├── http/ # HTTP services (one per entity type) +│ └── services/ # Menu, auth, utils +├── modules/ +│ ├── home/ +│ │ ├── components/ # Reusable home components (entity table, details panel, alarm, dashboard) +│ │ │ └── entity/ # EntitiesTableComponent, EntityDetailsPageComponent, EntityComponent base +│ │ └── pages/ # Page modules (one directory per page/feature) +│ └── login/ # Login, reset password, 2FA +└── shared/ + ├── components/ # Shared components (autocompletes, dialogs, time, image) + └── models/ # TypeScript interfaces/enums +``` + +--- + +## 2. Route-to-Source Mapping + +All page source paths are relative to `src/app/modules/home/pages/`. +Page types: `entity-table` = EntitiesTableComponent + resolver, `entity-details` = EntityDetailsPageComponent, `settings-form` = standalone form with ConfirmOnExitGuard, `dashboard-view` = DashboardViewComponent with resolver, `custom` = unique component. + +### Login (unauthenticated) + +Source paths below are relative to `src/app/modules/` (not `home/pages/`). + +| URL | Source | Type | +|-----|--------|------| +| `/login` | `login/pages/login/` | custom (LoginComponent) | +| `/login/resetPasswordRequest` | `login/pages/login/` | custom (ResetPasswordRequestComponent) | +| `/login/resetPassword` | `login/pages/login/` | custom (ResetPasswordComponent) | +| `/login/createPassword` | `login/pages/login/` | custom (CreatePasswordComponent) | +| `/login/mfa` | `login/pages/login/` | custom (TwoFactorAuthLoginComponent) | + +### Home + +| URL | Source | Type | Auth | +|-----|--------|------|------| +| `/home` | `home-links/` | custom (HomeLinksComponent + dashboard resolver) | ALL | + +### Tenants (SYS_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/tenants` | `tenant/` | entity-table | `TenantsTableConfigResolver` | +| `/tenants/:entityId` | `tenant/` | entity-details | `TenantsTableConfigResolver` | +| `/tenants/:tenantId/users` | `tenant/` + `user/` | entity-table | `UsersTableConfigResolver` | +| `/tenantProfiles` | `tenant-profile/` | entity-table | `TenantProfilesTableConfigResolver` | +| `/tenantProfiles/:entityId` | `tenant-profile/` | entity-details | `TenantProfilesTableConfigResolver` | + +### Alarms Center (TENANT_ADMIN, CUSTOMER_USER) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/alarms` | `alarm/` | custom (RouterTabsComponent) | — | +| `/alarms/alarms` | `alarm/` | custom (AlarmTableComponent) | — | +| `/alarms/alarm-rules` | `alarm/` | custom (AlarmRulesTableComponent) | — | +| `/alarms/alarm-rules/:entityId` | `alarm/` | entity-details | `AlarmRulesTableConfigResolver` | + +### Dashboards (TENANT_ADMIN, CUSTOMER_USER) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/dashboards` | `dashboard/` | entity-table | `DashboardsTableConfigResolver` | +| `/dashboards/:dashboardId` | `dashboard/` | custom (DashboardPageComponent) | `DashboardResolver` | + +### Entities (TENANT_ADMIN, CUSTOMER_USER) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/entities/devices` | `device/` | entity-table | `DevicesTableConfigResolver` | +| `/entities/devices/:entityId` | `device/` | entity-details | `DevicesTableConfigResolver` | +| `/entities/assets` | `asset/` | entity-table | `AssetsTableConfigResolver` | +| `/entities/assets/:entityId` | `asset/` | entity-details | `AssetsTableConfigResolver` | +| `/entities/entityViews` | `entity-view/` | entity-table | `EntityViewsTableConfigResolver` | +| `/entities/entityViews/:entityId` | `entity-view/` | entity-details | `EntityViewsTableConfigResolver` | +| `/entities/gateways` | `gateways/` | dashboard-view | `gatewaysDashboardResolver` | + +### Profiles (TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/profiles/deviceProfiles` | `device-profile/` | entity-table | `DeviceProfilesTableConfigResolver` | +| `/profiles/deviceProfiles/:entityId` | `device-profile/` | entity-details | `DeviceProfilesTableConfigResolver` | +| `/profiles/assetProfiles` | `asset-profile/` | entity-table | `AssetProfilesTableConfigResolver` | +| `/profiles/assetProfiles/:entityId` | `asset-profile/` | entity-details | `AssetProfilesTableConfigResolver` | + +### Customers (TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/customers` | `customer/` | entity-table | `CustomersTableConfigResolver` | +| `/customers/:entityId` | `customer/` | entity-details | `CustomersTableConfigResolver` | +| `/customers/:customerId/users` | `customer/` + `user/` | entity-table | `UsersTableConfigResolver` | +| `/customers/:customerId/devices` | `customer/` + `device/` | entity-table | `DevicesTableConfigResolver` | +| `/customers/:customerId/assets` | `customer/` + `asset/` | entity-table | `AssetsTableConfigResolver` | +| `/customers/:customerId/dashboards` | `customer/` + `dashboard/` | entity-table | `DashboardsTableConfigResolver` | +| `/customers/:customerId/edgeInstances` | `customer/` + `edge/` | entity-table | `EdgesTableConfigResolver` | + +### Calculated Fields (TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/calculatedFields` | `calculated-fields/` | custom (CalculatedFieldsTableComponent) | — | +| `/calculatedFields/:entityId` | `calculated-fields/` | entity-details | `CalculatedFieldsTableConfigResolver` | + +### Rule Chains (TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/ruleChains` | `rulechain/` | entity-table | `RuleChainsTableConfigResolver` | +| `/ruleChains/:ruleChainId` | `rulechain/` | custom (RuleChainPageComponent, lazy) | `RuleChainResolver` + others | + +### Edge Management (TENANT_ADMIN, conditional on edgesSupportEnabled) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/edgeManagement/instances` | `edge/` | entity-table | `EdgesTableConfigResolver` | +| `/edgeManagement/instances/:entityId` | `edge/` | entity-details | `EdgesTableConfigResolver` | +| `/edgeManagement/instances/:edgeId/assets` | `edge/` + `asset/` | entity-table | `AssetsTableConfigResolver` | +| `/edgeManagement/instances/:edgeId/devices` | `edge/` + `device/` | entity-table | `DevicesTableConfigResolver` | +| `/edgeManagement/instances/:edgeId/entityViews` | `edge/` + `entity-view/` | entity-table | `EntityViewsTableConfigResolver` | +| `/edgeManagement/instances/:edgeId/dashboards` | `edge/` + `dashboard/` | entity-table | `DashboardsTableConfigResolver` | +| `/edgeManagement/instances/:edgeId/ruleChains` | `edge/` + `rulechain/` | entity-table | `RuleChainsTableConfigResolver` | +| `/edgeManagement/ruleChains` | `edge/` + `rulechain/` | entity-table | `RuleChainsTableConfigResolver` | + +### Advanced Features (TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/features/otaUpdates` | `ota-update/` | entity-table | `OtaUpdateTableConfigResolve` | +| `/features/otaUpdates/:entityId` | `ota-update/` | entity-details | `OtaUpdateTableConfigResolve` | +| `/features/vc` | `vc/` | custom (VersionControlComponent) | — | + +### Resources (SYS_ADMIN, TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/resources/widgets-library/widget-types` | `widget/` | entity-table | `WidgetTypesTableConfigResolver` | +| `/resources/widgets-library/widgets-bundles` | `widget/` | entity-table | `WidgetsBundlesTableConfigResolver` | +| `/resources/widgets-library/widgets-bundles/:widgetsBundleId` | `widget/` | custom (WidgetsBundleWidgetsComponent) | — | +| `/resources/widgets-library/widget-types/:widgetTypeId` | `widget/` | custom (WidgetEditorComponent) | — | +| `/resources/images` | `admin/` | custom (ImageGalleryComponent) | — | +| `/resources/scada-symbols` | `admin/` | custom (ImageGalleryComponent) | — | +| `/resources/scada-symbols/:type/:key` | `scada-symbol/` | custom (ScadaSymbolComponent) | — | +| `/resources/resources-library` | `admin/` | entity-table | `ResourcesLibraryTableConfigResolver` | +| `/resources/resources-library/:entityId` | `admin/` | entity-details | `ResourcesLibraryTableConfigResolver` | +| `/resources/javascript-library` | `admin/` | entity-table | `JsLibraryTableConfigResolver` | +| `/resources/javascript-library/:entityId` | `admin/` | entity-details | `JsLibraryTableConfigResolver` | + +### Notifications (ALL authorities) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/notification/inbox` | `notification/inbox/` | entity-table | `InboxTableConfigResolver` | +| `/notification/sent` | `notification/sent/` | entity-table | `SentTableConfigResolver` | +| `/notification/recipients` | `notification/recipient/` | entity-table | `RecipientTableConfigResolver` | +| `/notification/templates` | `notification/template/` | entity-table | `TemplateTableConfigResolver` | +| `/notification/rules` | `notification/rule/` | entity-table | `RuleTableConfigResolver` | + +### Mobile Center (SYS_ADMIN, TENANT_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/mobile-center/bundles` | `mobile/bundes/` | entity-table | `MobileBundleTableConfigResolver` | +| `/mobile-center/applications` | `mobile/applications/` | entity-table | `MobileAppTableConfigResolver` | +| `/mobile-center/applications/:entityId` | `mobile/applications/` | entity-details | `MobileAppTableConfigResolver` | +| `/mobile-center/qr-code-widget` | `mobile/qr-code-widget/` | settings-form | — | + +### Settings — SYS_ADMIN + +| URL | Source | Type | +|-----|--------|------| +| `/settings/general` | `admin/` | settings-form (GeneralSettingsComponent) | +| `/settings/outgoing-mail` | `admin/` | settings-form (MailServerComponent) | +| `/settings/notifications` | `admin/` | settings-form (SmsProviderComponent) | +| `/settings/queues` | `admin/` | entity-table (`QueuesTableConfigResolver`) | + +### Settings — TENANT_ADMIN + +| URL | Source | Type | +|-----|--------|------| +| `/settings/home` | `admin/` | settings-form (HomeSettingsComponent) | +| `/settings/notifications` | `admin/` | settings-form (SmsProviderComponent) | +| `/settings/repository` | `admin/` | settings-form (RepositoryAdminSettingsComponent) | +| `/settings/auto-commit` | `admin/` | settings-form (AutoCommitAdminSettingsComponent) | +| `/settings/trendz` | `admin/` | settings-form (TrendzSettingsComponent) | +| `/settings/ai-models` | `ai-model/` | entity-table (`AiModelsTableConfigResolver`) | + +### Security Settings (SYS_ADMIN) + +| URL | Source | Type | Resolver | +|-----|--------|------|----------| +| `/security-settings/general` | `admin/` | settings-form (SecuritySettingsComponent) | — | +| `/security-settings/2fa` | `admin/` | settings-form (TwoFactorAuthSettingsComponent) | — | +| `/security-settings/oauth2/domains` | `admin/` | entity-table | `DomainTableConfigResolver` | +| `/security-settings/oauth2/clients` | `admin/` | entity-table | `ClientsTableConfigResolver` | +| `/security-settings/auditLogs` | `admin/` | custom (audit log table) | — | + +### Account (ALL authorities) + +| URL | Source | Type | +|-----|--------|------| +| `/account/profile` | `profile/` | settings-form (ProfileComponent) | +| `/account/security` | `security/` | settings-form (SecurityComponent) | + +### API Usage (TENANT_ADMIN) + +| URL | Source | Type | +|-----|--------|------| +| `/usage` | `api-usage/` | dashboard-view (loads `api_usage.json`) | + +--- + +## 3. Entity Table Pattern + +Most pages follow a 3-file pattern per entity type inside `pages//`: + +``` +-table-config.resolver.ts — Column definitions, actions, component wiring +.component.ts + .html — Entity detail form (extends EntityComponent) +-tabs.component.ts + .html — Additional detail tabs (relations, attributes, events, etc.) +``` + +**Framework files** (in `home/components/entity/`): +- `entities-table.component.ts` — The generic table component. Renders columns, actions, pagination from config. +- `entity-details-page.component.ts` — Full-page entity details with tabs. +- `entity-details-panel.component.ts` — Slide-out drawer panel for entity details. +- `entity.component.ts` — Abstract base directive all entity forms extend. +- `entities-table-config.models.ts` — TypeScript interfaces for table configuration. + +**Resolver structure** — each resolver returns an `EntityTableConfig` with: +- `entityType` — enum value (e.g., `EntityType.DEVICE`) +- `columns` — array of column definitions (header key, cell property/template, width) +- `cellActionDescriptors` — per-row action buttons +- `groupActionDescriptors` — bulk-selection action buttons +- `headerActionDescriptors` — toolbar action buttons (add, import) +- `entityComponent` — reference to the detail form component class +- `entityTabsComponent` — reference to the tabs component class +- `addDialogComponent` — optional dialog for add flow (e.g., `DeviceWizardDialogComponent`) + +### All Table Config Resolvers + +| Resolver | Entity Type | Source Dir | Entity Component | Tabs Component | +|----------|-------------|-----------|------------------|----------------| +| `TenantsTableConfigResolver` | TENANT | `tenant/` | `TenantComponent` | `TenantTabsComponent` | +| `TenantProfilesTableConfigResolver` | TENANT_PROFILE | `tenant-profile/` | `TenantProfileComponent` | `TenantProfileTabsComponent` | +| `DevicesTableConfigResolver` | DEVICE | `device/` | `DeviceComponent` | `DeviceTabsComponent` | +| `DeviceProfilesTableConfigResolver` | DEVICE_PROFILE | `device-profile/` | `DeviceProfileComponent` | `DeviceProfileTabsComponent` | +| `AssetsTableConfigResolver` | ASSET | `asset/` | `AssetComponent` | `AssetTabsComponent` | +| `AssetProfilesTableConfigResolver` | ASSET_PROFILE | `asset-profile/` | `AssetProfileComponent` | `AssetProfileTabsComponent` | +| `EntityViewsTableConfigResolver` | ENTITY_VIEW | `entity-view/` | `EntityViewComponent` | `EntityViewTabsComponent` | +| `CustomersTableConfigResolver` | CUSTOMER | `customer/` | `CustomerComponent` | `CustomerTabsComponent` | +| `UsersTableConfigResolver` | USER | `user/` | `UserComponent` | `UserTabsComponent` | +| `DashboardsTableConfigResolver` | DASHBOARD | `dashboard/` | `DashboardFormComponent` | `DashboardTabsComponent` | +| `EdgesTableConfigResolver` | EDGE | `edge/` | `EdgeComponent` | `EdgeTabsComponent` | +| `RuleChainsTableConfigResolver` | RULE_CHAIN | `rulechain/` | `RuleChainComponent` | `RuleChainTabsComponent` | +| `OtaUpdateTableConfigResolve` | OTA_PACKAGE | `ota-update/` | `OtaUpdateComponent` | `OtaUpdateTabsComponent` | +| `CalculatedFieldsTableConfigResolver` | CALCULATED_FIELD | `calculated-fields/` (config in `home/components/calculated-fields/`) | `CalculatedFieldComponent` | `CalculatedFieldsTabsComponent` | +| `WidgetTypesTableConfigResolver` | WIDGETS_BUNDLE | `widget/` | `WidgetTypeComponent` | `WidgetTypeTabsComponent` | +| `WidgetsBundlesTableConfigResolver` | WIDGETS_BUNDLE | `widget/` | `WidgetsBundleComponent` | `WidgetsBundleTabsComponent` | +| `ResourcesLibraryTableConfigResolver` | TB_RESOURCE | `admin/` | `ResourcesLibraryComponent` | `ResourceLibraryTabsComponent` | +| `JsLibraryTableConfigResolver` | TB_RESOURCE | `admin/` | `JsResourceComponent` | `ResourceTabsComponent` | +| `QueuesTableConfigResolver` | QUEUE | `admin/queue/` | `QueueComponent` | — | +| `AiModelsTableConfigResolver` | AI_MODEL | `ai-model/` | — | — | +| `ClientsTableConfigResolver` | OAUTH2_CLIENT | `admin/oauth2/clients/` | `ClientComponent` | — | +| `InboxTableConfigResolver` | NOTIFICATION | `notification/inbox/` | — (dialog) | — | +| `SentTableConfigResolver` | NOTIFICATION_REQUEST | `notification/sent/` | — (dialog) | — | +| `RecipientTableConfigResolver` | NOTIFICATION_TARGET | `notification/recipient/` | — (dialog) | — | +| `TemplateTableConfigResolver` | NOTIFICATION_TEMPLATE | `notification/template/` | — (dialog) | — | +| `RuleTableConfigResolver` | NOTIFICATION_RULE | `notification/rule/` | — (dialog) | — | +| `MobileAppTableConfigResolver` | MOBILE_APP | `mobile/applications/` | `MobileAppComponent` | — | +| `MobileBundleTableConfigResolver` | MOBILE_BUNDLE | `mobile/bundes/` | — | — | +| `AlarmRulesTableConfigResolver` | CALCULATED_FIELD | `alarm/` (config in `home/components/alarm-rules/`) | — | — | +| `DomainTableConfigResolver` | DOMAIN | `admin/oauth2/domains/` | `DomainComponent` | — | + +--- + +## 4. Shared Component Catalog + +### `@shared/components/entity/` + +| Selector | Purpose | +|----------|---------| +| `tb-entity-autocomplete` | Autocomplete input for selecting a single entity | +| `tb-entity-select` | Dropdown select for entity | +| `tb-entity-list` | Chip list for multiple entities | +| `tb-entity-list-select` | Dropdown with entity list selection | +| `tb-entity-key-autocomplete` | Autocomplete for entity attribute/telemetry keys | +| `tb-entity-keys-list` | Chip list for multiple entity keys | +| `tb-entity-type-select` | Dropdown for entity type enum | +| `tb-entity-type-list` | Chip list for multiple entity types | +| `tb-entity-subtype-select` | Dropdown for entity subtype (e.g., device type) | +| `tb-entity-subtype-list` | Chip list for multiple subtypes | +| `tb-entity-subtype-autocomplete` | Autocomplete for entity subtype | +| `tb-entity-gateway-select` | Dropdown for selecting a gateway device | + +### `@shared/components/time/` + +| Selector | Purpose | +|----------|---------| +| `tb-timewindow` | Timewindow selector (realtime/history) | +| `tb-timewindow-panel` | Expanded panel for timewindow config | +| `tb-timewindow-config-dialog` | Dialog for advanced timewindow settings | +| `tb-datetime` | Date-time picker input | +| `tb-datetime-period` | Date range picker | +| `tb-timezone` | Timezone display | +| `tb-timezone-select` | Dropdown for timezone selection | +| `tb-quick-time-interval` | Quick interval preset selector | +| `tb-timeinterval` | Duration input (number + unit) | +| `tb-datapoints-limit` | Numeric input for data point limits | +| `tb-aggregation-type-select` | Dropdown for aggregation type (AVG, SUM, etc.) | +| `tb-history-selector` | History navigation selector | + +### `@shared/components/image/` + +| Selector | Purpose | +|----------|---------| +| `tb-image-gallery` | Grid gallery of uploaded images | +| `tb-image-gallery-dialog` | Dialog wrapper for image gallery | +| `tb-image-input` | Single image upload input | +| `tb-gallery-image-input` | Image input that opens gallery for selection | +| `tb-multiple-gallery-image-input` | Multi-image input with gallery | +| `tb-upload-image-dialog` | Upload dialog | +| `tb-embed-image-dialog` | Embed external image dialog | +| `tb-scada-symbol-input` | SCADA symbol selector input | + +### `@shared/components/dialog/` + +| Selector | Purpose | +|----------|---------| +| `tb-confirm-dialog` | Yes/No confirmation dialog | +| `tb-alert-dialog` | Information alert dialog | +| `tb-error-alert-dialog` | Error message dialog | +| `tb-color-picker-dialog` | Color picker in a dialog | +| `tb-material-icons-dialog` | Material icon browser dialog | +| `tb-node-script-test-dialog` | Script testing dialog for rule nodes | +| `tb-object-edit-dialog` | JSON object editor dialog | +| `tb-todo-dialog` | Todo/checklist dialog | + +### `@shared/components/` (other) + +| Selector | Purpose | +|----------|---------| +| `tb-json-content` | JSON syntax-highlighted viewer | +| `tb-json-object-edit` | Editable JSON object component | +| `tb-json-object-view` | Read-only JSON object display | +| `tb-markdown` | Markdown editor/viewer | +| `tb-breadcrumb` | Breadcrumb navigation bar | +| `tb-icon` | Custom icon component | +| `tb-logo` | ThingsBoard logo | +| `tb-copy-button` | Copy-to-clipboard button | +| `tb-toggle-password` | Password visibility toggle | +| `tb-file-input` | File upload input | +| `tb-phone-input` | International phone input | +| `tb-unit-input` | Measurement unit input | +| `tb-value-input` | Generic value input with type selection | +| `tb-color-input` | Color picker input | +| `tb-color-picker` | Inline color picker | +| `tb-key-val-map` | Key-value pair map editor | +| `tb-nav-tree` | Tree navigation component | +| `tb-user-menu` | User profile dropdown menu | +| `tb-notification-bell` | Notification bell icon with badge | +| `tb-footer` | Page footer | +| `tb-scroll-grid` | Virtual-scroll grid | +| `tb-social-share-panel` | Social sharing buttons | + +### Autocomplete / Select Components + +| Selector | Purpose | +|----------|---------| +| `tb-dashboard-autocomplete` | Autocomplete for dashboard selection | +| `tb-dashboard-select` | Dropdown for dashboard | +| `tb-dashboard-state-autocomplete` | Autocomplete for dashboard states | +| `tb-country-autocomplete` | Country autocomplete | +| `tb-string-autocomplete` | Generic string autocomplete | +| `tb-message-type-autocomplete` | Rule engine message type autocomplete | +| `tb-queue-autocomplete` | Queue name autocomplete | +| `tb-relation-type-autocomplete` | Relation type autocomplete | +| `tb-resource-autocomplete` | Resource autocomplete | +| `tb-ota-package-autocomplete` | OTA package autocomplete | +| `tb-branch-autocomplete` | Git branch autocomplete | +| `tb-rule-chain-select` | Rule chain select dropdown | +| `tb-widgets-bundle-select` | Widgets bundle select dropdown | +| `tb-template-autocomplete` | Notification template autocomplete | +| `tb-material-icon-select` | Material icon picker | +| `tb-slack-conversation-autocomplete` | Slack conversation autocomplete | + +### `@home/components/` (key components) + +| Selector | Purpose | +|----------|---------| +| `tb-entities-table` | Main entity table (columns, sort, pagination, actions) | +| `tb-entity-details-panel` | Slide-out panel for entity details | +| `tb-entity-details-page` | Full-page entity details view | +| `tb-add-entity-dialog` | Generic add-entity dialog | +| `tb-entity-filter` | Entity data filter component | +| `tb-entity-chips` | Chip display for entity references | +| `tb-details-panel` | Base details panel (toolbar + edit/apply FABs) | +| `tb-alarm-table` | Alarm list table | +| `tb-alarm-details-dialog` | Alarm detail dialog with ack/clear | +| `tb-alarm-assignee` | Alarm assignee selector | +| `tb-alarm-comment` | Alarm comment thread | +| `tb-alarm-filter-config` | Alarm filter configuration | +| `tb-audit-log-table` | Audit log table | +| `tb-attribute-table` | Entity attribute key-value table | +| `tb-relation-table` | Entity relation table | +| `tb-event-table` | Entity event table | +| `tb-dashboard` | Dashboard renderer | +| `tb-dashboard-page` | Dashboard page (toolbar + dashboard) | +| `tb-dashboard-toolbar` | Dashboard edit/view toolbar | +| `tb-edit-widget` | Widget editor | +| `tb-router-tabs` | Tab navigation via router (used for section tabs) | +| `tb-device-credentials` | Device credentials form | + +--- + +## 5. Angular Material DOM Patterns + +**No `data-testid` attributes** — selectors must use Angular Material structure, `formControlName`, `matColumnDef`, labels, roles, and classes. + +### Login Form + +```html + + + + + + + + + + + + + + + +``` + +Key selectors: `#username-input`, `#password-input`, `button[type="submit"]`. + +### Main Layout + +```html + + + + + + + + + + + + + + +``` + +Key selectors: `mat-sidenav#sidenav`, `button#main`, `tb-breadcrumb`, `tb-user-menu`. + +### Entity Table + +```html + + + + + +
+ + + + + + + + + + + + + + + + + + +
+ + + + + + + + + + + + + + + + + + +
+
+ +
+
+
+``` + +Key selectors: `table[mat-table]`, `mat-row`, `mat-cell`, `mat-paginator`, `[matColumnDef="select"]`, `[matColumnDef="actions"]`. Note: the search input uses Angular template ref `#searchInput` (not an HTML `id`); target it via `.tb-entity-table mat-toolbar input[matInput]` in the search toolbar context. + +### Entity Details Page + +```html + + + +
+
+ + +
+
+ + + + + + +
+``` + +Key selectors: `mat-toolbar.details-toolbar`, `button[mat-fab][color="accent"]`, `mat-tab-group`, `mat-tab`. + +### Details Panel (Slide-Out Drawer) + +```html +
+ + + +
+ + +
+
+
+
+ +
+``` + +### Entity Detail Form (inside a tab) + +```html +
+
+
+ + + + + + + + + + + + + + +
+
+
+``` + +Key selectors: `input[formControlName="name"]`, `mat-slide-toggle[formControlName="..."]`, `textarea[formControlName="description"]`. + +### Dialog + +```html +
+ +

+ + +
+ +
+ +
+
+ + +
+ +``` + +Key selectors: `[mat-dialog-content]`, `[mat-dialog-actions]`, `button[type="submit"]`, `mat-toolbar[color="primary"] h2`. + +### Common Material Selector Patterns + +``` +# Find a form field by its control name +input[formControlName="name"] +mat-select[formControlName="type"] +mat-slide-toggle[formControlName="gateway"] +textarea[formControlName="description"] + +# Find table column content +[matColumnDef="name"] mat-cell + +# Find buttons by tooltip +button[matTooltip="Search"] + +# Find a specific tab +mat-tab-group mat-tab (by index or label text) + +# Find menu items (after opening menu) +button[mat-menu-item] + +# Find dialog actions +[mat-dialog-actions] button[mat-raised-button] +``` + +--- + +## 6. Form Structure Guide + +All entity detail forms extend the abstract `EntityComponent` directive (`@home/components/entity/entity.component.ts`). + +**Key properties:** +- `entityForm: UntypedFormGroup` — the reactive form instance +- `entity: T` — current entity data (input) +- `isEdit: boolean` — whether form is in edit mode (input) + +**Lifecycle:** + +1. **`buildForm(entity: T): UntypedFormGroup`** — Called in constructor. Creates the FormGroup with controls, validators, and initial values. Subscribe to valueChanges here for cascading updates. + +2. **`updateForm(entity: T): void`** — Called when entity input changes. Uses `entityForm.patchValue({...}, {emitEvent: false})` to populate form without triggering change events. + +3. **`updateFormState(): void`** — Called when `isEdit` changes. Calls `entityForm.enable()` or `entityForm.disable()` with `{emitEvent: false}`. + +4. **`entityFormValue(): any`** — Returns `entityForm.getRawValue()` (reads disabled fields too), passed through `prepareFormValue()` (default: `deepTrim`). + +**Read-only pattern:** Templates use `
` to visually disable all inputs. The form group is also programmatically disabled via `entityForm.disable()`. + +**Saving:** Merges `{...this.entity, ...this.entityFormValue()}` and calls the HTTP service. + +**Common validators:** `Validators.required`, `Validators.maxLength(255)`, custom `validateEmail`. + +**Nested FormGroups** — `additionalInfo` is almost always a nested group: +```typescript +additionalInfo: this.fb.group({ + description: [''], + gateway: [false], + // ... +}) +``` + +--- + +## 7. HTTP Services Reference + +All services are in `src/app/core/http/`. + +| Entity / Area | Service File | Class | +|---------------|-------------|-------| +| Device | `device.service.ts` | `DeviceService` | +| Asset | `asset.service.ts` | `AssetService` | +| Device Profile | `device-profile.service.ts` | `DeviceProfileService` | +| Asset Profile | `asset-profile.service.ts` | `AssetProfileService` | +| Entity View | `entity-view.service.ts` | `EntityViewService` | +| Customer | `customer.service.ts` | `CustomerService` | +| User | `user.service.ts` | `UserService` | +| Tenant | `tenant.service.ts` | `TenantService` | +| Tenant Profile | `tenant-profile.service.ts` | `TenantProfileService` | +| Dashboard | `dashboard.service.ts` | `DashboardService` | +| Rule Chain | `rule-chain.service.ts` | `RuleChainService` | +| Edge | `edge.service.ts` | `EdgeService` | +| Alarm | `alarm.service.ts` | `AlarmService` | +| Alarm Comment | `alarm-comment.service.ts` | `AlarmCommentService` | +| OTA Package | `ota-package.service.ts` | `OtaPackageService` | +| Calculated Fields | `calculated-fields.service.ts` | `CalculatedFieldsService` | +| Widget | `widget.service.ts` | `WidgetService` | +| Resource | `resource.service.ts` | `ResourceService` | +| Queue | `queue.service.ts` | `QueueService` | +| AI Model | `ai-model.service.ts` | `AiModelService` | +| Notification | `notification.service.ts` | `NotificationService` | +| OAuth2 | `oauth2.service.ts` | `OAuth2Service` | +| Domain | `domain.service.ts` | `DomainService` | +| Mobile App | `mobile-app.service.ts` | `MobileAppService` | +| Attribute | `attribute.service.ts` | `AttributeService` | +| Entity Relation | `entity-relation.service.ts` | `EntityRelationService` | +| Entity (generic) | `entity.service.ts` | `EntityService` | +| Event | `event.service.ts` | `EventService` | +| Audit Log | `audit-log.service.ts` | `AuditLogService` | +| Admin Settings | `admin.service.ts` | `AdminService` | +| Version Control | `entities-version-control.service.ts` | `EntitiesVersionControlService` | +| 2FA | `two-factor-authentication.service.ts` | `TwoFactorAuthenticationService` | +| User Settings | `user-settings.service.ts` | `UserSettingsService` | +| API Key | `api-key.service.ts` | `ApiKeyService` | +| Image | `image.service.ts` | `ImageService` | +| Trendz Settings | `trendz-settings.service.ts` | `TrendzSettingsService` | +| Usage Info | `usage-info.service.ts` | `UsageInfoService` | +| Component Descriptor | `component-descriptor.service.ts` | `ComponentDescriptorService` | +| GitHub | `git-hub.service.ts` | `GitHubService` | +| Mobile Application | `mobile-application.service.ts` | `MobileApplicationService` | +| UI Settings | `ui-settings.service.ts` | `UiSettingsService` | diff --git a/ui-ngx/yarn.lock b/ui-ngx/yarn.lock index 5f21abf7c8..42c08e4df8 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -2,6 +2,132 @@ # yarn lockfile v1 +"@algolia/abtesting@1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@algolia/abtesting/-/abtesting-1.1.0.tgz#32c84876956727dcdca8df03c567f12fb39edf0d" + integrity sha512-sEyWjw28a/9iluA37KLGu8vjxEIlb60uxznfTUmXImy7H5NvbpSO6yYgmgH5KiD7j+zTUUihiST0jEP12IoXow== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-abtesting@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-abtesting/-/client-abtesting-5.35.0.tgz#e4cb9ad90357c8b26fb6fbcd0157cc8bd1b600ed" + integrity sha512-uUdHxbfHdoppDVflCHMxRlj49/IllPwwQ2cQ8DLC4LXr3kY96AHBpW0dMyi6ygkn2MtFCc6BxXCzr668ZRhLBQ== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-analytics@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-analytics/-/client-analytics-5.35.0.tgz#ff0b9d9cbda074472d727a2d0a6b18bcc47076be" + integrity sha512-SunAgwa9CamLcRCPnPHx1V2uxdQwJGqb1crYrRWktWUdld0+B2KyakNEeVn5lln4VyeNtW17Ia7V7qBWyM/Skw== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-common@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-common/-/client-common-5.35.0.tgz#1d72b36308ceaa53d7d488cd3a161506ddeb0c42" + integrity sha512-ipE0IuvHu/bg7TjT2s+187kz/E3h5ssfTtjpg1LbWMgxlgiaZIgTTbyynM7NfpSJSKsgQvCQxWjGUO51WSCu7w== + +"@algolia/client-insights@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-insights/-/client-insights-5.35.0.tgz#8fd51eead7d89925522d48b5c164b3d7b656707c" + integrity sha512-UNbCXcBpqtzUucxExwTSfAe8gknAJ485NfPN6o1ziHm6nnxx97piIbcBQ3edw823Tej2Wxu1C0xBY06KgeZ7gA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-personalization@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-personalization/-/client-personalization-5.35.0.tgz#a661767851269b8ff091eaa900fe0b65b3937382" + integrity sha512-/KWjttZ6UCStt4QnWoDAJ12cKlQ+fkpMtyPmBgSS2WThJQdSV/4UWcqCUqGH7YLbwlj3JjNirCu3Y7uRTClxvA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-query-suggestions@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.35.0.tgz#b8704f3cdb2b49b8f0348bb77a3821a5ffa5b73b" + integrity sha512-8oCuJCFf/71IYyvQQC+iu4kgViTODbXDk3m7yMctEncRSRV+u2RtDVlpGGfPlJQOrAY7OONwJlSHkmbbm2Kp/w== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/client-search@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/client-search/-/client-search-5.35.0.tgz#61441c4de212d8a1af93ab1406f46fd3386c35a0" + integrity sha512-FfmdHTrXhIduWyyuko1YTcGLuicVbhUyRjO3HbXE4aP655yKZgdTIfMhZ/V5VY9bHuxv/fGEh3Od1Lvv2ODNTg== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/ingestion@1.35.0": + version "1.35.0" + resolved "https://registry.yarnpkg.com/@algolia/ingestion/-/ingestion-1.35.0.tgz#fb263cc190da421756f8f52e3fe5e5afdc89f68c" + integrity sha512-gPzACem9IL1Co8mM1LKMhzn1aSJmp+Vp434An4C0OBY4uEJRcqsLN3uLBlY+bYvFg8C8ImwM9YRiKczJXRk0XA== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/monitoring@1.35.0": + version "1.35.0" + resolved "https://registry.yarnpkg.com/@algolia/monitoring/-/monitoring-1.35.0.tgz#6be2b3e2a48328b57d98827da9c85ec2b42d49da" + integrity sha512-w9MGFLB6ashI8BGcQoVt7iLgDIJNCn4OIu0Q0giE3M2ItNrssvb8C0xuwJQyTy1OFZnemG0EB1OvXhIHOvQwWw== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/recommend@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/recommend/-/recommend-5.35.0.tgz#03c3d1dbbd1b5b07dda98350b45640c819081211" + integrity sha512-AhrVgaaXAb8Ue0u2nuRWwugt0dL5UmRgS9LXe0Hhz493a8KFeZVUE56RGIV3hAa6tHzmAV7eIoqcWTQvxzlJeQ== + dependencies: + "@algolia/client-common" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +"@algolia/requester-browser-xhr@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.35.0.tgz#b1c26fc9be80a0ddde6b2e45fb50d52c7c6c05f4" + integrity sha512-diY415KLJZ6x1Kbwl9u96Jsz0OstE3asjXtJ9pmk1d+5gPuQ5jQyEsgC+WmEXzlec3iuVszm8AzNYYaqw6B+Zw== + dependencies: + "@algolia/client-common" "5.35.0" + +"@algolia/requester-fetch@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-fetch/-/requester-fetch-5.35.0.tgz#29815c5bf501148ea619ed96bae09c099a5cced4" + integrity sha512-uydqnSmpAjrgo8bqhE9N1wgcB98psTRRQXcjc4izwMB7yRl9C8uuAQ/5YqRj04U0mMQ+fdu2fcNF6m9+Z1BzDQ== + dependencies: + "@algolia/client-common" "5.35.0" + +"@algolia/requester-node-http@5.35.0": + version "5.35.0" + resolved "https://registry.yarnpkg.com/@algolia/requester-node-http/-/requester-node-http-5.35.0.tgz#9095b80a0ae77f574036261f44053636e31d1baf" + integrity sha512-RgLX78ojYOrThJHrIiPzT4HW3yfQa0D7K+MQ81rhxqaNyNBu4F1r+72LNHYH/Z+y9I1Mrjrd/c/Ue5zfDgAEjQ== + dependencies: + "@algolia/client-common" "5.35.0" + "@alloc/quick-lru@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" @@ -15,350 +141,341 @@ "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" -"@angular-builders/common@2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@angular-builders/common/-/common-2.0.0.tgz#76920a63c50a84669c0a786b6281ae9fbf34560d" - integrity sha512-O5YJc++DtJVJhqA/OomRKN2jGYzvU/YXtfrPAqcA9Is3Ob5jvV0L0JHSAjSw/KaLvk/FjBIqoRVcYdLp5LKddA== +"@angular-builders/common@4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@angular-builders/common/-/common-4.0.0.tgz#1354988632eb76501e35bd1aa03178e98b84677f" + integrity sha512-bepZI1KdXUVhDGqHOudZQJwucSbZWxfWzM+EHFXUoExUO0u7XEuHTF5bhtQZ+YU+ZK2Ayl26QbI/26Yj72vcFQ== dependencies: - "@angular-devkit/core" "^18.0.0" + "@angular-devkit/core" "^20.0.0" ts-node "^10.0.0" - tsconfig-paths "^4.1.0" + tsconfig-paths "^4.2.0" -"@angular-builders/custom-esbuild@18.0.0": - version "18.0.0" - resolved "https://registry.yarnpkg.com/@angular-builders/custom-esbuild/-/custom-esbuild-18.0.0.tgz#7da11ce47cfa0bbab1e5e07a9bdefb8a6afe8ed8" - integrity sha512-SdAGatJMJJjPYrdkUupdRazid3xe1sIj/CAHDdDVP094vZ9CrZs6fgcsOF17waAo6uMxrQCWmPDrTjNgR3CvMQ== +"@angular-builders/custom-esbuild@20.0.0": + version "20.0.0" + resolved "https://registry.yarnpkg.com/@angular-builders/custom-esbuild/-/custom-esbuild-20.0.0.tgz#edea23ab855a88ba1faaaf1753d76922c008d8ea" + integrity sha512-ZFROfAiDPM+mvZQDCO7iP/0L9kZriMr9gPzs57WbME0KJIMjhcMnfRxOSx0uDwbwzt8lCTamv/nUvN1eW1d2bw== dependencies: - "@angular-builders/common" "2.0.0" - "@angular-devkit/architect" ">=0.1800.0 < 0.1900.0" - "@angular-devkit/build-angular" "^18.0.0" - "@angular-devkit/core" "^18.0.0" + "@angular-builders/common" "4.0.0" + "@angular-devkit/architect" ">=0.2000.0 < 0.2100.0" + "@angular-devkit/core" "^20.0.0" + "@angular/build" "^20.0.0" -"@angular-devkit/architect@0.1802.12", "@angular-devkit/architect@>= 0.1800.0 < 0.1900.0", "@angular-devkit/architect@>=0.1800.0 < 0.1900.0": - version "0.1802.12" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.1802.12.tgz#096f8e9cf71f8848c6f0172c03f3f1135509e133" - integrity sha512-bepVb2/GtJppYKaeW8yTGE6egmoWZ7zagFDsmBdbF+BYp+HmeoPsclARcdryBPVq68zedyTRdvhWSUTbw1AYuw== +"@angular-devkit/architect@0.2003.24", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.24.tgz#1c59822ffeada4248f669424c7c0534f39fe8bc5" + integrity sha512-E7mCdkL6SWnW60G1nGLuugmsopza/eVIrDWB1y0vLkWN8gepOvnHz2Uf637kdzed/F1WoqR+dhv1SfsaJapzKA== dependencies: - "@angular-devkit/core" "18.2.12" - rxjs "7.8.1" + "@angular-devkit/core" "20.3.24" + rxjs "7.8.2" -"@angular-devkit/build-angular@18.2.12", "@angular-devkit/build-angular@^18.0.0": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-18.2.12.tgz#1ab09bc5571a1fc8c6234ef346f0629761c27bdd" - integrity sha512-quVUi7eqTq9OHumQFNl9Y8t2opm8miu4rlYnuF6rbujmmBDvdUvR6trFChueRczl2p5HWqTOr6NPoDGQm8AyNw== +"@angular-devkit/build-angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.24.tgz#871b7c134d9c9f77b16cd68bca340801638e56a7" + integrity sha512-zBjETGqKtiojF8VHvjELW+UAMBRJ7ziqNc1xwUtqRuWZQVz8HH6x8m8Ncw4mSq9ecCFC4VB7OWTJ1VkAdYSBnQ== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.1802.12" - "@angular-devkit/build-webpack" "0.1802.12" - "@angular-devkit/core" "18.2.12" - "@angular/build" "18.2.12" - "@babel/core" "7.25.2" - "@babel/generator" "7.25.0" - "@babel/helper-annotate-as-pure" "7.24.7" + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/build-webpack" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular/build" "20.3.24" + "@babel/core" "7.28.3" + "@babel/generator" "7.28.3" + "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" - "@babel/plugin-transform-async-generator-functions" "7.25.0" - "@babel/plugin-transform-async-to-generator" "7.24.7" - "@babel/plugin-transform-runtime" "7.24.7" - "@babel/preset-env" "7.25.3" - "@babel/runtime" "7.25.0" - "@discoveryjs/json-ext" "0.6.1" - "@ngtools/webpack" "18.2.12" - "@vitejs/plugin-basic-ssl" "1.1.0" + "@babel/plugin-transform-async-generator-functions" "7.28.0" + "@babel/plugin-transform-async-to-generator" "7.27.1" + "@babel/plugin-transform-runtime" "7.28.3" + "@babel/preset-env" "7.28.3" + "@babel/runtime" "7.28.3" + "@discoveryjs/json-ext" "0.6.3" + "@ngtools/webpack" "20.3.24" ansi-colors "4.1.3" - autoprefixer "10.4.20" - babel-loader "9.1.3" + autoprefixer "10.4.21" + babel-loader "10.0.0" browserslist "^4.21.5" - copy-webpack-plugin "12.0.2" - critters "0.0.24" + copy-webpack-plugin "14.0.0" css-loader "7.1.2" - esbuild-wasm "0.23.0" - fast-glob "3.3.2" - http-proxy-middleware "3.0.3" - https-proxy-agent "7.0.5" + esbuild-wasm "0.28.0" + fast-glob "3.3.3" + http-proxy-middleware "3.0.5" istanbul-lib-instrument "6.0.3" jsonc-parser "3.3.1" karma-source-map-support "1.4.0" - less "4.2.0" - less-loader "12.2.0" + less "4.4.0" + less-loader "12.3.0" license-webpack-plugin "4.0.2" loader-utils "3.3.1" - magic-string "0.30.11" - mini-css-extract-plugin "2.9.0" - mrmime "2.0.0" - open "10.1.0" - ora "5.4.1" - parse5-html-rewriting-stream "7.0.0" - picomatch "4.0.2" - piscina "4.6.1" - postcss "8.4.41" + mini-css-extract-plugin "2.9.4" + open "10.2.0" + ora "8.2.0" + picomatch "4.0.4" + piscina "5.1.3" + postcss "8.5.6" postcss-loader "8.1.1" resolve-url-loader "5.0.0" - rxjs "7.8.1" - sass "1.77.6" - sass-loader "16.0.0" - semver "7.6.3" + rxjs "7.8.2" + sass "1.90.0" + sass-loader "16.0.5" + semver "7.7.2" source-map-loader "5.0.0" source-map-support "0.5.21" - terser "5.31.6" + terser "5.43.1" tree-kill "1.2.2" - tslib "2.6.3" - vite "5.4.6" - watchpack "2.4.1" - webpack "5.94.0" + tslib "2.8.1" + webpack "5.105.0" webpack-dev-middleware "7.4.2" - webpack-dev-server "5.0.4" + webpack-dev-server "5.2.2" webpack-merge "6.0.1" webpack-subresource-integrity "5.1.0" optionalDependencies: - esbuild "0.23.0" + esbuild "0.28.0" -"@angular-devkit/build-webpack@0.1802.12": - version "0.1802.12" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.1802.12.tgz#b46afdf8dccef681156118d545e37ac57e0da759" - integrity sha512-0Z3fdbZVRnjYWE2/VYyfy+uieY+6YZyEp4ylzklVkc+fmLNsnz4Zw6cK1LzzcBqAwKIyh1IdW20Cg7o8b0sONA== +"@angular-devkit/build-webpack@0.2003.24": + version "0.2003.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.24.tgz#9d96b1d2c05faf3bcb696059c736d9498230ba36" + integrity sha512-gfadsLK3SxbGpZQMSJGr8RK65R2mZi/VmHXdztXQHATvMpy+00CX1Nu3n+lPTxOVbC73aIczUkUbIuRC1HBCqg== dependencies: - "@angular-devkit/architect" "0.1802.12" - rxjs "7.8.1" + "@angular-devkit/architect" "0.2003.24" + rxjs "7.8.2" -"@angular-devkit/core@18.2.12", "@angular-devkit/core@>= 18.0.0 < 19.0.0", "@angular-devkit/core@^18.0.0": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-18.2.12.tgz#fb514e9b3c9ea87ddaa1582d3947f1b094c9b387" - integrity sha512-NtB6ypsaDyPE6/fqWOdfTmACs+yK5RqfH5tStEzWFeeDsIEDYKsJ06ypuRep7qTjYus5Rmttk0Ds+cFgz8JdUQ== +"@angular-devkit/core@20.3.24", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.24.tgz#e4108ad599d507d934b9ca990d98a4bb23bd0471" + integrity sha512-kmOjXJcbFxUI91nds9n6XZ6Y/DyQ7/TqRXbHHqvkz9RtlIpdbgWHlIZIq6mgsPOgPBzkxFjtncVARYZUI3yxaw== dependencies: - ajv "8.17.1" + ajv "8.18.0" ajv-formats "3.0.1" jsonc-parser "3.3.1" - picomatch "4.0.2" - rxjs "7.8.1" - source-map "0.7.4" + picomatch "4.0.4" + rxjs "7.8.2" + source-map "0.7.6" -"@angular-devkit/schematics@18.2.12", "@angular-devkit/schematics@>= 18.0.0 < 19.0.0": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-18.2.12.tgz#15d1a8611bf9f18215435604672411b1929bf4d1" - integrity sha512-mMea9txHbnCX5lXLHlo0RAgfhFHDio45/jMsREM2PA8UtVf2S8ltXz7ZwUrUyMQRv8vaSfn4ijDstF4hDMnRgQ== +"@angular-devkit/schematics@20.3.24", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.24.tgz#4631ec0755363173db0e89eadc0d756985e0bae1" + integrity sha512-B5fBPi0xnEDI0wLLkCjsrYjazRPyf+rnHLAHi34thMdeY9dqljJGWYdNuyUUBak6HNPBLdEo1EUSNcOF9OWt4A== dependencies: - "@angular-devkit/core" "18.2.12" + "@angular-devkit/core" "20.3.24" jsonc-parser "3.3.1" - magic-string "0.30.11" - ora "5.4.1" - rxjs "7.8.1" - -"@angular-eslint/builder@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/builder/-/builder-18.4.3.tgz#800b8a68b464ddfc0d737b0ad38c7804b463d8e1" - integrity sha512-NzmrXlr7GFE+cjwipY/CxBscZXNqnuK0us1mO6Z2T6MeH6m+rRcdlY/rZyKoRniyNNvuzl6vpEsfMIMmnfebrA== - dependencies: - "@angular-devkit/architect" ">= 0.1800.0 < 0.1900.0" - "@angular-devkit/core" ">= 18.0.0 < 19.0.0" - -"@angular-eslint/bundled-angular-compiler@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-18.4.3.tgz#0810f76045b854782e6370953cf5324112a65f80" - integrity sha512-zdrA8mR98X+U4YgHzUKmivRU+PxzwOL/j8G7eTOvBuq8GPzsP+hvak+tyxlgeGm9HsvpFj9ERHLtJ0xDUPs8fg== - -"@angular-eslint/eslint-plugin-template@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-18.4.3.tgz#3e9820735f087afad193361e3081fad54dbf4e51" - integrity sha512-ijGlX2N01ayMXTpeQivOA31AszO8OEbu9ZQUCxnu9AyMMhxyi2q50bujRChAvN9YXQfdQtbxuajxV6+aiWb5BQ== - dependencies: - "@angular-eslint/bundled-angular-compiler" "18.4.3" - "@angular-eslint/utils" "18.4.3" + magic-string "0.30.17" + ora "8.2.0" + rxjs "7.8.2" + +"@angular-eslint/builder@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/builder/-/builder-20.7.0.tgz#57df5635e287f3a583d495cabad0b1661d85aa87" + integrity sha512-qgf4Cfs1z0VsVpzF/OnxDRvBp60OIzeCsp4mzlckWYVniKo19EPIN6kFDol5eTAIOMPgiBQlMIwgQMHgocXEig== + dependencies: + "@angular-devkit/architect" ">= 0.2000.0 < 0.2100.0" + "@angular-devkit/core" ">= 20.0.0 < 21.0.0" + +"@angular-eslint/bundled-angular-compiler@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/bundled-angular-compiler/-/bundled-angular-compiler-20.7.0.tgz#1bf24d6167ed699e0a3bc40ea6d78f22bd8b1e10" + integrity sha512-9KPz24YoiL0SvTtTX6sd1zmysU5cKOCcmpEiXkCoO3L2oYZGlVxmMT4hfSaHMt8qmfvV2KzQMoR6DZM84BwRzQ== + +"@angular-eslint/eslint-plugin-template@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/eslint-plugin-template/-/eslint-plugin-template-20.7.0.tgz#3a536cc31bb9b861b3663eee7b83d484517d2b4f" + integrity sha512-WFmvW2vBR6ExsSKEaActQTteyw6ikWyuJau9XmWEPFd+2eusEt/+wO21ybjDn3uc5FTp1IcdhfYy+U5OdDjH5w== + dependencies: + "@angular-eslint/bundled-angular-compiler" "20.7.0" + "@angular-eslint/utils" "20.7.0" aria-query "5.3.2" axobject-query "4.1.0" -"@angular-eslint/eslint-plugin@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/eslint-plugin/-/eslint-plugin-18.4.3.tgz#7618bc6056086a98ed4d888f31185fc62e6be2d1" - integrity sha512-AyJbupiwTBR81P6T59v+aULEnPpZBCBxL2S5QFWfAhNCwWhcof4GihvdK2Z87yhvzDGeAzUFSWl/beJfeFa+PA== - dependencies: - "@angular-eslint/bundled-angular-compiler" "18.4.3" - "@angular-eslint/utils" "18.4.3" - -"@angular-eslint/schematics@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/schematics/-/schematics-18.4.3.tgz#1d6e9026e0054d556c37750ccff0ecce701561c1" - integrity sha512-D5maKn5e6n58+8n7jLFLD4g+RGPOPeDSsvPc1sqial5tEKLxAJQJS9WZ28oef3bhkob6C60D+1H0mMmEEVvyVA== - dependencies: - "@angular-devkit/core" ">= 18.0.0 < 19.0.0" - "@angular-devkit/schematics" ">= 18.0.0 < 19.0.0" - "@angular-eslint/eslint-plugin" "18.4.3" - "@angular-eslint/eslint-plugin-template" "18.4.3" - ignore "6.0.2" - semver "7.6.3" +"@angular-eslint/eslint-plugin@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/eslint-plugin/-/eslint-plugin-20.7.0.tgz#c28ad8b66513ebcef48db7b9cf6f994d665f62b4" + integrity sha512-aHH2YTiaonojsKN+y2z4IMugCwdsH/dYIjYBig6kfoSPyf9rGK4zx+gnNGq/pGRjF3bOYrmFgIviYpQVb80inQ== + dependencies: + "@angular-eslint/bundled-angular-compiler" "20.7.0" + "@angular-eslint/utils" "20.7.0" + ts-api-utils "^2.1.0" + +"@angular-eslint/schematics@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/schematics/-/schematics-20.7.0.tgz#610505927b0c088fcc08d7c1641fe2b90e00799c" + integrity sha512-S0onfRipDUIL6gFGTFjiWwUDhi42XYrBoi3kJ3wBbKBeIgYv9SP1ppTKDD4ZoDaDU9cQE8nToX7iPn9ifMw6eQ== + dependencies: + "@angular-devkit/core" ">= 20.0.0 < 21.0.0" + "@angular-devkit/schematics" ">= 20.0.0 < 21.0.0" + "@angular-eslint/eslint-plugin" "20.7.0" + "@angular-eslint/eslint-plugin-template" "20.7.0" + ignore "7.0.5" + semver "7.7.3" strip-json-comments "3.1.1" -"@angular-eslint/template-parser@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/template-parser/-/template-parser-18.4.3.tgz#2c6c396563a278a6f2dfdb3fbe9d4310ad0c6dc6" - integrity sha512-JZMPtEB8yNip3kg4WDEWQyObSo2Hwf+opq2ElYuwe85GQkGhfJSJ2CQYo4FSwd+c5MUQAqESNRg9QqGYauDsiw== +"@angular-eslint/template-parser@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/template-parser/-/template-parser-20.7.0.tgz#dfda748270d3d40f2f71fff8d3f81b68cf91a55e" + integrity sha512-CVskZnF38IIxVVlKWi1VCz7YH/gHMJu2IY9bD1AVoBBGIe0xA4FRXJkW2Y+EDs9vQqZTkZZljhK5gL65Ro1PeQ== dependencies: - "@angular-eslint/bundled-angular-compiler" "18.4.3" - eslint-scope "^8.0.2" + "@angular-eslint/bundled-angular-compiler" "20.7.0" + eslint-scope "^9.0.0" -"@angular-eslint/utils@18.4.3": - version "18.4.3" - resolved "https://registry.yarnpkg.com/@angular-eslint/utils/-/utils-18.4.3.tgz#1ad0558b21aaa987ce69604a7624d4b213e84d8c" - integrity sha512-w0bJ9+ELAEiPBSTPPm9bvDngfu1d8JbzUhvs2vU+z7sIz/HMwUZT5S4naypj2kNN0gZYGYrW0lt+HIbW87zTAQ== +"@angular-eslint/utils@20.7.0": + version "20.7.0" + resolved "https://registry.yarnpkg.com/@angular-eslint/utils/-/utils-20.7.0.tgz#e0541034e2529c78ab9628a8bdc3f96d418f15a7" + integrity sha512-B6EJHbsk2W/lnS3kS/gm56VGvX735419z/DzgbRDcOvqMGMLwD1ILzv5OTEcL1rzpnB0AHW+IxOu6y/aCzSNUA== dependencies: - "@angular-eslint/bundled-angular-compiler" "18.4.3" + "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-18.2.13.tgz#cbfec085fe68179ef2e559ea43a9f7efa95979fd" - integrity sha512-rG5J5Ek5Hg+Tz2NjkNOaG6PupiNK/lPfophXpsR1t/nWujqnMWX2krahD/i6kgD+jNWNKCJCYSOVvCx/BHOtKA== +"@angular/animations@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.19.tgz#636cf19528c548427f3f2ce6c4dd3b863b1475c4" + integrity sha512-/FjU9i7J58/yBURhgVSIiLDcuyOfJxAa0b7ZrOsx6P+FES+M2T2BKZl5V2NuiP2fDFtjsV7U+M/Z9UNUmeHCEw== dependencies: tslib "^2.3.0" -"@angular/build@18.2.12": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-18.2.12.tgz#d6be89fb572675cfa170f664c0b0ed49c317f33c" - integrity sha512-4Ohz+OSILoL+cCAQ4UTiCT5v6pctu3fXNoNpTEUK46OmxELk9jDITO5rNyNS7TxBn9wY69kjX5VcDf7MenquFQ== +"@angular/build@20.3.24", "@angular/build@^20.0.0": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.24.tgz#79e423a077a4177074b52e3a20c76f082fc08824" + integrity sha512-AMGXOr268y+kVutl4LpOXY2xv9P+RXLCyXUkzYwi8XwGyxAJZfyu/L5qtcO2llExp5CuvP0OxkWxk4JOGRi9TA== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.1802.12" - "@babel/core" "7.25.2" - "@babel/helper-annotate-as-pure" "7.24.7" + "@angular-devkit/architect" "0.2003.24" + "@babel/core" "7.28.3" + "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" - "@babel/plugin-syntax-import-attributes" "7.24.7" - "@inquirer/confirm" "3.1.22" - "@vitejs/plugin-basic-ssl" "1.1.0" + "@inquirer/confirm" "5.1.14" + "@vitejs/plugin-basic-ssl" "2.1.0" + beasties "0.3.5" browserslist "^4.23.0" - critters "0.0.24" - esbuild "0.23.0" - fast-glob "3.3.2" - https-proxy-agent "7.0.5" - listr2 "8.2.4" - lmdb "3.0.13" - magic-string "0.30.11" - mrmime "2.0.0" - parse5-html-rewriting-stream "7.0.0" - picomatch "4.0.2" - piscina "4.6.1" - rollup "4.22.4" - sass "1.77.6" - semver "7.6.3" - vite "5.4.6" - watchpack "2.4.1" - -"@angular/cdk@18.2.14": - version "18.2.14" - resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-18.2.14.tgz#6114f37a77f2f182de482da2273c70f74a6cc52d" - integrity sha512-vDyOh1lwjfVk9OqoroZAP8pf3xxKUvyl+TVR8nJxL4c5fOfUFkD7l94HaanqKSRwJcI2xiztuu92IVoHn8T33Q== + esbuild "0.28.0" + https-proxy-agent "7.0.6" + istanbul-lib-instrument "6.0.3" + jsonc-parser "3.3.1" + listr2 "9.0.1" + magic-string "0.30.17" + mrmime "2.0.1" + parse5-html-rewriting-stream "8.0.0" + picomatch "4.0.4" + piscina "5.1.3" + rollup "4.59.0" + sass "1.90.0" + semver "7.7.2" + source-map-support "0.5.21" + tinyglobby "0.2.14" + vite "7.3.2" + watchpack "2.4.4" + optionalDependencies: + lmdb "3.4.2" + +"@angular/cdk@20.2.14": + version "20.2.14" + resolved "https://registry.yarnpkg.com/@angular/cdk/-/cdk-20.2.14.tgz#5da8d29a0688cf0c6f58db77aee8afa0a05faaea" + integrity sha512-7bZxc01URbiPiIBWThQ69XwOxVduqEKN4PhpbF2AAyfMc/W8Hcr4VoIJOwL0O1Nkq5beS8pCAqoOeIgFyXd/kg== dependencies: + parse5 "^8.0.0" tslib "^2.3.0" - optionalDependencies: - parse5 "^7.1.2" - -"@angular/cli@18.2.12": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-18.2.12.tgz#ea1af54d1fd172bf8bd3c7ed977a422c1c1a94d9" - integrity sha512-xhuZ/b7IhqNw1MgXf+arWf4x+GfUSt/IwbdWU4+CO8A7h0Y46zQywouP/KUK3cMQZfVdHdciTBvlpF3vFacA6Q== - dependencies: - "@angular-devkit/architect" "0.1802.12" - "@angular-devkit/core" "18.2.12" - "@angular-devkit/schematics" "18.2.12" - "@inquirer/prompts" "5.3.8" - "@listr2/prompt-adapter-inquirer" "2.0.15" - "@schematics/angular" "18.2.12" + +"@angular/cli@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.24.tgz#e6ab456b83eede47ccc3503dd6068bdf04be4e05" + integrity sha512-TT7LldRJPCi1VGBJMzcrYU+P3w2G6zgubwhFUdJthUiS77A+At6WJqaRY2BdIS3l7HZOXTEaU2Vj2Gkf2ol2Yw== + dependencies: + "@angular-devkit/architect" "0.2003.24" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" + "@inquirer/prompts" "7.8.2" + "@listr2/prompt-adapter-inquirer" "3.0.1" + "@modelcontextprotocol/sdk" "1.26.0" + "@schematics/angular" "20.3.24" "@yarnpkg/lockfile" "1.1.0" - ini "4.1.3" + algoliasearch "5.35.0" + ini "5.0.0" jsonc-parser "3.3.1" - listr2 "8.2.4" - npm-package-arg "11.0.3" - npm-pick-manifest "9.1.0" - pacote "18.0.6" - resolve "1.22.8" - semver "7.6.3" - symbol-observable "4.0.0" - yargs "17.7.2" - -"@angular/common@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-18.2.13.tgz#cb277651585a6d5aa17c9c1f8e4bbcd85c770517" - integrity sha512-4ZqrNp1PoZo7VNvW+sbSc2CB2axP1sCH2wXl8B0wdjsj8JY1hF1OhuugwhpAHtGxqewed2kCXayE+ZJqSTV4jw== + listr2 "9.0.1" + npm-package-arg "13.0.0" + pacote "21.0.4" + resolve "1.22.10" + semver "7.7.2" + yargs "18.0.0" + zod "4.1.13" + +"@angular/common@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.19.tgz#fb19c335a5a5ea84cd6c90a4abda99c2de5c8b17" + integrity sha512-hcB1eUEN8LGcKGc4DlRJ+abS6AYfbEHDZKg8LnXNugkbwI6Ebyh2AUYTDhzZL2S4aH+C8biHKgSYHFCqieCRhA== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-18.2.13.tgz#3fb9389d9250ce1c09438913094459acfbc18235" - integrity sha512-DBSh4AQwkiJDSiVvJATRmjxf6wyUs9pwQLgaFdSlfuTRO+sdb0J2z1r3BYm8t0IqdoyXzdZq2YCH43EmyvD71g== +"@angular/compiler-cli@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.19.tgz#baeb020e693a759b0b8190931611309678b1e011" + integrity sha512-ET/JjO8s62kAHfgIsGXlvW5VUwLqHm03q1y/2yD7aQW/WdDvssMsvZv7Knl440989vdOFemIGTMwVPakmWqRmA== dependencies: - "@babel/core" "7.25.2" + "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" chokidar "^4.0.0" convert-source-map "^1.5.1" reflect-metadata "^0.2.0" semver "^7.0.0" tslib "^2.3.0" - yargs "^17.2.1" + yargs "^18.0.0" -"@angular/compiler@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-18.2.13.tgz#cf5d67254bd04a49ecfce0d2d6dc3d9f2234de3c" - integrity sha512-TzWcrkopyjFF+WeDr2cRe8CcHjU72KfYV3Sm2TkBkcXrkYX5sDjGWrBGrG3hRB4e4okqchrOCvm1MiTdy2vKMA== +"@angular/compiler@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.19.tgz#cce87072c55c1bab3fda92d83048d0136cea83ad" + integrity sha512-ETkgDKm0l2PuaBubgPJe0ccy8kE75DFu6/zKcz7TUuk3KrKF2OZAopbbjftsUSZGeCNvCdqHzjmcL6hQ6oAOwA== dependencies: tslib "^2.3.0" -"@angular/core@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-18.2.13.tgz#50a4269386201b31105dffd6e14c84ca29859cd9" - integrity sha512-8mbWHMgO95OuFV1Ejy4oKmbe9NOJ3WazQf/f7wks8Bck7pcihd0IKhlPBNjFllbF5o+04EYSwFhEtvEgjMDClA== +"@angular/core@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.19.tgz#ce8b2a0df48612b3d9bfefe8098a1018a3186406" + integrity sha512-SYnwW+q51bQoPtGFoGovm1P5GK9fMEXsG0lGaEAUapjskblAYyX7hLlM/jgueSojv2SjhqNF8aXR+gjHLhZVNA== dependencies: tslib "^2.3.0" -"@angular/forms@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-18.2.13.tgz#119f9b32b0da5e2f1bc3c07f506a645e5635f583" - integrity sha512-A67D867fu3DSBhdLWWZl/F5pr7v2+dRM2u3U7ZJ0ewh4a+sv+0yqWdJW+a8xIoiHxS+btGEJL2qAKJiH+MCFfg== +"@angular/forms@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.19.tgz#179fa22f6e2daf3fe58c400d3e3a7c10647f0ebc" + integrity sha512-WJotd+Lhl4FG2b0K+aQNyQDHhR515zKCuphjiUqEW7sifWrOQxANLKzPBngGrH75ayANFgPaDf7U3ZRIoblcQA== dependencies: tslib "^2.3.0" -"@angular/language-service@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-18.2.13.tgz#dd5555f7a74ffd5ba361e5bb615d6ea1e1719f93" - integrity sha512-4E4VJDrbOAxS69F9C1twQPbR9AjY47Qlz8+lwg5lJOyUJ4GoEThLbXKfadt/vIeYBwMJ7fIsYWXD0Dlmxh4k+w== +"@angular/language-service@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.19.tgz#ba9e04a35717d948ff972476486eae2ea25a109d" + integrity sha512-9J0XrAKXInz11KKyNMrMZmn2NSjVbxzt/DsAumbrzzixeZwiY7vDy2Kqw/LLFLi7IlfMQ/gznz/mCVVgUWI5Gg== -"@angular/material@18.2.14": - version "18.2.14" - resolved "https://registry.yarnpkg.com/@angular/material/-/material-18.2.14.tgz#2521916b650d16399b8d545d8f4213bc0922b5c6" - integrity sha512-28pxzJP49Mymt664WnCtPkKeg7kXUsQKTKGf/Kl95rNTEdTJLbnlcc8wV0rT0yQNR7kXgpfBnG7h0ETLv/iu5Q== +"@angular/material@20.2.14": + version "20.2.14" + resolved "https://registry.yarnpkg.com/@angular/material/-/material-20.2.14.tgz#93027a9eb052d639e8e4f1bd7a3d65e072dc2702" + integrity sha512-IbAgV6XLsvmHiJzxycVhcNC1PA4M30qi+ERCOir6cT333Bxm8vDV32gsOjfL52uzG5YRARroPC+8s1XqR2oxeA== dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-18.2.13.tgz#6b649f750d2a3d5b0cfaa5fa289f85841f972102" - integrity sha512-kbQCf9+8EpuJC7buBxhSiwBtXvjAwAKh6MznD6zd2pyCYqfY6gfRCZQRtK59IfgVtKmEONWI9grEyNIRoTmqJg== +"@angular/platform-browser-dynamic@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.19.tgz#b13cb94dc39be24e22d70712605ca879aa5e83b2" + integrity sha512-OgErw7wjcC+8yKF5h99hJq8x+tvc091wThfmdL5YC+U3HgRmUaNZFgB/jR7cb/NeeeC42QW5Vc0qoUTC9rMnLQ== dependencies: tslib "^2.3.0" -"@angular/platform-browser@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-18.2.13.tgz#59458eb486ec97b2257e7469bb650358492fb823" - integrity sha512-tu7ZzY6qD3ATdWFzcTcsAKe7M6cJeWbT/4/bF9unyGO3XBPcNYDKoiz10+7ap2PUd0fmPwvuvTvSNJiFEBnB8Q== +"@angular/platform-browser@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.19.tgz#4bffa36591d9ba5adc89a1b48952fdfd982d2f30" + integrity sha512-TRZfatH1B/kreDwFRwtpLEurJQ6044qh6DWpvxzTbugaG5otLQJKTk+1z81/KsJwQqc1+24v+yuywc1LM7aq7w== dependencies: tslib "^2.3.0" -"@angular/router@18.2.13": - version "18.2.13" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-18.2.13.tgz#c846ea8ea09a49fea02bda20daca0b46c66df758" - integrity sha512-VKmfgi/r/CkyBq9nChQ/ptmfu0JT/8ONnLVJ5H+SkFLRYJcIRyHLKjRihMCyVm6xM5yktOdCaW73NTQrFz7+bg== +"@angular/router@20.3.19": + version "20.3.19" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.19.tgz#2346ff945a8039194ba921a473a8f4565a92b949" + integrity sha512-qHrMniHOsCJ4neZmcQVodjutJilyXAXk7EhLa931QyL0qyVKVomv6E0I3UFzRaC3ZeHc+hzBdU6C6bvMFKTl1g== dependencies: tslib "^2.3.0" -"@antfu/install-pkg@^0.4.0": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-0.4.1.tgz#d1d7f3be96ecdb41581629cafe8626d1748c0cf1" - integrity sha512-T7yB5QNG29afhWVkVq7XeIMBa5U/vs9mX69YqayXypPRmYzUmzwnYltplHmPtZ4HPCn+sQKeXW8I47wCbuBOjw== +"@antfu/install-pkg@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@antfu/install-pkg/-/install-pkg-1.1.0.tgz#78fa036be1a6081b5a77a5cf59f50c7752b6ba26" + integrity sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ== dependencies: - package-manager-detector "^0.2.0" - tinyexec "^0.3.0" - -"@antfu/utils@^0.7.10": - version "0.7.10" - resolved "https://registry.yarnpkg.com/@antfu/utils/-/utils-0.7.10.tgz#ae829f170158e297a9b6a28f161a8e487d00814d" - integrity sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww== + package-manager-detector "^1.3.0" + tinyexec "^1.0.1" "@auth0/angular-jwt@^5.2.0": version "5.2.0" @@ -367,198 +484,181 @@ dependencies: tslib "^2.0.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.25.9": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" - integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6", "@babel/code-frame@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.29.0.tgz#7cd7a59f15b3cc0dcd803038f7792712a7d0b15c" + integrity sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw== dependencies: - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-validator-identifier" "^7.28.5" js-tokens "^4.0.0" - picocolors "^1.0.0" + picocolors "^1.1.1" -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2", "@babel/compat-data@^7.25.9": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.2.tgz#278b6b13664557de95b8f35b90d96785850bb56e" - integrity sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg== +"@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0", "@babel/compat-data@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.29.0.tgz#00d03e8c0ac24dd9be942c5370990cbe1f17d88d" + integrity sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg== -"@babel/core@7.25.2", "@babel/core@^7.23.9": - version "7.25.2" - resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.25.2.tgz#ed8eec275118d7613e77a352894cd12ded8eba77" - integrity sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA== +"@babel/core@7.28.3", "@babel/core@^7.23.9": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.28.3.tgz#aceddde69c5d1def69b839d09efa3e3ff59c97cb" + integrity sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ== dependencies: "@ampproject/remapping" "^2.2.0" - "@babel/code-frame" "^7.24.7" - "@babel/generator" "^7.25.0" - "@babel/helper-compilation-targets" "^7.25.2" - "@babel/helper-module-transforms" "^7.25.2" - "@babel/helpers" "^7.25.0" - "@babel/parser" "^7.25.0" - "@babel/template" "^7.25.0" - "@babel/traverse" "^7.25.2" - "@babel/types" "^7.25.2" + "@babel/code-frame" "^7.27.1" + "@babel/generator" "^7.28.3" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.3" + "@babel/parser" "^7.28.3" + "@babel/template" "^7.27.2" + "@babel/traverse" "^7.28.3" + "@babel/types" "^7.28.2" 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.25.0": - version "7.25.0" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.25.0.tgz#f858ddfa984350bc3d3b7f125073c9af6988f18e" - integrity sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw== - dependencies: - "@babel/types" "^7.25.0" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" - jsesc "^2.5.1" - -"@babel/generator@^7.25.0", "@babel/generator@^7.25.9": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.2.tgz#87b75813bec87916210e5e01939a4c823d6bb74f" - integrity sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw== +"@babel/generator@7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.3.tgz#9626c1741c650cbac39121694a0f2d7451b8ef3e" + integrity sha512-3lSpxGgvnmZznmBkCRnVREPUFJv2wrv9iAoFDvADJc0ypmdOxdUtcLeBgBJ6zE0PMeTKnxeQzyk0xTBq4Ep7zw== dependencies: - "@babel/parser" "^7.26.2" - "@babel/types" "^7.26.0" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" + "@babel/parser" "^7.28.3" + "@babel/types" "^7.28.2" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/helper-annotate-as-pure@7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz#5373c7bc8366b12a033b4be1ac13a206c6656aab" - integrity sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg== +"@babel/generator@^7.28.3", "@babel/generator@^7.29.0": + version "7.29.1" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.1.tgz#d09876290111abbb00ef962a7b83a5307fba0d50" + integrity sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw== dependencies: - "@babel/types" "^7.24.7" - -"@babel/helper-annotate-as-pure@^7.25.7", "@babel/helper-annotate-as-pure@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz#d8eac4d2dc0d7b6e11fa6e535332e0d3184f06b4" - integrity sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g== - dependencies: - "@babel/types" "^7.25.9" + "@babel/parser" "^7.29.0" + "@babel/types" "^7.29.0" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" + jsesc "^3.0.2" -"@babel/helper-builder-binary-assignment-operator-visitor@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.7.tgz#d721650c1f595371e0a23ee816f1c3c488c0d622" - integrity sha512-12xfNeKNH7jubQNm7PAkzlLwEmCs1tfuX3UjIw6vP6QXi+leKh6+LyC/+Ed4EIQermwd58wsyh070yjDHFlNGg== +"@babel/helper-annotate-as-pure@7.27.3", "@babel/helper-annotate-as-pure@^7.27.1", "@babel/helper-annotate-as-pure@^7.27.3": + version "7.27.3" + resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.27.3.tgz#f31fd86b915fc4daf1f3ac6976c59be7084ed9c5" + integrity sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg== dependencies: - "@babel/traverse" "^7.25.7" - "@babel/types" "^7.25.7" + "@babel/types" "^7.27.3" -"@babel/helper-compilation-targets@^7.22.6", "@babel/helper-compilation-targets@^7.25.2", "@babel/helper-compilation-targets@^7.25.7": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz#55af025ce365be3cdc0c1c1e56c6af617ce88875" - integrity sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ== +"@babel/helper-compilation-targets@^7.27.1", "@babel/helper-compilation-targets@^7.27.2", "@babel/helper-compilation-targets@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz#32c4a3f41f12ed1532179b108a4d746e105c2b25" + integrity sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA== dependencies: - "@babel/compat-data" "^7.25.9" - "@babel/helper-validator-option" "^7.25.9" + "@babel/compat-data" "^7.28.6" + "@babel/helper-validator-option" "^7.27.1" browserslist "^4.24.0" lru-cache "^5.1.1" semver "^6.3.1" -"@babel/helper-create-class-features-plugin@^7.25.7", "@babel/helper-create-class-features-plugin@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz#7644147706bb90ff613297d49ed5266bde729f83" - integrity sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ== - dependencies: - "@babel/helper-annotate-as-pure" "^7.25.9" - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/helper-replace-supers" "^7.25.9" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.9" - "@babel/traverse" "^7.25.9" +"@babel/helper-create-class-features-plugin@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.28.6.tgz#611ff5482da9ef0db6291bcd24303400bca170fb" + integrity sha512-dTOdvsjnG3xNT9Y0AUg1wAl38y+4Rl4sf9caSQZOXdNqVn+H+HbbJ4IyyHaIqNR6SW9oJpA/RuRjsjCw2IdIow== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/traverse" "^7.28.6" semver "^6.3.1" -"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.7.tgz#dcb464f0e2cdfe0c25cc2a0a59c37ab940ce894e" - integrity sha512-byHhumTj/X47wJ6C6eLpK7wW/WBEcnUeb7D0FNc/jFQnQVw7DOso3Zz5u9x/zLrFVkHa89ZGDbkAa1D54NdrCQ== +"@babel/helper-create-regexp-features-plugin@^7.18.6", "@babel/helper-create-regexp-features-plugin@^7.27.1", "@babel/helper-create-regexp-features-plugin@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.28.5.tgz#7c1ddd64b2065c7f78034b25b43346a7e19ed997" + integrity sha512-N1EhvLtHzOvj7QQOUCCS3NrPJP8c5W6ZXCHDn7Yialuy1iu4r5EmIYkXlKNqT99Ciw+W0mDqWoR6HWMZlFP3hw== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.7" - regexpu-core "^6.1.1" + "@babel/helper-annotate-as-pure" "^7.27.3" + regexpu-core "^6.3.1" semver "^6.3.1" -"@babel/helper-define-polyfill-provider@^0.6.2": - version "0.6.2" - resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.2.tgz#18594f789c3594acb24cfdb4a7f7b7d2e8bd912d" - integrity sha512-LV76g+C502biUK6AyZ3LK10vDpDyCzZnhZFXkH1L75zHPj68+qc8Zfpx2th+gzwA2MzyK+1g/3EPl62yFnVttQ== +"@babel/helper-define-polyfill-provider@^0.6.5": + version "0.6.5" + resolved "https://registry.yarnpkg.com/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.5.tgz#742ccf1cb003c07b48859fc9fa2c1bbe40e5f753" + integrity sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg== dependencies: - "@babel/helper-compilation-targets" "^7.22.6" - "@babel/helper-plugin-utils" "^7.22.5" - debug "^4.1.1" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + debug "^4.4.1" lodash.debounce "^4.0.8" - resolve "^1.14.2" + resolve "^1.22.10" -"@babel/helper-member-expression-to-functions@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz#9dfffe46f727005a5ea29051ac835fb735e4c1a3" - integrity sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ== - dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/helper-globals/-/helper-globals-7.28.0.tgz#b9430df2aa4e17bc28665eadeae8aa1d985e6674" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== -"@babel/helper-module-imports@^7.24.7", "@babel/helper-module-imports@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" - integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== +"@babel/helper-member-expression-to-functions@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.28.5.tgz#f3e07a10be37ed7a63461c63e6929575945a6150" + integrity sha512-cwM7SBRZcPCLgl8a7cY0soT1SptSzAlMH39vwiRpOQkJlh53r5hdHwLSCZpQdVLT39sZt+CRpNwYG4Y2v77atg== dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" -"@babel/helper-module-transforms@^7.25.2", "@babel/helper-module-transforms@^7.25.7": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" - integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== +"@babel/helper-module-imports@^7.27.1", "@babel/helper-module-imports@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz#60632cbd6ffb70b22823187201116762a03e2d5c" + integrity sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw== dependencies: - "@babel/helper-module-imports" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/helper-optimise-call-expression@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz#3324ae50bae7e2ab3c33f60c9a877b6a0146b54e" - integrity sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ== +"@babel/helper-module-transforms@^7.27.1", "@babel/helper-module-transforms@^7.28.3", "@babel/helper-module-transforms@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz#9312d9d9e56edc35aeb6e95c25d4106b50b9eb1e" + integrity sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA== dependencies: - "@babel/types" "^7.25.9" - -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.24.8", "@babel/helper-plugin-utils@^7.25.7", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz#9cbdd63a9443a2c92a725cca7ebca12cc8dd9f46" - integrity sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw== + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.6" -"@babel/helper-remap-async-to-generator@^7.24.7", "@babel/helper-remap-async-to-generator@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.7.tgz#9efdc39df5f489bcd15533c912b6c723a0a65021" - integrity sha512-kRGE89hLnPfcz6fTrlNU+uhgcwv0mBE4Gv3P9Ke9kLVJYpi4AMVVEElXvB5CabrPZW4nCM8P8UyyjrzCM0O2sw== +"@babel/helper-optimise-call-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.27.1.tgz#c65221b61a643f3e62705e5dd2b5f115e35f9200" + integrity sha512-URMGH08NzYFhubNSGJrpUEphGKQwMQYBySzat5cAByY1/YgIRkULnIy3tAMeszlL/so2HbeilYloUmSpd7GdVw== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.7" - "@babel/helper-wrap-function" "^7.25.7" - "@babel/traverse" "^7.25.7" + "@babel/types" "^7.27.1" + +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.27.1", "@babel/helper-plugin-utils@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz#6f13ea251b68c8532e985fd532f28741a8af9ac8" + integrity sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug== -"@babel/helper-replace-supers@^7.25.7", "@babel/helper-replace-supers@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz#ba447224798c3da3f8713fc272b145e33da6a5c5" - integrity sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ== +"@babel/helper-remap-async-to-generator@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.27.1.tgz#4601d5c7ce2eb2aea58328d43725523fcd362ce6" + integrity sha512-7fiA521aVw8lSPeI4ZOD3vRFkoqkJcS+z4hFo82bFSH/2tNd6eJ5qCVMS5OzDmZh/kaHQeBaeyxK6wljcPtveA== dependencies: - "@babel/helper-member-expression-to-functions" "^7.25.9" - "@babel/helper-optimise-call-expression" "^7.25.9" - "@babel/traverse" "^7.25.9" + "@babel/helper-annotate-as-pure" "^7.27.1" + "@babel/helper-wrap-function" "^7.27.1" + "@babel/traverse" "^7.27.1" -"@babel/helper-simple-access@^7.25.7": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz#6d51783299884a2c74618d6ef0f86820ec2e7739" - integrity sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q== +"@babel/helper-replace-supers@^7.27.1", "@babel/helper-replace-supers@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-replace-supers/-/helper-replace-supers-7.28.6.tgz#94aa9a1d7423a00aead3f204f78834ce7d53fe44" + integrity sha512-mq8e+laIk94/yFec3DxSjCRD2Z0TAjhVbEJY3UQrlwVo15Lmt7C2wAUbK4bjnTs4APkwsYLTahXRraQXhb1WCg== dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" + "@babel/helper-member-expression-to-functions" "^7.28.5" + "@babel/helper-optimise-call-expression" "^7.27.1" + "@babel/traverse" "^7.28.6" -"@babel/helper-skip-transparent-expression-wrappers@^7.25.7", "@babel/helper-skip-transparent-expression-wrappers@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz#0b2e1b62d560d6b1954893fd2b705dc17c91f0c9" - integrity sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA== +"@babel/helper-skip-transparent-expression-wrappers@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.27.1.tgz#62bb91b3abba8c7f1fec0252d9dbea11b3ee7a56" + integrity sha512-Tub4ZKEXqbPjXgWLl2+3JpQAYBJ8+ikpQ2Ocj/q/r0LwE3UhENh7EUabyHjz2kCEsrRY83ew2DQdHluuiDQFzg== dependencies: - "@babel/traverse" "^7.25.9" - "@babel/types" "^7.25.9" + "@babel/traverse" "^7.27.1" + "@babel/types" "^7.27.1" "@babel/helper-split-export-declaration@7.24.7": version "7.24.7" @@ -567,715 +667,612 @@ dependencies: "@babel/types" "^7.24.7" -"@babel/helper-string-parser@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" - integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== +"@babel/helper-string-parser@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz#54da796097ab19ce67ed9f88b47bb2ec49367687" + integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== -"@babel/helper-validator-identifier@^7.25.7", "@babel/helper-validator-identifier@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" - integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== +"@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz#010b6938fab7cb7df74aa2bbc06aa503b8fe5fb4" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== -"@babel/helper-validator-option@^7.24.8", "@babel/helper-validator-option@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" - integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== +"@babel/helper-validator-option@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz#fa52f5b1e7db1ab049445b421c4471303897702f" + integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== -"@babel/helper-wrap-function@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.25.7.tgz#9f6021dd1c4fdf4ad515c809967fc4bac9a70fe7" - integrity sha512-MA0roW3JF2bD1ptAaJnvcabsVlNQShUaThyJbCDD4bCp8NEgiFvpoqRI2YS22hHlc2thjO/fTg2ShLMC3jygAg== +"@babel/helper-wrap-function@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helper-wrap-function/-/helper-wrap-function-7.28.6.tgz#4e349ff9222dab69a93a019cc296cdd8442e279a" + integrity sha512-z+PwLziMNBeSQJonizz2AGnndLsP2DeGHIxDAn+wdHOGuo4Fo1x1HBPPXeE9TAOPHNNWQKCSlA2VZyYyyibDnQ== dependencies: - "@babel/template" "^7.25.7" - "@babel/traverse" "^7.25.7" - "@babel/types" "^7.25.7" + "@babel/template" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/helpers@^7.25.0": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.0.tgz#30e621f1eba5aa45fe6f4868d2e9154d884119a4" - integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== +"@babel/helpers@^7.28.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.28.6.tgz#fca903a313ae675617936e8998b814c415cbf5d7" + integrity sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw== dependencies: - "@babel/template" "^7.25.9" - "@babel/types" "^7.26.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.28.6" -"@babel/parser@^7.23.9", "@babel/parser@^7.25.0", "@babel/parser@^7.25.9", "@babel/parser@^7.26.2": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" - integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== +"@babel/parser@^7.23.9", "@babel/parser@^7.28.3", "@babel/parser@^7.28.6", "@babel/parser@^7.29.0": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.0.tgz#669ef345add7d057e92b7ed15f0bac07611831b6" + integrity sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww== dependencies: - "@babel/types" "^7.26.0" + "@babel/types" "^7.29.0" -"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.3": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.7.tgz#93969ac50ef4d68b2504b01b758af714e4cbdd64" - integrity sha512-UV9Lg53zyebzD1DwQoT9mzkEKa922LNUp5YkTJ6Uta0RbyXaQNUgcvSt7qIu1PpPzVb6rd10OVNTzkyBGeVmxQ== +"@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz#fbde57974707bbfa0376d34d425ff4fa6c732421" + integrity sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/traverse" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.28.5" -"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.7.tgz#a338d611adb9dcd599b8b1efa200c88ebeffe046" - integrity sha512-GDDWeVLNxRIkQTnJn2pDOM1pkCgYdSqPeT1a9vh9yIqu2uzzgw1zcqEb+IJOhy+dTBMlNdThrDIksr2o09qrrQ== +"@babel/plugin-bugfix-safari-class-field-initializer-scope@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz#43f70a6d7efd52370eefbdf55ae03d91b293856d" + integrity sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.7.tgz#c5f755e911dfac7ef6957300c0f9c4a8c18c06f4" - integrity sha512-wxyWg2RYaSUYgmd9MR0FyRGyeOMQE/Uzr1wzd/g5cf5bwi9A4v6HFdDm7y1MgDtod/fLOSTZY6jDgV0xU9d5bA== +"@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz#beb623bd573b8b6f3047bd04c32506adc3e58a72" + integrity sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.7.tgz#3b7ea04492ded990978b6deaa1dfca120ad4455a" - integrity sha512-Xwg6tZpLxc4iQjorYsyGMyfJE7nP5MV8t/Ka58BgiA7Jw0fRqQNcANlLfdJ/yvBt9z9LD2We+BEkT7vLqZRWng== +"@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz#e134a5479eb2ba9c02714e8c1ebf1ec9076124fd" + integrity sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.7" - "@babel/plugin-transform-optional-chaining" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" -"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.7.tgz#9622b1d597a703aa3a921e6f58c9c2d9a028d2c5" - integrity sha512-UVATLMidXrnH+GMUIuxq55nejlj02HP7F5ETyBONzP6G87fPBogG4CH6kxrSrdIuAjdwNO9VzyaYsrZPscWUrw== +"@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly@^7.28.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.6.tgz#0e8289cec28baaf05d54fd08d81ae3676065f69f" + integrity sha512-a0aBScVTlNaiUe35UtfxAN7A/tehvvG4/ByO6+46VPKTRSlfnAFsgKy0FUh+qAkQrDTmhDkT+IBOKlOoMUxQ0g== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/traverse" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/traverse" "^7.28.6" "@babel/plugin-proposal-private-property-in-object@7.21.0-placeholder-for-preset-env.2": version "7.21.0-placeholder-for-preset-env.2" resolved "https://registry.yarnpkg.com/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz#7844f9289546efa9febac2de4cfe358a050bd703" integrity sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w== -"@babel/plugin-syntax-async-generators@^7.8.4": - version "7.8.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz#a983fb1aeb2ec3f6ed042a210f640e90e786fe0d" - integrity sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw== +"@babel/plugin-syntax-import-assertions@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.28.6.tgz#ae9bc1923a6ba527b70104dd2191b0cd872c8507" + integrity sha512-pSJUpFHdx9z5nqTSirOCMtYVP2wFgoWhP0p3g8ONK/4IHhLIBd0B9NYqAvIUAhq+OkhO4VM1tENCt0cjlsNShw== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-class-properties@^7.12.13": - version "7.12.13" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz#b5c987274c4a3a82b89714796931a6b53544ae10" - integrity sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA== +"@babel/plugin-syntax-import-attributes@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.28.6.tgz#b71d5914665f60124e133696f17cd7669062c503" + integrity sha512-jiLC0ma9XkQT3TKJ9uYvlakm66Pamywo+qwL+oL8HJOvc6TWdZXVfhqJr8CCzbSGUAbDOzlGHJC1U+vRfLQDvw== dependencies: - "@babel/helper-plugin-utils" "^7.12.13" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-class-static-block@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz#195df89b146b4b78b3bf897fd7a257c84659d406" - integrity sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw== +"@babel/plugin-syntax-unicode-sets-regex@^7.18.6": + version "7.18.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" + integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg== dependencies: - "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-create-regexp-features-plugin" "^7.18.6" + "@babel/helper-plugin-utils" "^7.18.6" -"@babel/plugin-syntax-dynamic-import@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz#62bf98b2da3cd21d626154fc96ee5b3cb68eacb3" - integrity sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ== - dependencies: - "@babel/helper-plugin-utils" "^7.8.0" +"@babel/plugin-transform-arrow-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz#6e2061067ba3ab0266d834a9f94811196f2aba9a" + integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + +"@babel/plugin-transform-async-generator-functions@7.28.0": + version "7.28.0" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.0.tgz#1276e6c7285ab2cd1eccb0bc7356b7a69ff842c2" + integrity sha512-BEOdvX4+M765icNPZeidyADIvQ1m1gmunXufXxvRESy/jNNyfovIqUyE7MVgGBjWktCoJlzvFA1To2O4ymIO3Q== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/traverse" "^7.28.0" -"@babel/plugin-syntax-export-namespace-from@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz#028964a9ba80dbc094c915c487ad7c4e7a66465a" - integrity sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q== +"@babel/plugin-transform-async-generator-functions@^7.28.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.28.6.tgz#80cb86d3eaa2102e18ae90dd05ab87bdcad3877d" + integrity sha512-9knsChgsMzBV5Yh3kkhrZNxH3oCYAfMBkNNaVN4cP2RVlFPe8wYdwwcnOsAbkdDoV9UjFtOXWrWB52M8W4jNeA== dependencies: - "@babel/helper-plugin-utils" "^7.8.3" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-remap-async-to-generator" "^7.27.1" + "@babel/traverse" "^7.28.6" -"@babel/plugin-syntax-import-assertions@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.25.7.tgz#8ce248f9f4ed4b7ed4cb2e0eb4ed9efd9f52921f" - integrity sha512-ZvZQRmME0zfJnDQnVBKYzHxXT7lYBB3Revz1GuS7oLXWMgqUPX4G+DDbT30ICClht9WKV34QVrZhSw6WdklwZQ== +"@babel/plugin-transform-async-to-generator@7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.27.1.tgz#9a93893b9379b39466c74474f55af03de78c66e7" + integrity sha512-NREkZsZVJS4xmTr8qzE5y8AfIPqsdQfRuUiLRTEzb7Qii8iFWCyDKaUV2c0rCuh4ljDZ98ALHP/PetiBV2nddA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-remap-async-to-generator" "^7.27.1" -"@babel/plugin-syntax-import-attributes@7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz#b4f9ea95a79e6912480c4b626739f86a076624ca" - integrity sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A== +"@babel/plugin-transform-async-to-generator@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.28.6.tgz#bd97b42237b2d1bc90d74bcb486c39be5b4d7e77" + integrity sha512-ilTRcmbuXjsMmcZ3HASTe4caH5Tpo93PkTxF9oG2VZsSWsahydmcEHhix9Ik122RcTnZnUzPbmux4wh1swfv7g== dependencies: - "@babel/helper-plugin-utils" "^7.24.7" - -"@babel/plugin-syntax-import-attributes@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.25.7.tgz#d78dd0499d30df19a598e63ab895e21b909bc43f" - integrity sha512-AqVo+dguCgmpi/3mYBdu9lkngOBlQ2w2vnNpa6gfiCxQZLzV4ZbhsXitJ2Yblkoe1VQwtHSaNmIaGll/26YWRw== + "@babel/helper-module-imports" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-remap-async-to-generator" "^7.27.1" + +"@babel/plugin-transform-block-scoped-functions@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz#558a9d6e24cf72802dd3b62a4b51e0d62c0f57f9" + integrity sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-import-meta@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz#ee601348c370fa334d2207be158777496521fd51" - integrity sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g== +"@babel/plugin-transform-block-scoping@^7.28.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.28.6.tgz#e1ef5633448c24e76346125c2534eeb359699a99" + integrity sha512-tt/7wOtBmwHPNMPu7ax4pdPz6shjFrmHDghvNC+FG9Qvj7D6mJcoRQIF5dy4njmxR941l6rgtvfSB2zX3VlUIw== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-json-strings@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz#01ca21b668cd8218c9e640cb6dd88c5412b2c96a" - integrity sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA== +"@babel/plugin-transform-class-properties@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz#d274a4478b6e782d9ea987fda09bdb6d28d66b72" + integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-logical-assignment-operators@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz#ca91ef46303530448b906652bac2e9fe9941f699" - integrity sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig== +"@babel/plugin-transform-class-static-block@^7.28.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz#1257491e8259c6d125ac4d9a6f39f9d2bf3dba70" + integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-nullish-coalescing-operator@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz#167ed70368886081f74b5c36c65a88c03b66d1a9" - integrity sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ== +"@babel/plugin-transform-classes@^7.28.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz#8f6fb79ba3703978e701ce2a97e373aae7dda4b7" + integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/traverse" "^7.28.6" -"@babel/plugin-syntax-numeric-separator@^7.10.4": - version "7.10.4" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz#b9b070b3e33570cd9fd07ba7fa91c0dd37b9af97" - integrity sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug== +"@babel/plugin-transform-computed-properties@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz#936824fc71c26cb5c433485776d79c8e7b0202d2" + integrity sha512-bcc3k0ijhHbc2lEfpFHgx7eYw9KNXqOerKWfzbxEHUGKnS3sz9C4CNL9OiFN1297bDNfUiSO7DaLzbvHQQQ1BQ== dependencies: - "@babel/helper-plugin-utils" "^7.10.4" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/template" "^7.28.6" -"@babel/plugin-syntax-object-rest-spread@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-object-rest-spread/-/plugin-syntax-object-rest-spread-7.8.3.tgz#60e225edcbd98a640332a2e72dd3e66f1af55871" - integrity sha512-XoqMijGZb9y3y2XskN+P1wUGiVwWZ5JmoDRwx5+3GmEplNyVM2s2Dg8ILFQm8rWM48orGy5YpI5Bl8U1y7ydlA== +"@babel/plugin-transform-destructuring@^7.28.0", "@babel/plugin-transform-destructuring@^7.28.5": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.28.5.tgz#b8402764df96179a2070bb7b501a1586cf8ad7a7" + integrity sha512-Kl9Bc6D0zTUcFUvkNuQh4eGXPKKNDOJQXVyyM4ZAQPMveniJdxi8XMJwLo+xSoW3MIq81bD33lcUe9kZpl0MCw== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.28.5" -"@babel/plugin-syntax-optional-catch-binding@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz#6111a265bcfb020eb9efd0fdfd7d26402b9ed6c1" - integrity sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q== +"@babel/plugin-transform-dotall-regex@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.28.6.tgz#def31ed84e0fb6e25c71e53c124e7b76a4ab8e61" + integrity sha512-SljjowuNKB7q5Oayv4FoPzeB74g3QgLt8IVJw9ADvWy3QnUb/01aw8I4AVv8wYnPvQz2GDDZ/g3GhcNyDBI4Bg== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-create-regexp-features-plugin" "^7.28.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-optional-chaining@^7.8.3": - version "7.8.3" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz#4f69c2ab95167e0180cd5336613f8c5788f7d48a" - integrity sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg== +"@babel/plugin-transform-duplicate-keys@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz#f1fbf628ece18e12e7b32b175940e68358f546d1" + integrity sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q== dependencies: - "@babel/helper-plugin-utils" "^7.8.0" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-private-property-in-object@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz#0dc6671ec0ea22b6e94a1114f857970cd39de1ad" - integrity sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg== +"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.28.6.tgz#e0c59ba54f1655dd682f2edf5f101b5910a8f6f3" + integrity sha512-5suVoXjC14lUN6ZL9OLKIHCNVWCrqGqlmEp/ixdXjvgnEl/kauLvvMO/Xw9NyMc95Joj1AeLVPVMvibBgSoFlA== dependencies: - "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-create-regexp-features-plugin" "^7.28.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-syntax-top-level-await@^7.14.5": - version "7.14.5" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz#c1cfdadc35a646240001f06138247b741c34d94c" - integrity sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw== +"@babel/plugin-transform-dynamic-import@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz#4c78f35552ac0e06aa1f6e3c573d67695e8af5a4" + integrity sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A== dependencies: - "@babel/helper-plugin-utils" "^7.14.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-syntax-unicode-sets-regex@^7.18.6": - version "7.18.6" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz#d49a3b3e6b52e5be6740022317580234a6a47357" - integrity sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg== +"@babel/plugin-transform-explicit-resource-management@^7.28.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.6.tgz#dd6788f982c8b77e86779d1d029591e39d9d8be7" + integrity sha512-Iao5Konzx2b6g7EPqTy40UZbcdXE126tTxVFr/nAIj+WItNxjKSYTEw3RC+A2/ZetmdJsgueL1KhaMCQHkLPIg== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.18.6" - "@babel/helper-plugin-utils" "^7.18.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-destructuring" "^7.28.5" -"@babel/plugin-transform-arrow-functions@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.7.tgz#1b9ed22e6890a0e9ff470371c73b8c749bcec386" - integrity sha512-EJN2mKxDwfOUCPxMO6MUI58RN3ganiRAG/MS/S3HfB6QFNjroAMelQo/gybyYq97WerCBAZoyrAoW8Tzdq2jWg== +"@babel/plugin-transform-exponentiation-operator@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.6.tgz#5e477eb7eafaf2ab5537a04aaafcf37e2d7f1091" + integrity sha512-WitabqiGjV/vJ0aPOLSFfNY1u9U3R7W36B03r5I2KoNix+a3sOhJ3pKFB3R5It9/UiK78NiO0KE9P21cMhlPkw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-async-generator-functions@7.25.0", "@babel/plugin-transform-async-generator-functions@^7.25.0": - version "7.25.0" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz#b785cf35d73437f6276b1e30439a57a50747bddf" - integrity sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q== +"@babel/plugin-transform-export-namespace-from@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.27.1.tgz#71ca69d3471edd6daa711cf4dfc3400415df9c23" + integrity sha512-tQvHWSZ3/jH2xuq/vZDy0jNn+ZdXJeM8gHvX4lnJmsc3+50yPlWdZXIc5ay+umX+2/tJIqHqiEqcJvxlmIvRvQ== dependencies: - "@babel/helper-plugin-utils" "^7.24.8" - "@babel/helper-remap-async-to-generator" "^7.25.0" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/traverse" "^7.25.0" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-async-to-generator@7.24.7", "@babel/plugin-transform-async-to-generator@^7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz#72a3af6c451d575842a7e9b5a02863414355bdcc" - integrity sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA== +"@babel/plugin-transform-for-of@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.27.1.tgz#bc24f7080e9ff721b63a70ac7b2564ca15b6c40a" + integrity sha512-BfbWFFEJFQzLCQ5N8VocnCtA8J1CLkNTe2Ms2wocj75dd6VpiqS5Z5quTYcUoo4Yq+DN0rtikODccuv7RU81sw== dependencies: - "@babel/helper-module-imports" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" - "@babel/helper-remap-async-to-generator" "^7.24.7" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-block-scoped-functions@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.7.tgz#e0b8843d5571719a2f1bf7e284117a3379fcc17c" - integrity sha512-xHttvIM9fvqW+0a3tZlYcZYSBpSWzGBFIt/sYG3tcdSzBB8ZeVgz2gBP7Df+sM0N1850jrviYSSeUuc+135dmQ== +"@babel/plugin-transform-function-name@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.27.1.tgz#4d0bf307720e4dce6d7c30fcb1fd6ca77bdeb3a7" + integrity sha512-1bQeydJF9Nr1eBCMMbC+hdwmRlsv5XYOMu03YSWFwNs0HsAmtSxxF1fyuYPqemVldVyFmlCU7w8UE14LupUSZQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-compilation-targets" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/traverse" "^7.27.1" -"@babel/plugin-transform-block-scoping@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.7.tgz#6dab95e98adf780ceef1b1c3ab0e55cd20dd410a" - integrity sha512-ZEPJSkVZaeTFG/m2PARwLZQ+OG0vFIhPlKHK/JdIMy8DbRJ/htz6LRrTFtdzxi9EHmcwbNPAKDnadpNSIW+Aow== +"@babel/plugin-transform-json-strings@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.28.6.tgz#4c8c15b2dc49e285d110a4cf3dac52fd2dfc3038" + integrity sha512-Nr+hEN+0geQkzhbdgQVPoqr47lZbm+5fCUmO70722xJZd0Mvb59+33QLImGj6F+DkK3xgDi1YVysP8whD6FQAw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-class-properties@^7.24.7": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz#a8ce84fedb9ad512549984101fa84080a9f5f51f" - integrity sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q== +"@babel/plugin-transform-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.27.1.tgz#baaefa4d10a1d4206f9dcdda50d7d5827bb70b24" + integrity sha512-0HCFSepIpLTkLcsi86GG3mTUzxV5jpmbv97hTETW3yzrAij8aqlD36toB1D0daVFJM8NK6GvKO0gslVQmm+zZA== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.9" - "@babel/helper-plugin-utils" "^7.25.9" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-static-block@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.25.7.tgz#d2cf3c812e3b3162d56aadf4566f45c30538cb2c" - integrity sha512-rvUUtoVlkDWtDWxGAiiQj0aNktTPn3eFynBcMC2IhsXweehwgdI9ODe+XjWw515kEmv22sSOTp/rxIRuTiB7zg== +"@babel/plugin-transform-logical-assignment-operators@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.28.6.tgz#53028a3d77e33c50ef30a8fce5ca17065936e605" + integrity sha512-+anKKair6gpi8VsM/95kmomGNMD0eLz1NQ8+Pfw5sAwWH9fGYXT50E55ZpV0pHUHWf6IUTWPM+f/7AAff+wr9A== dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-class-static-block" "^7.14.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-classes@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.7.tgz#5103206cf80d02283bbbd044509ea3b65d0906bb" - integrity sha512-9j9rnl+YCQY0IGoeipXvnk3niWicIB6kCsWRGLwX241qSXpbA4MKxtp/EdvFxsc4zI5vqfLxzOd0twIJ7I99zg== +"@babel/plugin-transform-member-expression-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz#37b88ba594d852418e99536f5612f795f23aeaf9" + integrity sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.7" - "@babel/helper-compilation-targets" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-replace-supers" "^7.25.7" - "@babel/traverse" "^7.25.7" - globals "^11.1.0" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-computed-properties@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.7.tgz#7f621f0aa1354b5348a935ab12e3903842466f65" - integrity sha512-QIv+imtM+EtNxg/XBKL3hiWjgdLjMOmZ+XzQwSgmBfKbfxUjBzGgVPklUuE55eq5/uVoh8gg3dqlrwR/jw3ZeA== +"@babel/plugin-transform-modules-amd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz#a4145f9d87c2291fe2d05f994b65dba4e3e7196f" + integrity sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/template" "^7.25.7" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-destructuring@^7.24.8": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.7.tgz#f6f26a9feefb5aa41fd45b6f5838901b5333d560" - integrity sha512-xKcfLTlJYUczdaM1+epcdh1UGewJqr9zATgrNHcLBcV2QmfvPPEixo/sK/syql9cEmbr7ulu5HMFG5vbbt/sEA== +"@babel/plugin-transform-modules-commonjs@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.28.6.tgz#c0232e0dfe66a734cc4ad0d5e75fc3321b6fdef1" + integrity sha512-jppVbf8IV9iWWwWTQIxJMAJCWBuuKx71475wHwYytrRGQ2CWiDvYlADQno3tcYpS/T2UUWFQp3nVtYfK/YBQrA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-module-transforms" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-dotall-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.7.tgz#9d775c4a3ff1aea64045300fcd4309b4a610ef02" - integrity sha512-kXzXMMRzAtJdDEgQBLF4oaiT6ZCU3oWHgpARnTKDAqPkDJ+bs3NrZb310YYevR5QlRo3Kn7dzzIdHbZm1VzJdQ== +"@babel/plugin-transform-modules-systemjs@^7.27.1": + version "7.28.5" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz#7439e592a92d7670dfcb95d0cbc04bd3e64801d2" + integrity sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" + "@babel/traverse" "^7.28.5" -"@babel/plugin-transform-duplicate-keys@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.7.tgz#fbba7d1155eab76bd4f2a038cbd5d65883bd7a93" - integrity sha512-by+v2CjoL3aMnWDOyCIg+yxU9KXSRa9tN6MbqggH5xvymmr9p4AMjYkNlQy4brMceBnUyHZ9G8RnpvT8wP7Cfg== +"@babel/plugin-transform-modules-umd@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz#63f2cf4f6dc15debc12f694e44714863d34cd334" + integrity sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-duplicate-named-capturing-groups-regex@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.7.tgz#102b31608dcc22c08fbca1894e104686029dc141" - integrity sha512-HvS6JF66xSS5rNKXLqkk7L9c/jZ/cdIVIcoPVrnl8IsVpLggTjXs8OWekbLHs/VtYDDh5WXnQyeE3PPUGm22MA== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-dynamic-import@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.7.tgz#31905ab2cfa94dcf1b1f8ce66096720b2908e518" - integrity sha512-UvcLuual4h7/GfylKm2IAA3aph9rwvAM2XBA0uPKU3lca+Maai4jBjjEVUS568ld6kJcgbouuumCBhMd/Yz17w== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - -"@babel/plugin-transform-exponentiation-operator@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.7.tgz#5961a3a23a398faccd6cddb34a2182807d75fb5f" - integrity sha512-yjqtpstPfZ0h/y40fAXRv2snciYr0OAoMXY/0ClC7tm4C/nG5NJKmIItlaYlLbIVAWNfrYuy9dq1bE0SbX0PEg== - dependencies: - "@babel/helper-builder-binary-assignment-operator-visitor" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-export-namespace-from@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.7.tgz#beb2679db6fd3bdfe6ad6de2c8cac84a86ef2da1" - integrity sha512-h3MDAP5l34NQkkNulsTNyjdaR+OiB0Im67VU//sFupouP8Q6m9Spy7l66DcaAQxtmCqGdanPByLsnwFttxKISQ== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" + "@babel/helper-module-transforms" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-for-of@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.7.tgz#0acfea0f27aa290818b5b48a5a44b3f03fc13669" - integrity sha512-n/TaiBGJxYFWvpJDfsxSj9lEEE44BFM1EPGz4KEiTipTgkoFVVcCmzAL3qA7fdQU96dpo4gGf5HBx/KnDvqiHw== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.7" - -"@babel/plugin-transform-function-name@^7.25.1": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.7.tgz#7e394ccea3693902a8b50ded8b6ae1fa7b8519fd" - integrity sha512-5MCTNcjCMxQ63Tdu9rxyN6cAWurqfrDZ76qvVPrGYdBxIj+EawuuxTu/+dgJlhK5eRz3v1gLwp6XwS8XaX2NiQ== +"@babel/plugin-transform-named-capturing-groups-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.27.1.tgz#f32b8f7818d8fc0cc46ee20a8ef75f071af976e1" + integrity sha512-SstR5JYy8ddZvD6MhV0tM/j16Qds4mIpJTOd1Yu9J9pJjH93bxHECF7pgtc28XvkzTD6Pxcm/0Z73Hvk7kb3Ng== dependencies: - "@babel/helper-compilation-targets" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/traverse" "^7.25.7" - -"@babel/plugin-transform-json-strings@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.7.tgz#6626433554aff4bd6f76a2c621a1f40e802dfb0a" - integrity sha512-Ot43PrL9TEAiCe8C/2erAjXMeVSnE/BLEx6eyrKLNFCCw5jvhTHKyHxdI1pA0kz5njZRYAnMO2KObGqOCRDYSA== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-json-strings" "^7.8.3" - -"@babel/plugin-transform-literals@^7.25.2": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.7.tgz#70cbdc742f2cfdb1a63ea2cbd018d12a60b213c3" - integrity sha512-fwzkLrSu2fESR/cm4t6vqd7ebNIopz2QHGtjoU+dswQo/P6lwAG04Q98lliE3jkz/XqnbGFLnUcE0q0CVUf92w== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-logical-assignment-operators@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.7.tgz#93847feb513a1f191c5f5d903d991a0ee24fe99b" - integrity sha512-iImzbA55BjiovLyG2bggWS+V+OLkaBorNvc/yJoeeDQGztknRnDdYfp2d/UPmunZYEnZi6Lg8QcTmNMHOB0lGA== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-member-expression-literals@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.7.tgz#0a36c3fbd450cc9e6485c507f005fa3d1bc8fca5" - integrity sha512-Std3kXwpXfRV0QtQy5JJcRpkqP8/wG4XL7hSKZmGlxPlDqmpXtEPRmhF7ztnlTCtUN3eXRUJp+sBEZjaIBVYaw== +"@babel/plugin-transform-new-target@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz#259c43939728cad1706ac17351b7e6a7bea1abeb" + integrity sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-modules-amd@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.7.tgz#bb4e543b5611f6c8c685a2fd485408713a3adf3d" - integrity sha512-CgselSGCGzjQvKzghCvDTxKHP3iooenLpJDO842ehn5D2G5fJB222ptnDwQho0WjEvg7zyoxb9P+wiYxiJX5yA== - dependencies: - "@babel/helper-module-transforms" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-modules-commonjs@^7.24.8": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.7.tgz#173f0c791bb7407c092ce6d77ee90eb3f2d1d2fd" - integrity sha512-L9Gcahi0kKFYXvweO6n0wc3ZG1ChpSFdgG+eV1WYZ3/dGbJK7vvk91FgGgak8YwRgrCuihF8tE/Xg07EkL5COg== +"@babel/plugin-transform-nullish-coalescing-operator@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz#9bc62096e90ab7a887f3ca9c469f6adec5679757" + integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== dependencies: - "@babel/helper-module-transforms" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-simple-access" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-modules-systemjs@^7.25.0": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.7.tgz#8b14d319a177cc9c85ef8b0512afd429d9e2e60b" - integrity sha512-t9jZIvBmOXJsiuyOwhrIGs8dVcD6jDyg2icw1VL4A/g+FnWyJKwUfSSU2nwJuMV2Zqui856El9u+ElB+j9fV1g== - dependencies: - "@babel/helper-module-transforms" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-validator-identifier" "^7.25.7" - "@babel/traverse" "^7.25.7" - -"@babel/plugin-transform-modules-umd@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.7.tgz#00ee7a7e124289549381bfb0e24d87fd7f848367" - integrity sha512-p88Jg6QqsaPh+EB7I9GJrIqi1Zt4ZBHUQtjw3z1bzEXcLh6GfPqzZJ6G+G1HBGKUNukT58MnKG7EN7zXQBCODw== - dependencies: - "@babel/helper-module-transforms" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-named-capturing-groups-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.7.tgz#a2f3f6d7f38693b462542951748f0a72a34d196d" - integrity sha512-BtAT9LzCISKG3Dsdw5uso4oV1+v2NlVXIIomKJgQybotJY3OwCwJmkongjHgwGKoZXd0qG5UZ12JUlDQ07W6Ow== - dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - -"@babel/plugin-transform-new-target@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.7.tgz#52b2bde523b76c548749f38dc3054f1f45e82bc9" - integrity sha512-CfCS2jDsbcZaVYxRFo2qtavW8SpdzmBXC2LOI4oO0rP+JSRDxxF3inF4GcPsLgfb5FjkhXG5/yR/lxuRs2pySA== +"@babel/plugin-transform-numeric-separator@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz#1310b0292762e7a4a335df5f580c3320ee7d9e9f" + integrity sha512-SJR8hPynj8outz+SlStQSwvziMN4+Bq99it4tMIf5/Caq+3iOc0JtKyse8puvyXkk3eFRIA5ID/XfunGgO5i6w== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.7.tgz#0af84b86d4332654c43cf028dbdcf878b00ac168" - integrity sha512-FbuJ63/4LEL32mIxrxwYaqjJxpbzxPVQj5a+Ebrc8JICV6YX8nE53jY+K0RZT3um56GoNWgkS2BQ/uLGTjtwfw== +"@babel/plugin-transform-object-rest-spread@^7.28.0": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.28.6.tgz#fdd4bc2d72480db6ca42aed5c051f148d7b067f7" + integrity sha512-5rh+JR4JBC4pGkXLAcYdLHZjXudVxWMXbB6u6+E9lRL5TrGVbHt1TjxGbZ8CkmYw9zjkB7jutzOROArsqtncEA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-destructuring" "^7.28.5" + "@babel/plugin-transform-parameters" "^7.27.7" + "@babel/traverse" "^7.28.6" -"@babel/plugin-transform-numeric-separator@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.7.tgz#a516b78f894d1c08283f39d809b2048fd2f29448" - integrity sha512-8CbutzSSh4hmD+jJHIA8vdTNk15kAzOnFLVVgBSMGr28rt85ouT01/rezMecks9pkU939wDInImwCKv4ahU4IA== +"@babel/plugin-transform-object-super@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz#1c932cd27bf3874c43a5cac4f43ebf970c9871b5" + integrity sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-replace-supers" "^7.27.1" -"@babel/plugin-transform-object-rest-spread@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.7.tgz#fa0916521be96fd434e2db59780b24b308c6d169" - integrity sha512-1JdVKPhD7Y5PvgfFy0Mv2brdrolzpzSoUq2pr6xsR+m+3viGGeHEokFKsCgOkbeFOQxfB1Vt2F0cPJLRpFI4Zg== +"@babel/plugin-transform-optional-catch-binding@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.28.6.tgz#75107be14c78385978201a49c86414a150a20b4c" + integrity sha512-R8ja/Pyrv0OGAvAXQhSTmWyPJPml+0TMqXlO5w+AsMEiwb2fg3WkOvob7UxFSL3OIttFSGSRFKQsOhJ/X6HQdQ== dependencies: - "@babel/helper-compilation-targets" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-transform-parameters" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-object-super@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.7.tgz#582a9cea8cf0a1e02732be5b5a703a38dedf5661" - integrity sha512-pWT6UXCEW3u1t2tcAGtE15ornCBvopHj9Bps9D2DsH15APgNVOTwwczGckX+WkAvBmuoYKRCFa4DK+jM8vh5AA== +"@babel/plugin-transform-optional-chaining@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz#926cf150bd421fc8362753e911b4a1b1ce4356cd" + integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-replace-supers" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-optional-catch-binding@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.7.tgz#400e2d891f9288f5231694234696aa67164e4913" - integrity sha512-m9obYBA39mDPN7lJzD5WkGGb0GO54PPLXsbcnj1Hyeu8mSRz7Gb4b1A6zxNX32ZuUySDK4G6it8SDFWD1nCnqg== +"@babel/plugin-transform-parameters@^7.27.7": + version "7.27.7" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz#1fd2febb7c74e7d21cf3b05f7aebc907940af53a" + integrity sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-optional-chaining@^7.24.8", "@babel/plugin-transform-optional-chaining@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.7.tgz#b7f7c9321aa1d8414e67799c28d87c23682e4d68" - integrity sha512-h39agClImgPWg4H8mYVAbD1qP9vClFbEjqoJmt87Zen8pjqK8FTPUwrOXAvqu5soytwxrLMd2fx2KSCp2CHcNg== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.7" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - -"@babel/plugin-transform-parameters@^7.24.7", "@babel/plugin-transform-parameters@^7.25.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.7.tgz#80c38b03ef580f6d6bffe1c5254bb35986859ac7" - integrity sha512-FYiTvku63me9+1Nz7TOx4YMtW3tWXzfANZtrzHhUZrz4d47EEtMQhzFoZWESfXuAMMT5mwzD4+y1N8ONAX6lMQ== - dependencies: - "@babel/helper-plugin-utils" "^7.25.7" +"@babel/plugin-transform-private-methods@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.28.6.tgz#c76fbfef3b86c775db7f7c106fff544610bdb411" + integrity sha512-piiuapX9CRv7+0st8lmuUlRSmX6mBcVeNQ1b4AYzJxfCMuBfB0vBXDiGSmm03pKJw1v6cZ8KSeM+oUnM6yAExg== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-private-methods@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.7.tgz#c790a04f837b4bd61d6b0317b43aa11ff67dce80" - integrity sha512-KY0hh2FluNxMLwOCHbxVOKfdB5sjWG4M183885FmaqWWiGMhRZq4DQRKH6mHdEucbJnyDyYiZNwNG424RymJjA== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" +"@babel/plugin-transform-private-property-in-object@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.28.6.tgz#4fafef1e13129d79f1d75ac180c52aafefdb2811" + integrity sha512-b97jvNSOb5+ehyQmBpmhOCiUC5oVK4PMnpRvO7+ymFBoqYjeDHIU9jnrNUuwHOiL9RpGDoKBpSViarV+BU+eVA== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-private-property-in-object@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.7.tgz#aff877efd05b57c4ad04611d8de97bf155a53369" - integrity sha512-LzA5ESzBy7tqj00Yjey9yWfs3FKy4EmJyKOSWld144OxkTji81WWnUT8nkLUn+imN/zHL8ZQlOu/MTUAhHaX3g== +"@babel/plugin-transform-property-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz#07eafd618800591e88073a0af1b940d9a42c6424" + integrity sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.25.7" - "@babel/helper-create-class-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-property-literals@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.7.tgz#a8612b4ea4e10430f00012ecf0155662c7d6550d" - integrity sha512-lQEeetGKfFi0wHbt8ClQrUSUMfEeI3MMm74Z73T9/kuz990yYVtfofjf3NuA42Jy3auFOpbjDyCSiIkTs1VIYw== +"@babel/plugin-transform-regenerator@^7.28.3": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.28.6.tgz#6ca2ed5b76cff87980f96eaacfc2ce833e8e7a1b" + integrity sha512-eZhoEZHYQLL5uc1gS5e9/oTknS0sSSAtd5TkKMUp3J+S/CaUjagc0kOUPsEbDmMeva0nC3WWl4SxVY6+OBuxfw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-regenerator@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.7.tgz#6eb006e6d26f627bc2f7844a9f19770721ad6f3e" - integrity sha512-mgDoQCRjrY3XK95UuV60tZlFCQGXEtMg8H+IsW72ldw1ih1jZhzYXbJvghmAEpg5UVhhnCeia1CkGttUvCkiMQ== +"@babel/plugin-transform-regexp-modifiers@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.28.6.tgz#7ef0163bd8b4a610481b2509c58cf217f065290b" + integrity sha512-QGWAepm9qxpaIs7UM9FvUSnCGlb8Ua1RhyM4/veAxLwt3gMat/LSGrZixyuj4I6+Kn9iwvqCyPTtbdxanYoWYg== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - regenerator-transform "^0.15.2" + "@babel/helper-create-regexp-features-plugin" "^7.28.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-reserved-words@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.7.tgz#dc56b25e02afaabef3ce0c5b06b0916e8523e995" - integrity sha512-3OfyfRRqiGeOvIWSagcwUTVk2hXBsr/ww7bLn6TRTuXnexA+Udov2icFOxFX9abaj4l96ooYkcNN1qi2Zvqwng== +"@babel/plugin-transform-reserved-words@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz#40fba4878ccbd1c56605a4479a3a891ac0274bb4" + integrity sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-runtime@7.24.7": - version "7.24.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.24.7.tgz#00a5bfaf8c43cf5c8703a8a6e82b59d9c58f38ca" - integrity sha512-YqXjrk4C+a1kZjewqt+Mmu2UuV1s07y8kqcUf4qYLnoqemhR4gRQikhdAhSVJioMjVTu6Mo6pAbaypEA3jY6fw== - dependencies: - "@babel/helper-module-imports" "^7.24.7" - "@babel/helper-plugin-utils" "^7.24.7" - babel-plugin-polyfill-corejs2 "^0.4.10" - babel-plugin-polyfill-corejs3 "^0.10.1" - babel-plugin-polyfill-regenerator "^0.6.1" +"@babel/plugin-transform-runtime@7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.28.3.tgz#f5990a1b2d2bde950ed493915e0719841c8d0eaa" + integrity sha512-Y6ab1kGqZ0u42Zv/4a7l0l72n9DKP/MKoKWaUSBylrhNZO2prYuqFOLbn5aW5SIFXwSH93yfjbgllL8lxuGKLg== + dependencies: + "@babel/helper-module-imports" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" + babel-plugin-polyfill-corejs2 "^0.4.14" + babel-plugin-polyfill-corejs3 "^0.13.0" + babel-plugin-polyfill-regenerator "^0.6.5" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.7.tgz#92690a9c671915602d91533c278cc8f6bf12275f" - integrity sha512-uBbxNwimHi5Bv3hUccmOFlUy3ATO6WagTApenHz9KzoIdn0XeACdB12ZJ4cjhuB2WSi80Ez2FWzJnarccriJeA== +"@babel/plugin-transform-shorthand-properties@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz#532abdacdec87bfee1e0ef8e2fcdee543fe32b90" + integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-spread@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.7.tgz#df83e899a9fc66284ee601a7b738568435b92998" - integrity sha512-Mm6aeymI0PBh44xNIv/qvo8nmbkpZze1KvR8MkEqbIREDxoiWTi18Zr2jryfRMwDfVZF9foKh060fWgni44luw== +"@babel/plugin-transform-spread@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-spread/-/plugin-transform-spread-7.28.6.tgz#40a2b423f6db7b70f043ad027a58bcb44a9757b6" + integrity sha512-9U4QObUC0FtJl05AsUcodau/RWDytrU6uKgkxu09mLR9HLDAtUMoPuuskm5huQsoktmsYpI+bGmq+iapDcriKA== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" - "@babel/helper-skip-transparent-expression-wrappers" "^7.25.7" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" -"@babel/plugin-transform-sticky-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.7.tgz#341c7002bef7f29037be7fb9684e374442dd0d17" - integrity sha512-ZFAeNkpGuLnAQ/NCsXJ6xik7Id+tHuS+NT+ue/2+rn/31zcdnupCdmunOizEaP0JsUmTFSTOPoQY7PkK2pttXw== +"@babel/plugin-transform-sticky-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.27.1.tgz#18984935d9d2296843a491d78a014939f7dcd280" + integrity sha512-lhInBO5bi/Kowe2/aLdBAawijx+q1pQzicSgnkB6dUPc1+RC8QmJHKf2OjvU+NZWitguJHEaEmbV6VWEouT58g== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-template-literals@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.7.tgz#e566c581bb16d8541dd8701093bb3457adfce16b" - integrity sha512-SI274k0nUsFFmyQupiO7+wKATAmMFf8iFgq2O+vVFXZ0SV9lNfT1NGzBEhjquFmD8I9sqHLguH+gZVN3vww2AA== +"@babel/plugin-transform-template-literals@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz#1a0eb35d8bb3e6efc06c9fd40eb0bcef548328b8" + integrity sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-typeof-symbol@^7.24.8": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.7.tgz#debb1287182efd20488f126be343328c679b66eb" - integrity sha512-OmWmQtTHnO8RSUbL0NTdtpbZHeNTnm68Gj5pA4Y2blFNh+V4iZR68V1qL9cI37J21ZN7AaCnkfdHtLExQPf2uA== +"@babel/plugin-transform-typeof-symbol@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz#70e966bb492e03509cf37eafa6dcc3051f844369" + integrity sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-escapes@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.7.tgz#973592b6d13a914794e1de8cf1383e50e0f87f81" - integrity sha512-BN87D7KpbdiABA+t3HbVqHzKWUDN3dymLaTnPFAMyc8lV+KN3+YzNhVRNdinaCPA4AUqx7ubXbQ9shRjYBl3SQ== +"@babel/plugin-transform-unicode-escapes@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz#3e3143f8438aef842de28816ece58780190cf806" + integrity sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg== dependencies: - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-property-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.7.tgz#25349197cce964b1343f74fa7cfdf791a1b1919e" - integrity sha512-IWfR89zcEPQGB/iB408uGtSPlQd3Jpq11Im86vUgcmSTcoWAiQMCTOa2K2yNNqFJEBVICKhayctee65Ka8OB0w== +"@babel/plugin-transform-unicode-property-regex@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.28.6.tgz#63a7a6c21a0e75dae9b1861454111ea5caa22821" + integrity sha512-4Wlbdl/sIZjzi/8St0evF0gEZrgOswVO6aOzqxh1kDZOl9WmLrHq2HtGhnOJZmHZYKP8WZ1MDLCt5DAWwRo57A== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-create-regexp-features-plugin" "^7.28.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-unicode-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.7.tgz#f93a93441baf61f713b6d5552aaa856bfab34809" - integrity sha512-8JKfg/hiuA3qXnlLx8qtv5HWRbgyFx2hMMtpDDuU2rTckpKkGu4ycK5yYHwuEa16/quXfoxHBIApEsNyMWnt0g== +"@babel/plugin-transform-unicode-regex@^7.27.1": + version "7.27.1" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz#25948f5c395db15f609028e370667ed8bae9af97" + integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-create-regexp-features-plugin" "^7.27.1" + "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-unicode-sets-regex@^7.24.7": - version "7.25.7" - resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.7.tgz#d1b3295d29e0f8f4df76abc909ad1ebee919560c" - integrity sha512-YRW8o9vzImwmh4Q3Rffd09bH5/hvY0pxg+1H1i0f7APoUeg12G7+HhLj9ZFNIrYkgBXhIijPJ+IXypN0hLTIbw== +"@babel/plugin-transform-unicode-sets-regex@^7.27.1": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.28.6.tgz#924912914e5df9fe615ec472f88ff4788ce04d4e" + integrity sha512-/wHc/paTUmsDYN7SZkpWxogTOBNnlx7nBQYfy6JJlCT7G3mVhltk3e++N7zV0XfgGsrqBxd4rJQt9H16I21Y1Q== dependencies: - "@babel/helper-create-regexp-features-plugin" "^7.25.7" - "@babel/helper-plugin-utils" "^7.25.7" + "@babel/helper-create-regexp-features-plugin" "^7.28.5" + "@babel/helper-plugin-utils" "^7.28.6" -"@babel/preset-env@7.25.3": - version "7.25.3" - resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.25.3.tgz#0bf4769d84ac51d1073ab4a86f00f30a3a83c67c" - integrity sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g== +"@babel/preset-env@7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/preset-env/-/preset-env-7.28.3.tgz#2b18d9aff9e69643789057ae4b942b1654f88187" + integrity sha512-ROiDcM+GbYVPYBOeCR6uBXKkQpBExLl8k9HO1ygXEyds39j+vCCsjmj7S8GOniZQlEs81QlkdJZe76IpLSiqpg== dependencies: - "@babel/compat-data" "^7.25.2" - "@babel/helper-compilation-targets" "^7.25.2" - "@babel/helper-plugin-utils" "^7.24.8" - "@babel/helper-validator-option" "^7.24.8" - "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.25.3" - "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.25.0" - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.25.0" - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.24.7" - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.25.0" + "@babel/compat-data" "^7.28.0" + "@babel/helper-compilation-targets" "^7.27.2" + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-validator-option" "^7.27.1" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key" "^7.27.1" + "@babel/plugin-bugfix-safari-class-field-initializer-scope" "^7.27.1" + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression" "^7.27.1" + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining" "^7.27.1" + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly" "^7.28.3" "@babel/plugin-proposal-private-property-in-object" "7.21.0-placeholder-for-preset-env.2" - "@babel/plugin-syntax-async-generators" "^7.8.4" - "@babel/plugin-syntax-class-properties" "^7.12.13" - "@babel/plugin-syntax-class-static-block" "^7.14.5" - "@babel/plugin-syntax-dynamic-import" "^7.8.3" - "@babel/plugin-syntax-export-namespace-from" "^7.8.3" - "@babel/plugin-syntax-import-assertions" "^7.24.7" - "@babel/plugin-syntax-import-attributes" "^7.24.7" - "@babel/plugin-syntax-import-meta" "^7.10.4" - "@babel/plugin-syntax-json-strings" "^7.8.3" - "@babel/plugin-syntax-logical-assignment-operators" "^7.10.4" - "@babel/plugin-syntax-nullish-coalescing-operator" "^7.8.3" - "@babel/plugin-syntax-numeric-separator" "^7.10.4" - "@babel/plugin-syntax-object-rest-spread" "^7.8.3" - "@babel/plugin-syntax-optional-catch-binding" "^7.8.3" - "@babel/plugin-syntax-optional-chaining" "^7.8.3" - "@babel/plugin-syntax-private-property-in-object" "^7.14.5" - "@babel/plugin-syntax-top-level-await" "^7.14.5" + "@babel/plugin-syntax-import-assertions" "^7.27.1" + "@babel/plugin-syntax-import-attributes" "^7.27.1" "@babel/plugin-syntax-unicode-sets-regex" "^7.18.6" - "@babel/plugin-transform-arrow-functions" "^7.24.7" - "@babel/plugin-transform-async-generator-functions" "^7.25.0" - "@babel/plugin-transform-async-to-generator" "^7.24.7" - "@babel/plugin-transform-block-scoped-functions" "^7.24.7" - "@babel/plugin-transform-block-scoping" "^7.25.0" - "@babel/plugin-transform-class-properties" "^7.24.7" - "@babel/plugin-transform-class-static-block" "^7.24.7" - "@babel/plugin-transform-classes" "^7.25.0" - "@babel/plugin-transform-computed-properties" "^7.24.7" - "@babel/plugin-transform-destructuring" "^7.24.8" - "@babel/plugin-transform-dotall-regex" "^7.24.7" - "@babel/plugin-transform-duplicate-keys" "^7.24.7" - "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.25.0" - "@babel/plugin-transform-dynamic-import" "^7.24.7" - "@babel/plugin-transform-exponentiation-operator" "^7.24.7" - "@babel/plugin-transform-export-namespace-from" "^7.24.7" - "@babel/plugin-transform-for-of" "^7.24.7" - "@babel/plugin-transform-function-name" "^7.25.1" - "@babel/plugin-transform-json-strings" "^7.24.7" - "@babel/plugin-transform-literals" "^7.25.2" - "@babel/plugin-transform-logical-assignment-operators" "^7.24.7" - "@babel/plugin-transform-member-expression-literals" "^7.24.7" - "@babel/plugin-transform-modules-amd" "^7.24.7" - "@babel/plugin-transform-modules-commonjs" "^7.24.8" - "@babel/plugin-transform-modules-systemjs" "^7.25.0" - "@babel/plugin-transform-modules-umd" "^7.24.7" - "@babel/plugin-transform-named-capturing-groups-regex" "^7.24.7" - "@babel/plugin-transform-new-target" "^7.24.7" - "@babel/plugin-transform-nullish-coalescing-operator" "^7.24.7" - "@babel/plugin-transform-numeric-separator" "^7.24.7" - "@babel/plugin-transform-object-rest-spread" "^7.24.7" - "@babel/plugin-transform-object-super" "^7.24.7" - "@babel/plugin-transform-optional-catch-binding" "^7.24.7" - "@babel/plugin-transform-optional-chaining" "^7.24.8" - "@babel/plugin-transform-parameters" "^7.24.7" - "@babel/plugin-transform-private-methods" "^7.24.7" - "@babel/plugin-transform-private-property-in-object" "^7.24.7" - "@babel/plugin-transform-property-literals" "^7.24.7" - "@babel/plugin-transform-regenerator" "^7.24.7" - "@babel/plugin-transform-reserved-words" "^7.24.7" - "@babel/plugin-transform-shorthand-properties" "^7.24.7" - "@babel/plugin-transform-spread" "^7.24.7" - "@babel/plugin-transform-sticky-regex" "^7.24.7" - "@babel/plugin-transform-template-literals" "^7.24.7" - "@babel/plugin-transform-typeof-symbol" "^7.24.8" - "@babel/plugin-transform-unicode-escapes" "^7.24.7" - "@babel/plugin-transform-unicode-property-regex" "^7.24.7" - "@babel/plugin-transform-unicode-regex" "^7.24.7" - "@babel/plugin-transform-unicode-sets-regex" "^7.24.7" + "@babel/plugin-transform-arrow-functions" "^7.27.1" + "@babel/plugin-transform-async-generator-functions" "^7.28.0" + "@babel/plugin-transform-async-to-generator" "^7.27.1" + "@babel/plugin-transform-block-scoped-functions" "^7.27.1" + "@babel/plugin-transform-block-scoping" "^7.28.0" + "@babel/plugin-transform-class-properties" "^7.27.1" + "@babel/plugin-transform-class-static-block" "^7.28.3" + "@babel/plugin-transform-classes" "^7.28.3" + "@babel/plugin-transform-computed-properties" "^7.27.1" + "@babel/plugin-transform-destructuring" "^7.28.0" + "@babel/plugin-transform-dotall-regex" "^7.27.1" + "@babel/plugin-transform-duplicate-keys" "^7.27.1" + "@babel/plugin-transform-duplicate-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-dynamic-import" "^7.27.1" + "@babel/plugin-transform-explicit-resource-management" "^7.28.0" + "@babel/plugin-transform-exponentiation-operator" "^7.27.1" + "@babel/plugin-transform-export-namespace-from" "^7.27.1" + "@babel/plugin-transform-for-of" "^7.27.1" + "@babel/plugin-transform-function-name" "^7.27.1" + "@babel/plugin-transform-json-strings" "^7.27.1" + "@babel/plugin-transform-literals" "^7.27.1" + "@babel/plugin-transform-logical-assignment-operators" "^7.27.1" + "@babel/plugin-transform-member-expression-literals" "^7.27.1" + "@babel/plugin-transform-modules-amd" "^7.27.1" + "@babel/plugin-transform-modules-commonjs" "^7.27.1" + "@babel/plugin-transform-modules-systemjs" "^7.27.1" + "@babel/plugin-transform-modules-umd" "^7.27.1" + "@babel/plugin-transform-named-capturing-groups-regex" "^7.27.1" + "@babel/plugin-transform-new-target" "^7.27.1" + "@babel/plugin-transform-nullish-coalescing-operator" "^7.27.1" + "@babel/plugin-transform-numeric-separator" "^7.27.1" + "@babel/plugin-transform-object-rest-spread" "^7.28.0" + "@babel/plugin-transform-object-super" "^7.27.1" + "@babel/plugin-transform-optional-catch-binding" "^7.27.1" + "@babel/plugin-transform-optional-chaining" "^7.27.1" + "@babel/plugin-transform-parameters" "^7.27.7" + "@babel/plugin-transform-private-methods" "^7.27.1" + "@babel/plugin-transform-private-property-in-object" "^7.27.1" + "@babel/plugin-transform-property-literals" "^7.27.1" + "@babel/plugin-transform-regenerator" "^7.28.3" + "@babel/plugin-transform-regexp-modifiers" "^7.27.1" + "@babel/plugin-transform-reserved-words" "^7.27.1" + "@babel/plugin-transform-shorthand-properties" "^7.27.1" + "@babel/plugin-transform-spread" "^7.27.1" + "@babel/plugin-transform-sticky-regex" "^7.27.1" + "@babel/plugin-transform-template-literals" "^7.27.1" + "@babel/plugin-transform-typeof-symbol" "^7.27.1" + "@babel/plugin-transform-unicode-escapes" "^7.27.1" + "@babel/plugin-transform-unicode-property-regex" "^7.27.1" + "@babel/plugin-transform-unicode-regex" "^7.27.1" + "@babel/plugin-transform-unicode-sets-regex" "^7.27.1" "@babel/preset-modules" "0.1.6-no-external-plugins" - babel-plugin-polyfill-corejs2 "^0.4.10" - babel-plugin-polyfill-corejs3 "^0.10.4" - babel-plugin-polyfill-regenerator "^0.6.1" - core-js-compat "^3.37.1" + babel-plugin-polyfill-corejs2 "^0.4.14" + babel-plugin-polyfill-corejs3 "^0.13.0" + babel-plugin-polyfill-regenerator "^0.6.5" + core-js-compat "^3.43.0" semver "^6.3.1" "@babel/preset-modules@0.1.6-no-external-plugins": @@ -1287,86 +1284,75 @@ "@babel/types" "^7.4.4" esutils "^2.0.2" -"@babel/runtime@7.25.0": - version "7.25.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb" - integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw== - dependencies: - regenerator-runtime "^0.14.0" - -"@babel/runtime@^7.8.4": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.26.0.tgz#8600c2f595f277c60815256418b85356a65173c1" - integrity sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw== - dependencies: - regenerator-runtime "^0.14.0" - -"@babel/template@^7.25.0", "@babel/template@^7.25.7", "@babel/template@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" - integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/types" "^7.25.9" - -"@babel/traverse@^7.25.0", "@babel/traverse@^7.25.2", "@babel/traverse@^7.25.7", "@babel/traverse@^7.25.9": - version "7.25.9" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" - integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== - dependencies: - "@babel/code-frame" "^7.25.9" - "@babel/generator" "^7.25.9" - "@babel/parser" "^7.25.9" - "@babel/template" "^7.25.9" - "@babel/types" "^7.25.9" +"@babel/runtime@7.28.3": + version "7.28.3" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.28.3.tgz#75c5034b55ba868121668be5d5bb31cc64e6e61a" + integrity sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA== + +"@babel/template@^7.27.2", "@babel/template@^7.28.6": + version "7.28.6" + resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.28.6.tgz#0e7e56ecedb78aeef66ce7972b082fce76a23e57" + integrity sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ== + dependencies: + "@babel/code-frame" "^7.28.6" + "@babel/parser" "^7.28.6" + "@babel/types" "^7.28.6" + +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.0", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5", "@babel/traverse@^7.28.6": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.0.tgz#f323d05001440253eead3c9c858adbe00b90310a" + integrity sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA== + dependencies: + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.29.0" + "@babel/template" "^7.28.6" + "@babel/types" "^7.29.0" debug "^4.3.1" - globals "^11.1.0" -"@babel/types@^7.24.7", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.25.7", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.4.4": - version "7.26.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" - integrity sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA== +"@babel/types@^7.24.7", "@babel/types@^7.27.1", "@babel/types@^7.27.3", "@babel/types@^7.28.2", "@babel/types@^7.28.5", "@babel/types@^7.28.6", "@babel/types@^7.29.0", "@babel/types@^7.4.4": + version "7.29.0" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.0.tgz#9f5b1e838c446e72cf3cd4b918152b8c605e37c7" + integrity sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A== dependencies: - "@babel/helper-string-parser" "^7.25.9" - "@babel/helper-validator-identifier" "^7.25.9" + "@babel/helper-string-parser" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" -"@braintree/sanitize-url@^7.0.1": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.0.tgz#048e48aab4f1460e3121e22aa62459d16653dc85" - integrity sha512-o+UlMLt49RvtCASlOMW0AkHnabN9wR9rwCCherxO0yG4Npy34GkvrAqdXQvrhNs+jh+gkK8gB8Lf05qL/O7KWg== +"@braintree/sanitize-url@^7.1.1": + version "7.1.2" + resolved "https://registry.yarnpkg.com/@braintree/sanitize-url/-/sanitize-url-7.1.2.tgz#ca2035b0fefe956a8676ff0c69af73e605fcd81f" + integrity sha512-jigsZK+sMF/cuiB7sERuo9V7N9jx+dhmHHnQyDSVdpZwVutaBu7WvNYqMDLSgFgfB30n452TP3vjDAvFC973mA== -"@chevrotain/cst-dts-gen@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-11.0.3.tgz#5e0863cc57dc45e204ccfee6303225d15d9d4783" - integrity sha512-BvIKpRLeS/8UbfxXxgC33xOumsacaeCKAjAeLyOn7Pcp95HiRbrpl14S+9vaZLolnbssPIUuiUd8IvgkRyt6NQ== +"@chevrotain/cst-dts-gen@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/cst-dts-gen/-/cst-dts-gen-12.0.0.tgz#ec068e1e83c5fdad69d81773556cae97f0b5dcdb" + integrity sha512-fSL4KXjTl7cDgf0B5Rip9Q05BOrYvkJV/RrBTE/bKDN096E4hN/ySpcBK5B24T76dlQ2i32Zc3PAE27jFnFrKg== dependencies: - "@chevrotain/gast" "11.0.3" - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/gast" "12.0.0" + "@chevrotain/types" "12.0.0" -"@chevrotain/gast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-11.0.3.tgz#e84d8880323fe8cbe792ef69ce3ffd43a936e818" - integrity sha512-+qNfcoNk70PyS/uxmj3li5NiECO+2YKZZQMbmjTqRI3Qchu8Hig/Q9vgkHpI3alNjr7M+a2St5pw5w5F6NL5/Q== +"@chevrotain/gast@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/gast/-/gast-12.0.0.tgz#0e0cbf8eee01c7a4449b9caf19e5f3834dba2c35" + integrity sha512-1ne/m3XsIT8aEdrvT33so0GUC+wkctpUPK6zU9IlOyJLUbR0rg4G7ZiApiJbggpgPir9ERy3FRjT6T7lpgetnQ== dependencies: - "@chevrotain/types" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/types" "12.0.0" -"@chevrotain/regexp-to-ast@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-11.0.3.tgz#11429a81c74a8e6a829271ce02fc66166d56dcdb" - integrity sha512-1fMHaBZxLFvWI067AVbGJav1eRY7N8DDvYCTwGBiE/ytKBgP8azTdgyrKyWZ9Mfh09eHWb5PgTSO8wi7U824RA== +"@chevrotain/regexp-to-ast@12.0.0", "@chevrotain/regexp-to-ast@~12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/regexp-to-ast/-/regexp-to-ast-12.0.0.tgz#a90bc4b4f5337a883a88dddd0cca7c38cfe66a7a" + integrity sha512-p+EW9MaJwgaHguhoqwOtx/FwuGr+DnNn857sXWOi/mClXIkPGl3rn7hGNWvo31HA3vyeQxjqe+H36yZJwYU8cA== -"@chevrotain/types@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-11.0.3.tgz#f8a03914f7b937f594f56eb89312b3b8f1c91848" - integrity sha512-gsiM3G8b58kZC2HaWR50gu6Y1440cHiJ+i3JUvcp/35JchYejb2+5MVeJK0iKThYpAa/P2PYFV4hoi44HD+aHQ== +"@chevrotain/types@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/types/-/types-12.0.0.tgz#a762b5c2b4f07496b56c93c30ce224b3637cc2c8" + integrity sha512-S+04vjFQKeuYw0/eW3U52LkAHQsB1ASxsPGsLPUyQgrZ2iNNibQrsidruDzjEX2JYfespXMG0eZmXlhA6z7nWA== -"@chevrotain/utils@11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-11.0.3.tgz#e39999307b102cff3645ec4f5b3665f5297a2224" - integrity sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ== +"@chevrotain/utils@12.0.0": + version "12.0.0" + resolved "https://registry.yarnpkg.com/@chevrotain/utils/-/utils-12.0.0.tgz#9aab2055df43d0bb55919eaca76a9cda45e52b89" + integrity sha512-lB59uJoaGIfOOL9knQqQRfhl9g7x8/wqFkp13zTdkRu1huG9kg6IJs1O8hqj9rs6h7orGxHJUKb+mX3rPbWGhA== "@cspotcode/source-map-support@^0.8.0": version "0.8.1" @@ -1375,198 +1361,223 @@ dependencies: "@jridgewell/trace-mapping" "0.3.9" -"@discoveryjs/json-ext@0.6.1": - version "0.6.1" - resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.1.tgz#593da7a17a31a72a874e313677183334a49b01c9" - integrity sha512-boghen8F0Q8D+0/Q1/1r6DUEieUJ8w2a1gIknExMSHBsJFOr2+0KUfHiVYBvucPwl3+RU5PFBK833FjFCh3BhA== - -"@es-joy/jsdoccomment@~0.49.0": - version "0.49.0" - resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.49.0.tgz#e5ec1eda837c802eca67d3b29e577197f14ba1db" - integrity sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q== - dependencies: - comment-parser "1.4.1" - esquery "^1.6.0" - jsdoc-type-pratt-parser "~4.1.0" - -"@esbuild/aix-ppc64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.23.0.tgz#145b74d5e4a5223489cabdc238d8dad902df5259" - integrity sha512-3sG8Zwa5fMcA9bgqB8AfWPQ+HFke6uD3h1s3RIwUNK8EG7a4buxvuFTs3j1IMs2NXAk9F30C/FF4vxRgQCcmoQ== - -"@esbuild/android-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.23.0.tgz#453bbe079fc8d364d4c5545069e8260228559832" - integrity sha512-EuHFUYkAVfU4qBdyivULuu03FhJO4IJN9PGuABGrFy4vUuzk91P2d+npxHcFdpUnfYKy0PuV+n6bKIpHOB3prQ== - -"@esbuild/android-arm@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.23.0.tgz#26c806853aa4a4f7e683e519cd9d68e201ebcf99" - integrity sha512-+KuOHTKKyIKgEEqKbGTK8W7mPp+hKinbMBeEnNzjJGyFcWsfrXjSTNluJHCY1RqhxFurdD8uNXQDei7qDlR6+g== - -"@esbuild/android-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.23.0.tgz#1e51af9a6ac1f7143769f7ee58df5b274ed202e6" - integrity sha512-WRrmKidLoKDl56LsbBMhzTTBxrsVwTKdNbKDalbEZr0tcsBgCLbEtoNthOW6PX942YiYq8HzEnb4yWQMLQuipQ== - -"@esbuild/darwin-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.23.0.tgz#d996187a606c9534173ebd78c58098a44dd7ef9e" - integrity sha512-YLntie/IdS31H54Ogdn+v50NuoWF5BDkEUFpiOChVa9UnKpftgwzZRrI4J132ETIi+D8n6xh9IviFV3eXdxfow== - -"@esbuild/darwin-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.23.0.tgz#30c8f28a7ef4e32fe46501434ebe6b0912e9e86c" - integrity sha512-IMQ6eme4AfznElesHUPDZ+teuGwoRmVuuixu7sv92ZkdQcPbsNHzutd+rAfaBKo8YK3IrBEi9SLLKWJdEvJniQ== - -"@esbuild/freebsd-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.23.0.tgz#30f4fcec8167c08a6e8af9fc14b66152232e7fb4" - integrity sha512-0muYWCng5vqaxobq6LB3YNtevDFSAZGlgtLoAc81PjUfiFz36n4KMpwhtAd4he8ToSI3TGyuhyx5xmiWNYZFyw== - -"@esbuild/freebsd-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.23.0.tgz#1003a6668fe1f5d4439e6813e5b09a92981bc79d" - integrity sha512-XKDVu8IsD0/q3foBzsXGt/KjD/yTKBCIwOHE1XwiXmrRwrX6Hbnd5Eqn/WvDekddK21tfszBSrE/WMaZh+1buQ== - -"@esbuild/linux-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.23.0.tgz#3b9a56abfb1410bb6c9138790f062587df3e6e3a" - integrity sha512-j1t5iG8jE7BhonbsEg5d9qOYcVZv/Rv6tghaXM/Ug9xahM0nX/H2gfu6X6z11QRTMT6+aywOMA8TDkhPo8aCGw== - -"@esbuild/linux-arm@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.23.0.tgz#237a8548e3da2c48cd79ae339a588f03d1889aad" - integrity sha512-SEELSTEtOFu5LPykzA395Mc+54RMg1EUgXP+iw2SJ72+ooMwVsgfuwXo5Fn0wXNgWZsTVHwY2cg4Vi/bOD88qw== - -"@esbuild/linux-ia32@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.23.0.tgz#4269cd19cb2de5de03a7ccfc8855dde3d284a238" - integrity sha512-P7O5Tkh2NbgIm2R6x1zGJJsnacDzTFcRWZyTTMgFdVit6E98LTxO+v8LCCLWRvPrjdzXHx9FEOA8oAZPyApWUA== - -"@esbuild/linux-loong64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.23.0.tgz#82b568f5658a52580827cc891cb69d2cb4f86280" - integrity sha512-InQwepswq6urikQiIC/kkx412fqUZudBO4SYKu0N+tGhXRWUqAx+Q+341tFV6QdBifpjYgUndV1hhMq3WeJi7A== - -"@esbuild/linux-mips64el@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.23.0.tgz#9a57386c926262ae9861c929a6023ed9d43f73e5" - integrity sha512-J9rflLtqdYrxHv2FqXE2i1ELgNjT+JFURt/uDMoPQLcjWQA5wDKgQA4t/dTqGa88ZVECKaD0TctwsUfHbVoi4w== - -"@esbuild/linux-ppc64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.23.0.tgz#f3a79fd636ba0c82285d227eb20ed8e31b4444f6" - integrity sha512-cShCXtEOVc5GxU0fM+dsFD10qZ5UpcQ8AM22bYj0u/yaAykWnqXJDpd77ublcX6vdDsWLuweeuSNZk4yUxZwtw== - -"@esbuild/linux-riscv64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.23.0.tgz#f9d2ef8356ce6ce140f76029680558126b74c780" - integrity sha512-HEtaN7Y5UB4tZPeQmgz/UhzoEyYftbMXrBCUjINGjh3uil+rB/QzzpMshz3cNUxqXN7Vr93zzVtpIDL99t9aRw== - -"@esbuild/linux-s390x@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.23.0.tgz#45390f12e802201f38a0229e216a6aed4351dfe8" - integrity sha512-WDi3+NVAuyjg/Wxi+o5KPqRbZY0QhI9TjrEEm+8dmpY9Xir8+HE/HNx2JoLckhKbFopW0RdO2D72w8trZOV+Wg== - -"@esbuild/linux-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.23.0.tgz#c8409761996e3f6db29abcf9b05bee8d7d80e910" - integrity sha512-a3pMQhUEJkITgAw6e0bWA+F+vFtCciMjW/LPtoj99MhVt+Mfb6bbL9hu2wmTZgNd994qTAEw+U/r6k3qHWWaOQ== - -"@esbuild/netbsd-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.23.0.tgz#ba70db0114380d5f6cfb9003f1d378ce989cd65c" - integrity sha512-cRK+YDem7lFTs2Q5nEv/HHc4LnrfBCbH5+JHu6wm2eP+d8OZNoSMYgPZJq78vqQ9g+9+nMuIsAO7skzphRXHyw== - -"@esbuild/openbsd-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.23.0.tgz#72fc55f0b189f7a882e3cf23f332370d69dfd5db" - integrity sha512-suXjq53gERueVWu0OKxzWqk7NxiUWSUlrxoZK7usiF50C6ipColGR5qie2496iKGYNLhDZkPxBI3erbnYkU0rQ== - -"@esbuild/openbsd-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.23.0.tgz#b6ae7a0911c18fe30da3db1d6d17a497a550e5d8" - integrity sha512-6p3nHpby0DM/v15IFKMjAaayFhqnXV52aEmv1whZHX56pdkK+MEaLoQWj+H42ssFarP1PcomVhbsR4pkz09qBg== - -"@esbuild/sunos-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.23.0.tgz#58f0d5e55b9b21a086bfafaa29f62a3eb3470ad8" - integrity sha512-BFelBGfrBwk6LVrmFzCq1u1dZbG4zy/Kp93w2+y83Q5UGYF1d8sCzeLI9NXjKyujjBBniQa8R8PzLFAUrSM9OA== - -"@esbuild/win32-arm64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.23.0.tgz#b858b2432edfad62e945d5c7c9e5ddd0f528ca6d" - integrity sha512-lY6AC8p4Cnb7xYHuIxQ6iYPe6MfO2CC43XXKo9nBXDb35krYt7KGhQnOkRGar5psxYkircpCqfbNDB4uJbS2jQ== - -"@esbuild/win32-ia32@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.23.0.tgz#167ef6ca22a476c6c0c014a58b4f43ae4b80dec7" - integrity sha512-7L1bHlOTcO4ByvI7OXVI5pNN6HSu6pUQq9yodga8izeuB1KcT2UkHaH6118QJwopExPn0rMHIseCTx1CRo/uNA== - -"@esbuild/win32-x64@0.23.0": - version "0.23.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.23.0.tgz#db44a6a08520b5f25bbe409f34a59f2d4bcc7ced" - integrity sha512-Arm+WgUFLUATuoxCJcahGuk6Yj9Pzxd6l11Zb/2aAuv5kWWvvfhLFo2fni4uSK5vzlUdCGZ/BdV5tH8klj8p8g== - -"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": - version "4.4.1" - resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz#d1145bf2c20132d6400495d6df4bf59362fd9d56" - integrity sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA== +"@discoveryjs/json-ext@0.6.3": + version "0.6.3" + resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.6.3.tgz#f13c7c205915eb91ae54c557f5e92bddd8be0e83" + integrity sha512-4B4OijXeVNOPZlYA2oEwWOTkzyltLao+xbotHQeqN++Rv27Y6s818+n2Qkp8q+Fxhn0t/5lA5X1Mxktud8eayQ== + +"@es-joy/jsdoccomment@~0.83.0": + version "0.83.0" + resolved "https://registry.yarnpkg.com/@es-joy/jsdoccomment/-/jsdoccomment-0.83.0.tgz#db1eda42d7e10d81e2dbc871459d556897ab8b8f" + integrity sha512-e1MHSEPJ4m35zkBvNT6kcdeH1SvMaJDsPC3Xhfseg3hvF50FUE3f46Yn36jgbrPYYXezlWUQnevv23c+lx2MCA== + dependencies: + "@types/estree" "^1.0.8" + "@typescript-eslint/types" "^8.53.1" + comment-parser "1.4.5" + esquery "^1.7.0" + jsdoc-type-pratt-parser "~7.1.0" + +"@es-joy/resolve.exports@1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@es-joy/resolve.exports/-/resolve.exports-1.2.0.tgz#fe541a68aa080255f798c8561714ac8fad72cdd5" + integrity sha512-Q9hjxWI5xBM+qW2enxfe8wDKdFWMfd0Z29k5ZJnuBqD/CasY5Zryj09aCA6owbGATWz+39p5uIdaHXpopOcG8g== + +"@esbuild/aix-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz#7a289c158e29cbf59ea0afc83cc80f06d1c89402" + integrity sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA== + +"@esbuild/android-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz#b8828d9edfa3a92660644eb8de6e4f3c203d7b17" + integrity sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw== + +"@esbuild/android-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz#5ec1847605e05b5dbe5df90db9ff7e3e4c58dca7" + integrity sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ== + +"@esbuild/android-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz#390642175b88ef82bad4cce03f8ab13fe9b1912e" + integrity sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA== + +"@esbuild/darwin-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz#ae45325960d5950cd6951e4f97396f4e1ff7d8d3" + integrity sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q== + +"@esbuild/darwin-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz#c079247d589b6b99449659d94f06951b84bff2e4" + integrity sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ== + +"@esbuild/freebsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz#45c456215a486593c94900297202dc11c880a37a" + integrity sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q== + +"@esbuild/freebsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz#0399494c1c85e4388e9b7040bd60d48f2a5b0d2c" + integrity sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw== + +"@esbuild/linux-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz#d6d9f09ef0de54116bf459a4d53cac7e0952fe39" + integrity sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A== + +"@esbuild/linux-arm@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz#7b42ffa84c288ae94fdc431c1b28a89e3c3b9278" + integrity sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw== + +"@esbuild/linux-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz#deb15d112ed8dd605346b6b953d23a21ff81253f" + integrity sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ== + +"@esbuild/linux-loong64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz#81fb89d07eecc79b157dea61033757726fce0ca4" + integrity sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg== + +"@esbuild/linux-mips64el@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz#d0e42691b3ff7af9fb2217b70fc01f343bdb62bb" + integrity sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w== + +"@esbuild/linux-ppc64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz#389f3e5e98f17d477c467cc87136e1a076eead87" + integrity sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg== + +"@esbuild/linux-riscv64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz#763bd60d59b242be12da1e67d5729f3024c605fa" + integrity sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ== + +"@esbuild/linux-s390x@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz#aac6061634872e4677de693bce8030d73b1fd055" + integrity sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q== + +"@esbuild/linux-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz#4f2917747188fe77632bcec65b2d84b422419779" + integrity sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ== + +"@esbuild/netbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz#814df0ae57a0c386814491b8397eeba82094a947" + integrity sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw== + +"@esbuild/netbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz#e01bdf7e60fa1a08e46d46d960b0d9bb8ac210af" + integrity sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw== + +"@esbuild/openbsd-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz#4a15c36aacca68d2d5a4c90b710c06759f4c1ffa" + integrity sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g== + +"@esbuild/openbsd-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz#475e6101498a8ecce3008d7c388111d7a27c17bd" + integrity sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA== + +"@esbuild/openharmony-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz#cfdc3957f0b7a69f1bde129aad17fcc2f6fa033e" + integrity sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w== + +"@esbuild/sunos-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz#a013c856fecacd1c3aec985c8afe1d1cb017497d" + integrity sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw== + +"@esbuild/win32-arm64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz#eae05e0f35271cad3898b43168d3e9a3bbaf47e5" + integrity sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA== + +"@esbuild/win32-ia32@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz#06161ebc5bf75c08d69feb3c6b22560515913998" + integrity sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA== + +"@esbuild/win32-x64@0.28.0": + version "0.28.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz#04d90d5752b4ce65d2b6ac25eba08ff7624fe07c" + integrity sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw== + +"@eslint-community/eslint-utils@^4.8.0", "@eslint-community/eslint-utils@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz#4e90af67bc51ddee6cdef5284edf572ec376b595" + integrity sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ== dependencies: eslint-visitor-keys "^3.4.3" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": - version "4.12.1" - resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== +"@eslint-community/regexpp@^4.12.1", "@eslint-community/regexpp@^4.12.2": + version "4.12.2" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.19.0": - version "0.19.1" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.19.1.tgz#734aaea2c40be22bbb1f2a9dac687c57a6a4c984" - integrity sha512-fo6Mtm5mWyKjA/Chy1BYTdn5mGJoDNjC7C64ug20ADsRDGrA85bN3uK3MaKbeRkRuuIEAR5N33Jr1pbm411/PA== +"@eslint/config-array@^0.21.2": + version "0.21.2" + resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.2.tgz#f29e22057ad5316cf23836cee9a34c81fffcb7e6" + integrity sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw== dependencies: - "@eslint/object-schema" "^2.1.5" + "@eslint/object-schema" "^2.1.7" debug "^4.3.1" - minimatch "^3.1.2" + minimatch "^3.1.5" -"@eslint/core@^0.9.0": - version "0.9.1" - resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.9.1.tgz#31763847308ef6b7084a4505573ac9402c51f9d1" - integrity sha512-GuUdqkyyzQI5RMIWkHhvTWLCyLo1jNK3vzkSyaExH5kHPDHcuL2VOpHjmMY+y3+NC69qAKToBqldTBgYeLSr9Q== +"@eslint/config-helpers@^0.4.2": + version "0.4.2" + resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.4.2.tgz#1bd006ceeb7e2e55b2b773ab318d300e1a66aeda" + integrity sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw== + dependencies: + "@eslint/core" "^0.17.0" + +"@eslint/core@^0.17.0": + version "0.17.0" + resolved "https://registry.yarnpkg.com/@eslint/core/-/core-0.17.0.tgz#77225820413d9617509da9342190a2019e78761c" + integrity sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ== dependencies: "@types/json-schema" "^7.0.15" -"@eslint/eslintrc@^3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.2.0.tgz#57470ac4e2e283a6bf76044d63281196e370542c" - integrity sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w== +"@eslint/eslintrc@^3.3.5": + version "3.3.5" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60" + integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg== dependencies: - ajv "^6.12.4" + ajv "^6.14.0" debug "^4.3.2" espree "^10.0.1" globals "^14.0.0" ignore "^5.2.0" import-fresh "^3.2.1" - js-yaml "^4.1.0" - minimatch "^3.1.2" + js-yaml "^4.1.1" + minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@9.17.0": - version "9.17.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.17.0.tgz#1523e586791f80376a6f8398a3964455ecc651ec" - integrity sha512-Sxc4hqcs1kTu0iID3kcZDW3JHq2a77HO9P8CP6YEA/FpH3Ll8UXE2r/86Rz9YJLKme39S9vU5OWNjC6Xl0Cr3w== +"@eslint/js@9.39.4": + version "9.39.4" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.4.tgz#a3f83bfc6fd9bf33a853dfacd0b49b398eb596c1" + integrity sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw== -"@eslint/object-schema@^2.1.5": - version "2.1.5" - resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.5.tgz#8670a8f6258a2be5b2c620ff314a1d984c23eb2e" - integrity sha512-o0bhxnL89h5Bae5T318nFoFzGy+YE5i/gGkoPAgkmTVdRKTiv3p8JHevPiPaMwoloKfEiiaHlawCqaZMqRm+XQ== +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.yarnpkg.com/@eslint/object-schema/-/object-schema-2.1.7.tgz#6e2126a1347e86a4dedf8706ec67ff8e107ebbad" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== -"@eslint/plugin-kit@^0.2.3": - version "0.2.4" - resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.2.4.tgz#2b78e7bb3755784bb13faa8932a1d994d6537792" - integrity sha512-zSkKow6H5Kdm0ZUQUB2kV5JIXqoG0+uH5YADhaEHswm664N9Db8dXSi0nMJpacpMf+MyyglF1vnZohpEg5yUtg== +"@eslint/plugin-kit@^0.4.1": + version "0.4.1" + resolved "https://registry.yarnpkg.com/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz#9779e3fd9b7ee33571a57435cf4335a1794a6cb2" + integrity sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA== dependencies: + "@eslint/core" "^0.17.0" levn "^0.4.1" "@flowjs/flow.js@^2.14.1": @@ -1574,24 +1585,29 @@ resolved "https://registry.yarnpkg.com/@flowjs/flow.js/-/flow.js-2.14.1.tgz#267d9f9d0958f32267ea5815c2a7cc09b9219304" integrity sha512-99DWlPnksOOS8uHfo+bhSjvs8d2MfLTB/22JBDC2ONwz/OCdP+gL/iiM4puMSTE2wH4A2/+J0eMc7pKwusXunw== -"@flowjs/ngx-flow@18.0.1": - version "18.0.1" - resolved "https://registry.yarnpkg.com/@flowjs/ngx-flow/-/ngx-flow-18.0.1.tgz#4b2dd805f406a9ace2a29865b94e0f6313c2c70c" - integrity sha512-FZNjEY3cJ7Nh0jKrRQECGT5rugyc3weXxG4QzvMWFDXnoij+WPkrXQsUFE5dcKy2y6k+NnutmBoVuWkTwR7wgg== +"@flowjs/ngx-flow@20.0.2": + version "20.0.2" + resolved "https://registry.yarnpkg.com/@flowjs/ngx-flow/-/ngx-flow-20.0.2.tgz#571f4171213ef218128aa2c1795848c07392f860" + integrity sha512-zIIuH2/GDY5dN8kb0+P1FtM1ZfNGp8SB/f3hLseMLe5A55PpVyc0qb95fwnD+Uoh8lr/pKk6vtrLleuGoctL1Q== dependencies: tslib "^2.3.0" -"@geoman-io/leaflet-geoman-free@2.18.3": - version "2.18.3" - resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.18.3.tgz#a41489920b175931fba2a1e8e81347f9e3be5481" - integrity sha512-XzxSKRk2UJUVeGiOt1jU2hyo412Qee1Q0Xsfw4A2r8EoUIo48XKSWfusYe7E53fSPr0aYgZxPevnFdcUXimpdA== +"@geoman-io/leaflet-geoman-free@2.19.3": + version "2.19.3" + resolved "https://registry.yarnpkg.com/@geoman-io/leaflet-geoman-free/-/leaflet-geoman-free-2.19.3.tgz#42ea4d10be3a76c64376cdd8736b191dd0c5ae2d" + integrity sha512-HjbEpfAEUs0NyI1Dhvz3SMVG6m0pAN/1Eo0tRKsz9cpaROTrFtmJGY22swEir1Uj/8IeGF1NJId38C5Fu+nZGQ== dependencies: - "@turf/boolean-contains" "^6.5.0" - "@turf/kinks" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-split" "^6.5.0" - lodash "4.17.21" - polyclip-ts "^0.16.5" + "@turf/boolean-contains" "^7.3.3" + "@turf/kinks" "^7.3.3" + "@turf/line-intersect" "^7.3.3" + "@turf/line-split" "^7.3.3" + lodash "4.18.1" + polyclip-ts "^0.16.8" + +"@hono/node-server@^1.19.9": + version "1.19.11" + resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.11.tgz#dc419f0826dd2504e9fc86ad289d5636a0444e2f" + integrity sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g== "@humanfs/core@^0.19.1": version "0.19.1" @@ -1616,186 +1632,182 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.3.1.tgz#c72a5c76a9fbaf3488e231b13dc52c0da7bab42a" integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== -"@humanwhocodes/retry@^0.4.1": - version "0.4.1" - resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.1.tgz#9a96ce501bc62df46c4031fbd970e3cc6b10f07b" - integrity sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA== +"@humanwhocodes/retry@^0.4.2": + version "0.4.3" + resolved "https://registry.yarnpkg.com/@humanwhocodes/retry/-/retry-0.4.3.tgz#c2b9d2e374ee62c586d3adbea87199b1d7a7a6ba" + integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== "@iconify/types@^2.0.0": version "2.0.0" resolved "https://registry.yarnpkg.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== -"@iconify/utils@^2.1.32": - version "2.1.33" - resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-2.1.33.tgz#cbf7242a52fd0ec58c42d37d28e4406b5327e8c0" - integrity sha512-jP9h6v/g0BIZx0p7XGJJVtkVnydtbgTgt9mVNcGDYwaa7UhdHdI9dvoq+gKj9sijMSJKxUPEG2JyjsgXjxL7Kw== +"@iconify/utils@^3.0.2": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@iconify/utils/-/utils-3.1.0.tgz#fb41882915f97fee6f91a2fbb8263e8772ca0438" + integrity sha512-Zlzem1ZXhI1iHeeERabLNzBHdOa4VhQbqAcOQaMKuTuyZCpwKbC2R4Dd0Zo3g9EAc+Y4fiarO8HIHRAth7+skw== dependencies: - "@antfu/install-pkg" "^0.4.0" - "@antfu/utils" "^0.7.10" + "@antfu/install-pkg" "^1.1.0" "@iconify/types" "^2.0.0" - debug "^4.3.6" - kolorist "^1.8.0" - local-pkg "^0.5.0" - mlly "^1.7.1" - -"@inquirer/checkbox@^2.4.7": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-2.5.0.tgz#41c5c9dd332c0a8fa159be23982ce080d0b199d4" - integrity sha512-sMgdETOfi2dUHT8r7TT1BTKOwNvdDGFDXYWtQ2J69SvlYNntk9I/gJe7r5yvMwwsuKnYbuRs3pNhx4tgNck5aA== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/figures" "^1.0.5" - "@inquirer/type" "^1.5.3" - ansi-escapes "^4.3.2" - yoctocolors-cjs "^2.1.2" + mlly "^1.8.0" -"@inquirer/confirm@3.1.22": - version "3.1.22" - resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-3.1.22.tgz#23990624c11f60c6f7a5b0558c7505c35076a037" - integrity sha512-gsAKIOWBm2Q87CDfs9fEo7wJT3fwWIJfnDGMn9Qy74gBnNFOACDNfhUzovubbJjWnKLGBln7/NcSmZwj5DuEXg== - dependencies: - "@inquirer/core" "^9.0.10" - "@inquirer/type" "^1.5.2" +"@inquirer/ansi@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@inquirer/ansi/-/ansi-1.0.2.tgz#674a4c4d81ad460695cb2a1fc69d78cd187f337e" + integrity sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ== -"@inquirer/confirm@^3.1.22": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-3.2.0.tgz#6af1284670ea7c7d95e3f1253684cfbd7228ad6a" - integrity sha512-oOIwPs0Dvq5220Z8lGL/6LHRTEr9TgLHmiI99Rj1PJ1p1czTys+olrgBqZk4E2qC0YTzeHprxSQmoHioVdJ7Lw== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - -"@inquirer/core@^9.0.10", "@inquirer/core@^9.1.0": - version "9.2.1" - resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-9.2.1.tgz#677c49dee399c9063f31e0c93f0f37bddc67add1" - integrity sha512-F2VBt7W/mwqEU4bL0RnHNZmC/OxzNx9cOYxHqnXX3MP6ruYvZUZAW9imgN9+h/uBT/oP8Gh888J2OZSbjSeWcg== - dependencies: - "@inquirer/figures" "^1.0.6" - "@inquirer/type" "^2.0.0" - "@types/mute-stream" "^0.0.4" - "@types/node" "^22.5.5" - "@types/wrap-ansi" "^3.0.0" - ansi-escapes "^4.3.2" +"@inquirer/checkbox@^4.2.1": + version "4.3.2" + resolved "https://registry.yarnpkg.com/@inquirer/checkbox/-/checkbox-4.3.2.tgz#e1483e6519d6ffef97281a54d2a5baa0d81b3f3b" + integrity sha512-VXukHf0RR1doGe6Sm4F0Em7SWYLTHSsbGfJdS9Ja2bX5/D5uwVOEjr07cncLROdBvmnvCATYEWlHqYmXv2IlQA== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/confirm@5.1.14": + version "5.1.14" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.14.tgz#e6321edf51a3a5f54dc548b80ef6ba89891351ad" + integrity sha512-5yR4IBfe0kXe59r1YCTG8WXkUbl7Z35HK87Sw+WUyGD8wNUx7JvY7laahzeytyE1oLn74bQnL7hstctQxisQ8Q== + dependencies: + "@inquirer/core" "^10.1.15" + "@inquirer/type" "^3.0.8" + +"@inquirer/confirm@^5.1.14": + version "5.1.21" + resolved "https://registry.yarnpkg.com/@inquirer/confirm/-/confirm-5.1.21.tgz#610c4acd7797d94890a6e2dde2c98eb1e891dd12" + integrity sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/core@^10.1.15", "@inquirer/core@^10.3.2": + version "10.3.2" + resolved "https://registry.yarnpkg.com/@inquirer/core/-/core-10.3.2.tgz#535979ff3ff4fe1e7cc4f83e2320504c743b7e20" + integrity sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" cli-width "^4.1.0" - mute-stream "^1.0.0" + mute-stream "^2.0.0" signal-exit "^4.1.0" - strip-ansi "^6.0.1" wrap-ansi "^6.2.0" - yoctocolors-cjs "^2.1.2" - -"@inquirer/editor@^2.1.22": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-2.2.0.tgz#a41eb7b151bd9a6bc3c0b69219d02d82547bc387" - integrity sha512-9KHOpJ+dIL5SZli8lJ6xdaYLPPzB8xB9GZItg39MBybzhxA16vxmszmQFrRwbOA918WA2rvu8xhDEg/p6LXKbw== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - external-editor "^3.1.0" - -"@inquirer/expand@^2.1.22": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-2.3.0.tgz#afc44aee303315a85563e9d0275e658f0ee0e701" - integrity sha512-qnJsUcOGCSG1e5DTOErmv2BPQqrtT6uzqn1vI/aYGiPKq+FgslGZmtdnXbhuI7IlT7OByDoEEqdnhUnVR2hhLw== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - yoctocolors-cjs "^2.1.2" + yoctocolors-cjs "^2.1.3" -"@inquirer/figures@^1.0.5", "@inquirer/figures@^1.0.6": - version "1.0.7" - resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.7.tgz#d050ccc0eabfacc0248c4ff647a9dfba1b01594b" - integrity sha512-m+Trk77mp54Zma6xLkLuY+mvanPxlE4A7yNKs2HBiyZ4UkVs28Mv5c/pgWrHeInx+USHeX/WEPzjrWrcJiQgjw== - -"@inquirer/input@^2.2.9": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-2.3.0.tgz#9b99022f53780fecc842908f3f319b52a5a16865" - integrity sha512-XfnpCStx2xgh1LIRqPXrTNEEByqQWoxsWYzNRSEUxJ5c6EQlhMogJ3vHKu8aXuTacebtaZzMAHwEL0kAflKOBw== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - -"@inquirer/number@^1.0.10": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-1.1.0.tgz#4dac004021ea67c89552a261564f103a494cac96" - integrity sha512-ilUnia/GZUtfSZy3YEErXLJ2Sljo/mf9fiKc08n18DdwdmDbOzRcTv65H1jjDvlsAuvdFXf4Sa/aL7iw/NanVA== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - -"@inquirer/password@^2.1.22": - version "2.2.0" - resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-2.2.0.tgz#0b6f26336c259c8a9e5f5a3f2e1a761564f764ba" - integrity sha512-5otqIpgsPYIshqhgtEwSspBQE40etouR8VIxzpJkv9i0dVHIpyhiivbkH9/dGiMLdyamT54YRdGJLfl8TFnLHg== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - ansi-escapes "^4.3.2" - -"@inquirer/prompts@5.3.8": - version "5.3.8" - resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-5.3.8.tgz#f394050d95076c2f1b046be324f06f619b257c3e" - integrity sha512-b2BudQY/Si4Y2a0PdZZL6BeJtl8llgeZa7U2j47aaJSCeAl1e4UI7y8a9bSkO3o/ZbZrgT5muy/34JbsjfIWxA== - dependencies: - "@inquirer/checkbox" "^2.4.7" - "@inquirer/confirm" "^3.1.22" - "@inquirer/editor" "^2.1.22" - "@inquirer/expand" "^2.1.22" - "@inquirer/input" "^2.2.9" - "@inquirer/number" "^1.0.10" - "@inquirer/password" "^2.1.22" - "@inquirer/rawlist" "^2.2.4" - "@inquirer/search" "^1.0.7" - "@inquirer/select" "^2.4.7" - -"@inquirer/rawlist@^2.2.4": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-2.3.0.tgz#6b2c0da39c1cd855af5608b2d627681cdac7277d" - integrity sha512-zzfNuINhFF7OLAtGHfhwOW2TlYJyli7lOUoJUXw/uyklcwalV6WRXBXtFIicN8rTRK1XTiPWB4UY+YuW8dsnLQ== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/type" "^1.5.3" - yoctocolors-cjs "^2.1.2" - -"@inquirer/search@^1.0.7": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-1.1.0.tgz#665928cac2326b9501ddafbb8606ce4823b3106b" - integrity sha512-h+/5LSj51dx7hp5xOn4QFnUaKeARwUCLs6mIhtkJ0JYPBLmEYjdHSYh7I6GrLg9LwpJ3xeX0FZgAG1q0QdCpVQ== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/figures" "^1.0.5" - "@inquirer/type" "^1.5.3" - yoctocolors-cjs "^2.1.2" - -"@inquirer/select@^2.4.7": - version "2.5.0" - resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-2.5.0.tgz#345c6908ecfaeef3d84ddd2f9feb2f487c558efb" - integrity sha512-YmDobTItPP3WcEI86GvPo+T2sRHkxxOq/kXmsBjHS5BVXUgvgZ5AfJjkvQvZr03T81NnI3KrrRuMzeuYUQRFOA== - dependencies: - "@inquirer/core" "^9.1.0" - "@inquirer/figures" "^1.0.5" - "@inquirer/type" "^1.5.3" - ansi-escapes "^4.3.2" - yoctocolors-cjs "^2.1.2" - -"@inquirer/type@^1.5.1", "@inquirer/type@^1.5.2", "@inquirer/type@^1.5.3": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-1.5.5.tgz#303ea04ce7ad2e585b921b662b3be36ef7b4f09b" - integrity sha512-MzICLu4yS7V8AA61sANROZ9vT1H3ooca5dSmI1FjZkzq7o/koMsRfQSzRtFo+F3Ao4Sf1C0bpLKejpKB/+j6MA== +"@inquirer/editor@^4.2.17": + version "4.2.23" + resolved "https://registry.yarnpkg.com/@inquirer/editor/-/editor-4.2.23.tgz#fe046a3bfdae931262de98c1052437d794322e0b" + integrity sha512-aLSROkEwirotxZ1pBaP8tugXRFCxW94gwrQLxXfrZsKkfjOYC1aRvAZuhpJOb5cu4IBTJdsCigUlf2iCOu4ZDQ== dependencies: - mute-stream "^1.0.0" + "@inquirer/core" "^10.3.2" + "@inquirer/external-editor" "^1.0.3" + "@inquirer/type" "^3.0.10" -"@inquirer/type@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-2.0.0.tgz#08fa513dca2cb6264fe1b0a2fabade051444e3f6" - integrity sha512-XvJRx+2KR3YXyYtPUUy+qd9i7p+GO9Ko6VIIpWlBrpWwXDv8WLFeHTxz35CfQFUiBMLXlGHhGzys7lqit9gWag== +"@inquirer/expand@^4.0.17": + version "4.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/expand/-/expand-4.0.23.tgz#a38b5f32226d75717c370bdfed792313b92bdc05" + integrity sha512-nRzdOyFYnpeYTTR2qFwEVmIWypzdAx/sIkCMeTNTcflFOovfqUk+HcFhQQVBftAh9gmGrpFj6QcGEqrDMDOiew== dependencies: - mute-stream "^1.0.0" + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" -"@iplab/ngx-color-picker@^18.0.1": - version "18.0.1" - resolved "https://registry.yarnpkg.com/@iplab/ngx-color-picker/-/ngx-color-picker-18.0.1.tgz#d3e53f548a618ed6d8eda95c0887d0c742ae8bd3" - integrity sha512-FVRN75gsETJS11I4wd6vRZIHHYVV729EbUcb1Uohco3gmKJ461GW0EiLnpUfODcNDHG30A7Xwf++HkU3coIadg== +"@inquirer/external-editor@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@inquirer/external-editor/-/external-editor-1.0.3.tgz#c23988291ee676290fdab3fd306e64010a6d13b8" + integrity sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA== + dependencies: + chardet "^2.1.1" + iconv-lite "^0.7.0" + +"@inquirer/figures@^1.0.15": + version "1.0.15" + resolved "https://registry.yarnpkg.com/@inquirer/figures/-/figures-1.0.15.tgz#dbb49ed80df11df74268023b496ac5d9acd22b3a" + integrity sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g== + +"@inquirer/input@^4.2.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@inquirer/input/-/input-4.3.1.tgz#778683b4c4c4d95d05d4b05c4a854964b73565b4" + integrity sha512-kN0pAM4yPrLjJ1XJBjDxyfDduXOuQHrBB8aLDMueuwUGn+vNpF7Gq7TvyVxx8u4SHlFFj4trmj+a2cbpG4Jn1g== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/number@^3.0.17": + version "3.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/number/-/number-3.0.23.tgz#3fdec2540d642093fd7526818fd8d4bdc7335094" + integrity sha512-5Smv0OK7K0KUzUfYUXDXQc9jrf8OHo4ktlEayFlelCjwMXz0299Y8OrI+lj7i4gCBY15UObk76q0QtxjzFcFcg== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/password@^4.0.17": + version "4.0.23" + resolved "https://registry.yarnpkg.com/@inquirer/password/-/password-4.0.23.tgz#b9f5187c8c92fd7aa9eceb9d8f2ead0d7e7b000d" + integrity sha512-zREJHjhT5vJBMZX/IUbyI9zVtVfOLiTO66MrF/3GFZYZ7T4YILW5MSkEYHceSii/KtRk+4i3RE7E1CUXA2jHcA== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + +"@inquirer/prompts@7.8.2": + version "7.8.2" + resolved "https://registry.yarnpkg.com/@inquirer/prompts/-/prompts-7.8.2.tgz#5d9d8d7273831bd512e9cfaf3d827cce0f2eae0f" + integrity sha512-nqhDw2ZcAUrKNPwhjinJny903bRhI0rQhiDz1LksjeRxqa36i3l75+4iXbOy0rlDpLJGxqtgoPavQjmmyS5UJw== + dependencies: + "@inquirer/checkbox" "^4.2.1" + "@inquirer/confirm" "^5.1.14" + "@inquirer/editor" "^4.2.17" + "@inquirer/expand" "^4.0.17" + "@inquirer/input" "^4.2.1" + "@inquirer/number" "^3.0.17" + "@inquirer/password" "^4.0.17" + "@inquirer/rawlist" "^4.1.5" + "@inquirer/search" "^3.1.0" + "@inquirer/select" "^4.3.1" + +"@inquirer/rawlist@^4.1.5": + version "4.1.11" + resolved "https://registry.yarnpkg.com/@inquirer/rawlist/-/rawlist-4.1.11.tgz#313c8c3ffccb7d41e990c606465726b4a898a033" + integrity sha512-+LLQB8XGr3I5LZN/GuAHo+GpDJegQwuPARLChlMICNdwW7OwV2izlCSCxN6cqpL0sMXmbKbFcItJgdQq5EBXTw== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/search@^3.1.0": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@inquirer/search/-/search-3.2.2.tgz#4cc6fd574dcd434e4399badc37c742c3fd534ac8" + integrity sha512-p2bvRfENXCZdWF/U2BXvnSI9h+tuA8iNqtUKb9UWbmLYCRQxd8WkvwWvYn+3NgYaNwdUkHytJMGG4MMLucI1kA== + dependencies: + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/select@^4.3.1": + version "4.4.2" + resolved "https://registry.yarnpkg.com/@inquirer/select/-/select-4.4.2.tgz#2ac8fca960913f18f1d1b35323ed8fcd27d89323" + integrity sha512-l4xMuJo55MAe+N7Qr4rX90vypFwCajSakx59qe/tMaC1aEHWLyw68wF4o0A4SLAY4E0nd+Vt+EyskeDIqu1M6w== + dependencies: + "@inquirer/ansi" "^1.0.2" + "@inquirer/core" "^10.3.2" + "@inquirer/figures" "^1.0.15" + "@inquirer/type" "^3.0.10" + yoctocolors-cjs "^2.1.3" + +"@inquirer/type@^3.0.10", "@inquirer/type@^3.0.7", "@inquirer/type@^3.0.8": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@inquirer/type/-/type-3.0.10.tgz#11ed564ec78432a200ea2601a212d24af8150d50" + integrity sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA== + +"@iplab/ngx-color-picker@^20.0.0": + version "20.0.0" + resolved "https://registry.yarnpkg.com/@iplab/ngx-color-picker/-/ngx-color-picker-20.0.0.tgz#55ce7edc2f16b6f4a8d589e3c190f9200b139535" + integrity sha512-nxPMHl+MZgH7HHLMi8lC+Se+K5EGKpAvmc7Oub191h9VvQj+C+cIFdxJ/KfWET3NFiff6DTbpgfgskjck32tzA== dependencies: tslib "^2.3.0" @@ -1811,6 +1823,13 @@ wrap-ansi "^8.1.0" wrap-ansi-cjs "npm:wrap-ansi@^7.0.0" +"@isaacs/fs-minipass@^4.0.0": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz#2d59ae3ab4b38fb4270bfa23d30f8e2e86c7fe32" + integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== + dependencies: + minipass "^7.0.4" + "@istanbuljs/schema@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@istanbuljs/schema/-/schema-0.1.3.tgz#e45e384e4b8ec16bce2fd903af78450f6bf7ec98" @@ -1821,13 +1840,12 @@ resolved "https://registry.yarnpkg.com/@jcubic/lily/-/lily-0.3.0.tgz#00b229aab69fe094a57fd37f27d32dd4f380c1d1" integrity sha512-4z6p4jLGSthc8gQ7wu4nHfGYn/IgCKFr+7hjuf80VdXUs7sm029mZGGDpS8sb29PVZWUBvMMTBCVGFhH2nN4Vw== -"@jridgewell/gen-mapping@^0.3.2", "@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== +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.2", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz#6342a19f44347518c93e43b1ac69deb3c4656a1f" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/sourcemap-codec" "^1.5.0" "@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/resolve-uri@^3.0.3", "@jridgewell/resolve-uri@^3.1.0": @@ -1835,11 +1853,6 @@ 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/source-map@^0.3.3": version "0.3.6" resolved "https://registry.yarnpkg.com/@jridgewell/source-map/-/source-map-0.3.6.tgz#9d71ca886e32502eb9362c9a74a46787c36df81a" @@ -1849,9 +1862,9 @@ "@jridgewell/trace-mapping" "^0.3.25" "@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": - version "1.5.0" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + version "1.5.5" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== "@jridgewell/trace-mapping@0.3.9": version "0.3.9" @@ -1861,75 +1874,220 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" -"@jridgewell/trace-mapping@^0.3.20", "@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== +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz#db15d6781c931f3a251a3dac39501c98a6082fd0" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" -"@jsonjoy.com/base64@^1.1.1": +"@jsonjoy.com/base64@17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-17.65.0.tgz#ba3b023c69ab311e5b706289414a44ee46117824" + integrity sha512-Xrh7Fm/M0QAYpekSgmskdZYnFdSGnsxJ/tHaolA4bNwWdG9i65S8m83Meh7FOxyJyQAdo4d4J97NOomBLEfkDQ== + +"@jsonjoy.com/base64@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@jsonjoy.com/base64/-/base64-1.1.2.tgz#cf8ea9dcb849b81c95f14fc0aaa151c6b54d2578" integrity sha512-q6XAnWQDIMA3+FTiOYajoYqySkO+JSat0ytXGSuRdq9uXE7o92gzuQwQM14xaCRlBLGq3v5miDGC4vkVTn54xA== -"@jsonjoy.com/json-pack@^1.0.3": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.1.0.tgz#33ca57ee29d12feef540f2139225597469dec894" - integrity sha512-zlQONA+msXPPwHWZMKFVS78ewFczIll5lXiVPwFPCZUsrOKdxc2AvxU1HoNBmMRhqDZUR9HkC3UOm+6pME6Xsg== - dependencies: - "@jsonjoy.com/base64" "^1.1.1" - "@jsonjoy.com/util" "^1.1.2" - hyperdyperid "^1.2.0" - thingies "^1.20.0" +"@jsonjoy.com/buffers@17.65.0", "@jsonjoy.com/buffers@^17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-17.65.0.tgz#d6890737d9cbc49c17e2c5d1a2d796c57205152c" + integrity sha512-eBrIXd0/Ld3p9lpDDlMaMn6IEfWqtHMD+z61u0JrIiPzsV1r7m6xDZFRxJyvIFTEO+SWdYF9EiQbXZGd8BzPfA== -"@jsonjoy.com/util@^1.1.2", "@jsonjoy.com/util@^1.3.0": - version "1.3.0" - resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.3.0.tgz#e5623885bb5e0c48c1151e4dae422fb03a5887a1" - integrity sha512-Cebt4Vk7k1xHy87kHY7KSPLT77A7Ev7IfOblyLZhtYEhrdQ6fX4EoLq3xOQ3O/DRMEh2ok5nyC180E+ABS8Wmw== +"@jsonjoy.com/buffers@^1.0.0", "@jsonjoy.com/buffers@^1.2.0": + version "1.2.1" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/buffers/-/buffers-1.2.1.tgz#8d99c7f67eaf724d3428dfd9826c6455266a5c83" + integrity sha512-12cdlDwX4RUM3QxmUbVJWqZ/mrK6dFQH4Zxq6+r1YXKXYBNgZXndx2qbCJwh3+WWkCSn67IjnlG3XYTvmvYtgA== -"@leichtgewicht/ip-codec@^2.0.1": - version "2.0.5" - resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" - integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== +"@jsonjoy.com/codegen@17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-17.65.0.tgz#531524f37fd3e1d1189de18fef346e998eee8952" + integrity sha512-7MXcRYe7n3BG+fo3jicvjB0+6ypl2Y/bQp79Sp7KeSiiCgLqw4Oled6chVv07/xLVTdo3qa1CD0VCCnPaw+RGA== -"@listr2/prompt-adapter-inquirer@2.0.15": - version "2.0.15" - resolved "https://registry.yarnpkg.com/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.15.tgz#45f13178b13327a28a220057a34a886cab18218e" - integrity sha512-MZrGem/Ujjd4cPTLYDfCZK2iKKeiO/8OX13S6jqxldLs0Prf2aGqVlJ77nMBqMv7fzqgXEgjrNHLXcKR8l9lOg== - dependencies: - "@inquirer/type" "^1.5.1" +"@jsonjoy.com/codegen@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/codegen/-/codegen-1.0.0.tgz#5c23f796c47675f166d23b948cdb889184b93207" + integrity sha512-E8Oy+08cmCf0EK/NMxpaJZmOxPqM+6iSe2S4nlSBrPZOORoDJILxtbSUEDKQyTamm/BVAhIGllOBNU79/dwf0g== + +"@jsonjoy.com/fs-core@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-core/-/fs-core-4.56.10.tgz#320728b4b7bef63abb60e7630351623899237411" + integrity sha512-PyAEA/3cnHhsGcdY+AmIU+ZPqTuZkDhCXQ2wkXypdLitSpd6d5Ivxhnq4wa2ETRWFVJGabYynBWxIijOswSmOw== + dependencies: + "@jsonjoy.com/fs-node-builtins" "4.56.10" + "@jsonjoy.com/fs-node-utils" "4.56.10" + thingies "^2.5.0" + +"@jsonjoy.com/fs-fsa@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-fsa/-/fs-fsa-4.56.10.tgz#02bac88c4968ddf2effbd7452861aaed60ba3557" + integrity sha512-/FVK63ysNzTPOnCCcPoPHt77TOmachdMS422txM4KhxddLdbW1fIbFMYH0AM0ow/YchCyS5gqEjKLNyv71j/5Q== + dependencies: + "@jsonjoy.com/fs-core" "4.56.10" + "@jsonjoy.com/fs-node-builtins" "4.56.10" + "@jsonjoy.com/fs-node-utils" "4.56.10" + thingies "^2.5.0" + +"@jsonjoy.com/fs-node-builtins@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-node-builtins/-/fs-node-builtins-4.56.10.tgz#a32a5bcb093f8b34a99aa8957e993a52ec316662" + integrity sha512-uUnKz8R0YJyKq5jXpZtkGV9U0pJDt8hmYcLRrPjROheIfjMXsz82kXMgAA/qNg0wrZ1Kv+hrg7azqEZx6XZCVw== + +"@jsonjoy.com/fs-node-to-fsa@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-node-to-fsa/-/fs-node-to-fsa-4.56.10.tgz#33fc503e50d283ac5fc510e3accced7fccecf2f4" + integrity sha512-oH+O6Y4lhn9NyG6aEoFwIBNKZeYy66toP5LJcDOMBgL99BKQMUf/zWJspdRhMdn/3hbzQsZ8EHHsuekbFLGUWw== + dependencies: + "@jsonjoy.com/fs-fsa" "4.56.10" + "@jsonjoy.com/fs-node-builtins" "4.56.10" + "@jsonjoy.com/fs-node-utils" "4.56.10" + +"@jsonjoy.com/fs-node-utils@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-node-utils/-/fs-node-utils-4.56.10.tgz#788e95052aa99744f6e8e55b5098afc203df2b9e" + integrity sha512-8EuPBgVI2aDPwFdaNQeNpHsyqPi3rr+85tMNG/lHvQLiVjzoZsvxA//Xd8aB567LUhy4QS03ptT+unkD/DIsNg== + dependencies: + "@jsonjoy.com/fs-node-builtins" "4.56.10" + +"@jsonjoy.com/fs-node@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-node/-/fs-node-4.56.10.tgz#70b18bfaf14544a9820d2016e913dde12c6de991" + integrity sha512-7R4Gv3tkUdW3dXfXiOkqxkElxKNVdd8BDOWC0/dbERd0pXpPY+s2s1Mino+aTvkGrFPiY+mmVxA7zhskm4Ue4Q== + dependencies: + "@jsonjoy.com/fs-core" "4.56.10" + "@jsonjoy.com/fs-node-builtins" "4.56.10" + "@jsonjoy.com/fs-node-utils" "4.56.10" + "@jsonjoy.com/fs-print" "4.56.10" + "@jsonjoy.com/fs-snapshot" "4.56.10" + glob-to-regex.js "^1.0.0" + thingies "^2.5.0" + +"@jsonjoy.com/fs-print@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-print/-/fs-print-4.56.10.tgz#7c181b9aefcc1b268be0e6233bff26310c355335" + integrity sha512-JW4fp5mAYepzFsSGrQ48ep8FXxpg4niFWHdF78wDrFGof7F3tKDJln72QFDEn/27M1yHd4v7sKHHVPh78aWcEw== + dependencies: + "@jsonjoy.com/fs-node-utils" "4.56.10" + tree-dump "^1.1.0" + +"@jsonjoy.com/fs-snapshot@4.56.10": + version "4.56.10" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/fs-snapshot/-/fs-snapshot-4.56.10.tgz#05aadd2c0eaa855b13d6cb17d29b7c8cee239c8c" + integrity sha512-DkR6l5fj7+qj0+fVKm/OOXMGfDFCGXLfyHkORH3DF8hxkpDgIHbhf/DwncBMs2igu/ST7OEkexn1gIqoU6Y+9g== + dependencies: + "@jsonjoy.com/buffers" "^17.65.0" + "@jsonjoy.com/fs-node-utils" "4.56.10" + "@jsonjoy.com/json-pack" "^17.65.0" + "@jsonjoy.com/util" "^17.65.0" + +"@jsonjoy.com/json-pack@^1.11.0": + version "1.21.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-1.21.0.tgz#93f8dd57fe3a3a92132b33d1eb182dcd9e7629fa" + integrity sha512-+AKG+R2cfZMShzrF2uQw34v3zbeDYUqnQ+jg7ORic3BGtfw9p/+N6RJbq/kkV8JmYZaINknaEQ2m0/f693ZPpg== + dependencies: + "@jsonjoy.com/base64" "^1.1.2" + "@jsonjoy.com/buffers" "^1.2.0" + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/json-pointer" "^1.0.2" + "@jsonjoy.com/util" "^1.9.0" + hyperdyperid "^1.2.0" + thingies "^2.5.0" + tree-dump "^1.1.0" + +"@jsonjoy.com/json-pack@^17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pack/-/json-pack-17.65.0.tgz#4ea06dd0aee1c29954bd978c4f107401dbf713fb" + integrity sha512-e0SG/6qUCnVhHa0rjDJHgnXnbsacooHVqQHxspjvlYQSkHm+66wkHw6Gql+3u/WxI/b1VsOdUi0M+fOtkgKGdQ== + dependencies: + "@jsonjoy.com/base64" "17.65.0" + "@jsonjoy.com/buffers" "17.65.0" + "@jsonjoy.com/codegen" "17.65.0" + "@jsonjoy.com/json-pointer" "17.65.0" + "@jsonjoy.com/util" "17.65.0" + hyperdyperid "^1.2.0" + thingies "^2.5.0" + tree-dump "^1.1.0" -"@lmdb/lmdb-darwin-arm64@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.0.13.tgz#b09af72bde5a9933abfab34dc5c10e5b6c16d4a5" - integrity sha512-uiKPB0Fv6WEEOZjruu9a6wnW/8jrjzlZbxXscMB8kuCJ1k6kHpcBnuvaAWcqhbI7rqX5GKziwWEdD+wi2gNLfA== +"@jsonjoy.com/json-pointer@17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-17.65.0.tgz#4bad42d86c9ee0ad1758c082b065bd5e16f8dc36" + integrity sha512-uhTe+XhlIZpWOxgPcnO+iSCDgKKBpwkDVTyYiXX9VayGV8HSFVJM67M6pUE71zdnXF1W0Da21AvnhlmdwYPpow== + dependencies: + "@jsonjoy.com/util" "17.65.0" -"@lmdb/lmdb-darwin-x64@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.0.13.tgz#bc27aadf475954254b787ef609a9f598adc8ac6b" - integrity sha512-bEVIIfK5mSQoG1R19qA+fJOvCB+0wVGGnXHT3smchBVahYBdlPn2OsZZKzlHWfb1E+PhLBmYfqB5zQXFP7hJig== +"@jsonjoy.com/json-pointer@^1.0.2": + version "1.0.2" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/json-pointer/-/json-pointer-1.0.2.tgz#049cb530ac24e84cba08590c5e36b431c4843408" + integrity sha512-Fsn6wM2zlDzY1U+v4Nc8bo3bVqgfNTGcn6dMgs6FjrEnt4ZCe60o6ByKRjOGlI2gow0aE/Q41QOigdTqkyK5fg== + dependencies: + "@jsonjoy.com/codegen" "^1.0.0" + "@jsonjoy.com/util" "^1.9.0" -"@lmdb/lmdb-linux-arm64@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.0.13.tgz#fd49c447e1e8304fc4101bb7e6b641f08d69ac28" - integrity sha512-afbVrsMgZ9dUTNUchFpj5VkmJRxvht/u335jUJ7o23YTbNbnpmXif3VKQGCtnjSh+CZaqm6N3CPG8KO3zwyZ1Q== +"@jsonjoy.com/util@17.65.0", "@jsonjoy.com/util@^17.65.0": + version "17.65.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-17.65.0.tgz#b27832bdf7aeaf4a36f9cb8721cb4ffb086f06a1" + integrity sha512-cWiEHZccQORf96q2y6zU3wDeIVPeidmGqd9cNKJRYoVHTV0S1eHPy5JTbHpMnGfDvtvujQwQozOqgO9ABu6h0w== + dependencies: + "@jsonjoy.com/buffers" "17.65.0" + "@jsonjoy.com/codegen" "17.65.0" -"@lmdb/lmdb-linux-arm@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.0.13.tgz#14ebce383cbc9d51cbe12581dfec3ef8ffc6b685" - integrity sha512-Yml1KlMzOnXj/tnW7yX8U78iAzTk39aILYvCPbqeewAq1kSzl+w59k/fiVkTBfvDi/oW/5YRxL+Fq+Y1Fr1r2Q== +"@jsonjoy.com/util@^1.9.0": + version "1.9.0" + resolved "https://registry.yarnpkg.com/@jsonjoy.com/util/-/util-1.9.0.tgz#7ee95586aed0a766b746cd8d8363e336c3c47c46" + integrity sha512-pLuQo+VPRnN8hfPqUTLTHk126wuYdXVxE6aDmjSeV4NCAgyxWbiOIeNJVtID3h1Vzpoi9m4jXezf73I6LgabgQ== + dependencies: + "@jsonjoy.com/buffers" "^1.0.0" + "@jsonjoy.com/codegen" "^1.0.0" -"@lmdb/lmdb-linux-x64@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.0.13.tgz#49b9bfcd52df55ccc9e466cf27c0651e5434b320" - integrity sha512-vOtxu0xC0SLdQ2WRXg8Qgd8T32ak4SPqk5zjItRszrJk2BdeXqfGxBJbP7o4aOvSPSmSSv46Lr1EP4HXU8v7Kg== +"@leichtgewicht/ip-codec@^2.0.1": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.5.tgz#4fc56c15c580b9adb7dc3c333a134e540b44bfb1" + integrity sha512-Vo+PSpZG2/fmgmiNzYK9qWRh8h/CHrwD0mo1h1DzL4yzHNSfWYujGTYsWGreD000gcgmZ7K4Ys6Tx9TxtsKdDw== -"@lmdb/lmdb-win32-x64@3.0.13": - version "3.0.13" - resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.0.13.tgz#93bcd6dc24afd1cc60dd88a65b9e4fab32dcf397" - integrity sha512-UCrMJQY/gJnOl3XgbWRZZUvGGBuKy6i0YNSptgMzHBjs+QYDYR1Mt/RLTOPy4fzzves65O1EDmlL//OzEqoLlA== +"@listr2/prompt-adapter-inquirer@3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-3.0.1.tgz#7e081b4dcde2f1bd5ac16c620ee2402908f03fe7" + integrity sha512-3XFmGwm3u6ioREG+ynAQB7FoxfajgQnMhIu8wC5eo/Lsih4aKDg0VuIMGaOsYn7hJSJagSeaD4K8yfpkEoDEmA== + dependencies: + "@inquirer/type" "^3.0.7" + +"@lmdb/lmdb-darwin-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.4.2.tgz#316848b41e26cf0ddb379fad193d49052f09d7df" + integrity sha512-NK80WwDoODyPaSazKbzd3NEJ3ygePrkERilZshxBViBARNz21rmediktGHExoj9n5t9+ChlgLlxecdFKLCuCKg== + +"@lmdb/lmdb-darwin-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-darwin-x64/-/lmdb-darwin-x64-3.4.2.tgz#c2048ad9d4c4c23e420f8fb2f3c418f713715cb7" + integrity sha512-zevaowQNmrp3U7Fz1s9pls5aIgpKRsKb3dZWDINtLiozh3jZI9fBrI19lYYBxqdyiIyNdlyiidPnwPShj4aK+w== + +"@lmdb/lmdb-linux-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm64/-/lmdb-linux-arm64-3.4.2.tgz#382a820e422c189feb0b64ce6ab2ad379ba7b097" + integrity sha512-ZBEfbNZdkneebvZs98Lq30jMY8V9IJzckVeigGivV7nTHJc+89Ctomp1kAIWKlwIG0ovCDrFI448GzFPORANYg== + +"@lmdb/lmdb-linux-arm@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-arm/-/lmdb-linux-arm-3.4.2.tgz#a3dbb70e1a1e999d7c82ccff3d6c938872241acd" + integrity sha512-OmHCULY17rkx/RoCoXlzU7LyR8xqrksgdYWwtYa14l/sseezZ8seKWXcogHcjulBddER5NnEFV4L/Jtr2nyxeg== + +"@lmdb/lmdb-linux-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-linux-x64/-/lmdb-linux-x64-3.4.2.tgz#aa073eac33ede369d51afd0314b2e2af06a368e0" + integrity sha512-vL9nM17C77lohPYE4YaAQvfZCSVJSryE4fXdi8M7uWPBnU+9DJabgKVAeyDb84ZM2vcFseoBE4/AagVtJeRE7g== + +"@lmdb/lmdb-win32-arm64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-arm64/-/lmdb-win32-arm64-3.4.2.tgz#7e73500f0c81ca269a6ad9290fb1e7bf7137863d" + integrity sha512-SXWjdBfNDze4ZPeLtYIzsIeDJDJ/SdsA0pEXcUBayUIMO0FQBHfVZZyHXQjjHr4cvOAzANBgIiqaXRwfMhzmLw== + +"@lmdb/lmdb-win32-x64@3.4.2": + version "3.4.2" + resolved "https://registry.yarnpkg.com/@lmdb/lmdb-win32-x64/-/lmdb-win32-x64-3.4.2.tgz#99caf114bb74d39c9e1baca99b907353ad8bf461" + integrity sha512-IY+r3bxKW6Q6sIPiMC0L533DEfRJSXibjSI3Ft/w9Q8KQBNqEIvUFXt+09wV8S5BRk0a8uSF19YWxuRwEfI90g== "@mapbox/geojson-rewind@^0.5.2": version "0.5.2" @@ -1950,9 +2108,9 @@ integrity sha512-6j56HdLTwWGO0fJPlrZtdU/B13q8Uwmo18Ck2GnGgN9PCFyKTZ3UbXeEdRFh18i9XQ92eH2VdtpJHpBD3aripQ== "@mapbox/tiny-sdf@^2.0.6": - version "2.0.6" - resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-2.0.6.tgz#9a1d33e5018093e88f6a4df2343e886056287282" - integrity sha512-qMqa27TLw+ZQz5Jk+RcwZGH7BQf5G/TrutJhspsca/3SHwmgKQ1iq+d3Jxz5oysPVYTGP6aXxCo5Lk9Er6YBAA== + version "2.0.7" + resolved "https://registry.yarnpkg.com/@mapbox/tiny-sdf/-/tiny-sdf-2.0.7.tgz#0d67d65a43195003b282764f2297c619736bbc6e" + integrity sha512-25gQLQMcpivjOSA40g3gO6qgiFPDpWRoMfd+G/GoppPIeP6JDaMMkMrEJnMZhKyyS6iKwVt5YKu02vCUyJM3Ug== "@mapbox/unitbezier@^0.0.1": version "0.0.1" @@ -1972,9 +2130,9 @@ integrity sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q== "@maplibre/maplibre-gl-style-spec@^23.1.0": - version "23.1.0" - resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.1.0.tgz#ad59731b0547ee0986ba4ccff699894dd60f0650" - integrity sha512-R6/ihEuC5KRexmKIYkWqUv84Gm+/QwsOUgHyt1yy2XqCdGdLvlBWVWIIeTZWN4NGdwmY6xDzdSGU2R9oBLNg2w== + version "23.3.0" + resolved "https://registry.yarnpkg.com/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-23.3.0.tgz#b69ab48cb3abead4e49213396c8f83492638b97c" + integrity sha512-IGJtuBbaGzOUgODdBRg66p8stnwj9iDXkgbYKoYcNiiQmaez5WVRfXm4b03MCDwmZyX93csbfHFWEJJYHnn5oA== dependencies: "@mapbox/jsonlint-lines-primitives" "~2.0.2" "@mapbox/unitbezier" "^0.0.1" @@ -1984,24 +2142,24 @@ rw "^1.3.3" tinyqueue "^3.0.0" -"@mat-datetimepicker/core@~14.0.0": - version "14.0.0" - resolved "https://registry.yarnpkg.com/@mat-datetimepicker/core/-/core-14.0.0.tgz#1776ae74c3ff94b2bf4e1c6c01c9366c8ab876b5" - integrity sha512-jds4b/BPdu1Yy90y6lyucGfLxq3DKutvDLYTt4+YdSItMHu6vfkclAxllky0Qb1ANlNrFQAOdfNOCUIGTnnWag== +"@mat-datetimepicker/core@~16.0.1": + version "16.0.1" + resolved "https://registry.yarnpkg.com/@mat-datetimepicker/core/-/core-16.0.1.tgz#f2b19b6ed85f83a83f93be866af1a9564ac0d978" + integrity sha512-WknoCOBP3E9VQfRrFGQ5oQlm6EHvfaGjjlHdat7mP0qdfIHqq1efVXZ4/0aXEhiV6s9KM5m4y9ZUfJDLWIz0Zg== dependencies: - tslib "^2.6.3" + tslib "~2.8.1" "@mdi/svg@^7.4.47": version "7.4.47" resolved "https://registry.yarnpkg.com/@mdi/svg/-/svg-7.4.47.tgz#f8e5516aae129764a76d1bb2f27e55bee03e6e90" integrity sha512-WQ2gDll12T9WD34fdRFgQVgO8bag3gavrAgJ0frN4phlwdJARpE6gO1YvLEMJR0KKgoc+/Ea/A0Pp11I00xBvw== -"@mermaid-js/parser@^0.3.0": - version "0.3.0" - resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-0.3.0.tgz#7a28714599f692f93df130b299fa1aadc9f9c8ab" - integrity sha512-HsvL6zgE5sUPGgkIDlmAWR1HTNHz2Iy11BAWPTa4Jjabkpguy4Ze2gzfLrg6pdRuBvFwgUYyxiaNqZwrEEXepA== +"@mermaid-js/parser@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@mermaid-js/parser/-/parser-1.1.0.tgz#8f96c35ddab34a1b12af58f2c59f5abb7d4743fc" + integrity sha512-gxK9ZX2+Fex5zu8LhRQoMeMPEHbc73UKZ0FQ54YrQtUxE1VVhMwzeNtKRPAu5aXks4FasbMe4xB4bWrmq6Jlxw== dependencies: - langium "3.0.0" + langium "^4.0.0" "@messageformat/core@^3.4.0": version "3.4.0" @@ -2039,6 +2197,29 @@ dependencies: make-plural "^7.0.0" +"@modelcontextprotocol/sdk@1.26.0": + version "1.26.0" + resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.26.0.tgz#5b35d73062125f126cc70b0be83cbab53bcdde74" + integrity sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg== + dependencies: + "@hono/node-server" "^1.19.9" + ajv "^8.17.1" + ajv-formats "^3.0.1" + content-type "^1.0.5" + cors "^2.8.5" + cross-spawn "^7.0.5" + eventsource "^3.0.2" + eventsource-parser "^3.0.0" + express "^5.2.1" + express-rate-limit "^8.2.1" + hono "^4.11.4" + jose "^6.1.3" + json-schema-typed "^8.0.2" + pkce-challenge "^5.0.0" + raw-body "^3.0.0" + zod "^3.25 || ^4.0" + zod-to-json-schema "^3.25.1" + "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": version "3.0.3" resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz#9edec61b22c3082018a79f6d1c30289ddf3d9d11" @@ -2069,6 +2250,114 @@ resolved "https://registry.yarnpkg.com/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz#0aa5502d547b57abfc4ac492de68e2006e417242" integrity sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ== +"@napi-rs/nice-android-arm-eabi@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm-eabi/-/nice-android-arm-eabi-1.1.1.tgz#4ebd966821cd6c2cc7cc020eb468de397bb9b40f" + integrity sha512-kjirL3N6TnRPv5iuHw36wnucNqXAO46dzK9oPb0wj076R5Xm8PfUVA9nAFB5ZNMmfJQJVKACAPd/Z2KYMppthw== + +"@napi-rs/nice-android-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-android-arm64/-/nice-android-arm64-1.1.1.tgz#e183ba874512bc005852daab8b78c63e0a4288a8" + integrity sha512-blG0i7dXgbInN5urONoUCNf+DUEAavRffrO7fZSeoRMJc5qD+BJeNcpr54msPF6qfDD6kzs9AQJogZvT2KD5nw== + +"@napi-rs/nice-darwin-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-arm64/-/nice-darwin-arm64-1.1.1.tgz#64b1585809774cbb8bf95cea3d4c8827c9897394" + integrity sha512-s/E7w45NaLqTGuOjC2p96pct4jRfo61xb9bU1unM/MJ/RFkKlJyJDx7OJI/O0ll/hrfpqKopuAFDV8yo0hfT7A== + +"@napi-rs/nice-darwin-x64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-darwin-x64/-/nice-darwin-x64-1.1.1.tgz#99c0c7f62cb1e23ca76881bb29cc6000aeccc6f0" + integrity sha512-dGoEBnVpsdcC+oHHmW1LRK5eiyzLwdgNQq3BmZIav+9/5WTZwBYX7r5ZkQC07Nxd3KHOCkgbHSh4wPkH1N1LiQ== + +"@napi-rs/nice-freebsd-x64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-freebsd-x64/-/nice-freebsd-x64-1.1.1.tgz#9a5ca0e3ced86207887c98a5a560de8cde5a909e" + integrity sha512-kHv4kEHAylMYmlNwcQcDtXjklYp4FCf0b05E+0h6nDHsZ+F0bDe04U/tXNOqrx5CmIAth4vwfkjjUmp4c4JktQ== + +"@napi-rs/nice-linux-arm-gnueabihf@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm-gnueabihf/-/nice-linux-arm-gnueabihf-1.1.1.tgz#b8a6a1bc88d0de3e99ac3fdea69980dc6e20b502" + integrity sha512-E1t7K0efyKXZDoZg1LzCOLxgolxV58HCkaEkEvIYQx12ht2pa8hoBo+4OB3qh7e+QiBlp1SRf+voWUZFxyhyqg== + +"@napi-rs/nice-linux-arm64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-gnu/-/nice-linux-arm64-gnu-1.1.1.tgz#226f1ef30fcb80fa40370e843b75cc86e39e1183" + integrity sha512-CIKLA12DTIZlmTaaKhQP88R3Xao+gyJxNWEn04wZwC2wmRapNnxCUZkVwggInMJvtVElA+D4ZzOU5sX4jV+SmQ== + +"@napi-rs/nice-linux-arm64-musl@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-arm64-musl/-/nice-linux-arm64-musl-1.1.1.tgz#01345c3db79210ba5406c8729e8db75ed11c5f14" + integrity sha512-+2Rzdb3nTIYZ0YJF43qf2twhqOCkiSrHx2Pg6DJaCPYhhaxbLcdlV8hCRMHghQ+EtZQWGNcS2xF4KxBhSGeutg== + +"@napi-rs/nice-linux-ppc64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-ppc64-gnu/-/nice-linux-ppc64-gnu-1.1.1.tgz#ce7a1025227daab491ded40784b561394d688fcb" + integrity sha512-4FS8oc0GeHpwvv4tKciKkw3Y4jKsL7FRhaOeiPei0X9T4Jd619wHNe4xCLmN2EMgZoeGg+Q7GY7BsvwKpL22Tg== + +"@napi-rs/nice-linux-riscv64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-riscv64-gnu/-/nice-linux-riscv64-gnu-1.1.1.tgz#9bef5dc89a0425d03163853b4968dbb686d98fd5" + integrity sha512-HU0nw9uD4FO/oGCCk409tCi5IzIZpH2agE6nN4fqpwVlCn5BOq0MS1dXGjXaG17JaAvrlpV5ZeyZwSon10XOXw== + +"@napi-rs/nice-linux-s390x-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-s390x-gnu/-/nice-linux-s390x-gnu-1.1.1.tgz#247c8c7c45876877bdb337cfeb290ff4fd82de62" + integrity sha512-2YqKJWWl24EwrX0DzCQgPLKQBxYDdBxOHot1KWEq7aY2uYeX+Uvtv4I8xFVVygJDgf6/92h9N3Y43WPx8+PAgQ== + +"@napi-rs/nice-linux-x64-gnu@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-gnu/-/nice-linux-x64-gnu-1.1.1.tgz#7fd1f5e037cb44ab4f5f95a3b3225a99e3248f12" + integrity sha512-/gaNz3R92t+dcrfCw/96pDopcmec7oCcAQ3l/M+Zxr82KT4DljD37CpgrnXV+pJC263JkW572pdbP3hP+KjcIg== + +"@napi-rs/nice-linux-x64-musl@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-linux-x64-musl/-/nice-linux-x64-musl-1.1.1.tgz#d447cd7157ae5da5c0b15fc618bf61f0c344ff6f" + integrity sha512-xScCGnyj/oppsNPMnevsBe3pvNaoK7FGvMjT35riz9YdhB2WtTG47ZlbxtOLpjeO9SqqQ2J2igCmz6IJOD5JYw== + +"@napi-rs/nice-openharmony-arm64@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-openharmony-arm64/-/nice-openharmony-arm64-1.1.1.tgz#1120e457d2cc6b2bc86ef0a697faefe2e194dfce" + integrity sha512-6uJPRVwVCLDeoOaNyeiW0gp2kFIM4r7PL2MczdZQHkFi9gVlgm+Vn+V6nTWRcu856mJ2WjYJiumEajfSm7arPQ== + +"@napi-rs/nice-win32-arm64-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-arm64-msvc/-/nice-win32-arm64-msvc-1.1.1.tgz#91e4cfecf339b43fa7934f0c8b19d04f4cdd9bc0" + integrity sha512-uoTb4eAvM5B2aj/z8j+Nv8OttPf2m+HVx3UjA5jcFxASvNhQriyCQF1OB1lHL43ZhW+VwZlgvjmP5qF3+59atA== + +"@napi-rs/nice-win32-ia32-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-ia32-msvc/-/nice-win32-ia32-msvc-1.1.1.tgz#ed9300bba074d3e3b0a077d6b157f2b4ff70af0e" + integrity sha512-CNQqlQT9MwuCsg1Vd/oKXiuH+TcsSPJmlAFc5frFyX/KkOh0UpBLEj7aoY656d5UKZQMQFP7vJNa1DNUNORvug== + +"@napi-rs/nice-win32-x64-msvc@1.1.1": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice-win32-x64-msvc/-/nice-win32-x64-msvc-1.1.1.tgz#8292b82fb46458618ccff5b8130f78974349541e" + integrity sha512-vB+4G/jBQCAh0jelMTY3+kgFy00Hlx2f2/1zjMoH821IbplbWZOkLiTYXQkygNTzQJTq5cvwBDgn2ppHD+bglQ== + +"@napi-rs/nice@^1.0.4": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/nice/-/nice-1.1.1.tgz#c1aacd631ecd4c500c959e3e7cfedd5c73bffe2a" + integrity sha512-xJIPs+bYuc9ASBl+cvGsKbGrJmS6fAKaSZCnT0lhahT5rhA2VVy9/EcIgd2JhtEuFOJNx7UHNn/qiTPTY4nrQw== + optionalDependencies: + "@napi-rs/nice-android-arm-eabi" "1.1.1" + "@napi-rs/nice-android-arm64" "1.1.1" + "@napi-rs/nice-darwin-arm64" "1.1.1" + "@napi-rs/nice-darwin-x64" "1.1.1" + "@napi-rs/nice-freebsd-x64" "1.1.1" + "@napi-rs/nice-linux-arm-gnueabihf" "1.1.1" + "@napi-rs/nice-linux-arm64-gnu" "1.1.1" + "@napi-rs/nice-linux-arm64-musl" "1.1.1" + "@napi-rs/nice-linux-ppc64-gnu" "1.1.1" + "@napi-rs/nice-linux-riscv64-gnu" "1.1.1" + "@napi-rs/nice-linux-s390x-gnu" "1.1.1" + "@napi-rs/nice-linux-x64-gnu" "1.1.1" + "@napi-rs/nice-linux-x64-musl" "1.1.1" + "@napi-rs/nice-openharmony-arm64" "1.1.1" + "@napi-rs/nice-win32-arm64-msvc" "1.1.1" + "@napi-rs/nice-win32-ia32-msvc" "1.1.1" + "@napi-rs/nice-win32-x64-msvc" "1.1.1" + "@nghedgehog/core@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@nghedgehog/core/-/core-0.0.4.tgz#4e3231847d0dac557a2e2dbdf1e3a52b106dd57c" @@ -2076,36 +2365,38 @@ dependencies: tslib "^2.3.0" -"@ngrx/effects@^18.1.1": - version "18.1.1" - resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-18.1.1.tgz#df51cc2874e2470bfe073aa3e01f317977d8fd49" - integrity sha512-XXob8kYEvYMaZwgHtrrTW0XZargbu5PloEpNHLnzB8jPk0yWEw6keryxaF09Ylws1779MWvMmF/YP2rPl04nHQ== +"@ngrx/effects@^20.1.0": + version "20.1.0" + resolved "https://registry.yarnpkg.com/@ngrx/effects/-/effects-20.1.0.tgz#e8947a0840dc76d289eafcaa929113ab303335ea" + integrity sha512-wkIu0QnTarBd1zUswk643H5SILO9Be1OeswMe7g4tXlkNLFRPkyu0BUzXT80PsUPP5p0VrdFz3akrn3HHjjTjA== dependencies: tslib "^2.0.0" -"@ngrx/store-devtools@^18.1.1": - version "18.1.1" - resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-18.1.1.tgz#0a62949a73885728d1a1908f66779c13be307eaf" - integrity sha512-tfn9Ms1q2bl7VDWwHyj7dUbwmGiiVzIpOKZs39rsms8tG8hbvsxoczyQAp2w5erVtoJb8ltIh1/birKkS7HmuQ== +"@ngrx/store-devtools@^20.1.0": + version "20.1.0" + resolved "https://registry.yarnpkg.com/@ngrx/store-devtools/-/store-devtools-20.1.0.tgz#7d03929964e4fb959110e5a74ca053c5f56d47fe" + integrity sha512-4N6X/uAfjiCFcuauX2gG+6TnDAuixrfMfUVebLYvifLkrDmqDO8lbeH+Lhk3EVGnpUih4yJiCaU437awVyKckw== dependencies: tslib "^2.0.0" -"@ngrx/store@^18.1.1": - version "18.1.1" - resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-18.1.1.tgz#e189967bd8d385ea48e14a7d96542cbf062e8382" - integrity sha512-K0v1akJ2sEnIeb1AUA064+ksgRgbMgVG9HbSsLBxENbFjK2ZvKRxo1bpOw6WHW9+hyDTlhZGl7+gUtjmo3497g== +"@ngrx/store@^20.1.0": + version "20.1.0" + resolved "https://registry.yarnpkg.com/@ngrx/store/-/store-20.1.0.tgz#107e49efddf816df5f3e5b482ce8320f1eed971a" + integrity sha512-o8j3CGAGedm+BIb+QDhNXrVaU//n9uF0wH0HZWtXHmW1mjRBaQiUA+ZPMUkDwAeN8KuOcoIEC+2QUXxXGVI7ow== dependencies: tslib "^2.0.0" -"@ngtools/webpack@18.2.12": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-18.2.12.tgz#876a09f2b3acb0d09d93e906a9ba330d92703568" - integrity sha512-FFJAwtWbtpncMOVNuULPBwFJB7GSjiUwO93eGTzRp8O4EPQ8lCQeFbezQm/NP34+T0+GBLGzPSuQT+muob8YKw== +"@ngtools/webpack@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.24.tgz#8e344ebaea46d7e669220f02a33bb61826bb35f6" + integrity sha512-PIP9hFVF6OOmDxG0s6vX7cHm/6wwWK8jXd6e1I/CewR0zpVPtR1vxhhw9CrY4VEUCFSL6x2NuW/U3cI7LU+Z1Q== -"@ngx-translate/core@^15.0.0": - version "15.0.0" - resolved "https://registry.yarnpkg.com/@ngx-translate/core/-/core-15.0.0.tgz#0fe55b9bd47e75b03d1123658f15fb7b5a534f3c" - integrity sha512-Am5uiuR0bOOxyoercDnAA3rJVizo4RRqJHo8N3RqJ+XfzVP/I845yEnMADykOHvM6HkVm4SZSnJBOiz0Anx5BA== +"@ngx-translate/core@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@ngx-translate/core/-/core-17.0.0.tgz#d6c0738c5004b53273bc97e6ca5488f6a6816f25" + integrity sha512-Rft2D5ns2pq4orLZjEtx1uhNuEBerUdpFUG1IcqtGuipj6SavgB8SkxtNQALNDA+EVlvsNCCjC2ewZVtUeN6rg== + dependencies: + tslib "^2.3.0" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -2128,257 +2419,395 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@npmcli/agent@^2.0.0": - version "2.2.2" - resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-2.2.2.tgz#967604918e62f620a648c7975461c9c9e74fc5d5" - integrity sha512-OrcNPXdpSl9UX7qPVRWbmWMCSXrcDa2M9DvrbOTj7ao1S4PlqVFYv9/yLKMkrJKZ/V5A/kDBC690or307i26Og== +"@npmcli/agent@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/agent/-/agent-4.0.0.tgz#2bb2b1c0a170940511554a7986ae2a8be9fedcce" + integrity sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA== dependencies: agent-base "^7.1.0" http-proxy-agent "^7.0.0" https-proxy-agent "^7.0.1" - lru-cache "^10.0.1" + lru-cache "^11.2.1" socks-proxy-agent "^8.0.3" -"@npmcli/fs@^3.1.0": - version "3.1.1" - resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-3.1.1.tgz#59cdaa5adca95d135fc00f2bb53f5771575ce726" - integrity sha512-q9CRWjpHCMIh5sVyefoD1cA7PkvILqCZsnSOEUUivORLjxCO/Irmue2DprETiNgEqktDBZaM1Bi+jrarx1XdCg== +"@npmcli/fs@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/fs/-/fs-5.0.0.tgz#674619771907342b3d1ac197aaf1deeb657e3539" + integrity sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og== dependencies: semver "^7.3.5" -"@npmcli/git@^5.0.0": - version "5.0.8" - resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-5.0.8.tgz#8ba3ff8724192d9ccb2735a2aa5380a992c5d3d1" - integrity sha512-liASfw5cqhjNW9UFd+ruwwdEf/lbOAQjLL2XY2dFW/bkJheXDYZgOyul/4gVvEV4BWkTXjYGmDqMw9uegdbJNQ== +"@npmcli/git@^7.0.0": + version "7.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/git/-/git-7.0.1.tgz#d1f6462af0e9901536e447beea922bc20dcc5762" + integrity sha512-+XTFxK2jJF/EJJ5SoAzXk3qwIDfvFc5/g+bD274LZ7uY7LE8sTfG6Z8rOanPl2ZEvZWqNvmEdtXC25cE54VcoA== dependencies: - "@npmcli/promise-spawn" "^7.0.0" - ini "^4.1.3" - lru-cache "^10.0.1" - npm-pick-manifest "^9.0.0" - proc-log "^4.0.0" - promise-inflight "^1.0.1" + "@npmcli/promise-spawn" "^9.0.0" + ini "^6.0.0" + lru-cache "^11.2.1" + npm-pick-manifest "^11.0.1" + proc-log "^6.0.0" promise-retry "^2.0.1" semver "^7.3.5" - which "^4.0.0" + which "^6.0.0" -"@npmcli/installed-package-contents@^2.0.1": - version "2.1.0" - resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-2.1.0.tgz#63048e5f6e40947a3a88dcbcb4fd9b76fdd37c17" - integrity sha512-c8UuGLeZpm69BryRykLuKRyKFZYJsZSCT4aVY5ds4omyZqJ172ApzgfKJ5eV/r3HgLdUYgFVe54KSFVjKoe27w== +"@npmcli/installed-package-contents@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/installed-package-contents/-/installed-package-contents-4.0.0.tgz#18e5070704cfe0278f9ae48038558b6efd438426" + integrity sha512-yNyAdkBxB72gtZ4GrwXCM0ZUedo9nIbOMKfGjt6Cu6DXf0p8y1PViZAKDC8q8kv/fufx0WTjRBdSlyrvnP7hmA== dependencies: - npm-bundled "^3.0.0" - npm-normalize-package-bin "^3.0.0" - -"@npmcli/node-gyp@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz#101b2d0490ef1aa20ed460e4c0813f0db560545a" - integrity sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA== + npm-bundled "^5.0.0" + npm-normalize-package-bin "^5.0.0" -"@npmcli/package-json@^5.0.0", "@npmcli/package-json@^5.1.0": - version "5.2.1" - resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-5.2.1.tgz#df69477b1023b81ff8503f2b9db4db4faea567ed" - integrity sha512-f7zYC6kQautXHvNbLEWgD/uGu1+xCn9izgqBfgItWSx22U0ZDekxN08A1vM8cTxj/cRVe0Q94Ode+tdoYmIOOQ== - dependencies: - "@npmcli/git" "^5.0.0" - glob "^10.2.2" - hosted-git-info "^7.0.0" - json-parse-even-better-errors "^3.0.0" - normalize-package-data "^6.0.0" - proc-log "^4.0.0" +"@npmcli/node-gyp@^5.0.0": + version "5.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/node-gyp/-/node-gyp-5.0.0.tgz#35475a58b5d791764a7252231197a14deefe8e47" + integrity sha512-uuG5HZFXLfyFKqg8QypsmgLQW7smiRjVc45bqD/ofZZcR/uxEjgQU8qDPv0s9TEeMUiAAU/GC5bR6++UdTirIQ== + +"@npmcli/package-json@^7.0.0": + version "7.0.4" + resolved "https://registry.yarnpkg.com/@npmcli/package-json/-/package-json-7.0.4.tgz#f4178e5d90b888f3bdf666915706f613c2d870d7" + integrity sha512-0wInJG3j/K40OJt/33ax47WfWMzZTm6OQxB9cDhTt5huCP2a9g2GnlsxmfN+PulItNPIpPrZ+kfwwUil7eHcZQ== + dependencies: + "@npmcli/git" "^7.0.0" + glob "^13.0.0" + hosted-git-info "^9.0.0" + json-parse-even-better-errors "^5.0.0" + proc-log "^6.0.0" semver "^7.5.3" + validate-npm-package-license "^3.0.4" -"@npmcli/promise-spawn@^7.0.0": - version "7.0.2" - resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-7.0.2.tgz#1d53d34ffeb5d151bfa8ec661bcccda8bbdfd532" - integrity sha512-xhfYPXoV5Dy4UkY0D+v2KkwvnDfiA/8Mt3sWCGI/hM03NsYIH8ZaG6QzS9x7pje5vHZBZJ2v6VRFVTWACnqcmQ== +"@npmcli/promise-spawn@^9.0.0": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@npmcli/promise-spawn/-/promise-spawn-9.0.1.tgz#20e80cbdd2f24ad263a15de3ebbb1673cb82005b" + integrity sha512-OLUaoqBuyxeTqUvjA3FZFiXUfYC1alp3Sa99gW3EUDz3tZ3CbXDdcZ7qWKBzicrJleIgucoWamWH1saAmH/l2Q== dependencies: - which "^4.0.0" + which "^6.0.0" -"@npmcli/redact@^2.0.0": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@npmcli/redact/-/redact-2.0.1.tgz#95432fd566e63b35c04494621767a4312c316762" - integrity sha512-YgsR5jCQZhVmTJvjduTOIHph0L73pK8xwMVaDY0PatySqVM9AZj93jpoXYSJqfHFxFkN9dmqTw6OiqExsS3LPw== +"@npmcli/redact@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@npmcli/redact/-/redact-4.0.0.tgz#c91121e02b7559a997614a2c1057cd7fc67608c4" + integrity sha512-gOBg5YHMfZy+TfHArfVogwgfBeQnKbbGo3pSUyK/gSI0AVu+pEiDVcKlQb0D8Mg1LNRZILZ6XG8I5dJ4KuAd9Q== -"@npmcli/run-script@^8.0.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-8.1.0.tgz#a563e5e29b1ca4e648a6b1bbbfe7220b4bfe39fc" - integrity sha512-y7efHHwghQfk28G2z3tlZ67pLG0XdfYbcVG26r7YIXALRsrVQcTq4/tdenSmdOrEsNahIYA/eh8aEVROWGFUDg== - dependencies: - "@npmcli/node-gyp" "^3.0.0" - "@npmcli/package-json" "^5.0.0" - "@npmcli/promise-spawn" "^7.0.0" - node-gyp "^10.0.0" - proc-log "^4.0.0" - which "^4.0.0" +"@npmcli/run-script@^10.0.0": + version "10.0.3" + resolved "https://registry.yarnpkg.com/@npmcli/run-script/-/run-script-10.0.3.tgz#85c16cd893e44cad5edded441b002d8a1d3a8a8e" + integrity sha512-ER2N6itRkzWbbtVmZ9WKaWxVlKlOeBFF1/7xx+KA5J1xKa4JjUwBdb6tDpk0v1qA+d+VDwHI9qmLcXSWcmi+Rw== + dependencies: + "@npmcli/node-gyp" "^5.0.0" + "@npmcli/package-json" "^7.0.0" + "@npmcli/promise-spawn" "^9.0.0" + node-gyp "^12.1.0" + proc-log "^6.0.0" + which "^6.0.0" "@one-ini/wasm@0.1.1": version "0.1.1" resolved "https://registry.yarnpkg.com/@one-ini/wasm/-/wasm-0.1.1.tgz#6013659736c9dbfccc96e8a9c2b3de317df39323" integrity sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw== +"@parcel/watcher-android-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.6.tgz#5f32e0dba356f4ac9a11068d2a5c134ca3ba6564" + integrity sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A== + +"@parcel/watcher-darwin-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.6.tgz#88d3e720b59b1eceffce98dac46d7c40e8be5e8e" + integrity sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA== + +"@parcel/watcher-darwin-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.6.tgz#bf05d76a78bc15974f15ec3671848698b0838063" + integrity sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg== + +"@parcel/watcher-freebsd-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.6.tgz#8bc26e9848e7303ac82922a5ae1b1ef1bdb48a53" + integrity sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng== + +"@parcel/watcher-linux-arm-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.6.tgz#1328fee1deb0c2d7865079ef53a2ba4cc2f8b40a" + integrity sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ== + +"@parcel/watcher-linux-arm-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.6.tgz#bad0f45cb3e2157746db8b9d22db6a125711f152" + integrity sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg== + +"@parcel/watcher-linux-arm64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.6.tgz#b75913fbd501d9523c5f35d420957bf7d0204809" + integrity sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA== + +"@parcel/watcher-linux-arm64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.6.tgz#da5621a6a576070c8c0de60dea8b46dc9c3827d4" + integrity sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA== + +"@parcel/watcher-linux-x64-glibc@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.6.tgz#ce437accdc4b30f93a090b4a221fd95cd9b89639" + integrity sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ== + +"@parcel/watcher-linux-x64-musl@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.6.tgz#02400c54b4a67efcc7e2327b249711920ac969e2" + integrity sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg== + +"@parcel/watcher-win32-arm64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.6.tgz#caae3d3c7583ca0a7171e6bd142c34d20ea1691e" + integrity sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q== + +"@parcel/watcher-win32-ia32@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.6.tgz#9ac922550896dfe47bfc5ae3be4f1bcaf8155d6d" + integrity sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g== + +"@parcel/watcher-win32-x64@2.5.6": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.6.tgz#73fdafba2e21c448f0e456bbe13178d8fe11739d" + integrity sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw== + +"@parcel/watcher@^2.4.1": + version "2.5.6" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.5.6.tgz#3f932828c894f06d0ad9cfefade1756ecc6ef1f1" + integrity sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ== + dependencies: + detect-libc "^2.0.3" + is-glob "^4.0.3" + node-addon-api "^7.0.0" + picomatch "^4.0.3" + optionalDependencies: + "@parcel/watcher-android-arm64" "2.5.6" + "@parcel/watcher-darwin-arm64" "2.5.6" + "@parcel/watcher-darwin-x64" "2.5.6" + "@parcel/watcher-freebsd-x64" "2.5.6" + "@parcel/watcher-linux-arm-glibc" "2.5.6" + "@parcel/watcher-linux-arm-musl" "2.5.6" + "@parcel/watcher-linux-arm64-glibc" "2.5.6" + "@parcel/watcher-linux-arm64-musl" "2.5.6" + "@parcel/watcher-linux-x64-glibc" "2.5.6" + "@parcel/watcher-linux-x64-musl" "2.5.6" + "@parcel/watcher-win32-arm64" "2.5.6" + "@parcel/watcher-win32-ia32" "2.5.6" + "@parcel/watcher-win32-x64" "2.5.6" + "@pkgjs/parseargs@^0.11.0": version "0.11.0" resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@pkgr/core@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" - integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== - -"@rollup/rollup-android-arm-eabi@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz#8b613b9725e8f9479d142970b106b6ae878610d5" - integrity sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w== - -"@rollup/rollup-android-arm64@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz#654ca1049189132ff602bfcf8df14c18da1f15fb" - integrity sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA== - -"@rollup/rollup-darwin-arm64@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz#6d241d099d1518ef0c2205d96b3fa52e0fe1954b" - integrity sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q== - -"@rollup/rollup-darwin-x64@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz#42bd19d292a57ee11734c980c4650de26b457791" - integrity sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw== - -"@rollup/rollup-linux-arm-gnueabihf@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz#f23555ee3d8fe941c5c5fd458cd22b65eb1c2232" - integrity sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ== - -"@rollup/rollup-linux-arm-musleabihf@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz#f3bbd1ae2420f5539d40ac1fde2b38da67779baa" - integrity sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg== - -"@rollup/rollup-linux-arm64-gnu@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz#7abe900120113e08a1f90afb84c7c28774054d15" - integrity sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw== - -"@rollup/rollup-linux-arm64-musl@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz#9e655285c8175cd44f57d6a1e8e5dedfbba1d820" - integrity sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA== - -"@rollup/rollup-linux-powerpc64le-gnu@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz#9a79ae6c9e9d8fe83d49e2712ecf4302db5bef5e" - integrity sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg== - -"@rollup/rollup-linux-riscv64-gnu@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz#67ac70eca4ace8e2942fabca95164e8874ab8128" - integrity sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA== - -"@rollup/rollup-linux-s390x-gnu@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz#9f883a7440f51a22ed7f99e1d070bd84ea5005fc" - integrity sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q== - -"@rollup/rollup-linux-x64-gnu@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz#70116ae6c577fe367f58559e2cffb5641a1dd9d0" - integrity sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg== - -"@rollup/rollup-linux-x64-musl@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz#f473f88219feb07b0b98b53a7923be716d1d182f" - integrity sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g== - -"@rollup/rollup-win32-arm64-msvc@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz#4349482d17f5d1c58604d1c8900540d676f420e0" - integrity sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw== - -"@rollup/rollup-win32-ia32-msvc@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz#a6fc39a15db618040ec3c2a24c1e26cb5f4d7422" - integrity sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g== - -"@rollup/rollup-win32-x64-msvc@4.22.4": - version "4.22.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz#3dd5d53e900df2a40841882c02e56f866c04d202" - integrity sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q== +"@rollup/rollup-android-arm-eabi@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz#a6742c74c7d9d6d604ef8a48f99326b4ecda3d82" + integrity sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg== + +"@rollup/rollup-android-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz#97247be098de4df0c11971089fd2edf80a5da8cf" + integrity sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q== + +"@rollup/rollup-darwin-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz#674852cf14cf11b8056e0b1a2f4e872b523576cf" + integrity sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg== + +"@rollup/rollup-darwin-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz#36dfd7ed0aaf4d9d89d9ef983af72632455b0246" + integrity sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w== + +"@rollup/rollup-freebsd-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz#2f87c2074b4220260fdb52a9996246edfc633c22" + integrity sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA== + +"@rollup/rollup-freebsd-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz#9b5a26522a38a95dc06616d1939d4d9a76937803" + integrity sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg== + +"@rollup/rollup-linux-arm-gnueabihf@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz#86aa4859385a8734235b5e40a48e52d770758c3a" + integrity sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw== + +"@rollup/rollup-linux-arm-musleabihf@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz#cbe70e56e6ece8dac83eb773b624fc9e5a460976" + integrity sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA== + +"@rollup/rollup-linux-arm64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz#d14992a2e653bc3263d284bc6579b7a2890e1c45" + integrity sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA== + +"@rollup/rollup-linux-arm64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz#2fdd1ddc434ea90aeaa0851d2044789b4d07f6da" + integrity sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA== + +"@rollup/rollup-linux-loong64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz#8a181e6f89f969f21666a743cd411416c80099e7" + integrity sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg== + +"@rollup/rollup-linux-loong64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz#904125af2babc395f8061daa27b5af1f4e3f2f78" + integrity sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q== + +"@rollup/rollup-linux-ppc64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz#a57970ac6864c9a3447411a658224bdcf948be22" + integrity sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA== + +"@rollup/rollup-linux-ppc64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz#bb84de5b26870567a4267666e08891e80bb56a63" + integrity sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA== + +"@rollup/rollup-linux-riscv64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz#72d00d2c7fb375ce3564e759db33f17a35bffab9" + integrity sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg== + +"@rollup/rollup-linux-riscv64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz#4c166ef58e718f9245bd31873384ba15a5c1a883" + integrity sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg== + +"@rollup/rollup-linux-s390x-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz#bb5025cde9a61db478c2ca7215808ad3bce73a09" + integrity sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w== + +"@rollup/rollup-linux-x64-gnu@4.46.2": + version "4.46.2" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.46.2.tgz#1e936446f90b2574ea4a83b4842a762cc0a0aed3" + integrity sha512-Jj5a9RUoe5ra+MEyERkDKLwTXVu6s3aACP51nkfnK9wJTraCC8IMe3snOfALkrjTYd2G1ViE1hICj0fZ7ALBPA== + +"@rollup/rollup-linux-x64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz#9b66b1f9cd95c6624c788f021c756269ffed1552" + integrity sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg== + +"@rollup/rollup-linux-x64-musl@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz#b007ca255dc7166017d57d7d2451963f0bd23fd9" + integrity sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg== + +"@rollup/rollup-openbsd-x64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz#e8b357b2d1aa2c8d76a98f5f0d889eabe93f4ef9" + integrity sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ== + +"@rollup/rollup-openharmony-arm64@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz#96c2e3f4aacd3d921981329831ff8dde492204dc" + integrity sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA== + +"@rollup/rollup-win32-arm64-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz#2d865149d706d938df8b4b8f117e69a77646d581" + integrity sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A== + +"@rollup/rollup-win32-ia32-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz#abe1593be0fa92325e9971c8da429c5e05b92c36" + integrity sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA== + +"@rollup/rollup-win32-x64-gnu@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz#c4af3e9518c9a5cd4b1c163dc81d0ad4d82e7eab" + integrity sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA== + +"@rollup/rollup-win32-x64-msvc@4.59.0": + version "4.59.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz#4584a8a87b29188a4c1fe987a9fcf701e256d86c" + integrity sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA== "@rtsao/scc@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@schematics/angular@18.2.12": - version "18.2.12" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-18.2.12.tgz#28df36bcd34e85a33dbd0d39932d5ca2de2a9e52" - integrity sha512-sIoeipsisK5eTLW3XuNZYcal83AfslBbgI7LnV+3VrXwpasKPGHwo2ZdwhCd2IXAkuJ02Iyu7MyV0aQRM9i/3g== +"@sanity/diff-match-patch@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sanity/diff-match-patch/-/diff-match-patch-3.2.0.tgz#7ce587273f7372a146308cb1075ba26177d42cdb" + integrity sha512-4hPADs0qUThFZkBK/crnfKKHg71qkRowfktBljH2UIxGHHTxIzt8g8fBiXItyCjxkuNy+zpYOdRMifQNv8+Yww== + +"@schematics/angular@20.3.24": + version "20.3.24" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.24.tgz#1cef967a08a55336b296dfd0e2c5207dc1c43226" + integrity sha512-GNB8zI8Lz0rJl4Q7FH4Y8ZmRpODkNDKGxWObfZ39POgiyr3CtT5sMRTQq1lWRWTlZeV8uD51DvW/EsAsbaS4HA== dependencies: - "@angular-devkit/core" "18.2.12" - "@angular-devkit/schematics" "18.2.12" + "@angular-devkit/core" "20.3.24" + "@angular-devkit/schematics" "20.3.24" jsonc-parser "3.3.1" -"@sigstore/bundle@^2.3.2": - version "2.3.2" - resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-2.3.2.tgz#ad4dbb95d665405fd4a7a02c8a073dbd01e4e95e" - integrity sha512-wueKWDk70QixNLB363yHc2D2ItTgYiMTdPwK8D9dKQMR3ZQ0c35IxP5xnwQ8cNLoCgCRcHf14kE+CLIvNX1zmA== +"@sigstore/bundle@^4.0.0": + version "4.0.0" + resolved "https://registry.yarnpkg.com/@sigstore/bundle/-/bundle-4.0.0.tgz#854eda43eb6a59352037e49000177c8904572f83" + integrity sha512-NwCl5Y0V6Di0NexvkTqdoVfmjTaQwoLM236r89KEojGmq/jMls8S+zb7yOwAPdXvbwfKDlP+lmXgAL4vKSQT+A== dependencies: - "@sigstore/protobuf-specs" "^0.3.2" + "@sigstore/protobuf-specs" "^0.5.0" -"@sigstore/core@^1.0.0", "@sigstore/core@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-1.1.0.tgz#5583d8f7ffe599fa0a89f2bf289301a5af262380" - integrity sha512-JzBqdVIyqm2FRQCulY6nbQzMpJJpSiJ8XXWMhtOX9eKgaXXpfNOF53lzQEjIydlStnd/eFtuC1dW4VYdD93oRg== +"@sigstore/core@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@sigstore/core/-/core-3.1.0.tgz#b418de73f56333ad9e369b915173d8c98e9b96d5" + integrity sha512-o5cw1QYhNQ9IroioJxpzexmPjfCe7gzafd2RY3qnMpxr4ZEja+Jad/U8sgFpaue6bOaF+z7RVkyKVV44FN+N8A== -"@sigstore/protobuf-specs@^0.3.2": - version "0.3.2" - resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.3.2.tgz#5becf88e494a920f548d0163e2978f81b44b7d6f" - integrity sha512-c6B0ehIWxMI8wiS/bj6rHMPqeFvngFV7cDU/MY+B16P9Z3Mp9k8L93eYZ7BYzSickzuqAQqAq0V956b3Ju6mLw== +"@sigstore/protobuf-specs@^0.5.0": + version "0.5.0" + resolved "https://registry.yarnpkg.com/@sigstore/protobuf-specs/-/protobuf-specs-0.5.0.tgz#e5f029edcb3a4329853a09b603011e61043eb005" + integrity sha512-MM8XIwUjN2bwvCg1QvrMtbBmpcSHrkhFSCu1D11NyPvDQ25HEc4oG5/OcQfd/Tlf/OxmKWERDj0zGE23jQaMwA== -"@sigstore/sign@^2.3.2": - version "2.3.2" - resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-2.3.2.tgz#d3d01e56d03af96fd5c3a9b9897516b1233fc1c4" - integrity sha512-5Vz5dPVuunIIvC5vBb0APwo7qKA4G9yM48kPWJT+OEERs40md5GoUR1yedwpekWZ4m0Hhw44m6zU+ObsON+iDA== - dependencies: - "@sigstore/bundle" "^2.3.2" - "@sigstore/core" "^1.0.0" - "@sigstore/protobuf-specs" "^0.3.2" - make-fetch-happen "^13.0.1" - proc-log "^4.2.0" +"@sigstore/sign@^4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@sigstore/sign/-/sign-4.1.0.tgz#63df15a137337b29f463a1d1c51e1f7d4c1db2f1" + integrity sha512-Vx1RmLxLGnSUqx/o5/VsCjkuN5L7y+vxEEwawvc7u+6WtX2W4GNa7b9HEjmcRWohw/d6BpATXmvOwc78m+Swdg== + dependencies: + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.1.0" + "@sigstore/protobuf-specs" "^0.5.0" + make-fetch-happen "^15.0.3" + proc-log "^6.1.0" promise-retry "^2.0.1" -"@sigstore/tuf@^2.3.4": - version "2.3.4" - resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-2.3.4.tgz#da1d2a20144f3b87c0172920cbc8dcc7851ca27c" - integrity sha512-44vtsveTPUpqhm9NCrbU8CWLe3Vck2HO1PNLw7RIajbB7xhtn5RBPm1VNSCMwqGYHhDsBJG8gDF0q4lgydsJvw== +"@sigstore/tuf@^4.0.1": + version "4.0.1" + resolved "https://registry.yarnpkg.com/@sigstore/tuf/-/tuf-4.0.1.tgz#9b080390936d79ea3b6a893b64baf3123e92d6d3" + integrity sha512-OPZBg8y5Vc9yZjmWCHrlWPMBqW5yd8+wFNl+thMdtcWz3vjVSoJQutF8YkrzI0SLGnkuFof4HSsWUhXrf219Lw== dependencies: - "@sigstore/protobuf-specs" "^0.3.2" - tuf-js "^2.2.1" + "@sigstore/protobuf-specs" "^0.5.0" + tuf-js "^4.1.0" -"@sigstore/verify@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-1.2.1.tgz#c7e60241b432890dcb8bd8322427f6062ef819e1" - integrity sha512-8iKx79/F73DKbGfRf7+t4dqrc0bRr0thdPrxAtCKWRm/F0tG71i6O1rvlnScncJLLBZHn3h8M3c1BSUAb9yu8g== +"@sigstore/verify@^3.1.0": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@sigstore/verify/-/verify-3.1.0.tgz#4046d4186421db779501fe87fa5acaa5d4d21b08" + integrity sha512-mNe0Iigql08YupSOGv197YdHpPPr+EzDZmfCgMc7RPNaZTw5aLN01nBl6CHJOh3BGtnMIj83EeN4butBchc8Ag== dependencies: - "@sigstore/bundle" "^2.3.2" - "@sigstore/core" "^1.1.0" - "@sigstore/protobuf-specs" "^0.3.2" + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.1.0" + "@sigstore/protobuf-specs" "^0.5.0" -"@sindresorhus/merge-streams@^2.1.0": - version "2.3.0" - resolved "https://registry.yarnpkg.com/@sindresorhus/merge-streams/-/merge-streams-2.3.0.tgz#719df7fb41766bc143369eaa0dd56d8dc87c9958" - integrity sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg== +"@sindresorhus/base62@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@sindresorhus/base62/-/base62-1.0.0.tgz#c47c42410e5212e4fa4657670e118ddfba39acd6" + integrity sha512-TeheYy0ILzBEI/CO55CP6zJCSdSWeRtGnHy8U8dWSUH4I68iqTsy7HkMktR4xakThc9jotkPQUXT4ITdbV7cHA== -"@svgdotjs/svg.filter.js@^3.0.8": - version "3.0.8" - resolved "https://registry.yarnpkg.com/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.8.tgz#998cb2481a871fa70d7dbaa891c886b335c562d7" - integrity sha512-YshF2YDaeRA2StyzAs5nUPrev7npQ38oWD0eTRwnsciSL2KrRPMoUw8BzjIXItb3+dccKGTX3IQOd2NFzmHkog== +"@svgdotjs/svg.filter.js@^3.0.9": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@svgdotjs/svg.filter.js/-/svg.filter.js-3.0.9.tgz#758e336b79e73a6797358d655b60842131a9a52b" + integrity sha512-/69XMRCDoam2HgC4ldHIaDgeQf1ViHIsa0Ld4uWgiXtZ+E24DWHe/9Ib6kbNiZ7WRIdlVokUDR1Fg0kjIpkfbw== dependencies: - "@svgdotjs/svg.js" "^3.1.1" + "@svgdotjs/svg.js" "^3.2.4" -"@svgdotjs/svg.js@^3.0.16", "@svgdotjs/svg.js@^3.1.1", "@svgdotjs/svg.js@^3.2.4": +"@svgdotjs/svg.js@3.2.4", "@svgdotjs/svg.js@^3.0.16", "@svgdotjs/svg.js@^3.2.4": version "3.2.4" resolved "https://registry.yarnpkg.com/@svgdotjs/svg.js/-/svg.js-3.2.4.tgz#4716be92a64c66b29921b63f7235fcfb953fb13a" integrity sha512-BjJ/7vWNowlX3Z8O4ywT58DqbNRyYlkk6Yz/D13aB7hGmfQTvGX4Tkgtm/ApYlu9M7lCQi15xUEidqMUmdMYwg== @@ -2423,189 +2852,175 @@ resolved "https://registry.yarnpkg.com/@tufjs/canonical-json/-/canonical-json-2.0.0.tgz#a52f61a3d7374833fca945b2549bc30a2dd40d0a" integrity sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA== -"@tufjs/models@2.0.1": - version "2.0.1" - resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-2.0.1.tgz#e429714e753b6c2469af3212e7f320a6973c2812" - integrity sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg== +"@tufjs/models@4.1.0": + version "4.1.0" + resolved "https://registry.yarnpkg.com/@tufjs/models/-/models-4.1.0.tgz#494b39cf5e2f6855d80031246dd236d8086069b3" + integrity sha512-Y8cK9aggNRsqJVaKUlEYs4s7CvQ1b1ta2DVPyAimb0I2qhzjNk+A+mxvll/klL0RlfuIUei8BF7YWiua4kQqww== dependencies: "@tufjs/canonical-json" "2.0.0" - minimatch "^9.0.4" + minimatch "^10.1.1" -"@turf/bbox@*": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.1.0.tgz#45a9287c084f7b79577ee88b7b539d83562b923b" - integrity sha512-PdWPz9tW86PD78vSZj2fiRaB8JhUHy6piSa/QXb83lucxPK+HTAdzlDQMTKj5okRCU8Ox/25IR2ep9T8NdopRA== +"@turf/bbox@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-7.3.5.tgz#bf93e0a81aa98ca8809bea1bfa6cdfd03ce2470a" + integrity sha512-oG1ya/HtBjAIg4TimbWx+nOYPbY0bCvt82Bq8tm6sBw3qqtbOyRSfDz79Sq90TnH7DXJprJ1qnVGKNtZ6jemfw== dependencies: - "@turf/helpers" "^7.1.0" - "@turf/meta" "^7.1.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" "@types/geojson" "^7946.0.10" - tslib "^2.6.2" - -"@turf/bbox@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bbox/-/bbox-6.5.0.tgz#bec30a744019eae420dac9ea46fb75caa44d8dc5" - integrity sha512-RBbLaao5hXTYyyg577iuMtDB8ehxMlUqHEJiMs8jT1GHkFhr6sYre3lmLsPeYEi/ZKj5TP5tt7fkzNdJ4GIVyw== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/bearing@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/bearing/-/bearing-6.5.0.tgz#462a053c6c644434bdb636b39f8f43fb0cd857b0" - integrity sha512-dxINYhIEMzgDOztyMZc20I7ssYVNEpSv04VbMo5YPQsqa80KO3TFvbuCahMsCAW5z8Tncc8dwBlEFrmRjJG33A== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/boolean-contains@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-6.5.0.tgz#f802e7432fb53109242d5bf57393ef2f53849bbf" - integrity sha512-4m8cJpbw+YQcKVGi8y0cHhBUnYT+QRfx6wzM4GI1IdtYH3p4oh/DOBJKrepQyiDzFDaNIjxuWXBh0ai1zVwOQQ== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/boolean-point-in-polygon" "^6.5.0" - "@turf/boolean-point-on-line" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/boolean-point-in-polygon@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz#6d2e9c89de4cd2e4365004c1e51490b7795a63cf" - integrity sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A== - dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + tslib "^2.8.1" + +"@turf/boolean-contains@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-contains/-/boolean-contains-7.3.5.tgz#8f3d3bd996b98a23953c42a5db932f17bcb98b68" + integrity sha512-P4JUAHgvJkD+8ybQ6d1OHp9TBsGsjJxF5lWeXJgp0k4+Hd/D0CVy4/mhLkZdNa6QdljVdwNcfU0CTqy1WsSQig== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/boolean-point-in-polygon" "7.3.5" + "@turf/boolean-point-on-line" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-split" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/boolean-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-6.5.0.tgz#a8efa7bad88760676f395afb9980746bc5b376e9" - integrity sha512-A1BbuQ0LceLHvq7F/P7w3QvfpmZqbmViIUPHdNLvZimFNLo4e6IQunmzbe+8aSStH9QRZm3VOflyvNeXvvpZEQ== +"@turf/boolean-point-in-polygon@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-7.3.5.tgz#4416dbde721225153590cc2204f614e44f388b55" + integrity sha512-ba7+B0wzaS9GtERZOoXUZ6oW8IcIJHNQZf3c+tiD9ESjcsPO1Q/4qIJGTKl92nBLhhracHJxMWBM/U6hAVkaRg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + point-in-polygon-hao "^1.1.0" + tslib "^2.8.1" -"@turf/destination@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/destination/-/destination-6.5.0.tgz#30a84702f9677d076130e0440d3223ae503fdae1" - integrity sha512-4cnWQlNC8d1tItOz9B4pmJdWpXqS0vEvv65bI/Pj/genJnsL7evI0/Xw42RvEGROS481MPiU80xzvwxEvhQiMQ== +"@turf/boolean-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/boolean-point-on-line/-/boolean-point-on-line-7.3.5.tgz#9ae059b836240c5e4619c5632682db8acbe511b6" + integrity sha512-TuWfrAT63W43BDzgYc94UzQ5/PjF1aTnh4AIzmQwez1YnimShYcOTwo8OIHzDaB6gbbvFsfxYMuOA5JOp942Kg== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/distance@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-6.5.0.tgz#21f04d5f86e864d54e2abde16f35c15b4f36149a" - integrity sha512-xzykSLfoURec5qvQJcfifw/1mJa+5UwByZZ5TZ8iaqjGYN0vomhV9aiSLeYdUGtYRESZ+DYC/OzY+4RclZYgMg== +"@turf/distance@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/distance/-/distance-7.3.5.tgz#3aa16a1fde30e5cf4cf40b7e497be8cc5d6bbaaf" + integrity sha512-uQAC63zg/l91KUxzfhqio7Ii3+UXTrPOVJScIdRj6EO6+9XHI4kC+AdyIS4cPAv14sZfJLIBxzMnzcGrss+kEA== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - -"@turf/helpers@6.x", "@turf/helpers@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-6.5.0.tgz#f79af094bd6b8ce7ed2bd3e089a8493ee6cae82e" - integrity sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw== + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/helpers@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.1.0.tgz#eb734e291c9c205822acdd289fe20e91c3cb1641" - integrity sha512-dTeILEUVeNbaEeoZUOhxH5auv7WWlOShbx7QSd4s0T4Z0/iz90z9yaVCtZOLbU89umKotwKaJQltBNO9CzVgaQ== +"@turf/geojson-rbush@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/geojson-rbush/-/geojson-rbush-7.3.5.tgz#32e38b0b85b4dcf9af5aa433ddbb2e2702048e0a" + integrity sha512-30/hQqc+ErnlcavvDdxGfgm8VtsJDEzSYpf3mPqYxOyI976l49T6+1jCQD5xKswml6o8zZAaTSe6ZcSKF+SCNw== dependencies: + "@turf/bbox" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" "@types/geojson" "^7946.0.10" - tslib "^2.6.2" + rbush "^3.0.1" + tslib "^2.8.1" -"@turf/invariant@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-6.5.0.tgz#970afc988023e39c7ccab2341bd06979ddc7463f" - integrity sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg== +"@turf/helpers@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/helpers/-/helpers-7.3.5.tgz#051928c03cdf9ffcc7ae36581c317afc49bfd999" + integrity sha512-E/NMGV5MwbjjP7AJXBtsanC3yY8N2MQ87IGdIgkB2ji5AtBpwnH4L3gEqpYN4RlCJJWbLbzO91BbKv2waUd0eg== dependencies: - "@turf/helpers" "^6.5.0" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/kinks@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-6.5.0.tgz#80e7456367535365012f658cf1a988b39a2c920b" - integrity sha512-ViCngdPt1eEL7hYUHR2eHR662GvCgTc35ZJFaNR6kRtr6D8plLaDju0FILeFFWSc+o8e3fwxZEJKmFj9IzPiIQ== +"@turf/invariant@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/invariant/-/invariant-7.3.5.tgz#5619d0e0ef3755e2be69855bad47e10158a1820b" + integrity sha512-ZVIvsBvjr8lO7WxC5zYNjRsjSDvyGvWkJMjuWaJjTU8x+1tmfNnw3gDX/TI2Sit83gcRYLYkNo23lB/udqx/Hg== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/line-intersect@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-6.5.0.tgz#dea48348b30c093715d2195d2dd7524aee4cf020" - integrity sha512-CS6R1tZvVQD390G9Ea4pmpM6mJGPWoL82jD46y0q1KSor9s6HupMIo1kY4Ny+AEYQl9jd21V3Scz20eldpbTVA== +"@turf/kinks@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/kinks/-/kinks-7.3.5.tgz#746c2295dd8e7ddc8822d13fc6b9ae8d4148babc" + integrity sha512-dPW8d4vs1v8WMobjyq/TVqajjPwkMsl94IF58yp1UYlmJDQrW4iNRUmI9fFzww+fl7epCKNwY+jZhXf1DRi93w== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - geojson-rbush "3.x" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/line-segment@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-6.5.0.tgz#ee73f3ffcb7c956203b64ed966d96af380a4dd65" - integrity sha512-jI625Ho4jSuJESNq66Mmi290ZJ5pPZiQZruPVpmHkUw257Pew0alMmb6YrqYNnLUuiVVONxAAKXUVeeUGtycfw== +"@turf/line-intersect@7.3.5", "@turf/line-intersect@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-intersect/-/line-intersect-7.3.5.tgz#841f8f713218cbd4532269754e3e1f6a15d4e374" + integrity sha512-2Cl4oPsjaDdfIwz/5IRDdG2fNdfp3W6atICm81vnzl/GwURoVP+CLjXJ64QWWzpzIbgX2XprJQTmamByDt5MDw== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/meta" "^6.5.0" + "@turf/helpers" "7.3.5" + "@types/geojson" "^7946.0.10" + sweepline-intersections "^1.5.0" + tslib "^2.8.1" -"@turf/line-split@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-6.5.0.tgz#116d7fbf714457878225187f5820ef98db7b02c2" - integrity sha512-/rwUMVr9OI2ccJjw7/6eTN53URtGThNSD5I0GgxyFXMtxWiloRJ9MTff8jBbtPWrRka/Sh2GkwucVRAEakx9Sw== - dependencies: - "@turf/bbox" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/line-segment" "^6.5.0" - "@turf/meta" "^6.5.0" - "@turf/nearest-point-on-line" "^6.5.0" - "@turf/square" "^6.5.0" - "@turf/truncate" "^6.5.0" - geojson-rbush "3.x" - -"@turf/meta@6.x", "@turf/meta@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-6.5.0.tgz#b725c3653c9f432133eaa04d3421f7e51e0418ca" - integrity sha512-RrArvtsV0vdsCBegoBtOalgdSOfkBrTJ07VkpiCnq/491W67hnMWmDu7e6Ztw0C3WldRYTXkg3SumfdzZxLBHA== +"@turf/line-segment@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-segment/-/line-segment-7.3.5.tgz#275f03fd86640fce44b40d6806b95dc842801156" + integrity sha512-TM1aCu7utM6fllAEHO8PNqBJZ/uoFJVNp2A0YI7FyWN928hPbacsvNtLeVz/Kq1ZbeqQ1ZIKRxo9FdVjaj8hGg== dependencies: - "@turf/helpers" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" + +"@turf/line-split@7.3.5", "@turf/line-split@^7.3.3": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/line-split/-/line-split-7.3.5.tgz#63aa7c5d3fc606db7c4beff33dccc8786fc9c07b" + integrity sha512-GEuy+LdbbaqtYjHk/i1G8sK51wfCdPqTO8uH0dJZ6WlcIcZQfRcKKI4ksFm7NkVyfmw8gXWbpMJD8lO380GFBQ== + dependencies: + "@turf/bbox" "7.3.5" + "@turf/geojson-rbush" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/line-intersect" "7.3.5" + "@turf/line-segment" "7.3.5" + "@turf/meta" "7.3.5" + "@turf/nearest-point-on-line" "7.3.5" + "@turf/truncate" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/meta@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.1.0.tgz#b2af85afddd0ef08aeae8694a12370a4f06b6d13" - integrity sha512-ZgGpWWiKz797Fe8lfRj7HKCkGR+nSJ/5aKXMyofCvLSc2PuYJs/qyyifDPWjASQQCzseJ7AlF2Pc/XQ/3XkkuA== +"@turf/meta@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/meta/-/meta-7.3.5.tgz#c498a6f1e8603e014e8bfbc9a4b2760b6219edb9" + integrity sha512-r+ohqxoyqeigFB0oFrQx/YEHIkOKqcKpCjvZkvZs7Tkv+IFco5MezAd2zd4rzK+0DfFgDP3KpJc7HqrYjvEjhg== dependencies: - "@turf/helpers" "^7.1.0" + "@turf/helpers" "7.3.5" "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/nearest-point-on-line@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-6.5.0.tgz#8e1cd2cdc0b5acaf4c8d8b3b33bb008d3cb99e7b" - integrity sha512-WthrvddddvmymnC+Vf7BrkHGbDOUu6Z3/6bFYUGv1kxw8tiZ6n83/VG6kHz4poHOfS0RaNflzXSkmCi64fLBlg== - dependencies: - "@turf/bearing" "^6.5.0" - "@turf/destination" "^6.5.0" - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" - "@turf/invariant" "^6.5.0" - "@turf/line-intersect" "^6.5.0" - "@turf/meta" "^6.5.0" - -"@turf/square@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/square/-/square-6.5.0.tgz#ab43eef99d39c36157ab5b80416bbeba1f6b2122" - integrity sha512-BM2UyWDmiuHCadVhHXKIx5CQQbNCpOxB6S/aCNOCLbhCeypKX5Q0Aosc5YcmCJgkwO5BERCC6Ee7NMbNB2vHmQ== +"@turf/nearest-point-on-line@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/nearest-point-on-line/-/nearest-point-on-line-7.3.5.tgz#e74515e017feff2c8d7c208b634a5cbfdbebe998" + integrity sha512-MZn6OkEFZpjS6BNUANfqiHMIbQSivu7TNji3a+OAIrnPJ71vp8cbz0N2aVEa5M7I8ipvxoxAPIV3eqg3h280Vg== dependencies: - "@turf/distance" "^6.5.0" - "@turf/helpers" "^6.5.0" + "@turf/distance" "7.3.5" + "@turf/helpers" "7.3.5" + "@turf/invariant" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" -"@turf/truncate@^6.5.0": - version "6.5.0" - resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-6.5.0.tgz#c3a16cad959f1be1c5156157d5555c64b19185d8" - integrity sha512-pFxg71pLk+eJj134Z9yUoRhIi8vqnnKvCYwdT4x/DQl/19RVdq1tV3yqOT3gcTQNfniteylL5qV1uTBDV5sgrg== +"@turf/truncate@7.3.5": + version "7.3.5" + resolved "https://registry.yarnpkg.com/@turf/truncate/-/truncate-7.3.5.tgz#d57a856994e2928e798fc7929e42cc2890d1b201" + integrity sha512-Qx2iv3KIqKuDAUduMfaJ5fFegEWBeRve5zePalRevS16bMUqEX+jnKPK9fWGyUuPqT61qP1Kybz0PTWPbUbljQ== dependencies: - "@turf/helpers" "^6.5.0" - "@turf/meta" "^6.5.0" + "@turf/helpers" "7.3.5" + "@turf/meta" "7.3.5" + "@types/geojson" "^7946.0.10" + tslib "^2.8.1" "@types/ace-diff@^2.1.4": version "2.1.4" @@ -2647,15 +3062,241 @@ dependencies: "@types/node" "*" -"@types/estree@1.0.5": - 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/d3-array@*": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" + integrity sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw== + +"@types/d3-axis@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-axis/-/d3-axis-3.0.6.tgz#e760e5765b8188b1defa32bc8bb6062f81e4c795" + integrity sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-brush@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-brush/-/d3-brush-3.0.6.tgz#c2f4362b045d472e1b186cdbec329ba52bdaee6c" + integrity sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-chord@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-chord/-/d3-chord-3.0.6.tgz#1706ca40cf7ea59a0add8f4456efff8f8775793d" + integrity sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg== + +"@types/d3-color@*": + version "3.1.3" + resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" + integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== + +"@types/d3-contour@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-contour/-/d3-contour-3.0.6.tgz#9ada3fa9c4d00e3a5093fed0356c7ab929604231" + integrity sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg== + dependencies: + "@types/d3-array" "*" + "@types/geojson" "*" + +"@types/d3-delaunay@*": + version "6.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-delaunay/-/d3-delaunay-6.0.4.tgz#185c1a80cc807fdda2a3fe960f7c11c4a27952e1" + integrity sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw== + +"@types/d3-dispatch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dispatch/-/d3-dispatch-3.0.7.tgz#ef004d8a128046cfce434d17182f834e44ef95b2" + integrity sha512-5o9OIAdKkhN1QItV2oqaE5KMIiXAvDWBDPrD85e58Qlz1c1kI/J0NcqbEG88CoTwJrYe7ntUCVfeUl2UJKbWgA== + +"@types/d3-drag@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-dsv@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-dsv/-/d3-dsv-3.0.7.tgz#0a351f996dc99b37f4fa58b492c2d1c04e3dac17" + integrity sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g== + +"@types/d3-ease@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" + integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== + +"@types/d3-fetch@*": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-fetch/-/d3-fetch-3.0.7.tgz#c04a2b4f23181aa376f30af0283dbc7b3b569980" + integrity sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA== + dependencies: + "@types/d3-dsv" "*" + +"@types/d3-force@*": + version "3.0.10" + resolved "https://registry.yarnpkg.com/@types/d3-force/-/d3-force-3.0.10.tgz#6dc8fc6e1f35704f3b057090beeeb7ac674bff1a" + integrity sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw== + +"@types/d3-format@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-format/-/d3-format-3.0.4.tgz#b1e4465644ddb3fdf3a263febb240a6cd616de90" + integrity sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g== + +"@types/d3-geo@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-geo/-/d3-geo-3.1.0.tgz#b9e56a079449174f0a2c8684a9a4df3f60522440" + integrity sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ== + dependencies: + "@types/geojson" "*" + +"@types/d3-hierarchy@*": + version "3.1.7" + resolved "https://registry.yarnpkg.com/@types/d3-hierarchy/-/d3-hierarchy-3.1.7.tgz#6023fb3b2d463229f2d680f9ac4b47466f71f17b" + integrity sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg== + +"@types/d3-interpolate@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" + integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== + dependencies: + "@types/d3-color" "*" + +"@types/d3-path@*": + version "3.1.1" + resolved "https://registry.yarnpkg.com/@types/d3-path/-/d3-path-3.1.1.tgz#f632b380c3aca1dba8e34aa049bcd6a4af23df8a" + integrity sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg== + +"@types/d3-polygon@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-polygon/-/d3-polygon-3.0.2.tgz#dfae54a6d35d19e76ac9565bcb32a8e54693189c" + integrity sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA== -"@types/estree@^1.0.5", "@types/estree@^1.0.6": - version "1.0.6" - resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" - integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== +"@types/d3-quadtree@*": + version "3.0.6" + resolved "https://registry.yarnpkg.com/@types/d3-quadtree/-/d3-quadtree-3.0.6.tgz#d4740b0fe35b1c58b66e1488f4e7ed02952f570f" + integrity sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg== + +"@types/d3-random@*": + version "3.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-random/-/d3-random-3.0.3.tgz#ed995c71ecb15e0cd31e22d9d5d23942e3300cfb" + integrity sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ== + +"@types/d3-scale-chromatic@*": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@types/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz#dc6d4f9a98376f18ea50bad6c39537f1b5463c39" + integrity sha512-iWMJgwkK7yTRmWqRB5plb1kadXyQ5Sj8V/zYlFGMUBbIPKQScw+Dku9cAAMgJG+z5GYDoMjWGLVOvjghDEFnKQ== + +"@types/d3-scale@*": + version "4.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-scale/-/d3-scale-4.0.9.tgz#57a2f707242e6fe1de81ad7bfcccaaf606179afb" + integrity sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw== + dependencies: + "@types/d3-time" "*" + +"@types/d3-selection@*": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + +"@types/d3-shape@*": + version "3.1.8" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" + integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + dependencies: + "@types/d3-path" "*" + +"@types/d3-time-format@*": + version "4.0.3" + resolved "https://registry.yarnpkg.com/@types/d3-time-format/-/d3-time-format-4.0.3.tgz#d6bc1e6b6a7db69cccfbbdd4c34b70632d9e9db2" + integrity sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg== + +"@types/d3-time@*": + version "3.0.4" + resolved "https://registry.yarnpkg.com/@types/d3-time/-/d3-time-3.0.4.tgz#8472feecd639691450dd8000eb33edd444e1323f" + integrity sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g== + +"@types/d3-timer@*": + version "3.0.2" + resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" + integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== + +"@types/d3-transition@*": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@*": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + +"@types/d3@^7.4.3": + version "7.4.3" + resolved "https://registry.yarnpkg.com/@types/d3/-/d3-7.4.3.tgz#d4550a85d08f4978faf0a4c36b848c61eaac07e2" + integrity sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww== + dependencies: + "@types/d3-array" "*" + "@types/d3-axis" "*" + "@types/d3-brush" "*" + "@types/d3-chord" "*" + "@types/d3-color" "*" + "@types/d3-contour" "*" + "@types/d3-delaunay" "*" + "@types/d3-dispatch" "*" + "@types/d3-drag" "*" + "@types/d3-dsv" "*" + "@types/d3-ease" "*" + "@types/d3-fetch" "*" + "@types/d3-force" "*" + "@types/d3-format" "*" + "@types/d3-geo" "*" + "@types/d3-hierarchy" "*" + "@types/d3-interpolate" "*" + "@types/d3-path" "*" + "@types/d3-polygon" "*" + "@types/d3-quadtree" "*" + "@types/d3-random" "*" + "@types/d3-scale" "*" + "@types/d3-scale-chromatic" "*" + "@types/d3-selection" "*" + "@types/d3-shape" "*" + "@types/d3-time" "*" + "@types/d3-time-format" "*" + "@types/d3-timer" "*" + "@types/d3-transition" "*" + "@types/d3-zoom" "*" + +"@types/eslint-scope@^3.7.7": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/esrecurse@^4.3.1": + version "4.3.1" + resolved "https://registry.yarnpkg.com/@types/esrecurse/-/esrecurse-4.3.1.tgz#6f636af962fbe6191b830bd676ba5986926bccec" + integrity sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw== + +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.6", "@types/estree@^1.0.8": + version "1.0.8" + resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.8.tgz#958b91c991b1867ced318bedea0e215ee050726e" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/express-serve-static-core@*", "@types/express-serve-static-core@^5.0.0": version "5.0.0" @@ -2667,10 +3308,10 @@ "@types/range-parser" "*" "@types/send" "*" -"@types/express-serve-static-core@^4.17.33": - version "4.19.6" - resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.6.tgz#e01324c2a024ff367d92c66f48553ced0ab50267" - integrity sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A== +"@types/express-serve-static-core@^4.17.21", "@types/express-serve-static-core@^4.17.33": + version "4.19.8" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz#99b960322a4d576b239a640ab52ef191989b036f" + integrity sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA== dependencies: "@types/node" "*" "@types/qs" "*" @@ -2721,11 +3362,6 @@ resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.16.tgz#8ebe53d69efada7044454e3305c19017d97ced2a" integrity sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg== -"@types/geojson@7946.0.8": - version "7946.0.8" - resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.8.tgz#30744afdb385e2945e22f3b033f897f76b1f12ca" - integrity sha512-1rkryxURpr6aWP7R786/UQOkJ3PcpQiWkAXBmdWc7ryFWqN6a4xfK7BtjXvFBKO9LjQ+MWQSWxYeZX1OApnArA== - "@types/hammerjs@^2.0.45": version "2.0.45" resolved "https://registry.yarnpkg.com/@types/hammerjs/-/hammerjs-2.0.45.tgz#ffa764bb68a66c08db6efb9c816eb7be850577b1" @@ -2743,10 +3379,10 @@ dependencies: "@types/node" "*" -"@types/jquery@*", "@types/jquery@^3.5.29", "@types/jquery@^3.5.32": - version "3.5.32" - resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.32.tgz#3eb0da20611b92c7c49ebed6163b52a4fdc57def" - integrity sha512-b9Xbf4CkMqS02YH8zACqN1xzdxc3cO735Qe5AbSUFmyOiaWAbcpqh9Wna+Uk0vgACvoQHpWDg2rGdHkYPLmCiQ== +"@types/jquery@*", "@types/jquery@^3.5.29", "@types/jquery@^3.5.33": + version "3.5.33" + resolved "https://registry.yarnpkg.com/@types/jquery/-/jquery-3.5.33.tgz#f42f40bac3edd84abdc9f6297d28e570fe463b35" + integrity sha512-SeyVJXlCZpEki5F0ghuYe+L+PprQta6nRZqhONt9F13dWBtR/ftoaIbdRQ7cis7womE+X2LKhsDdDtkkDhJS6g== dependencies: "@types/sizzle" "*" @@ -2755,7 +3391,7 @@ resolved "https://registry.yarnpkg.com/@types/js-beautify/-/js-beautify-1.14.3.tgz#6ced76f79935e37e0d613110dea369881d93c1ff" integrity sha512-FMbQHz+qd9DoGvgLHxeqqVPaNRffpIu5ZjozwV8hf9JAGpIOzuAf4wGbRSo8LNITHqGjmmVjaMggTT5P4v4IHg== -"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8", "@types/json-schema@^7.0.9": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.9": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -2800,10 +3436,10 @@ dependencies: "@types/geojson" "*" -"@types/lodash@^4.17.13": - version "4.17.13" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.13.tgz#786e2d67cfd95e32862143abe7463a7f90c300eb" - integrity sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg== +"@types/lodash@^4.17.22": + version "4.17.23" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.17.23.tgz#c1bb06db218acc8fc232da0447473fc2fb9d9841" + integrity sha512-RDvF6wTulMPjrNdCoYRC8gNR880JNGT8uB+REUpC2Ns4pRqQJhGz90wh7rgdXDPpCczF3VGktDuFGVnz8zP7HA== "@types/mapbox__point-geometry@*", "@types/mapbox__point-geometry@^0.1.4": version "0.1.4" @@ -2829,13 +3465,6 @@ resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.15.tgz#f144a0c539a4cef553a631824651d48267e53c86" integrity sha512-qL0hyIMNPow317QWW/63RvL1x5MVMV+Ru3NaY9f/CuEpCqrmb7WeuK2071ZY5hczOnm38qExWM2i2WtkXLSqFw== -"@types/mute-stream@^0.0.4": - version "0.0.4" - resolved "https://registry.yarnpkg.com/@types/mute-stream/-/mute-stream-0.0.4.tgz#77208e56a08767af6c5e1237be8888e2f255c478" - integrity sha512-CPM9nzrCPPJHQNA9keH9CVkVI+WR5kMa+7XEs5jcGQ0VoAGnLv242w8lIVgwAEfmE4oufJRaTc9PNLQl0ioAow== - dependencies: - "@types/node" "*" - "@types/node-forge@^1.3.0": version "1.3.11" resolved "https://registry.yarnpkg.com/@types/node-forge/-/node-forge-1.3.11.tgz#0972ea538ddb0f4d9c2fa0ec5db5724773a604da" @@ -2843,19 +3472,12 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^22.5.5": - version "22.8.7" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.8.7.tgz#04ab7a073d95b4a6ee899f235d43f3c320a976f4" - integrity sha512-LidcG+2UeYIWcMuMUpBKOnryBWG/rnmOHQR5apjn8myTQcx3rinFRn7DcIFhMnS0PPFSC6OafdIKEad0lj6U0Q== +"@types/node@*", "@types/node@~22.18.13": + version "22.18.13" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.18.13.tgz#a037c4f474b860be660e05dbe92a9ef945472e28" + integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A== dependencies: - undici-types "~6.19.8" - -"@types/node@~20.17.8": - version "20.17.8" - resolved "https://registry.yarnpkg.com/@types/node/-/node-20.17.8.tgz#42748cdb169adf5be7c9760604c72820c7b7d560" - integrity sha512-ahz2g6/oqbKalW9sPv6L2iRbhLnojxjYWspAqhjvqSWBgGebEJT5GvRmk0QXPj3sbC6rU0GTQjPLQkmR8CObvA== - dependencies: - undici-types "~6.19.2" + undici-types "~6.21.0" "@types/pbf@*", "@types/pbf@^3.0.5": version "3.0.5" @@ -2925,10 +3547,10 @@ dependencies: "@types/geojson" "*" -"@types/systemjs@6.15.1": - version "6.15.1" - resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.15.1.tgz#dae1ec2fbe66af7c6ca1a110e2c9ca6b85135eec" - integrity sha512-MfDFIN+jRQOX1JRBrbbb72tsFJnK0n7mtLC+L2Y3t7As/vFxJiFGA/09FE+6ssFheHAibd8Q3gs959c+Sgf/9A== +"@types/systemjs@6.15.3": + version "6.15.3" + resolved "https://registry.yarnpkg.com/@types/systemjs/-/systemjs-6.15.3.tgz#d758dceafee62d2ed4aad7d07342b145d623e260" + integrity sha512-STyj2LUevlyVqEQ1wjOORLQTJbNnM2V1DNzmemxVHlOovdKBKqccALDbR9aCcTRThhcXzew88SMbN4SMm6JOcw== "@types/tinycolor2@^1.4.6": version "1.4.6" @@ -2942,10 +3564,10 @@ dependencies: "@types/jquery" "*" -"@types/wrap-ansi@^3.0.0": - version "3.0.0" - resolved "https://registry.yarnpkg.com/@types/wrap-ansi/-/wrap-ansi-3.0.0.tgz#18b97a972f94f60a679fd5c796d96421b9abb9fd" - integrity sha512-ltIpx+kM7g/MLRZfkbL7EsCEjfzCcScLpkg37eXEtx5kmrAKBkTJwd1GIAjDSL8wTpM6Hzn5YO4pSb91BEwu1g== +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== "@types/ws@^8.5.10": version "8.5.12" @@ -2954,211 +3576,234 @@ dependencies: "@types/node" "*" -"@typescript-eslint/eslint-plugin@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.1.tgz#992e5ac1553ce20d0d46aa6eccd79dc36dedc805" - integrity sha512-Ncvsq5CT3Gvh+uJG0Lwlho6suwDfUXH0HztslDf5I+F2wAFAZMRwYLEorumpKLzmO2suAXZ/td1tBg4NZIi9CQ== - dependencies: - "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.18.1" - "@typescript-eslint/type-utils" "8.18.1" - "@typescript-eslint/utils" "8.18.1" - "@typescript-eslint/visitor-keys" "8.18.1" - graphemer "^1.4.0" - ignore "^5.3.1" +"@typescript-eslint/eslint-plugin@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz#d8899e5c2eccf5c4a20d01c036a193753748454d" + integrity sha512-hAAP5io/7csFStuOmR782YmTthKBJ9ND3WVL60hcOjvtGFb+HJxH4O5huAcmcZ9v9G8P+JETiZ/G1B8MALnWZQ== + dependencies: + "@eslint-community/regexpp" "^4.12.2" + "@typescript-eslint/scope-manager" "8.54.0" + "@typescript-eslint/type-utils" "8.54.0" + "@typescript-eslint/utils" "8.54.0" + "@typescript-eslint/visitor-keys" "8.54.0" + ignore "^7.0.5" natural-compare "^1.4.0" - ts-api-utils "^1.3.0" - -"@typescript-eslint/parser@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.18.1.tgz#c258bae062778b7696793bc492249027a39dfb95" - integrity sha512-rBnTWHCdbYM2lh7hjyXqxk70wvon3p2FyaniZuey5TrcGBpfhVp0OxOa6gxr9Q9YhZFKyfbEnxc24ZnVbbUkCA== - dependencies: - "@typescript-eslint/scope-manager" "8.18.1" - "@typescript-eslint/types" "8.18.1" - "@typescript-eslint/typescript-estree" "8.18.1" - "@typescript-eslint/visitor-keys" "8.18.1" - debug "^4.3.4" - -"@typescript-eslint/scope-manager@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.18.1.tgz#52cedc3a8178d7464a70beffed3203678648e55b" - integrity sha512-HxfHo2b090M5s2+/9Z3gkBhI6xBH8OJCFjH9MhQ+nnoZqxU3wNxkLT+VWXWSFWc3UF3Z+CfPAyqdCTdoXtDPCQ== - dependencies: - "@typescript-eslint/types" "8.18.1" - "@typescript-eslint/visitor-keys" "8.18.1" - -"@typescript-eslint/type-utils@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.18.1.tgz#10f41285475c0bdee452b79ff7223f0e43a7781e" - integrity sha512-jAhTdK/Qx2NJPNOTxXpMwlOiSymtR2j283TtPqXkKBdH8OAMmhiUfP0kJjc/qSE51Xrq02Gj9NY7MwK+UxVwHQ== - dependencies: - "@typescript-eslint/typescript-estree" "8.18.1" - "@typescript-eslint/utils" "8.18.1" - debug "^4.3.4" - ts-api-utils "^1.3.0" - -"@typescript-eslint/types@8.18.1", "@typescript-eslint/types@^8.0.0": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.18.1.tgz#d7f4f94d0bba9ebd088de840266fcd45408a8fff" - integrity sha512-7uoAUsCj66qdNQNpH2G8MyTFlgerum8ubf21s3TSM3XmKXuIn+H2Sifh/ES2nPOPiYSRJWAk0fDkW0APBWcpfw== - -"@typescript-eslint/typescript-estree@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.1.tgz#2a86cd64b211a742f78dfa7e6f4860413475367e" - integrity sha512-z8U21WI5txzl2XYOW7i9hJhxoKKNG1kcU4RzyNvKrdZDmbjkmLBo8bgeiOJmA06kizLI76/CCBAAGlTlEeUfyg== - dependencies: - "@typescript-eslint/types" "8.18.1" - "@typescript-eslint/visitor-keys" "8.18.1" - debug "^4.3.4" - fast-glob "^3.3.2" - is-glob "^4.0.3" - minimatch "^9.0.4" - semver "^7.6.0" - ts-api-utils "^1.3.0" - -"@typescript-eslint/utils@8.18.1", "@typescript-eslint/utils@^8.0.0": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.18.1.tgz#c4199ea23fc823c736e2c96fd07b1f7235fa92d5" - integrity sha512-8vikiIj2ebrC4WRdcAdDcmnu9Q/MXXwg+STf40BVfT8exDqBCUPdypvzcUPxEqRGKg9ALagZ0UWcYCtn+4W2iQ== - dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "8.18.1" - "@typescript-eslint/types" "8.18.1" - "@typescript-eslint/typescript-estree" "8.18.1" - -"@typescript-eslint/visitor-keys@8.18.1": - version "8.18.1" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.1.tgz#344b4f6bc83f104f514676facf3129260df7610a" - integrity sha512-Vj0WLm5/ZsD013YeUKn+K0y8p1M0jPpxOkKdbD1wB0ns53a5piVY02zjf072TblEweAbcYiFiPoSMF3kp+VhhQ== - dependencies: - "@typescript-eslint/types" "8.18.1" - eslint-visitor-keys "^4.2.0" + ts-api-utils "^2.4.0" + +"@typescript-eslint/parser@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.54.0.tgz#3d01a6f54ed247deb9982621f70e7abf1810bd97" + integrity sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA== + dependencies: + "@typescript-eslint/scope-manager" "8.54.0" + "@typescript-eslint/types" "8.54.0" + "@typescript-eslint/typescript-estree" "8.54.0" + "@typescript-eslint/visitor-keys" "8.54.0" + debug "^4.4.3" + +"@typescript-eslint/project-service@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.54.0.tgz#f582aceb3d752544c8e1b11fea8d95d00cf9adc6" + integrity sha512-YPf+rvJ1s7MyiWM4uTRhE4DvBXrEV+d8oC3P9Y2eT7S+HBS0clybdMIPnhiATi9vZOYDc7OQ1L/i6ga6NFYK/g== + dependencies: + "@typescript-eslint/tsconfig-utils" "^8.54.0" + "@typescript-eslint/types" "^8.54.0" + debug "^4.4.3" + +"@typescript-eslint/scope-manager@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.54.0.tgz#307dc8cbd80157e2772c2d36216857415a71ab33" + integrity sha512-27rYVQku26j/PbHYcVfRPonmOlVI6gihHtXFbTdB5sb6qA0wdAQAbyXFVarQ5t4HRojIz64IV90YtsjQSSGlQg== + dependencies: + "@typescript-eslint/types" "8.54.0" + "@typescript-eslint/visitor-keys" "8.54.0" + +"@typescript-eslint/tsconfig-utils@8.54.0", "@typescript-eslint/tsconfig-utils@^8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.54.0.tgz#71dd7ba1674bd48b172fc4c85b2f734b0eae3dbc" + integrity sha512-dRgOyT2hPk/JwxNMZDsIXDgyl9axdJI3ogZ2XWhBPsnZUv+hPesa5iuhdYt2gzwA9t8RE5ytOJ6xB0moV0Ujvw== + +"@typescript-eslint/type-utils@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.54.0.tgz#64965317dd4118346c2fa5ee94492892200e9fb9" + integrity sha512-hiLguxJWHjjwL6xMBwD903ciAwd7DmK30Y9Axs/etOkftC3ZNN9K44IuRD/EB08amu+Zw6W37x9RecLkOo3pMA== + dependencies: + "@typescript-eslint/types" "8.54.0" + "@typescript-eslint/typescript-estree" "8.54.0" + "@typescript-eslint/utils" "8.54.0" + debug "^4.4.3" + ts-api-utils "^2.4.0" + +"@typescript-eslint/types@8.54.0", "@typescript-eslint/types@^8.0.0", "@typescript-eslint/types@^8.53.1", "@typescript-eslint/types@^8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.54.0.tgz#c12d41f67a2e15a8a96fbc5f2d07b17331130889" + integrity sha512-PDUI9R1BVjqu7AUDsRBbKMtwmjWcn4J3le+5LpcFgWULN3LvHC5rkc9gCVxbrsrGmO1jfPybN5s6h4Jy+OnkAA== + +"@typescript-eslint/typescript-estree@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.54.0.tgz#3c7716905b2b811fadbd2114804047d1bfc86527" + integrity sha512-BUwcskRaPvTk6fzVWgDPdUndLjB87KYDrN5EYGetnktoeAvPtO4ONHlAZDnj5VFnUANg0Sjm7j4usBlnoVMHwA== + dependencies: + "@typescript-eslint/project-service" "8.54.0" + "@typescript-eslint/tsconfig-utils" "8.54.0" + "@typescript-eslint/types" "8.54.0" + "@typescript-eslint/visitor-keys" "8.54.0" + debug "^4.4.3" + minimatch "^9.0.5" + semver "^7.7.3" + tinyglobby "^0.2.15" + ts-api-utils "^2.4.0" + +"@typescript-eslint/utils@8.54.0", "@typescript-eslint/utils@^8.0.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.54.0.tgz#c79a4bcbeebb4f571278c0183ed1cb601d84c6c8" + integrity sha512-9Cnda8GS57AQakvRyG0PTejJNlA2xhvyNtEVIMlDWOOeEyBkYWhGPnfrIAnqxLMTSTo6q8g12XVjjev5l1NvMA== + dependencies: + "@eslint-community/eslint-utils" "^4.9.1" + "@typescript-eslint/scope-manager" "8.54.0" + "@typescript-eslint/types" "8.54.0" + "@typescript-eslint/typescript-estree" "8.54.0" + +"@typescript-eslint/visitor-keys@8.54.0": + version "8.54.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.54.0.tgz#0e4b50124b210b8600b245dd66cbad52deb15590" + integrity sha512-VFlhGSl4opC0bprJiItPQ1RfUhGDIBokcPwaFH4yiBCaNPeld/9VeXbiPO1cLyorQi1G1vL+ecBk1x8o1axORA== + dependencies: + "@typescript-eslint/types" "8.54.0" + eslint-visitor-keys "^4.2.1" + +"@upsetjs/venn.js@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@upsetjs/venn.js/-/venn.js-2.0.0.tgz#3be192038cdda927aa4f8b22ab51af82abf47f34" + integrity sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw== + optionalDependencies: + d3-selection "^3.0.0" + d3-transition "^3.0.1" -"@vitejs/plugin-basic-ssl@1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-1.1.0.tgz#8b840305a6b48e8764803435ec0c716fa27d3802" - integrity sha512-wO4Dk/rm8u7RNhOf95ZzcEmC9rYOncYgvq4z3duaJrCgjN8BxAnDVyndanfcJZ0O6XZzHz6Q0hTimxTg8Y9g/A== - -"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" - integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== - dependencies: - "@webassemblyjs/helper-numbers" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - -"@webassemblyjs/floating-point-hex-parser@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" - integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== - -"@webassemblyjs/helper-api-error@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" - integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== - -"@webassemblyjs/helper-buffer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" - integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== - -"@webassemblyjs/helper-numbers@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" - integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== - dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.6" - "@webassemblyjs/helper-api-error" "1.11.6" +"@vitejs/plugin-basic-ssl@2.1.0": + version "2.1.0" + resolved "https://registry.yarnpkg.com/@vitejs/plugin-basic-ssl/-/plugin-basic-ssl-2.1.0.tgz#c70d2a922bc437f154089d7ef0505db4b383eb7b" + integrity sha512-dOxxrhgyDIEUADhb/8OlV9JIqYLgos03YorAueTIeOUskLJSEsfwCByjbu98ctXitUN3znXKp0bYD/WHSudCeA== + +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== + dependencies: + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== + +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== + +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== + +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== + dependencies: + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" - integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== -"@webassemblyjs/helper-wasm-section@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" - integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" -"@webassemblyjs/ieee754@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" - integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" - integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" - integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== - -"@webassemblyjs/wasm-edit@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" - integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-opt" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - "@webassemblyjs/wast-printer" "1.12.1" - -"@webassemblyjs/wasm-gen@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" - integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wasm-opt@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" - integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - -"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" - integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-api-error" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wast-printer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" - integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== - dependencies: - "@webassemblyjs/ast" "1.12.1" +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" "@xtuc/ieee754@^1.2.0": @@ -3181,6 +3826,19 @@ abbrev@^2.0.0: resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-2.0.0.tgz#cf59829b8b4f03f89dda2771cb7f3653828c89bf" integrity sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ== +abbrev@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/abbrev/-/abbrev-4.0.0.tgz#ec933f0e27b6cd60e89b5c6b2a304af42209bb05" + integrity sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA== + +accepts@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/accepts/-/accepts-2.0.0.tgz#bbcf4ba5075467f3f2131eab3cffc73c2f5d7895" + integrity sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng== + dependencies: + mime-types "^3.0.0" + negotiator "^1.0.0" + accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: version "1.3.8" resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" @@ -3189,22 +3847,22 @@ accepts@~1.3.4, accepts@~1.3.5, accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -ace-builds@1.36.5: - version "1.36.5" - resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.36.5.tgz#ae9cc7a32eccc2f484926131c00545cd6b78a6a6" - integrity sha512-mZ5KVanRT6nLRDLqtG/1YQQLX/gZVC/v526cm1Ru/MTSlrbweSmqv2ZT0d2GaHpJq035MwCMIrj+LgDAUnDXrg== +ace-builds@1.43.6: + version "1.43.6" + resolved "https://registry.yarnpkg.com/ace-builds/-/ace-builds-1.43.6.tgz#6feffd0283da5bbf45a2052470fe0fc3c442831a" + integrity sha512-L1ddibQ7F3vyXR2k2fg+I8TQTPWVA6CKeDQr/h2+8CeyTp3W6EQL8xNFZRTztuP8xNOAqL3IYPqdzs31GCjDvg== -ace-diff@^3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/ace-diff/-/ace-diff-3.0.3.tgz#84f685ff3d0b1910539fc39259ac73d8b6581e28" - integrity sha512-CJaV9Oi6BWLWGL2Kj//h5BNXlRCRu1GYHPOT7o+ZSAuJv9PaL9FWr/cCf16IuSVDo7oj6VriO+qgoHR8G9McLA== +ace-diff@^3.3.0: + version "3.3.0" + resolved "https://registry.yarnpkg.com/ace-diff/-/ace-diff-3.3.0.tgz#5790cd1197b31d644544b19c2c9b86bc973594c0" + integrity sha512-EKHtp9wY3j9J4nmfxiwWzDmd5maDpWp3CAbkzdRMLXUrxwTynvsbmfads27/sBL353R1Pz+lNpLswWRtX1BiEw== dependencies: - diff-match-patch "^1.0.5" + "@sanity/diff-match-patch" "3.2.0" -acorn-import-attributes@^1.9.5: - version "1.9.5" - resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" - integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== +acorn-import-phases@^1.0.3: + version "1.0.4" + resolved "https://registry.yarnpkg.com/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz#16eb850ba99a056cb7cbfe872ffb8972e18c8bd7" + integrity sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ== acorn-jsx@^5.3.2: version "5.3.2" @@ -3218,10 +3876,10 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^8.11.0, acorn@^8.11.3, acorn@^8.14.0, acorn@^8.4.1, acorn@^8.7.1, acorn@^8.8.2: - version "8.14.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" - integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== +acorn@^8.11.0, acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.1: + version "8.16.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" + integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== adjust-sourcemap-loader@^4.0.0: version "4.0.0" @@ -3231,22 +3889,12 @@ adjust-sourcemap-loader@^4.0.0: loader-utils "^2.0.0" regex-parser "^2.2.11" -agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1: - 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" - -aggregate-error@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/aggregate-error/-/aggregate-error-3.1.0.tgz#92670ff50f5359bdb7a3e0d40d0ec30c5737687a" - integrity sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA== - dependencies: - clean-stack "^2.0.0" - indent-string "^4.0.0" +agent-base@^7.1.0, agent-base@^7.1.2: + version "7.1.4" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.4.tgz#e3cd76d4c548ee895d3c3fd8dc1f6c5b9032e7a8" + integrity sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ== -ajv-formats@3.0.1: +ajv-formats@3.0.1, ajv-formats@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/ajv-formats/-/ajv-formats-3.0.1.tgz#3d5dc762bca17679c3c2ea7e90ad6b7532309578" integrity sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ== @@ -3260,11 +3908,6 @@ ajv-formats@^2.1.1: dependencies: ajv "^8.0.0" -ajv-keywords@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-3.5.2.tgz#31f29da5ab6e00d1c2d329acf7b5929614d5014d" - integrity sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ== - ajv-keywords@^5.1.0: version "5.1.0" resolved "https://registry.yarnpkg.com/ajv-keywords/-/ajv-keywords-5.1.0.tgz#69d4d385a4733cdbeab44964a1170a88f87f0e16" @@ -3272,45 +3915,65 @@ ajv-keywords@^5.1.0: dependencies: fast-deep-equal "^3.1.3" -ajv@8.17.1, ajv@^8.0.0, ajv@^8.9.0: - version "8.17.1" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.17.1.tgz#37d9a5c776af6bc92d7f4f9510eba4c0a60d11a6" - integrity sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g== +ajv@8.18.0, ajv@^8.0.0, ajv@^8.17.1, ajv@^8.9.0: + version "8.18.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-8.18.0.tgz#8864186b6738d003eb3a933172bb3833e10cefbc" + integrity sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A== dependencies: fast-deep-equal "^3.1.3" fast-uri "^3.0.1" json-schema-traverse "^1.0.0" require-from-string "^2.0.2" -ajv@^6.12.4, ajv@^6.12.5: - version "6.12.6" - resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4" - integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g== +ajv@^6.14.0: + version "6.14.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.14.0.tgz#fd067713e228210636ebb08c60bd3765d6dbe73a" + integrity sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw== 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" -angular-eslint@~18.4.3: - version "18.4.3" - resolved "https://registry.yarnpkg.com/angular-eslint/-/angular-eslint-18.4.3.tgz#d149df075304af9d4f57661c6886b0e1135c2f2b" - integrity sha512-0ZjLzzADGRLUhZC8ZpwSo6CE/m6QhQB/oljMJ0mEfP+lB1sy1v8PBKNsJboIcfEEgGW669Z/efVQ3df88yJLYg== - dependencies: - "@angular-devkit/core" ">= 18.0.0 < 19.0.0" - "@angular-devkit/schematics" ">= 18.0.0 < 19.0.0" - "@angular-eslint/builder" "18.4.3" - "@angular-eslint/eslint-plugin" "18.4.3" - "@angular-eslint/eslint-plugin-template" "18.4.3" - "@angular-eslint/schematics" "18.4.3" - "@angular-eslint/template-parser" "18.4.3" +algoliasearch@5.35.0: + version "5.35.0" + resolved "https://registry.yarnpkg.com/algoliasearch/-/algoliasearch-5.35.0.tgz#ce12d1d287d6f4a80b9998568f806c92dabba566" + integrity sha512-Y+moNhsqgLmvJdgTsO4GZNgsaDWv8AOGAaPeIeHKlDn/XunoAqYbA+XNpBd1dW8GOXAUDyxC9Rxc7AV4kpFcIg== + dependencies: + "@algolia/abtesting" "1.1.0" + "@algolia/client-abtesting" "5.35.0" + "@algolia/client-analytics" "5.35.0" + "@algolia/client-common" "5.35.0" + "@algolia/client-insights" "5.35.0" + "@algolia/client-personalization" "5.35.0" + "@algolia/client-query-suggestions" "5.35.0" + "@algolia/client-search" "5.35.0" + "@algolia/ingestion" "1.35.0" + "@algolia/monitoring" "1.35.0" + "@algolia/recommend" "5.35.0" + "@algolia/requester-browser-xhr" "5.35.0" + "@algolia/requester-fetch" "5.35.0" + "@algolia/requester-node-http" "5.35.0" + +angular-eslint@~20.7.0: + version "20.7.0" + resolved "https://registry.yarnpkg.com/angular-eslint/-/angular-eslint-20.7.0.tgz#b6468b0fd5c0eaa1d45e755550356180d9e1470a" + integrity sha512-BCiTCLO3dr8pGPaM7qLcCruWNcoNNHnLn4DPqE5tHk1TAnTx5TcGy0p/FygharZw5RjWfDHLBjFfpeh4XWLMmQ== + dependencies: + "@angular-devkit/core" ">= 20.0.0 < 21.0.0" + "@angular-devkit/schematics" ">= 20.0.0 < 21.0.0" + "@angular-eslint/builder" "20.7.0" + "@angular-eslint/eslint-plugin" "20.7.0" + "@angular-eslint/eslint-plugin-template" "20.7.0" + "@angular-eslint/schematics" "20.7.0" + "@angular-eslint/template-parser" "20.7.0" "@typescript-eslint/types" "^8.0.0" "@typescript-eslint/utils" "^8.0.0" -angular-gridster2@~18.0.1: - version "18.0.1" - resolved "https://registry.yarnpkg.com/angular-gridster2/-/angular-gridster2-18.0.1.tgz#ad04eff2c05aa693fe892ee66b6aa5e956e4dd13" - integrity sha512-+Nar4lkRGl5kgmU+WFi5d57F7X4jbehRzBZ+JQifVZKtGA1OYKzBYA2Wd1ZQP246oHXakl14BBkxTu8hLer0Cg== +angular-gridster2@~20.2.4: + version "20.2.4" + resolved "https://registry.yarnpkg.com/angular-gridster2/-/angular-gridster2-20.2.4.tgz#0ac9958db523ed4b75479cf88bee5f0d1a4aa207" + integrity sha512-UditqVW6AkFK528c08i4hXlGxofX2l5+FKF9BgHtNAiB9w2/5SJx7TMaqsHi10EKU0RW13ugE8MbAlv5bRdvzw== dependencies: tslib "^2.4.0" @@ -3328,17 +3991,10 @@ ansi-colors@4.1.3: resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.3.tgz#37611340eb2243e70cc604cad35d63270d48781b" integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== -ansi-escapes@^4.3.2: - version "4.3.2" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-4.3.2.tgz#6b2291d1db7d98b6521d5f1efa42d0f3a9feb65e" - integrity sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ== - dependencies: - type-fest "^0.21.3" - ansi-escapes@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.0.0.tgz#00fc19f491bbb18e1d481b97868204f92109bfe7" - integrity sha512-GdYO7a61mR0fOlAsvC9/rIHf7L96sBc6dEWzeOu+KAea5bZyQRPIpojrVoI4AXGJS/ycu/fBTdLrUkA4ODrvjw== + version "7.3.0" + resolved "https://registry.yarnpkg.com/ansi-escapes/-/ansi-escapes-7.3.0.tgz#5395bb74b2150a4a1d6e3c2565f4aeca78d28627" + integrity sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg== dependencies: environment "^1.0.0" @@ -3352,10 +4008,10 @@ ansi-regex@^5.0.1: resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304" integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== -ansi-regex@^6.0.1: - version "6.1.0" - resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.1.0.tgz#95ec409c69619d6cb1b8b34f14b660ef28ebd654" - integrity sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA== +ansi-regex@^6.2.2: + version "6.2.2" + resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-6.2.2.tgz#60216eea464d864597ce2832000738a0589650c1" + integrity sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg== ansi-styles@^3.2.1: version "3.2.1" @@ -3372,9 +4028,9 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0: color-convert "^2.0.1" ansi-styles@^6.0.0, ansi-styles@^6.1.0, ansi-styles@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.1.tgz#0e62320cf99c21afff3b3012192546aacbfb05c5" - integrity sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug== + version "6.2.3" + resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-6.2.3.tgz#c044d5dcc521a076413472597a1acb1f103c4041" + integrity sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg== ansidec@^0.3.4: version "0.3.4" @@ -3436,76 +4092,83 @@ array-back@^4.0.1, array-back@^4.0.2: resolved "https://registry.yarnpkg.com/array-back/-/array-back-4.0.2.tgz#8004e999a6274586beeb27342168652fdb89fa1e" integrity sha512-NbdMezxqf94cnNfWLL7V/im0Ub+Anbb0IoZhvzie8+4HJ4nMQuzHuy49FkGYCJK2yAloZ3meiB6AVMClbrI1vg== -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== +array-buffer-byte-length@^1.0.1, array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== dependencies: - call-bind "^1.0.5" - is-array-buffer "^3.0.4" + call-bound "^1.0.3" + is-array-buffer "^3.0.5" array-flatten@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== -array-includes@^3.1.8: - version "3.1.8" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" - integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" 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" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" -array.prototype.findlastindex@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d" - integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ== +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" define-properties "^1.2.1" - es-abstract "^1.23.2" + es-abstract "^1.23.9" es-errors "^1.3.0" - es-object-atoms "^1.0.0" - es-shim-unscopables "^1.0.2" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" -array.prototype.flat@^1.3.2: - 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== +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" -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== +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + 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== +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== dependencies: array-buffer-byte-length "^1.0.1" - call-bind "^1.0.5" + call-bind "^1.0.8" define-properties "^1.2.1" - es-abstract "^1.22.3" - es-errors "^1.2.1" - get-intrinsic "^1.2.3" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" - is-shared-array-buffer "^1.0.2" + +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== async@~2.6.3: version "2.6.4" @@ -3519,21 +4182,27 @@ asynckit@^0.4.0: resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -at-least-node@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/at-least-node/-/at-least-node-1.0.0.tgz#602cd4b46e844ad4effc92a8011a3c46e0238dc2" - integrity sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg== - -autoprefixer@10.4.20, autoprefixer@^10.4.20: - version "10.4.20" - resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.20.tgz#5caec14d43976ef42e32dcb4bd62878e96be5b3b" - integrity sha512-XY25y5xSv/wEoqzDyXXME4AFfkZI0P23z6Fs3YgymDnKJkCGOnkL0iTxCa85UTqaSgfcqyf3UA6+c7wUvx/16g== +autoprefixer@10.4.21: + version "10.4.21" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.21.tgz#77189468e7a8ad1d9a37fbc08efc9f480cf0a95d" + integrity sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ== dependencies: - browserslist "^4.23.3" - caniuse-lite "^1.0.30001646" + browserslist "^4.24.4" + caniuse-lite "^1.0.30001702" fraction.js "^4.3.7" normalize-range "^0.1.2" - picocolors "^1.0.1" + picocolors "^1.1.1" + postcss-value-parser "^4.2.0" + +autoprefixer@^10.4.23: + version "10.4.23" + resolved "https://registry.yarnpkg.com/autoprefixer/-/autoprefixer-10.4.23.tgz#c6aa6db8e7376fcd900f9fd79d143ceebad8c4e6" + integrity sha512-YYTXSFulfwytnjAPlw8QHncHJmlvFKtczb8InXaAx9Q0LbfDnfEYDE55omerIJKihhmU61Ft+cAOSzQVaBUmeA== + dependencies: + browserslist "^4.28.1" + caniuse-lite "^1.0.30001760" + fraction.js "^5.3.4" + picocolors "^1.1.1" postcss-value-parser "^4.2.0" available-typed-arrays@^1.0.7: @@ -3548,58 +4217,76 @@ axobject-query@4.1.0: resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-4.1.0.tgz#28768c76d0e3cff21bc62a9e2d0b6ac30042a1ee" integrity sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ== -babel-loader@9.1.3: - version "9.1.3" - resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-9.1.3.tgz#3d0e01b4e69760cc694ee306fe16d358aa1c6f9a" - integrity sha512-xG3ST4DglodGf8qSwv0MdeWLhrDsw/32QMdTO5T1ZIp9gQur0HkCyFs7Awskr10JKXFXwpAhiCuYX5oGXnRGbw== +babel-loader@10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/babel-loader/-/babel-loader-10.0.0.tgz#b9743714c0e1e084b3e4adef3cd5faee33089977" + integrity sha512-z8jt+EdS61AMw22nSfoNJAZ0vrtmhPRVi6ghL3rCeRZI8cdNYFiV5xeV3HbE7rlZZNmGH8BVccwWt8/ED0QOHA== dependencies: - find-cache-dir "^4.0.0" - schema-utils "^4.0.0" + find-up "^5.0.0" -babel-plugin-polyfill-corejs2@^0.4.10: - version "0.4.11" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.11.tgz#30320dfe3ffe1a336c15afdcdafd6fd615b25e33" - integrity sha512-sMEJ27L0gRHShOh5G54uAAPaiCOygY/5ratXuiyb2G46FmlSpc9eFCzYVyDiPxfNbwzA7mYahmjQc5q+CZQ09Q== +babel-plugin-polyfill-corejs2@^0.4.14: + version "0.4.14" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz#8101b82b769c568835611542488d463395c2ef8f" + integrity sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg== dependencies: - "@babel/compat-data" "^7.22.6" - "@babel/helper-define-polyfill-provider" "^0.6.2" + "@babel/compat-data" "^7.27.7" + "@babel/helper-define-polyfill-provider" "^0.6.5" semver "^6.3.1" -babel-plugin-polyfill-corejs3@^0.10.1, babel-plugin-polyfill-corejs3@^0.10.4: - version "0.10.6" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz#2deda57caef50f59c525aeb4964d3b2f867710c7" - integrity sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA== +babel-plugin-polyfill-corejs3@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.13.0.tgz#bb7f6aeef7addff17f7602a08a6d19a128c30164" + integrity sha512-U+GNwMdSFgzVmfhNm8GJUX88AadB3uo9KpJqS3FaqNIPKgySuvMb+bHPsOmmuWyIcuqZj/pzt1RUIUZns4y2+A== dependencies: - "@babel/helper-define-polyfill-provider" "^0.6.2" - core-js-compat "^3.38.0" + "@babel/helper-define-polyfill-provider" "^0.6.5" + core-js-compat "^3.43.0" -babel-plugin-polyfill-regenerator@^0.6.1: - version "0.6.2" - resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.2.tgz#addc47e240edd1da1058ebda03021f382bba785e" - integrity sha512-2R25rQZWP63nGwaAswvDazbPXfrM3HwVoBXK6HcqeKrSrL/JqcC/rDcf95l4r7LXLyxDXc8uQDa064GubtCABg== +babel-plugin-polyfill-regenerator@^0.6.5: + version "0.6.5" + resolved "https://registry.yarnpkg.com/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.5.tgz#32752e38ab6f6767b92650347bf26a31b16ae8c5" + integrity sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg== dependencies: - "@babel/helper-define-polyfill-provider" "^0.6.2" + "@babel/helper-define-polyfill-provider" "^0.6.5" 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== +balanced-match@^4.0.2: + version "4.0.4" + resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-4.0.4.tgz#bfb10662feed8196a2c62e7c68e17720c274179a" + integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== + base64-arraybuffer@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz#1c37589a7c4b0746e34bd1feb951da2df01c1bdc" integrity sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ== -base64-js@^1.3.1: - version "1.5.1" - resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" - integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.9.0: + version "2.10.0" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz#5b09935025bf8a80e29130251e337c6a7fc8cbb9" + integrity sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA== batch@0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/batch/-/batch-0.6.1.tgz#dc34314f4e679318093fc760272525f94bf25c16" integrity sha512-x+VAiMRL6UPkx+kudNvxTl6hB2XNNCG2r+7wixVfIYwu/2HKRXimwQyaumLjMveWvT2Hkd/cAJw+QBMfJ/EKVw== +beasties@0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/beasties/-/beasties-0.3.5.tgz#a52abea4ead105c1aded66b1a01a74e96585dc81" + integrity sha512-NaWu+f4YrJxEttJSm16AzMIFtVldCvaJ68b1L098KpqXmxt9xOLtKoLkKxb8ekhOrLqEJAbvT6n6SEvB/sac7A== + dependencies: + css-select "^6.0.0" + css-what "^7.0.0" + dom-serializer "^2.0.0" + domhandler "^5.0.3" + htmlparser2 "^10.0.0" + picocolors "^1.1.1" + postcss "^8.4.49" + postcss-media-query-parser "^0.2.3" + big.js@^5.2.2: version "5.2.2" resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328" @@ -3615,14 +4302,20 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bl@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/bl/-/bl-4.1.0.tgz#451535264182bec2fbbc83a62ab98cf11d9f7b3a" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" +body-parser@^2.2.1: + version "2.2.2" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-2.2.2.tgz#1a32cdb966beaf68de50a9dfbe5b58f83cb8890c" + integrity sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA== + dependencies: + bytes "^3.1.2" + content-type "^1.0.5" + debug "^4.4.3" + http-errors "^2.0.0" + iconv-lite "^0.7.0" + on-finished "^2.4.1" + qs "^6.14.1" + raw-body "^3.0.1" + type-is "^2.0.1" body-parser@~1.20.3: version "1.20.4" @@ -3656,19 +4349,19 @@ boolbase@^1.0.0: integrity sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww== 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== + version "1.1.13" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.13.tgz#d37875c01dc9eff988dd49d112a57cb67b54efe6" + integrity sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w== 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== +brace-expansion@^5.0.2: + version "5.0.5" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.5.tgz#dcc3a37116b79f3e1b46db994ced5d570e930fdb" + integrity sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ== dependencies: - balanced-match "^1.0.0" + balanced-match "^4.0.2" braces@^3.0.3, braces@~3.0.2: version "3.0.3" @@ -3677,29 +4370,22 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.21.10, browserslist@^4.21.5, browserslist@^4.23.0, browserslist@^4.23.3, browserslist@^4.24.0: - version "4.24.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.2.tgz#f5845bc91069dbd55ee89faf9822e1d885d16580" - integrity sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg== +browserslist@^4.21.5, browserslist@^4.23.0, browserslist@^4.24.0, browserslist@^4.24.4, browserslist@^4.28.1: + version "4.28.1" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.1.tgz#7f534594628c53c63101079e27e40de490456a95" + integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== dependencies: - caniuse-lite "^1.0.30001669" - electron-to-chromium "^1.5.41" - node-releases "^2.0.18" - update-browserslist-db "^1.1.1" + baseline-browser-mapping "^2.9.0" + caniuse-lite "^1.0.30001759" + electron-to-chromium "^1.5.263" + node-releases "^2.0.27" + update-browserslist-db "^1.2.0" 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== -buffer@^5.5.0: - version "5.7.1" - resolved "https://registry.yarnpkg.com/buffer/-/buffer-5.7.1.tgz#ba62e7c13133053582197160851a8f648e99eed0" - integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== - dependencies: - base64-js "^1.3.1" - ieee754 "^1.1.13" - bundle-name@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/bundle-name/-/bundle-name-4.1.0.tgz#f3b96b34160d6431a19d7688135af7cfb8797889" @@ -3712,30 +4398,29 @@ bytes@3.0.0: resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.0.0.tgz#d32815404d689699f85a4ea4fa8755dd13a96048" integrity sha512-pMhOfFDPiv9t5jjIXkHosWmkSyQbvsgEVNkz0ERHbuLh2T/7j4Mqqpz523Fe8MVY89KC6Sh/QfS2sM+SjgFDcw== -bytes@~3.1.2: +bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== -cacache@^18.0.0: - version "18.0.4" - resolved "https://registry.yarnpkg.com/cacache/-/cacache-18.0.4.tgz#4601d7578dadb59c66044e157d02a3314682d6a5" - integrity sha512-B+L5iIa9mgcjLbliir2th36yEwPftrzteHYujzsx3dFP/31GCHcIeS8f5MGd80odLOjaOvSpU3EEAmRQptkxLQ== +cacache@^20.0.0, cacache@^20.0.1: + version "20.0.3" + resolved "https://registry.yarnpkg.com/cacache/-/cacache-20.0.3.tgz#bd65205d5e6d86e02bbfaf8e4ce6008f1b81d119" + integrity sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw== dependencies: - "@npmcli/fs" "^3.1.0" + "@npmcli/fs" "^5.0.0" fs-minipass "^3.0.0" - glob "^10.2.2" - lru-cache "^10.0.1" + glob "^13.0.0" + lru-cache "^11.1.0" minipass "^7.0.3" minipass-collect "^2.0.1" minipass-flush "^1.0.5" minipass-pipeline "^1.2.4" - p-map "^4.0.0" - ssri "^10.0.0" - tar "^6.1.11" - unique-filename "^3.0.0" + p-map "^7.0.2" + ssri "^13.0.0" + unique-filename "^5.0.0" -call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== @@ -3743,18 +4428,17 @@ call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: es-errors "^1.3.0" function-bind "^1.1.2" -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== +call-bind@^1.0.5, call-bind@^1.0.7, call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== dependencies: + call-bind-apply-helpers "^1.0.0" 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" + set-function-length "^1.2.2" -call-bound@^1.0.2: +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== @@ -3777,10 +4461,10 @@ camelcase@^5.0.0: resolved "https://registry.yarnpkg.com/camelcase/-/camelcase-5.3.1.tgz#e3c9b31569e106811df242f715725a1f4c494320" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669: - version "1.0.30001677" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001677.tgz#27c2e2c637e007cfa864a16f7dfe7cde66b38b5f" - integrity sha512-fmfjsOlJUpMWu+mAAtZZZHz7UEwsUxIIvu1TJfO1HqFQvB/B+ii0xr9B5HpbZY/mC4XZ8SvjHJqtAY6pDPQEog== +caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760: + version "1.0.30001775" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001775.tgz#9572266e3f7f77efee5deac1efeb4795879d1b7f" + integrity sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A== canvas-gauges@^2.1.7: version "2.1.7" @@ -3796,7 +4480,7 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: +chalk@^4.0.0, chalk@^4.1.2: version "4.1.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01" integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== @@ -3804,31 +4488,35 @@ chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: ansi-styles "^4.1.0" supports-color "^7.1.0" -chardet@^0.7.0: - version "0.7.0" - resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e" - integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA== +chalk@^5.3.0: + version "5.6.2" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.6.2.tgz#b1238b6e23ea337af71c7f8a295db5af0c158aea" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== -chevrotain-allstar@~0.3.0: - version "0.3.1" - resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.3.1.tgz#b7412755f5d83cc139ab65810cdb00d8db40e6ca" - integrity sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw== +chardet@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/chardet/-/chardet-2.1.1.tgz#5c75593704a642f71ee53717df234031e65373c8" + integrity sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ== + +chevrotain-allstar@~0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/chevrotain-allstar/-/chevrotain-allstar-0.4.1.tgz#04e1429faca94a14d4572e0107c4865beac36298" + integrity sha512-PvVJm3oGqrveUVW2Vt/eZGeiAIsJszYweUcYwcskg9e+IubNYKKD+rHHem7A6XVO22eDAL+inxNIGAzZ/VIWlA== dependencies: lodash-es "^4.17.21" -chevrotain@~11.0.3: - version "11.0.3" - resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-11.0.3.tgz#88ffc1fb4b5739c715807eaeedbbf200e202fc1b" - integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== +chevrotain@~12.0.0: + version "12.0.0" + resolved "https://registry.yarnpkg.com/chevrotain/-/chevrotain-12.0.0.tgz#8ebefe0a0516b1b314a8d9c7f4e948a509098d1c" + integrity sha512-csJvb+6kEiQaqo1woTdSAuOWdN0WTLIydkKrBnS+V5gZz0oqBrp4kQ35519QgK6TpBThiG3V1vNSHlIkv4AglQ== dependencies: - "@chevrotain/cst-dts-gen" "11.0.3" - "@chevrotain/gast" "11.0.3" - "@chevrotain/regexp-to-ast" "11.0.3" - "@chevrotain/types" "11.0.3" - "@chevrotain/utils" "11.0.3" - lodash-es "4.17.21" + "@chevrotain/cst-dts-gen" "12.0.0" + "@chevrotain/gast" "12.0.0" + "@chevrotain/regexp-to-ast" "12.0.0" + "@chevrotain/types" "12.0.0" + "@chevrotain/utils" "12.0.0" -"chokidar@>=3.0.0 <4.0.0", chokidar@^3.6.0: +chokidar@^3.6.0: version "3.6.0" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.6.0.tgz#197c6cc669ef2a8dc5e7b4d97ee4e092c3eb0d5b" integrity sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw== @@ -3844,16 +4532,16 @@ chevrotain@~11.0.3: fsevents "~2.3.2" chokidar@^4.0.0: - version "4.0.1" - resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.1.tgz#4a6dff66798fb0f72a94f616abbd7e1a19f31d41" - integrity sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA== + version "4.0.3" + resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-4.0.3.tgz#7be37a4c03c9aee1ecfe862a4a23b2c70c205d30" + integrity sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA== dependencies: readdirp "^4.0.1" -chownr@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/chownr/-/chownr-2.0.0.tgz#15bfbe53d2eab4cf70f18a8cd68ebe5b3cb1dece" - integrity sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ== +chownr@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/chownr/-/chownr-3.0.0.tgz#9855e64ecd240a9cc4267ce8a4aa5d24a1da15e4" + integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== chrome-trace-event@^1.0.2: version "1.0.4" @@ -3865,18 +4553,6 @@ ci-info@^3.7.0: resolved "https://registry.yarnpkg.com/ci-info/-/ci-info-3.9.0.tgz#4279a62028a7b1f262f3473fc9605f5e218c59b4" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -clean-stack@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/clean-stack/-/clean-stack-2.2.0.tgz#ee8472dbb129e727b31e8a10a427dee9dfe4008b" - integrity sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A== - -cli-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-3.1.0.tgz#264305a7ae490d1d03bf0c9ba7c925d1753af307" - integrity sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw== - dependencies: - restore-cursor "^3.1.0" - cli-cursor@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-5.0.0.tgz#24a4831ecf5a6b01ddeb32fb71a4b2088b0dce38" @@ -3884,7 +4560,7 @@ cli-cursor@^5.0.0: dependencies: restore-cursor "^5.0.0" -cli-spinners@^2.5.0: +cli-spinners@^2.9.2: version "2.9.2" resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.9.2.tgz#1773a8f4b9c4d6ac31563df53b3fc1d79462fe41" integrity sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg== @@ -3920,14 +4596,14 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" -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== +cliui@^9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-9.0.1.tgz#6f7890f386f6f1f79953adc1f78dec46fcc2d291" + integrity sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w== dependencies: - string-width "^4.2.0" - strip-ansi "^6.0.1" - wrap-ansi "^7.0.0" + string-width "^7.2.0" + strip-ansi "^7.1.0" + wrap-ansi "^9.0.0" clone-deep@^4.0.1: version "4.0.1" @@ -4024,15 +4700,10 @@ commander@^8.3.0: resolved "https://registry.yarnpkg.com/commander/-/commander-8.3.0.tgz#4837ea1b2da67b9c616a67afbb0fafee567bca66" integrity sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww== -comment-parser@1.4.1: - version "1.4.1" - resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.1.tgz#bdafead37961ac079be11eb7ec65c4d021eaf9cc" - integrity sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg== - -common-path-prefix@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/common-path-prefix/-/common-path-prefix-3.0.0.tgz#7d007a7e07c58c4b4d5f433131a19141b29f11e0" - integrity sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w== +comment-parser@1.4.5: + version "1.4.5" + resolved "https://registry.yarnpkg.com/comment-parser/-/comment-parser-1.4.5.tgz#6c595cd090737a1010fe5ff40d86e1d21b7bd6ce" + integrity sha512-aRDkn3uyIlCFfk5NUA+VdwMmMsh8JGhc4hapfV4yxymHGQ3BVskMQfoXGpCo5IoBuQ9tS5iiVKhCpTcB4pW4qw== compressible@~2.0.16: version "2.0.18" @@ -4059,10 +4730,10 @@ concat-map@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== +confbox@^0.1.8: + version "0.1.8" + resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.1.8.tgz#820d73d3b3c82d9bd910652c5d4d599ef8ff8b06" + integrity sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w== config-chain@^1.1.13: version "1.1.13" @@ -4077,6 +4748,11 @@ connect-history-api-fallback@^2.0.0: resolved "https://registry.yarnpkg.com/connect-history-api-fallback/-/connect-history-api-fallback-2.0.0.tgz#647264845251a0daf25b97ce87834cace0f5f1c8" integrity sha512-U73+6lQFmfiNPrYbXqr6kZ1i1wiRqXnp2nhMsINseWXO8lDau0LGEffJ8kQi4EjLZympVgRdvqjAgiZ1tgzDDA== +content-disposition@^1.0.0: + version "1.0.1" + resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-1.0.1.tgz#a8b7bbeb2904befdfb6787e5c0c086959f605f9b" + integrity sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q== + content-disposition@~0.5.4: version "0.5.4" resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" @@ -4084,7 +4760,7 @@ content-disposition@~0.5.4: dependencies: safe-buffer "5.2.1" -content-type@~1.0.4, content-type@~1.0.5: +content-type@^1.0.5, content-type@~1.0.4, content-type@~1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.5.tgz#8b773162656d1d1086784c8f23a54ce6d73d7918" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== @@ -4099,12 +4775,17 @@ convert-source-map@^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== +cookie-signature@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.2.2.tgz#57c7fc3cc293acab9fec54d73e15690ebe4a1793" + integrity sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg== + cookie-signature@~1.0.6: version "1.0.7" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.7.tgz#ab5dd7ab757c54e60f37ef6550f481c426d10454" integrity sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA== -cookie@~0.7.1: +cookie@^0.7.1, cookie@~0.7.1: version "0.7.2" resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.7.2.tgz#556369c472a2ba910f2979891b526b3436237ed7" integrity sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w== @@ -4116,35 +4797,42 @@ copy-anything@^2.0.1: dependencies: is-what "^3.14.1" -copy-webpack-plugin@12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-12.0.2.tgz#935e57b8e6183c82f95bd937df658a59f6a2da28" - integrity sha512-SNwdBeHyII+rWvee/bTnAYyO8vfVdcSTud4EIb6jcZ8inLeWucJE0DnxXQBjlQ5zlteuuvooGQy3LIyGxhvlOA== +copy-webpack-plugin@14.0.0: + version "14.0.0" + resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-14.0.0.tgz#cd253b60e8e55bb41019dfe3ef2979ba705592c7" + integrity sha512-3JLW90aBGeaTLpM7mYQKpnVdgsUZRExY55giiZgLuX/xTQRUs1dOCwbBnWnvY6Q6rfZoXMNwzOQJCSZPppfqXA== dependencies: - fast-glob "^3.3.2" glob-parent "^6.0.1" - globby "^14.0.0" normalize-path "^3.0.0" schema-utils "^4.2.0" - serialize-javascript "^6.0.2" + serialize-javascript "^7.0.3" + tinyglobby "^0.2.12" -core-js-compat@^3.37.1, core-js-compat@^3.38.0: - version "3.38.1" - resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.38.1.tgz#2bc7a298746ca5a7bcb9c164bcb120f2ebc09a09" - integrity sha512-JRH6gfXxGmrzF3tZ57lFx97YARxCXPaMzPo6jELZhv88pBH5VXpQ+y0znKGlFnzuaihqhLbefxSJxWJMPtfDzw== +core-js-compat@^3.43.0: + version "3.48.0" + resolved "https://registry.yarnpkg.com/core-js-compat/-/core-js-compat-3.48.0.tgz#7efbe1fc1cbad44008190462217cc5558adaeaa6" + integrity sha512-OM4cAF3D6VtH/WkLtWvyNC56EZVXsZdU3iqaMG2B4WvYrlqU831pc4UtG5yp0sE9z8Y02wVN7PjW5Zf9Gt0f1Q== dependencies: - browserslist "^4.23.3" + browserslist "^4.28.1" -core-js@^3.39.0: - version "3.39.0" - resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.39.0.tgz#57f7647f4d2d030c32a72ea23a0555b2eaa30f83" - integrity sha512-raM0ew0/jJUqkJ0E6e8UDtl+y/7ktFivgWvqw8dNSQeNWoSDLvQ1H/RN3aPXB9tBd4/FhyR4RDPGhsNIMsAn7g== +core-js@^3.48.0: + version "3.48.0" + resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.48.0.tgz#1f813220a47bbf0e667e3885c36cd6f0593bf14d" + integrity sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ== core-util-is@~1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== +cors@^2.8.5: + version "2.8.6" + resolved "https://registry.yarnpkg.com/cors/-/cors-2.8.6.tgz#ff5dd69bd95e547503820d29aba4f8faf8dfec96" + integrity sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw== + dependencies: + object-assign "^4" + vary "^1" + cose-base@^1.0.0: version "1.0.3" resolved "https://registry.yarnpkg.com/cose-base/-/cose-base-1.0.3.tgz#650334b41b869578a543358b80cda7e0abe0a60a" @@ -4185,20 +4873,7 @@ create-require@^1.1.0: resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== -critters@0.0.24: - version "0.0.24" - resolved "https://registry.yarnpkg.com/critters/-/critters-0.0.24.tgz#d20b16c28908d2dae4b9cd4851d4d2c93de98a0b" - integrity sha512-Oyqew0FGM0wYUSNqR0L6AteO5MpMoUU0rhKRieXeiKs+PmRTxiJMyaunYB2KF6fQ3dzChXKCpbFOEJx3OQ1v/Q== - dependencies: - chalk "^4.1.0" - css-select "^5.1.0" - dom-serializer "^2.0.0" - domhandler "^5.0.2" - htmlparser2 "^8.0.2" - postcss "^8.4.23" - postcss-media-query-parser "^0.2.3" - -cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.6: +cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.5, cross-spawn@^7.0.6: version "7.0.6" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.6.tgz#8a58fe78f00dcd70c370451759dfbfaf03e8ee9f" integrity sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA== @@ -4228,21 +4903,21 @@ css-loader@7.1.2: postcss-value-parser "^4.2.0" semver "^7.5.4" -css-select@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/css-select/-/css-select-5.1.0.tgz#b8ebd6554c3637ccc76688804ad3f6a6fdaea8a6" - integrity sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg== +css-select@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/css-select/-/css-select-6.0.0.tgz#7e63f09881ad118084091048ed543786dad96644" + integrity sha512-rZZVSLle8v0+EY8QAkDWrKhpgt6SA5OtHsgBnsj6ZaLb5dmDVOWUDtQitd9ydxxvEjhewNudS6eTVU7uOyzvXw== dependencies: boolbase "^1.0.0" - css-what "^6.1.0" - domhandler "^5.0.2" - domutils "^3.0.1" - nth-check "^2.0.1" + css-what "^7.0.0" + domhandler "^5.0.3" + domutils "^3.2.2" + nth-check "^2.1.1" -css-what@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/css-what/-/css-what-6.1.0.tgz#fb5effcf76f1ddea2c81bdfaa4de44e79bac70f4" - integrity sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw== +css-what@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/css-what/-/css-what-7.0.0.tgz#5796fbebd43571d73c60ba0dd7a6e75dd0d22fe4" + integrity sha512-wD5oz5xibMOPHzy13CyGmogB3phdvcDaB5t0W/Nr5Z2O/agcB8YwOz6e2Lsp10pNDzBoDO9nVa3RGs/2BttpHQ== cssesc@^3.0.0: version "3.0.0" @@ -4263,10 +4938,10 @@ cytoscape-fcose@^2.2.0: dependencies: cose-base "^2.2.0" -cytoscape@^3.29.2: - version "3.30.2" - resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.30.2.tgz#94149707fb6547a55e3b44f03ffe232706212161" - integrity sha512-oICxQsjW8uSaRmn4UK/jkczKOqTrVqt5/1WL0POiJUT2EKNc9STM4hYFHv917yu55aTBMFNRzymlJhVAiWPCxw== +cytoscape@^3.33.1: + version "3.33.2" + resolved "https://registry.yarnpkg.com/cytoscape/-/cytoscape-3.33.2.tgz#3a58906b4002b7c237f54dfc9b971983757da791" + integrity sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw== "d3-array@1 - 2": version "2.12.1" @@ -4443,7 +5118,7 @@ d3-scale@4: d3-time "2.1.1 - 3" d3-time-format "2 - 4" -"d3-selection@2 - 3", d3-selection@3: +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== @@ -4481,7 +5156,7 @@ d3-shape@^1.2.0: resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== -"d3-transition@2 - 3", d3-transition@3: +"d3-transition@2 - 3", d3-transition@3, d3-transition@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== @@ -4503,7 +5178,7 @@ d3-zoom@3: d3-selection "2 - 3" d3-transition "2 - 3" -d3@^7.8.2, d3@^7.9.0: +d3@^7.9.0: version "7.9.0" resolved "https://registry.yarnpkg.com/d3/-/d3-7.9.0.tgz#579e7acb3d749caf8860bd1741ae8d371070cd5d" integrity sha512-e1U46jVP+w7Iut8Jt8ri1YsPOvFpg46k+K8TpCb0P+zjCkjkPnV7WzfDJzMHy1LnA+wj5pLT1wjO901gLXeEhA== @@ -4539,12 +5214,12 @@ d3@^7.8.2, d3@^7.9.0: d3-transition "3" d3-zoom "3" -dagre-d3-es@7.0.10: - version "7.0.10" - resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.10.tgz#19800d4be674379a3cd8c86a8216a2ac6827cadc" - integrity sha512-qTCQmEhcynucuaZgY5/+ti3X/rnszKZhEQH/ZdWdtP1tA/y3VoHJzcVrO9pjjJCNpigfscAtoUB5ONcd2wNn0A== +dagre-d3-es@7.0.14: + version "7.0.14" + resolved "https://registry.yarnpkg.com/dagre-d3-es/-/dagre-d3-es-7.0.14.tgz#1272276e26457cf3b97dac569f8f0531ec33c377" + integrity sha512-P4rFMVq9ESWqmOgK+dlXvOtLwYg0i7u0HBGJER0LZDJT2VHIPAMZ/riPxqJceWMStH5+E61QxFra9kIS3AqdMg== dependencies: - d3 "^7.8.2" + d3 "^7.9.0" lodash-es "^4.17.21" data-uri-to-buffer@^4.0.0: @@ -4552,37 +5227,37 @@ data-uri-to-buffer@^4.0.0: resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz#d8feb2b2881e6a4f58c2e08acfd0e2834e26222e" integrity sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A== -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== +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== dependencies: - call-bind "^1.0.6" + call-bound "^1.0.3" es-errors "^1.3.0" - is-data-view "^1.0.1" + is-data-view "^1.0.2" -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== +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.3" es-errors "^1.3.0" - is-data-view "^1.0.1" + is-data-view "^1.0.2" -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== +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== dependencies: - call-bind "^1.0.6" + call-bound "^1.0.2" es-errors "^1.3.0" is-data-view "^1.0.1" -dayjs@1.11.13, dayjs@^1.11.10, dayjs@^1.11.5: - version "1.11.13" - resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.13.tgz#92430b0139055c3ebb60150aa13e860a4b5a366c" - integrity sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg== +dayjs@1.11.20, dayjs@^1.11.19, dayjs@^1.11.5: + version "1.11.20" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.20.tgz#88d919fd639dc991415da5f4cb6f1b6650811938" + integrity sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ== debug@2.6.9: version "2.6.9" @@ -4591,10 +5266,10 @@ debug@2.6.9: dependencies: ms "2.0.0" -debug@4, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6: - version "4.3.7" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.7.tgz#87945b4151a011d76d95a198d7111c865c360a52" - integrity sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ== +debug@4, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.6, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3: + version "4.4.3" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" @@ -4638,13 +5313,6 @@ default-browser@^5.2.1: bundle-name "^4.1.0" default-browser-id "^5.0.0" -default-gateway@^6.0.3: - version "6.0.3" - resolved "https://registry.yarnpkg.com/default-gateway/-/default-gateway-6.0.3.tgz#819494c888053bdb743edbf343d6cdf7f2943a71" - integrity sha512-fwSOJsbbNzZ/CUFpqFBqYfYNLj1NbMPm8MMCIzHjC83iSJRBEGmDUxU+WP661BaBQImeC2yHwXtz+P/O9o+XEg== - dependencies: - execa "^5.0.0" - defaults@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/defaults/-/defaults-1.0.4.tgz#b0b02062c1e2aa62ff5d9528f0f98baa90978d7a" @@ -4666,7 +5334,7 @@ define-lazy-prop@^3.0.0: resolved "https://registry.yarnpkg.com/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz#dbb19adfb746d7fc6d734a06b72f4a00d021255f" integrity sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg== -define-properties@^1.2.0, define-properties@^1.2.1: +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== @@ -4692,7 +5360,7 @@ delegate@^3.1.2: resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166" integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw== -depd@2.0.0, depd@~2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -4707,10 +5375,10 @@ destroy@1.2.0, destroy@~1.2.0: resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.1: - version "2.0.3" - resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.0.3.tgz#f0cd503b40f9939b894697d19ad50895e30cf700" - integrity sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw== +detect-libc@^2.0.1, detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-2.1.2.tgz#689c5dcdc1900ef5583a4cb9f6d7b473742074ad" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== detect-node@^2.0.4: version "2.1.0" @@ -4722,11 +5390,6 @@ didyoumean@^1.2.2: resolved "https://registry.yarnpkg.com/didyoumean/-/didyoumean-1.2.2.tgz#989346ffe9e839b4555ecf5666edea0d3e8ad037" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== -diff-match-patch@^1.0.5: - version "1.0.5" - resolved "https://registry.yarnpkg.com/diff-match-patch/-/diff-match-patch-1.0.5.tgz#abb584d5f10cd1196dfc55aa03701592ae3f7b37" - integrity sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw== - diff@^4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" @@ -4785,21 +5448,23 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.0.11: - version "3.1.7" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.1.7.tgz#711a8c96479fb6ced93453732c160c3c72418a6a" - integrity sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ== +dompurify@^3.3.1: + version "3.4.0" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.0.tgz#b1fc33ebdadb373241621e0a30e4ad81573dfd0b" + integrity sha512-nolgK9JcaUXMSmW+j1yaSvaEaoXYHwWyGJlkoCTghc97KgGDDSnpoU/PlEnw63Ah+TGKFOyY+X5LnxaWbCSfXg== + optionalDependencies: + "@types/trusted-types" "^2.0.7" -domutils@^3.0.1: - version "3.1.0" - resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.1.0.tgz#c47f551278d3dc4b0b1ab8cbb42d751a6f0d824e" - integrity sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA== +domutils@^3.2.2: + version "3.2.2" + resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" + integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== dependencies: dom-serializer "^2.0.0" domelementtype "^2.3.0" domhandler "^5.0.3" -dunder-proto@^1.0.1: +dunder-proto@^1.0.0, dunder-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== @@ -4809,18 +5474,18 @@ dunder-proto@^1.0.1: gopd "^1.2.0" earcut@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.1.tgz#f60b3f671c5657cca9d3e131c5527c5dde00ef38" - integrity sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw== + version "3.0.2" + resolved "https://registry.yarnpkg.com/earcut/-/earcut-3.0.2.tgz#d478a29aaf99acf418151493048aa197d0512248" + integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== eastasianwidth@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/eastasianwidth/-/eastasianwidth-0.2.0.tgz#696ce2ec0aa0e6ea93a397ffcf24aa7840c827cb" integrity sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA== -"echarts@https://github.com/thingsboard/echarts/archive/5.5.1-TB.tar.gz": - version "5.5.1-TB" - resolved "https://github.com/thingsboard/echarts/archive/5.5.1-TB.tar.gz#8cf0cbb1b4c6161f0b587a1a649ff4f8eecbbf42" +"echarts@https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz": + version "5.5.2-TB" + resolved "https://github.com/thingsboard/echarts/archive/5.5.2-TB.tar.gz#779494c24fb5448ecaf79315bf609bb52b431df9" dependencies: tslib "2.3.0" zrender "https://github.com/thingsboard/zrender/archive/5.5.0-TB.tar.gz" @@ -4840,15 +5505,15 @@ ee-first@1.1.1: resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== -electron-to-chromium@^1.5.41: - version "1.5.50" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.50.tgz#d9ba818da7b2b5ef1f3dd32bce7046feb7e93234" - integrity sha512-eMVObiUQ2LdgeO1F/ySTXsvqvxb6ZH2zPGaMYsWzRDdOddUa77tdmI0ltg+L16UpbWdhPmuF3wIQYyQq65WfZw== +electron-to-chromium@^1.5.263: + version "1.5.302" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz#032a5802b31f7119269959c69fe2015d8dad5edb" + integrity sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg== emoji-regex@^10.3.0: - version "10.4.0" - resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.4.0.tgz#03553afea80b3975749cfcb36f776ca268e413d4" - integrity sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw== + version "10.6.0" + resolved "https://registry.yarnpkg.com/emoji-regex/-/emoji-regex-10.6.0.tgz#bf3d6e8f7f8fd22a65d9703475bc0147357a6b0d" + integrity sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A== emoji-regex@^8.0.0: version "8.0.0" @@ -4870,7 +5535,7 @@ emojis-list@^3.0.0: resolved "https://registry.yarnpkg.com/emojis-list/-/emojis-list-3.0.0.tgz#5570662046ad29e2e916e71aae260abdff4f6a78" integrity sha512-/kyM18EfinwXZbno9FyUGeFh87KC8HRQBQGildHZbEuRyWFOmv1U10o9BBp8XVZDVNNuQKyIGIu5ZYAAXJ0V2Q== -encodeurl@~2.0.0: +encodeurl@^2.0.0, encodeurl@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-2.0.0.tgz#7b8ea898077d7e409d3ac45474ea38eaf0857a58" integrity sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg== @@ -4882,19 +5547,29 @@ encoding@^0.1.13: dependencies: iconv-lite "^0.6.2" -enhanced-resolve@^5.17.1: - version "5.17.1" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" - integrity sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg== +enhanced-resolve@^5.19.0: + version "5.19.0" + resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz#6687446a15e969eaa63c2fa2694510e17ae6d97c" + integrity sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg== dependencies: graceful-fs "^4.2.4" - tapable "^2.2.0" + tapable "^2.3.0" -entities@^4.2.0, entities@^4.3.0, entities@^4.4.0, entities@^4.5.0: +entities@^4.2.0: version "4.5.0" resolved "https://registry.yarnpkg.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== +entities@^6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== + +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + env-paths@^2.2.0, env-paths@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -4924,72 +5599,80 @@ error-ex@^1.3.1: 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.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== +es-abstract@^1.23.2, es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.1" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.1.tgz#f0c131ed5ea1bb2411134a8dd94def09c46c7899" + integrity sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw== dependencies: - array-buffer-byte-length "^1.0.1" - arraybuffer.prototype.slice "^1.0.3" + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" 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" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" 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" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" has-property-descriptors "^1.0.2" - has-proto "^1.0.3" - has-symbols "^1.0.3" + has-proto "^1.2.0" + has-symbols "^1.1.0" hasown "^2.0.2" - internal-slot "^1.0.7" - is-array-buffer "^3.0.4" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" is-callable "^1.2.7" - is-data-view "^1.0.1" + is-data-view "^1.0.2" 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" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" 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" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" 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" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" es-define-property@^1.0.0, es-define-property@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== -es-errors@^1.2.1, es-errors@^1.3.0: +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-module-lexer@^1.2.1, es-module-lexer@^1.5.3: - version "1.5.4" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-1.5.4.tgz#a8efec3a3da991e60efa6b633a7cad6ab8d26b78" - integrity sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw== +es-module-lexer@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.0.0.tgz#f657cd7a9448dcdda9c070a3cb75e5dc1e85f5b1" + integrity sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.1" @@ -4998,7 +5681,7 @@ es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: dependencies: es-errors "^1.3.0" -es-set-tostringtag@^2.0.3, es-set-tostringtag@^2.1.0: +es-set-tostringtag@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== @@ -5008,63 +5691,65 @@ es-set-tostringtag@^2.0.3, es-set-tostringtag@^2.1.0: has-tostringtag "^1.0.2" hasown "^2.0.2" -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== +es-shim-unscopables@^1.0.2, es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== dependencies: - hasown "^2.0.0" + hasown "^2.0.2" -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" - -esbuild-wasm@0.23.0: - version "0.23.0" - resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.23.0.tgz#7b09c7bc669b702b440aeb3d5c9210f96766e1b2" - integrity sha512-6jP8UmWy6R6TUUV8bMuC3ZyZ6lZKI56x0tkxyCIqWwRRJ/DgeQKneh/Oid5EoGoPFLrGNkz47ZEtWAYuiY/u9g== - -esbuild@0.23.0, esbuild@^0.21.3: - version "0.23.0" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.23.0.tgz#de06002d48424d9fdb7eb52dbe8e95927f852599" - integrity sha512-1lvV17H2bMYda/WaFb2jLPeHU3zml2k4/yagNMG8Q/YtfMjCwEUZa2eXXMgZTVSL5q1n4H7sQ0X6CdJDqqeCFA== +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + +esbuild-wasm@0.28.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild-wasm/-/esbuild-wasm-0.28.0.tgz#e2e60b86ed19e0f6f5a1e6c1c722f5a811d7d5f7" + integrity sha512-5TRVKExcEmeMkccIZMzUq+Az6X2RoMAJyfl6SMMO1dMVhmvt0I2mx7gAb6zYi42n4d1ETcatFXazGKzA+aW7fg== + +esbuild@0.28.0, esbuild@^0.27.0: + version "0.28.0" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.0.tgz#5dee347ffb3e3874212a35a69836b077b1ce6d96" + integrity sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw== optionalDependencies: - "@esbuild/aix-ppc64" "0.23.0" - "@esbuild/android-arm" "0.23.0" - "@esbuild/android-arm64" "0.23.0" - "@esbuild/android-x64" "0.23.0" - "@esbuild/darwin-arm64" "0.23.0" - "@esbuild/darwin-x64" "0.23.0" - "@esbuild/freebsd-arm64" "0.23.0" - "@esbuild/freebsd-x64" "0.23.0" - "@esbuild/linux-arm" "0.23.0" - "@esbuild/linux-arm64" "0.23.0" - "@esbuild/linux-ia32" "0.23.0" - "@esbuild/linux-loong64" "0.23.0" - "@esbuild/linux-mips64el" "0.23.0" - "@esbuild/linux-ppc64" "0.23.0" - "@esbuild/linux-riscv64" "0.23.0" - "@esbuild/linux-s390x" "0.23.0" - "@esbuild/linux-x64" "0.23.0" - "@esbuild/netbsd-x64" "0.23.0" - "@esbuild/openbsd-arm64" "0.23.0" - "@esbuild/openbsd-x64" "0.23.0" - "@esbuild/sunos-x64" "0.23.0" - "@esbuild/win32-arm64" "0.23.0" - "@esbuild/win32-ia32" "0.23.0" - "@esbuild/win32-x64" "0.23.0" + "@esbuild/aix-ppc64" "0.28.0" + "@esbuild/android-arm" "0.28.0" + "@esbuild/android-arm64" "0.28.0" + "@esbuild/android-x64" "0.28.0" + "@esbuild/darwin-arm64" "0.28.0" + "@esbuild/darwin-x64" "0.28.0" + "@esbuild/freebsd-arm64" "0.28.0" + "@esbuild/freebsd-x64" "0.28.0" + "@esbuild/linux-arm" "0.28.0" + "@esbuild/linux-arm64" "0.28.0" + "@esbuild/linux-ia32" "0.28.0" + "@esbuild/linux-loong64" "0.28.0" + "@esbuild/linux-mips64el" "0.28.0" + "@esbuild/linux-ppc64" "0.28.0" + "@esbuild/linux-riscv64" "0.28.0" + "@esbuild/linux-s390x" "0.28.0" + "@esbuild/linux-x64" "0.28.0" + "@esbuild/netbsd-arm64" "0.28.0" + "@esbuild/netbsd-x64" "0.28.0" + "@esbuild/openbsd-arm64" "0.28.0" + "@esbuild/openbsd-x64" "0.28.0" + "@esbuild/openharmony-arm64" "0.28.0" + "@esbuild/sunos-x64" "0.28.0" + "@esbuild/win32-arm64" "0.28.0" + "@esbuild/win32-ia32" "0.28.0" + "@esbuild/win32-x64" "0.28.0" escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.2.0.tgz#011a3f69856ba189dffa7dc8fcce99d2a87903e5" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== -escape-html@~1.0.3: +escape-html@^1.0.3, escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== @@ -5088,64 +5773,67 @@ eslint-import-resolver-node@^0.3.9: is-core-module "^2.13.0" resolve "^1.22.4" -eslint-module-utils@^2.12.0: - version "2.12.0" - resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.0.tgz#fe4cfb948d61f49203d7b08871982b65b9af0b0b" - integrity sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg== +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== dependencies: debug "^3.2.7" -eslint-plugin-import@latest: - version "2.31.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.31.0.tgz#310ce7e720ca1d9c0bb3f69adfd1c6bdd7d9e0e7" - integrity sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A== +eslint-plugin-import@^2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.12.0" + eslint-module-utils "^2.12.1" hasown "^2.0.2" - is-core-module "^2.15.1" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" object.fromentries "^2.0.8" object.groupby "^1.0.3" - object.values "^1.2.0" + object.values "^1.2.1" semver "^6.3.1" - string.prototype.trimend "^1.0.8" + string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" -eslint-plugin-jsdoc@^50.6.0: - version "50.6.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-50.6.0.tgz#2c6049a40305313174a30212bc360e775b797a0a" - integrity sha512-tCNp4fR79Le3dYTPB0dKEv7yFyvGkUCa+Z3yuTrrNGGOxBlXo9Pn0PEgroOZikUQOGjxoGMVKNjrOHcYEdfszg== +eslint-plugin-jsdoc@^62.4.1: + version "62.4.1" + resolved "https://registry.yarnpkg.com/eslint-plugin-jsdoc/-/eslint-plugin-jsdoc-62.4.1.tgz#6ad51350c364597dadf820e690f846d5b43b4e07" + integrity sha512-HgX2iN4j104D/mCUqRbhtzSZbph+KO9jfMHiIJjJ19Q+IwLQ5Na2IqvOJYq4S+4kgvEk1w6KYF4vVus6H2wcHg== dependencies: - "@es-joy/jsdoccomment" "~0.49.0" + "@es-joy/jsdoccomment" "~0.83.0" + "@es-joy/resolve.exports" "1.2.0" are-docs-informative "^0.0.2" - comment-parser "1.4.1" - debug "^4.3.6" + comment-parser "1.4.5" + debug "^4.4.3" escape-string-regexp "^4.0.0" - espree "^10.1.0" - esquery "^1.6.0" - parse-imports "^2.1.1" - semver "^7.6.3" + espree "^11.1.0" + esquery "^1.7.0" + html-entities "^2.6.0" + object-deep-merge "^2.0.0" + parse-imports-exports "^0.2.4" + semver "^7.7.3" spdx-expression-parse "^4.0.0" - synckit "^0.9.1" + to-valid-identifier "^1.0.0" -eslint-plugin-prefer-arrow@latest: +eslint-plugin-prefer-arrow@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/eslint-plugin-prefer-arrow/-/eslint-plugin-prefer-arrow-1.2.3.tgz#e7fbb3fa4cd84ff1015b9c51ad86550e55041041" integrity sha512-J9I5PKCOJretVuiZRGvPQxCbllxGAV/viI20JO3LYblAodofBxyMnZAJ+WGeClHgANnSJberTNoFWWjrWKBuXQ== -eslint-plugin-tailwindcss@^3.17.5: - version "3.17.5" - resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.17.5.tgz#6bf9403e77a5f3f930fb3444a3e22b29cd0fee07" - integrity sha512-8Mi7p7dm+mO1dHgRHHFdPu4RDTBk69Cn4P0B40vRQR+MrguUpwmKwhZy1kqYe3Km8/4nb+cyrCF+5SodOEmaow== +eslint-plugin-tailwindcss@^3.18.2: + version "3.18.2" + resolved "https://registry.yarnpkg.com/eslint-plugin-tailwindcss/-/eslint-plugin-tailwindcss-3.18.2.tgz#c67ff432cfad470dae54998b3bc6811af81f9087" + integrity sha512-QbkMLDC/OkkjFQ1iz/5jkMdHfiMu/uwujUHLAJK5iwNHD8RTxVTlsUezE0toTZ6VhybNBsk+gYGPDq2agfeRNA== dependencies: fast-glob "^3.2.5" postcss "^8.4.4" @@ -5158,11 +5846,21 @@ eslint-scope@5.1.1: esrecurse "^4.3.0" estraverse "^4.1.1" -eslint-scope@^8.0.2, eslint-scope@^8.2.0: - version "8.2.0" - resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.2.0.tgz#377aa6f1cb5dc7592cfd0b7f892fd0cf352ce442" - integrity sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A== +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-8.4.0.tgz#88e646a207fad61436ffa39eb505147200655c82" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== + dependencies: + esrecurse "^4.3.0" + estraverse "^5.2.0" + +eslint-scope@^9.0.0: + version "9.1.0" + resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-9.1.0.tgz#dfcb41d6c0d73df6b977a50cf3e91c41ddb4154e" + integrity sha512-CkWE42hOJsNj9FJRaoMX9waUFYhqY4jmyLFdAdzZr6VaCg3ynLYx4WnOdkaIifGfH4gsUcBTn4OZbHXkpLD0FQ== dependencies: + "@types/esrecurse" "^4.3.1" + "@types/estree" "^1.0.8" esrecurse "^4.3.0" estraverse "^5.2.0" @@ -5171,36 +5869,41 @@ eslint-visitor-keys@^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-visitor-keys@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz#687bacb2af884fcdda8a6e7d65c606f46a14cd45" - integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + +eslint-visitor-keys@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz#b9aa1a74aa48c44b3ae46c1597ce7171246a94a9" + integrity sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q== -eslint@~9.17.0: - version "9.17.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.17.0.tgz#faa1facb5dd042172fdc520106984b5c2421bb0c" - integrity sha512-evtlNcpJg+cZLcnVKwsai8fExnqjGPicK7gnUtlNuzu+Fv9bI0aLpND5T44VLQtoMEnI57LoXO9XAkIXwohKrA== +eslint@9.39.4: + version "9.39.4" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.4.tgz#855da1b2e2ad66dc5991195f35e262bcec8117b5" + integrity sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.19.0" - "@eslint/core" "^0.9.0" - "@eslint/eslintrc" "^3.2.0" - "@eslint/js" "9.17.0" - "@eslint/plugin-kit" "^0.2.3" + "@eslint/config-array" "^0.21.2" + "@eslint/config-helpers" "^0.4.2" + "@eslint/core" "^0.17.0" + "@eslint/eslintrc" "^3.3.5" + "@eslint/js" "9.39.4" + "@eslint/plugin-kit" "^0.4.1" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" - "@humanwhocodes/retry" "^0.4.1" + "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - "@types/json-schema" "^7.0.15" - ajv "^6.12.4" + ajv "^6.14.0" chalk "^4.0.0" cross-spawn "^7.0.6" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.2.0" - eslint-visitor-keys "^4.2.0" - espree "^10.3.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -5212,23 +5915,32 @@ eslint@~9.17.0: is-glob "^4.0.0" json-stable-stringify-without-jsonify "^1.0.1" lodash.merge "^4.6.2" - minimatch "^3.1.2" + minimatch "^3.1.5" natural-compare "^1.4.0" optionator "^0.9.3" -espree@^10.0.1, espree@^10.1.0, espree@^10.3.0: - version "10.3.0" - resolved "https://registry.yarnpkg.com/espree/-/espree-10.3.0.tgz#29267cf5b0cb98735b65e64ba07e0ed49d1eed8a" - integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-10.4.0.tgz#d54f4949d4629005a1fa168d937c3ff1f7e2a837" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== + dependencies: + acorn "^8.15.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^4.2.1" + +espree@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-11.1.0.tgz#7d0c82a69f8df670728dba256264b383fbf73e8f" + integrity sha512-WFWYhO1fV4iYkqOOvq8FbqIhr2pYfoDY0kCotMkDeNtGpiGGkZ1iov2u8ydjtgM8yF8rzK7oaTbw2NAzbAbehw== dependencies: - acorn "^8.14.0" + acorn "^8.15.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.2.0" + eslint-visitor-keys "^5.0.0" -esquery@^1.5.0, esquery@^1.6.0: - version "1.6.0" - resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.6.0.tgz#91419234f804d852a82dceec3e16cdc22cf9dae7" - integrity sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg== +esquery@^1.5.0, esquery@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.7.0.tgz#08d048f261f0ddedb5bae95f46809463d9c9496d" + integrity sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g== dependencies: estraverse "^5.1.0" @@ -5254,7 +5966,7 @@ esutils@^2.0.2: resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64" integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g== -etag@~1.8.1: +etag@^1.8.1, etag@~1.8.1: version "1.8.1" resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== @@ -5270,36 +5982,40 @@ eventemitter3@^4.0.0: integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw== eventemitter3@^5.0.1: - version "5.0.1" - resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.1.tgz#53f5ffd0a492ac800721bb42c66b841de96423c4" - integrity sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA== + version "5.0.4" + resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-5.0.4.tgz#a86d66170433712dde814707ac52b5271ceb1feb" + integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== events@^3.2.0: version "3.3.0" resolved "https://registry.yarnpkg.com/events/-/events-3.3.0.tgz#31a95ad0a924e2d2c419a813aeb2c4e878ea7400" integrity sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q== -execa@^5.0.0: - version "5.1.1" - resolved "https://registry.yarnpkg.com/execa/-/execa-5.1.1.tgz#f80ad9cbf4298f7bd1d4c9555c21e93741c411dd" - integrity sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg== +eventsource-parser@^3.0.0, eventsource-parser@^3.0.1: + version "3.0.6" + resolved "https://registry.yarnpkg.com/eventsource-parser/-/eventsource-parser-3.0.6.tgz#292e165e34cacbc936c3c92719ef326d4aeb4e90" + integrity sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg== + +eventsource@^3.0.2: + version "3.0.7" + resolved "https://registry.yarnpkg.com/eventsource/-/eventsource-3.0.7.tgz#1157622e2f5377bb6aef2114372728ba0c156989" + integrity sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA== dependencies: - cross-spawn "^7.0.3" - get-stream "^6.0.0" - human-signals "^2.1.0" - is-stream "^2.0.0" - merge-stream "^2.0.0" - npm-run-path "^4.0.1" - onetime "^5.1.2" - signal-exit "^3.0.3" - strip-final-newline "^2.0.0" + eventsource-parser "^3.0.1" exponential-backoff@^3.1.1: version "3.1.1" resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== -express@^4.17.3: +express-rate-limit@^8.2.1: + version "8.3.1" + resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-8.3.1.tgz#0aaba098eadd40f6737f30a98e6b16fa1a29edfb" + integrity sha512-D1dKN+cmyPWuvB+G2SREQDzPY1agpBIcTa9sJxOPMCNeH3gwzhqJRDWCXW3gg0y//+LQ/8j52JbMROWyrKdMdw== + dependencies: + ip-address "10.1.0" + +express@^4.21.2: version "4.22.1" resolved "https://registry.yarnpkg.com/express/-/express-4.22.1.tgz#1de23a09745a4fffdb39247b344bb5eaff382069" integrity sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g== @@ -5336,30 +6052,55 @@ express@^4.17.3: utils-merge "1.0.1" vary "~1.1.2" -external-editor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/external-editor/-/external-editor-3.1.0.tgz#cb03f740befae03ea4d283caed2741a83f335495" - integrity sha512-hMQ4CX1p1izmuLYyZqLMO/qGNw10wSv9QDCPfzXfyFrOaCSSoRfqE1Kf1s5an66J5JZC62NewG+mK49jOCtQew== - dependencies: - chardet "^0.7.0" - iconv-lite "^0.4.24" - tmp "^0.0.33" +express@^5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" + integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== + dependencies: + accepts "^2.0.0" + body-parser "^2.2.1" + content-disposition "^1.0.0" + content-type "^1.0.5" + cookie "^0.7.1" + cookie-signature "^1.2.1" + debug "^4.4.0" + depd "^2.0.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + finalhandler "^2.1.0" + fresh "^2.0.0" + http-errors "^2.0.0" + merge-descriptors "^2.0.0" + mime-types "^3.0.0" + on-finished "^2.4.1" + once "^1.4.0" + parseurl "^1.3.3" + proxy-addr "^2.0.7" + qs "^6.14.0" + range-parser "^1.2.1" + router "^2.2.0" + send "^1.1.0" + serve-static "^2.2.0" + statuses "^2.0.1" + type-is "^2.0.1" + vary "^1.1.2" 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.3.2, fast-glob@^3.2.5, fast-glob@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" - integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== +fast-glob@3.3.3, fast-glob@^3.2.5, fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== 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" + micromatch "^4.0.8" fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -5372,9 +6113,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fast-uri@^3.0.1: - version "3.0.3" - resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.0.3.tgz#892a1c91802d5d7860de728f18608a0573142241" - integrity sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw== + version "3.1.0" + resolved "https://registry.yarnpkg.com/fast-uri/-/fast-uri-3.1.0.tgz#66eecff6c764c0df9b762e62ca7edcfb53b4edfa" + integrity sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA== fastq@^1.6.0: version "1.17.1" @@ -5390,6 +6131,11 @@ faye-websocket@^0.11.3: dependencies: websocket-driver ">=0.5.1" +fdir@^6.4.4, fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + fetch-blob@^3.1.2, fetch-blob@^3.1.4: version "3.2.0" resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-3.2.0.tgz#f09b8d4bbd45adc6f0c20b7e787e793e309dcce9" @@ -5412,6 +6158,18 @@ fill-range@^7.1.1: dependencies: to-regex-range "^5.0.1" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-2.1.1.tgz#a2c517a6559852bcdb06d1f8bd7f51b68fad8099" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + finalhandler@~1.3.1: version "1.3.2" resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.3.2.tgz#1ebc2228fc7673aac4a472c310cc05b77d852b88" @@ -5425,14 +6183,6 @@ finalhandler@~1.3.1: statuses "~2.0.2" unpipe "~1.0.0" -find-cache-dir@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/find-cache-dir/-/find-cache-dir-4.0.0.tgz#a30ee0448f81a3990708f6453633c733e2f6eec2" - integrity sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg== - dependencies: - common-path-prefix "^3.0.0" - pkg-dir "^7.0.0" - find-replace@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/find-replace/-/find-replace-3.0.0.tgz#3e7e23d3b05167a76f770c9fbd5258b0def68c38" @@ -5456,14 +6206,6 @@ find-up@^5.0.0: locate-path "^6.0.0" path-exists "^4.0.0" -find-up@^6.3.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/find-up/-/find-up-6.3.0.tgz#2abab3d3280b2dc7ac10199ef324c4e002c8c790" - integrity sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw== - dependencies: - locate-path "^7.1.0" - path-exists "^5.0.0" - find-yarn-workspace-root@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/find-yarn-workspace-root/-/find-yarn-workspace-root-2.0.0.tgz#f47fb8d239c900eb78179aa81b66673eac88f7bd" @@ -5485,9 +6227,9 @@ flat@^5.0.2: integrity sha512-b6suED+5/3rTpUBdG1gupIl8MPFCAMA0QXwmljLhvCUKcUvdE4gWky9zpuGCcXHOsz4J9wPGNWq6OKpmIzz3hQ== flatted@^3.2.9: - version "3.3.2" - resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.3.2.tgz#adba1448a9841bec72b42c532ea23dbbedef1a27" - integrity sha512-AiwGJM8YcNOaobumgtng+6NHuOqC3A7MixFeDafM3X9cIUM+xUXoS5Vfgf+OihAYe20fxqNM9yPBXJzRtZ/4eA== + version "3.4.2" + resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.4.2.tgz#f5c23c107f0f37de8dbdf24f13722b3b98d52726" + integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA== "flot.curvedlines@https://github.com/MichaelZinsmaier/CurvedLines.git#master": version "1.1.1" @@ -5498,21 +6240,21 @@ flatted@^3.2.9: resolved "https://github.com/thingsboard/flot.git#c2734540477d8b261d04ee18d4d38af3b0ecb81b" follow-redirects@^1.0.0: - version "1.15.9" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.9.tgz#a604fa10e443bf98ca94228d9eebcc2e8a2c8ee1" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + version "1.16.0" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc" + integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw== font-awesome@^4.7.0: version "4.7.0" resolved "https://registry.yarnpkg.com/font-awesome/-/font-awesome-4.7.0.tgz#8fa8cf0411a1a31afd07b06d2902bb9fc815a133" integrity sha512-U6kGnykA/6bFmg1M/oT9EkFeIYv7JlX3bozwQJWiiLz6L0w3F5vBVPxHlwyX/vtNq1ckcpRKOB9f2Qal/VtFpg== -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== +for-each@^0.3.3, for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== dependencies: - is-callable "^1.1.3" + is-callable "^1.2.7" foreground-child@^3.1.0: version "3.3.0" @@ -5522,10 +6264,10 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -form-data@4.0.0, form-data@4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.4.tgz#784cdcce0669a9d68e94d11ac4eea98088edd2c4" - integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== +form-data@4.0.0, form-data@>=4.0.4: + version "4.0.5" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053" + integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" @@ -5550,28 +6292,30 @@ fraction.js@^4.3.7: resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-4.3.7.tgz#06ca0085157e42fda7f9e726e79fefc4068840f7" integrity sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew== +fraction.js@^5.3.4: + version "5.3.4" + resolved "https://registry.yarnpkg.com/fraction.js/-/fraction.js-5.3.4.tgz#8c0fcc6a9908262df4ed197427bdeef563e0699a" + integrity sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ== + +fresh@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fresh/-/fresh-2.0.0.tgz#8dd7df6a1b3a1b3a5cf186c05a5dd267622635a4" + integrity sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A== + fresh@~0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-extra@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" - integrity sha512-hcg3ZmepS30/7BSFqRvoo3DOMQu7IjqxO5nCDt+zM9XWjb33Wg7ziNT+Qvqbuc3+gWpzO02JubVyk2G4Zvo1OQ== +fs-extra@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-10.1.0.tgz#02873cfbc4084dde127eaa5f9905eef2325d1abf" + integrity sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ== dependencies: - at-least-node "^1.0.0" graceful-fs "^4.2.0" jsonfile "^6.0.1" universalify "^2.0.0" -fs-minipass@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-2.1.0.tgz#7f5036fdbf12c63c169190cbe4199c852271f9fb" - integrity sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg== - dependencies: - minipass "^3.0.0" - fs-minipass@^3.0.0: version "3.0.3" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-3.0.3.tgz#79a85981c4dc120065e96f62086bf6f9dc26cc54" @@ -5589,37 +6333,33 @@ function-bind@^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.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== +function.prototype.name@^1.1.6, function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" 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== +generator-function@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/generator-function/-/generator-function-2.0.1.tgz#0e75dd410d1243687a0ba2e951b94eedb8f737a2" + integrity sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g== + gensync@^1.0.0-beta.2: version "1.0.0-beta.2" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== -geojson-rbush@3.x: - version "3.2.0" - resolved "https://registry.yarnpkg.com/geojson-rbush/-/geojson-rbush-3.2.0.tgz#8b543cf0d56f99b78faf1da52bb66acad6dfc290" - integrity sha512-oVltQTXolxvsz1sZnutlSuLDEcQAKYC/uXt9zDzJJ6bu0W+baTI8LZBaTup5afzibEH4N3jlq2p+a152wlBJ7w== - dependencies: - "@turf/bbox" "*" - "@turf/helpers" "6.x" - "@turf/meta" "6.x" - "@types/geojson" "7946.0.8" - rbush "^3.0.1" - geojson-vt@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/geojson-vt/-/geojson-vt-4.0.2.tgz#1162f6c7d61a0ba305b1030621e6e111f847828a" @@ -5630,12 +6370,12 @@ get-caller-file@^2.0.1, get-caller-file@^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-east-asian-width@^1.0.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.3.0.tgz#21b4071ee58ed04ee0db653371b55b4299875389" - integrity sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ== +get-east-asian-width@^1.0.0, get-east-asian-width@^1.3.1: + version "1.5.0" + resolved "https://registry.yarnpkg.com/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz#ce7008fe345edcf5497a6f557cfa54bc318a9ce7" + integrity sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA== -get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.3.0: +get-intrinsic@^1.2.4, get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== @@ -5659,24 +6399,24 @@ get-proto@^1.0.1: dunder-proto "^1.0.1" es-object-atoms "^1.0.0" -get-stream@^6.0.0, get-stream@^6.0.1: +get-stream@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7" integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg== -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== +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== dependencies: - call-bind "^1.0.5" + call-bound "^1.0.3" es-errors "^1.3.0" - get-intrinsic "^1.2.4" + get-intrinsic "^1.2.6" gl-matrix@^3.4.3: - version "3.4.3" - resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.3.tgz#fc1191e8320009fd4d20e9339595c6041ddc22c9" - integrity sha512-wcCp8vu8FT22BnvKVPjXa/ICBWRq/zjFfdofZy1WSpQZpphblv12/bOQLBC1rMM7SGOFS9ltVmKOHil5+Ml7gA== + version "3.4.4" + resolved "https://registry.yarnpkg.com/gl-matrix/-/gl-matrix-3.4.4.tgz#7789ee4982f62c7a7af447ee488f3bd6b0c77003" + integrity sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ== glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" @@ -5692,12 +6432,17 @@ glob-parent@^6.0.1, glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob-to-regex.js@^1.0.0, glob-to-regex.js@^1.0.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/glob-to-regex.js/-/glob-to-regex.js-1.2.0.tgz#2b323728271d133830850e32311f40766c5f6413" + integrity sha512-QMwlOQKU/IzqMUOAZWubUOT8Qft+Y0KQWnX9nK3ch0CJg0tTp4TvGZsTfudYKv2NzoQSyPcnA6TYeIQ3jGichQ== + glob-to-regexp@^0.4.1: version "0.4.1" resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@10.5.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.3, glob@^10.3.7, glob@^7.1.3: +glob@^10.4.2: version "10.5.0" resolved "https://registry.yarnpkg.com/glob/-/glob-10.5.0.tgz#8ec0355919cd3338c28428a23d4f24ecc5fe738c" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -5709,6 +6454,15 @@ glob@10.5.0, glob@^10.2.2, glob@^10.3.10, glob@^10.3.3, glob@^10.3.7, glob@^7.1. package-json-from-dist "^1.0.0" path-scurry "^1.11.1" +glob@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.0.tgz#9d9233a4a274fc28ef7adce5508b7ef6237a1be3" + integrity sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA== + dependencies: + minimatch "^10.1.1" + minipass "^7.1.2" + path-scurry "^2.0.0" + global-prefix@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/global-prefix/-/global-prefix-4.0.0.tgz#e9cc79aab9be1d03287e156a3f912dd0895463ed" @@ -5718,17 +6472,12 @@ global-prefix@^4.0.0: kind-of "^6.0.3" which "^4.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@^14.0.0: version "14.0.0" resolved "https://registry.yarnpkg.com/globals/-/globals-14.0.0.tgz#898d7413c29babcf6bafe56fcadded858ada724e" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globalthis@^1.0.3: +globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== @@ -5736,18 +6485,6 @@ globalthis@^1.0.3: define-properties "^1.2.1" gopd "^1.0.1" -globby@^14.0.0: - version "14.0.2" - resolved "https://registry.yarnpkg.com/globby/-/globby-14.0.2.tgz#06554a54ccfe9264e5a9ff8eded46aa1e306482f" - integrity sha512-s3Fq41ZVh7vbbe2PN3nrW7yC7U7MFVc5c98/iTl9c2GawNMKx/J648KQRW6WKkuU8GIbbh2IXfIRQjOZnXcTnw== - dependencies: - "@sindresorhus/merge-streams" "^2.1.0" - fast-glob "^3.3.2" - ignore "^5.2.4" - path-type "^5.0.0" - slash "^5.1.0" - unicorn-magic "^0.1.0" - good-listener@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" @@ -5765,11 +6502,6 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== -graphemer@^1.4.0: - version "1.4.0" - resolved "https://registry.yarnpkg.com/graphemer/-/graphemer-1.4.0.tgz#fb2f1d55e0e3a1849aeffc90c4fa0dd53a0e66c6" - integrity sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag== - hachure-fill@^0.5.2: version "0.5.2" resolved "https://registry.yarnpkg.com/hachure-fill/-/hachure-fill-0.5.2.tgz#d19bc4cc8750a5962b47fb1300557a85fcf934cc" @@ -5785,7 +6517,7 @@ handle-thing@^2.0.0: resolved "https://registry.yarnpkg.com/handle-thing/-/handle-thing-2.0.1.tgz#857f79ce359580c340d43081cc648970d0bb234e" integrity sha512-9Qn4yBxelxoh2Ow62nP+Ka/kMnOXRi8BXnRaUwezLNhqelnN49xKz4F/dPP8OYLxLxq6JDtZb2i9XznUQbNPTg== -has-bigints@^1.0.1, has-bigints@^1.0.2: +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== @@ -5807,36 +6539,43 @@ has-property-descriptors@^1.0.0, has-property-descriptors@^1.0.2: dependencies: es-define-property "^1.0.0" -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-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" -has-symbols@^1.0.2, has-symbols@^1.0.3, has-symbols@^1.1.0: +has-symbols@^1.0.3, has-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== -has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: +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.2: +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" -hosted-git-info@^7.0.0: - version "7.0.2" - resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-7.0.2.tgz#9b751acac097757667f30114607ef7b661ff4f17" - integrity sha512-puUZAUKT5m8Zzvs72XWy3HtvVbTWljRE66cP60bxJzAqf2DgICo7lYTY2IHUmLnNpjYvw5bvmoHvPc0QO2a62w== +hono@^4.11.4: + version "4.12.8" + resolved "https://registry.yarnpkg.com/hono/-/hono-4.12.8.tgz#5f3a9c0d5339ff460b2c652a4c64dd79059930ad" + integrity sha512-VJCEvtrezO1IAR+kqEYnxUOoStaQPGrCmX3j4wDTNOcD1uRPFpGlwQUIW8niPuvHXaTUxeOUl5MMDGrl+tmO9A== + +hosted-git-info@^9.0.0: + version "9.0.2" + resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-9.0.2.tgz#b38c8a802b274e275eeeccf9f4a1b1a0a8557ada" + integrity sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg== dependencies: - lru-cache "^10.0.1" + lru-cache "^11.1.0" hpack.js@^2.1.6: version "2.1.6" @@ -5848,10 +6587,10 @@ hpack.js@^2.1.6: readable-stream "^2.0.1" wbuf "^1.1.0" -html-entities@^2.4.0: - version "2.5.2" - resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.5.2.tgz#201a3cf95d3a15be7099521620d19dfb4f65359f" - integrity sha512-K//PSRMQk4FZ78Kyau+mZurHn3FH0Vwr+H36eE0rPbeYkRRi9YxceYPhuN60UwWorxyKHhqoAJl2OFKa4BVtaA== +html-entities@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.6.0.tgz#7c64f1ea3b36818ccae3d3fb48b6974208e984f8" + integrity sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ== html2canvas@^1.4.1: version "1.4.1" @@ -5861,15 +6600,15 @@ html2canvas@^1.4.1: css-line-break "^2.1.0" text-segmentation "^1.0.3" -htmlparser2@^8.0.2: - version "8.0.2" - resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" - integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== +htmlparser2@^10.0.0: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== dependencies: domelementtype "^2.3.0" domhandler "^5.0.3" - domutils "^3.0.1" - entities "^4.4.0" + domutils "^3.2.2" + entities "^7.0.1" http-cache-semantics@^4.1.1: version "4.1.1" @@ -5881,17 +6620,7 @@ http-deceiver@^1.2.7: resolved "https://registry.yarnpkg.com/http-deceiver/-/http-deceiver-1.2.7.tgz#fa7168944ab9a519d337cb0bec7284dc3e723d87" integrity sha512-LmpOGxTfbpgtGVxJrj5k7asXHCgNZp5nLfp+hWc8QQRqtb7fUy6kRY3BO1h9ddF6yIPYUARgxGOwB42DnxIaNw== -http-errors@~1.6.2: - version "1.6.3" - resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" - integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== - dependencies: - depd "~1.1.2" - inherits "2.0.3" - setprototypeof "1.1.0" - statuses ">= 1.4.0 < 2" - -http-errors@~2.0.0, http-errors@~2.0.1: +http-errors@^2.0.0, http-errors@^2.0.1, http-errors@~2.0.0, http-errors@~2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.1.tgz#36d2f65bc909c8790018dd36fb4d93da6caae06b" integrity sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ== @@ -5902,6 +6631,16 @@ http-errors@~2.0.0, http-errors@~2.0.1: statuses "~2.0.2" toidentifier "~1.0.1" +http-errors@~1.6.2: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + integrity sha512-lks+lVC8dgGyh97jxvxeYTWQFvh4uw4yC12gVl63Cg30sjPX4wuGcdkICVXDAESr6OJGjqGA8Iz5mkeN6zlD7A== + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + http-parser-js@>=0.5.1: version "0.5.8" resolved "https://registry.yarnpkg.com/http-parser-js/-/http-parser-js-0.5.8.tgz#af23090d9ac4e24573de6f6aecc9d84a48bf20e3" @@ -5915,10 +6654,10 @@ http-proxy-agent@^7.0.0: agent-base "^7.1.0" debug "^4.3.4" -http-proxy-middleware@3.0.3: - version "3.0.3" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.3.tgz#dc1313c75bd00d81e103823802551ee30130ebd1" - integrity sha512-usY0HG5nyDUwtqpiZdETNbmKtw3QQ1jwYFZ9wi5iHzX2BcILwQKtYDJPo7XHTsu5Z0B2Hj3W9NNnbd+AjFWjqg== +http-proxy-middleware@3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-3.0.5.tgz#9dcde663edc44079bc5a9c63e03fe5e5d6037fab" + integrity sha512-GLZZm1X38BPY4lkXA01jhwxvDoOkkXqjgVyUzVxiEK4iuRu03PZoYHhHRwxnfhQMDuaxi3vVri0YgSro/1oWqg== dependencies: "@types/http-proxy" "^1.17.15" debug "^4.3.6" @@ -5927,10 +6666,10 @@ http-proxy-middleware@3.0.3: is-plain-object "^5.0.0" micromatch "^4.0.8" -http-proxy-middleware@^2.0.3: - version "2.0.7" - resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.7.tgz#915f236d92ae98ef48278a95dedf17e991936ec6" - integrity sha512-fgVY8AV7qU7z/MmXJ/rxwbrtQH4jBQ9m7kp3llF0liB7glmFeVZFBepQb32T3y8n8k2+AEYuMPCpinYW+/CuRA== +http-proxy-middleware@^2.0.9: + version "2.0.9" + resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-2.0.9.tgz#e9e63d68afaa4eee3d147f39149ab84c0c2815ef" + integrity sha512-c1IyJYLYppU574+YI7R4QyX2ystMtVXZwIdzazUIPIJsHuWNd+mho2j+bKoHftndicGj9yh+xjd+l0yj7VeT1Q== dependencies: "@types/http-proxy" "^1.17.8" http-proxy "^1.18.1" @@ -5947,19 +6686,14 @@ http-proxy@^1.18.1: follow-redirects "^1.0.0" requires-port "^1.0.0" -https-proxy-agent@7.0.5, https-proxy-agent@^7.0.1: - version "7.0.5" - resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" - integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== +https-proxy-agent@7.0.6, https-proxy-agent@^7.0.1: + version "7.0.6" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz#da8dfeac7da130b05c2ba4b59c9b6cd66611a6b9" + integrity sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw== dependencies: - agent-base "^7.0.2" + agent-base "^7.1.2" debug "4" -human-signals@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" - integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== - hyperdyperid@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/hyperdyperid/-/hyperdyperid-1.2.0.tgz#59668d323ada92228d2a869d3e474d5a33b69e6b" @@ -5972,7 +6706,14 @@ iconv-lite@0.6, iconv-lite@^0.6.2, iconv-lite@^0.6.3: dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@^0.4.24, iconv-lite@~0.4.24: +iconv-lite@^0.7.0, iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.7.2.tgz#d0bdeac3f12b4835b7359c2ad89c422a4d1cc72e" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@~0.4.24: version "0.4.24" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== @@ -5984,24 +6725,24 @@ icss-utils@^5.0.0, icss-utils@^5.1.0: resolved "https://registry.yarnpkg.com/icss-utils/-/icss-utils-5.1.0.tgz#c6be6858abd013d768e98366ae47e25d5887b1ae" integrity sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA== -ieee754@^1.1.12, ieee754@^1.1.13: +ieee754@^1.1.12: version "1.2.1" resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352" integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== -ignore-walk@^6.0.4: - version "6.0.5" - resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-6.0.5.tgz#ef8d61eab7da169078723d1f82833b36e200b0dd" - integrity sha512-VuuG0wCnjhnylG1ABXT3dAuIpTNDs/G8jlpmwXY03fXoXy/8ZK8/T+hMzt8L4WnrLCJgdybqgPagnF/f97cg3A== +ignore-walk@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/ignore-walk/-/ignore-walk-8.0.0.tgz#380c173badc3a18c57ff33440753f0052f572b14" + integrity sha512-FCeMZT4NiRQGh+YkeKMtWrOmBgWjHjMJ26WQWrRQyoyzqevdaGSakUaJW5xQYmjLlUVk2qUnCjYVBax9EKKg8A== dependencies: - minimatch "^9.0.0" + minimatch "^10.0.3" -ignore@6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/ignore/-/ignore-6.0.2.tgz#77cccb72a55796af1b6d2f9eb14fa326d24f4283" - integrity sha512-InwqeHHN2XpumIkMvpl/DCJVrAHgCsG5+cn1XlnLWGwtZBm8QJfSusItfrwx81CTp5agNZqpKU2J/ccC5nGT4A== +ignore@7.0.5, ignore@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== -ignore@^5.2.0, ignore@^5.2.4, ignore@^5.3.1: +ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== @@ -6016,10 +6757,10 @@ immediate@~3.0.5: resolved "https://registry.yarnpkg.com/immediate/-/immediate-3.0.6.tgz#9db1dbd0faf8de6fbe0f5dd5e56bb606280de69b" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -immutable@^4.0.0: - version "4.3.7" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" - integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== +immutable@^5.0.2: + version "5.1.5" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165" + integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A== import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.0" @@ -6034,39 +6775,44 @@ imurmurhash@^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== - inherits@2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.3.tgz#633c2c83e3da42a502f52466022480f4208261de" integrity sha512-x00IRNXNy63jwGkJmzPigoySHbaqpNuzKbBOmzK+g2OdZpQ9w+sxCN+VSB3ja7IAge2OP2qpfxTjeNcyjmW1uw== -inherits@^2.0.1, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: +inherits@^2.0.1, inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== -ini@4.1.3, ini@^4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795" - integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== +ini@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-5.0.0.tgz#a7a4615339843d9a8ccc2d85c9d81cf93ffbc638" + integrity sha512-+N0ngpO3e7cRUWOJAS7qw0IZIVc6XPrW4MlFBdD066F2L4k1L6ker3hLqSq7iXxU5tgS4WGkIUElWn5vogAEnw== ini@^1.3.4: version "1.3.8" resolved "https://registry.yarnpkg.com/ini/-/ini-1.3.8.tgz#a29da425b48806f34767a4efce397269af28432c" integrity sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew== -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== +ini@^4.1.3: + version "4.1.3" + resolved "https://registry.yarnpkg.com/ini/-/ini-4.1.3.tgz#4c359675a6071a46985eb39b14e4a2c0ec98a795" + integrity sha512-X7rqawQBvfdjS10YU1y1YVreA3SsLrW9dX2CewP2EbBJM4ypVNLDkO5y04gejPwKIY9lR+7r9gn3rFPt/kmWFg== + +ini@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/ini/-/ini-6.0.0.tgz#efc7642b276f6a37d22fdf56ef50889d7146bf30" + integrity sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ== + +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== dependencies: es-errors "^1.3.0" - hasown "^2.0.0" - side-channel "^1.0.4" + hasown "^2.0.2" + side-channel "^1.1.0" "internmap@1 - 2": version "2.0.3" @@ -6078,13 +6824,10 @@ internmap@^1.0.0: resolved "https://registry.yarnpkg.com/internmap/-/internmap-1.0.1.tgz#0017cc8a3b99605f0302f2b198d272e015e5df95" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== -ip-address@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" - integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== - dependencies: - jsbn "1.1.0" - sprintf-js "^1.1.3" +ip-address@10.1.0, ip-address@^10.0.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-10.1.0.tgz#d8dcffb34d0e02eb241427444a6e23f5b0595aa4" + integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== ipaddr.js@1.9.1: version "1.9.1" @@ -6096,25 +6839,37 @@ ipaddr.js@^2.1.0: resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-2.2.0.tgz#d33fa7bac284f4de7af949638c9d68157c6b92e8" integrity sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA== -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== +is-array-buffer@^3.0.4, is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== dependencies: - call-bind "^1.0.2" - get-intrinsic "^1.2.1" + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" 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-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== +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== dependencies: - has-bigints "^1.0.1" + has-bigints "^1.0.2" is-binary-path@~2.1.0: version "2.1.0" @@ -6123,39 +6878,42 @@ is-binary-path@~2.1.0: 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== +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== dependencies: - call-bind "^1.0.2" - has-tostringtag "^1.0.0" + call-bound "^1.0.3" + has-tostringtag "^1.0.2" -is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.7: +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, is-core-module@^2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" - integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== +is-core-module@^2.13.0, is-core-module@^2.16.0, is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== dependencies: hasown "^2.0.2" -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== +is-data-view@^1.0.1, is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" is-typed-array "^1.1.13" -is-date-object@^1.0.1: - version "1.0.5" - resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" - integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== dependencies: - has-tostringtag "^1.0.0" + call-bound "^1.0.2" + has-tostringtag "^1.0.2" is-docker@^2.0.0: version "2.2.1" @@ -6172,6 +6930,13 @@ is-extglob@^2.1.1: resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + 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" @@ -6183,11 +6948,22 @@ is-fullwidth-code-point@^4.0.0: integrity sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ== is-fullwidth-code-point@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.0.0.tgz#9609efced7c2f97da7b60145ef481c787c7ba704" - integrity sha512-OVa3u9kkBbw7b8Xw5F9P+D/T9X+Z4+JruYVNapTjPYZYUznQ5YfWeFkOj606XYYW8yugTfC8Pj0hYqvi4ryAhA== + version "5.1.0" + resolved "https://registry.yarnpkg.com/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz#046b2a6d4f6b156b2233d3207d4b5a9783999b98" + integrity sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ== dependencies: - get-east-asian-width "^1.0.0" + get-east-asian-width "^1.3.1" + +is-generator-function@^1.0.10: + version "1.1.2" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.2.tgz#ae3b61e3d5ea4e4839b90bad22b02335051a17d5" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.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" @@ -6203,15 +6979,15 @@ is-inside-container@^1.0.0: dependencies: is-docker "^3.0.0" -is-interactive@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-1.0.0.tgz#cea6e6ae5c870a7b0a0004070b7b587e0252912e" - integrity sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w== +is-interactive@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/is-interactive/-/is-interactive-2.0.0.tgz#40c57614593826da1100ade6059778d597f16e90" + integrity sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ== -is-lambda@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/is-lambda/-/is-lambda-1.0.1.tgz#3d9877899e6a53efc0160504cde15f82e6f061d5" - integrity sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ== +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" @@ -6223,12 +6999,13 @@ is-network-error@^1.0.0: resolved "https://registry.yarnpkg.com/is-network-error/-/is-network-error-1.1.0.tgz#d26a760e3770226d11c169052f266a4803d9c997" integrity sha512-tUdRRAnhT+OtCZR/LxZelH/C7QtjtFrTu5tXCA8pl55eTUElUHT+GPYV8MBMBvea/j+NxQqVt3LbWMRir7Gx9g== -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== +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== dependencies: - has-tostringtag "^1.0.0" + call-bound "^1.0.3" + has-tostringtag "^1.0.2" is-number@^7.0.0: version "7.0.0" @@ -6252,58 +7029,86 @@ is-plain-object@^5.0.0: resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== -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-promise@^4.0.0: + version "4.0.0" + resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-4.0.0.tgz#42ff9f84206c1991d26debf520dd5c01042dd2f3" + integrity sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ== -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== +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" -is-stream@^2.0.0: - version "2.0.1" - resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-2.0.1.tgz#fac1e3d53b97ad5a9d0ae9cef2389f5810a5c077" - integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== +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-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== +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== dependencies: - has-tostringtag "^1.0.0" + call-bound "^1.0.3" -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== +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== dependencies: - has-symbols "^1.0.2" + call-bound "^1.0.3" + has-tostringtag "^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== +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== dependencies: - which-typed-array "^1.1.14" + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" -is-unicode-supported@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-0.1.0.tgz#3f26c76a809593b52bfa2ecb5710ed2779b522a7" - integrity sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw== +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" -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== +is-unicode-supported@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-1.3.0.tgz#d824984b616c292a2e198207d4a609983842f714" + integrity sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ== + +is-unicode-supported@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz#09f0ab0de6d3744d48d265ebb98f65d11f2a9b3a" + integrity sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ== + +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, is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== dependencies: - call-bind "^1.0.2" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" is-what@^3.14.1: version "3.14.1" @@ -6383,15 +7188,20 @@ jest-worker@^27.4.5: merge-stream "^2.0.0" supports-color "^8.0.0" -jiti@^1.20.0, jiti@^1.21.6: - version "1.21.6" - resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.6.tgz#6c7f7398dd4b3142767f9a168af2f317a428d268" - integrity sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w== +jiti@^1.20.0, jiti@^1.21.7: + version "1.21.7" + resolved "https://registry.yarnpkg.com/jiti/-/jiti-1.21.7.tgz#9dd81043424a3d28458b193d965f0d18a2300ba9" + integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== + +jose@^6.1.3: + version "6.1.3" + resolved "https://registry.yarnpkg.com/jose/-/jose-6.1.3.tgz#8453d7be88af7bb7d64a0481d6a35a0145ba3ea5" + integrity sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ== -jquery.terminal@^2.44.1: - version "2.44.1" - resolved "https://registry.yarnpkg.com/jquery.terminal/-/jquery.terminal-2.44.1.tgz#9a66b28cbc0ffad086dcc375dad08c93bdeae001" - integrity sha512-uyvTsnmtvz7tHo4gvdPImZiKf2p9pQhAg/qkk9sj8vePP1xOTHjm70WKAkeiY4ZUWgIRDfAkyWlaJ6UZWy9m6w== +jquery.terminal@^2.45.2: + version "2.45.2" + resolved "https://registry.yarnpkg.com/jquery.terminal/-/jquery.terminal-2.45.2.tgz#b954785fdf966070e435ff75f1d574c2de2568b1" + integrity sha512-7MtA3Hc8w+xSaANF5ZfN7ibDTp2DhecWsy4n8o+3+m62WDvg39gW6j+XcCQN6fc+b22Sy8m2jnKIJyb9nk4H5w== dependencies: "@jcubic/lily" "^0.3.0" "@types/jquery" "^3.5.29" @@ -6400,7 +7210,7 @@ jquery.terminal@^2.44.1: iconv-lite "^0.6.3" jquery "^3.7.1" node-fetch "^3.3.2" - prismjs "^1.27.0" + prismjs "^1.30.0" wcwidth "^1.0.1" optionalDependencies: fsevents "^2.3.2" @@ -6410,16 +7220,16 @@ jquery@>=1.9.1, jquery@^3.5.0, jquery@^3.7.1: resolved "https://registry.yarnpkg.com/jquery/-/jquery-3.7.1.tgz#083ef98927c9a6a74d05a6af02806566d16274de" integrity sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg== -js-beautify@1.15.1: - version "1.15.1" - resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.1.tgz#4695afb508c324e1084ee0b952a102023fc65b64" - integrity sha512-ESjNzSlt/sWE8sciZH8kBF8BPlwXPwhR6pWKAw8bw4Bwj+iZcnKW6ONWUutJ7eObuBZQpiIb8S7OYspWrKt7rA== +js-beautify@1.15.4: + version "1.15.4" + resolved "https://registry.yarnpkg.com/js-beautify/-/js-beautify-1.15.4.tgz#f579f977ed4c930cef73af8f98f3f0a608acd51e" + integrity sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA== dependencies: config-chain "^1.1.13" editorconfig "^1.0.4" - glob "^10.3.3" + glob "^10.4.2" js-cookie "^3.0.5" - nopt "^7.2.0" + nopt "^7.2.1" js-cookie@^3.0.5: version "3.0.5" @@ -6431,32 +7241,29 @@ js-tokens@^4.0.0: resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== -js-yaml@4.1.0, js-yaml@^4.1.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" -jsbn@1.1.0: - version "1.1.0" - resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-1.1.0.tgz#b01307cb29b618a1ed26ec79e911f803c4da0040" - integrity sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A== - -jsdoc-type-pratt-parser@~4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-4.1.0.tgz#ff6b4a3f339c34a6c188cbf50a16087858d22113" - integrity sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg== +js-yaml@^4.1.0, js-yaml@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.1.tgz#854c292467705b699476e1a2decc0c8a3458806b" + integrity sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA== + dependencies: + argparse "^2.0.1" -jsesc@^2.5.1: - version "2.5.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" - integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== +jsdoc-type-pratt-parser@~7.1.0: + version "7.1.0" + resolved "https://registry.yarnpkg.com/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-7.1.0.tgz#f2d63cbbc3d0d4eaea257eb5f847e8ebc5908dd5" + integrity sha512-SX7q7XyCwzM/MEDCYz0l8GgGbJAACGFII9+WfNYr5SLEKukHWRy2Jk3iWRe7P+lpYJNs7oQ+OSei4JtKGUjd7A== -jsesc@^3.0.2, jsesc@~3.0.2: - version "3.0.2" - resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.0.2.tgz#bb8b09a6597ba426425f2e4a07245c3d00b9343e" - integrity sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g== +jsesc@^3.0.2, jsesc@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-3.1.0.tgz#74d335a234f67ed19907fdadfac7ccf9d409825d" + integrity sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA== json-buffer@3.0.1: version "3.0.1" @@ -6468,10 +7275,10 @@ json-parse-even-better-errors@^2.3.0, json-parse-even-better-errors@^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-parse-even-better-errors@^3.0.0: - version "3.0.2" - resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.2.tgz#b43d35e89c0f3be6b5fbbe9dc6c82467b30c28da" - integrity sha512-fi0NG4bPjCHunUJffmLd0gxssIgkNmArMvis4iNah6Owg1MCJjWhEcDLmsK6iGkJq3tHwbDkTlce70/tmXN4cQ== +json-parse-even-better-errors@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-5.0.0.tgz#93c89f529f022e5dadc233409324f0167b1e903e" + integrity sha512-ZF1nxZ28VhQouRWhUcVlUIN3qwSgPuswK05s/HIaoetAoE/9tngVmCHjSxmSQPav1nd+lPtTL0YZ/2AFdR/iYQ== json-schema-defaults@^0.4.0: version "0.4.0" @@ -6490,6 +7297,11 @@ json-schema-traverse@^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-schema-typed@^8.0.2: + version "8.0.2" + resolved "https://registry.yarnpkg.com/json-schema-typed/-/json-schema-typed-8.0.2.tgz#e98ee7b1899ff4a184534d1f167c288c66bbeff4" + integrity sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA== + 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" @@ -6577,10 +7389,10 @@ karma-source-map-support@1.4.0: dependencies: source-map-support "^0.5.5" -katex@^0.16.0, katex@^0.16.9: - version "0.16.11" - resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.11.tgz#4bc84d5584f996abece5f01c6ad11304276a33f5" - integrity sha512-RQrI8rlHY92OLf3rho/Ts8i/XvjgguEjOkO1BEXcU3N8BqPpSzBNwV/G0Ukr+P/l3ivvJUE/Fa/CwbS6HesGNQ== +katex@^0.16.0, katex@^0.16.25: + version "0.16.45" + resolved "https://registry.yarnpkg.com/katex/-/katex-0.16.45.tgz#ba60d39c54746b6b8d39ce0e7f6eace07143149c" + integrity sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA== dependencies: commander "^8.3.0" @@ -6613,21 +7425,17 @@ klaw-sync@^6.0.0: dependencies: graceful-fs "^4.1.11" -kolorist@^1.8.0: - version "1.8.0" - resolved "https://registry.yarnpkg.com/kolorist/-/kolorist-1.8.0.tgz#edddbbbc7894bc13302cdf740af6374d4a04743c" - integrity sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ== - -langium@3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/langium/-/langium-3.0.0.tgz#4938294eb57c59066ef955070ac4d0c917b26026" - integrity sha512-+Ez9EoiByeoTu/2BXmEaZ06iPNXM6thWJp02KfBO/raSMyCJ4jw7AkWWa+zBCTm0+Tw1Fj9FOxdqSskyN5nAwg== +langium@^4.0.0: + version "4.2.2" + resolved "https://registry.yarnpkg.com/langium/-/langium-4.2.2.tgz#d7409c23475d591ed6fc7d123c396e4fa4134e60" + integrity sha512-JUshTRAfHI4/MF9dH2WupvjSXyn8JBuUEWazB8ZVJUtXutT0doDlAv1XKbZ1Pb5sMexa8FF4CFBc0iiul7gbUQ== dependencies: - chevrotain "~11.0.3" - chevrotain-allstar "~0.3.0" + "@chevrotain/regexp-to-ast" "~12.0.0" + chevrotain "~12.0.0" + chevrotain-allstar "~0.4.1" vscode-languageserver "~9.0.1" vscode-languageserver-textdocument "~1.0.11" - vscode-uri "~3.0.8" + vscode-uri "~3.1.0" launch-editor@^2.6.1: version "2.9.1" @@ -6684,15 +7492,15 @@ leaflet@1.9.4: resolved "https://registry.yarnpkg.com/leaflet/-/leaflet-1.9.4.tgz#23fae724e282fa25745aff82ca4d394748db7d8d" integrity sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA== -less-loader@12.2.0: - version "12.2.0" - resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.2.0.tgz#e1e94522f6abe9e064ef396c29a3151bc6c1b6cc" - integrity sha512-MYUxjSQSBUQmowc0l5nPieOYwMzGPUaTzB6inNW/bdPEG9zOL3eAAD1Qw5ZxSPk7we5dMojHwNODYMV1hq4EVg== +less-loader@12.3.0: + version "12.3.0" + resolved "https://registry.yarnpkg.com/less-loader/-/less-loader-12.3.0.tgz#d4a00361568be86a97da3df4f16954b0d4c15340" + integrity sha512-0M6+uYulvYIWs52y0LqN4+QM9TqWAohYSNTo4htE8Z7Cn3G/qQMEmktfHmyJT23k+20kU9zHH2wrfFXkxNLtVw== -less@4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/less/-/less-4.2.0.tgz#cbefbfaa14a4cd388e2099b2b51f956e1465c450" - integrity sha512-P3b3HJDBtSzsXUl0im2L7gTO5Ubg8mEN6G8qoTS77iXxXX4Hvu4Qj540PZDvQ8V6DmX6iXo98k7Md0Cm1PrLaA== +less@4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/less/-/less-4.4.0.tgz#deaf881f4880ee80691beae925b8fac699d3a76d" + integrity sha512-kdTwsyRuncDfjEs0DlRILWNvxhDG/Zij4YLO4TMJgDLW+8OzpfkdPnRgrsRuY1o+oaxJGWsps5f/RVBgGmmN0w== dependencies: copy-anything "^2.0.1" parse-node-version "^1.0.1" @@ -6714,10 +7522,10 @@ levn@^0.4.1: prelude-ls "^1.2.1" type-check "~0.4.0" -libphonenumber-js@^1.11.15: - version "1.11.15" - resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.11.15.tgz#0947ba02208cf6c44fdf3b07e097a98b3ec945f4" - integrity sha512-M7+rtYi9l5RvMmHyjyoF3BHHUpXTYdJ0PezZGHNs0GyW1lO+K7jxlXpbdIb7a56h0nqLYdjIw+E+z0ciGaJP7g== +libphonenumber-js@^1.12.35: + version "1.12.35" + resolved "https://registry.yarnpkg.com/libphonenumber-js/-/libphonenumber-js-1.12.35.tgz#149d4a66b2a7a518998c430c6d33465abaed23c4" + integrity sha512-T/Cz6iLcsZdb5jDncDcUNhSAJ0VlSC9TnsqtBNdpkaAmy24/R1RhErtNWVWBrcUZKs9hSgaVsBkc7HxYnazIfw== license-webpack-plugin@4.0.2: version "4.0.2" @@ -6733,25 +7541,20 @@ lie@~3.3.0: dependencies: immediate "~3.0.5" -lilconfig@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" - integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== - -lilconfig@^3.0.0: - version "3.1.2" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.2.tgz#e4a7c3cb549e3a606c8dcc32e5ae1005e62c05cb" - integrity sha512-eop+wDAvpItUys0FWkHIKeC9ybYrTGbU41U5K7+bttZZeohvnY7M9dZ5kB21GNWiFT2q1OoPTvncPCgSOVO5ow== +lilconfig@^3.1.1, lilconfig@^3.1.3: + version "3.1.3" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-3.1.3.tgz#a1bcfd6257f9585bf5ae14ceeebb7b559025e4c4" + integrity sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw== 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== -listr2@8.2.4: - version "8.2.4" - resolved "https://registry.yarnpkg.com/listr2/-/listr2-8.2.4.tgz#486b51cbdb41889108cb7e2c90eeb44519f5a77f" - integrity sha512-opevsywziHd3zHCVQGAj8zu+Z3yHNkkoYhWIGnq54RrCVwLz0MozotJEDnKsIBLvkfLGN6BLOyAeRrYI0pKA4g== +listr2@9.0.1: + version "9.0.1" + resolved "https://registry.yarnpkg.com/listr2/-/listr2-9.0.1.tgz#3cad12d81d998f8024621d9b35c969dba5da4103" + integrity sha512-SL0JY3DaxylDuo/MecFeiC+7pedM0zia33zl0vcjgwcq1q1FWWF1To9EIauPbl8GbMCU0R2e0uJ8bZunhYKD2g== dependencies: cli-truncate "^4.0.0" colorette "^2.0.20" @@ -6760,28 +7563,29 @@ listr2@8.2.4: rfdc "^1.4.1" wrap-ansi "^9.0.0" -lmdb@3.0.13: - version "3.0.13" - resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-3.0.13.tgz#5f326ac13cd60c239c481c42b601ebae39935026" - integrity sha512-UGe+BbaSUQtAMZobTb4nHvFMrmvuAQKSeaqAX2meTEQjfsbpl5sxdHD8T72OnwD4GU9uwNhYXIVe4QGs8N9Zyw== +lmdb@3.4.2: + version "3.4.2" + resolved "https://registry.yarnpkg.com/lmdb/-/lmdb-3.4.2.tgz#8d90d12ec9a575052d30262e69b0a02a3d103ceb" + integrity sha512-nwVGUfTBUwJKXd6lRV8pFNfnrCC1+l49ESJRM19t/tFb/97QfJEixe5DYRvug5JO7DSFKoKaVy7oGMt5rVqZvg== dependencies: - msgpackr "^1.10.2" + msgpackr "^1.11.2" node-addon-api "^6.1.0" node-gyp-build-optional-packages "5.2.2" - ordered-binary "^1.4.1" + ordered-binary "^1.5.3" weak-lru-cache "^1.2.2" optionalDependencies: - "@lmdb/lmdb-darwin-arm64" "3.0.13" - "@lmdb/lmdb-darwin-x64" "3.0.13" - "@lmdb/lmdb-linux-arm" "3.0.13" - "@lmdb/lmdb-linux-arm64" "3.0.13" - "@lmdb/lmdb-linux-x64" "3.0.13" - "@lmdb/lmdb-win32-x64" "3.0.13" - -loader-runner@^4.2.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.0.tgz#c1b4a163b99f614830353b16755e7149ac2314e1" - integrity sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg== + "@lmdb/lmdb-darwin-arm64" "3.4.2" + "@lmdb/lmdb-darwin-x64" "3.4.2" + "@lmdb/lmdb-linux-arm" "3.4.2" + "@lmdb/lmdb-linux-arm64" "3.4.2" + "@lmdb/lmdb-linux-x64" "3.4.2" + "@lmdb/lmdb-win32-arm64" "3.4.2" + "@lmdb/lmdb-win32-x64" "3.4.2" + +loader-runner@^4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/loader-runner/-/loader-runner-4.3.1.tgz#6c76ed29b0ccce9af379208299f07f876de737e3" + integrity sha512-IWqP2SCPhyVFTBtRcgMHdzlf9ul25NwaFx4wCEH/KjAXuuHY4yNjvPXsBokp8jCB936PyWRaPKUNh8NvylLp2Q== loader-utils@3.3.1: version "3.3.1" @@ -6797,14 +7601,6 @@ loader-utils@^2.0.0: emojis-list "^3.0.0" json5 "^2.1.2" -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@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-5.0.0.tgz#1afba396afd676a6d42504d0a67a3a7eb9f62aa0" @@ -6819,17 +7615,10 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -locate-path@^7.1.0: - version "7.2.0" - resolved "https://registry.yarnpkg.com/locate-path/-/locate-path-7.2.0.tgz#69cb1779bd90b35ab1e771e1f2f89a202c2a8a8a" - integrity sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA== - dependencies: - p-locate "^6.0.0" - -lodash-es@4.17.21, lodash-es@^4.17.21: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.17.21, lodash-es@^4.17.23: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.18.1.tgz#b962eeb80d9d983a900bf342961fb7418ca10b1d" + integrity sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A== lodash.camelcase@^4.3.0: version "4.3.0" @@ -6846,23 +7635,23 @@ lodash.merge@^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.14: - version "4.17.21" - resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" - integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== +lodash@4.18.1, lodash@^4.17.14: + version "4.18.1" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.18.1.tgz#ff2b66c1f6326d59513de2407bf881439812771c" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== log-driver@1.2.7: version "1.2.7" resolved "https://registry.yarnpkg.com/log-driver/-/log-driver-1.2.7.tgz#63b95021f0702fedfa2c9bb0a24e7797d71871d8" integrity sha512-U7KCmLdqsGHBLeWqYlFA0V0Sl6P08EE1ZrmA9cxjUE0WVqT9qnyVDPz1kzpFEP0jdJuFnasWIfSd7fsaNXkpbg== -log-symbols@^4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.1.0.tgz#3fbdbb95b4683ac9fc785111e792e558d4abd503" - integrity sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg== +log-symbols@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-6.0.0.tgz#bb95e5f05322651cac30c0feb6404f9f2a8a9439" + integrity sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw== dependencies: - chalk "^4.1.0" - is-unicode-supported "^0.1.0" + chalk "^5.3.0" + is-unicode-supported "^1.3.0" log-update@^6.1.0: version "6.1.0" @@ -6875,11 +7664,16 @@ log-update@^6.1.0: strip-ansi "^7.1.0" wrap-ansi "^9.0.0" -lru-cache@^10.0.1, lru-cache@^10.2.0: +lru-cache@^10.2.0: version "10.4.3" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-10.4.3.tgz#410fc8a17b70e598013df257c2446b7f3383f119" integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== +lru-cache@^11.0.0, lru-cache@^11.1.0, lru-cache@^11.2.1: + version "11.2.4" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.2.4.tgz#ecb523ebb0e6f4d837c807ad1abaea8e0619770d" + integrity sha512-B5Y16Jr9LB9dHVkh6ZevG+vAbOsNOYCX+sXvFWFu7B3Iz5mijW3zdbMyhsh8ANd2mSWBYdJgnqi+mL7/LrOPYg== + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920" @@ -6887,10 +7681,10 @@ lru-cache@^5.1.1: dependencies: yallist "^3.0.2" -magic-string@0.30.11: - version "0.30.11" - resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.11.tgz#301a6f93b3e8c2cb13ac1a7a673492c0dfd12954" - integrity sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A== +magic-string@0.30.17: + version "0.30.17" + resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== dependencies: "@jridgewell/sourcemap-codec" "^1.5.0" @@ -6907,30 +7701,29 @@ make-error@^1.1.1: resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== -make-fetch-happen@^13.0.0, make-fetch-happen@^13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-13.0.1.tgz#273ba2f78f45e1f3a6dca91cede87d9fa4821e36" - integrity sha512-cKTUFc/rbKUd/9meOvgrpJ2WrNzymt6jfRDdwg5UCnVzv9dTpEj9JS5m3wtziXVCjluIXyL8pcaukYqezIzZQA== +make-fetch-happen@^15.0.0, make-fetch-happen@^15.0.1, make-fetch-happen@^15.0.3: + version "15.0.3" + resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz#1578d72885f2b3f9e5daa120b36a14fc31a84610" + integrity sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw== dependencies: - "@npmcli/agent" "^2.0.0" - cacache "^18.0.0" + "@npmcli/agent" "^4.0.0" + cacache "^20.0.1" http-cache-semantics "^4.1.1" - is-lambda "^1.0.1" minipass "^7.0.2" - minipass-fetch "^3.0.0" + minipass-fetch "^5.0.0" minipass-flush "^1.0.5" minipass-pipeline "^1.2.4" - negotiator "^0.6.3" - proc-log "^4.2.0" + negotiator "^1.0.0" + proc-log "^6.0.0" promise-retry "^2.0.1" - ssri "^10.0.0" + ssri "^13.0.0" make-plural@^7.0.0: version "7.4.0" resolved "https://registry.yarnpkg.com/make-plural/-/make-plural-7.4.0.tgz#fa6990dd550dea4de6b20163f74e5ed83d8a8d6d" integrity sha512-4/gC9KVNTV6pvYg2gFeQYTW3mWaoJt7WZE5vrp1KnQDgW92JtYZnzmZT81oj/dUTqAIu0ufI2x3dkgu3bB1tYg== -maplibre-gl@^5.2.0: +maplibre-gl@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.2.0.tgz#e3cdb66c82232cffbe149b032776484722caee4e" integrity sha512-9zZKD0M80qtDsqBet+EDuAhoCeA/cnAuZAA0p3hcGKGbyjM/SH+R6wQvnBEgvJz9UhDynnkoKdUwhI+fUkHoXQ== @@ -6962,15 +7755,10 @@ maplibre-gl@^5.2.0: tinyqueue "^3.0.0" vt-pbf "^3.1.3" -marked@^13.0.2: - version "13.0.3" - resolved "https://registry.yarnpkg.com/marked/-/marked-13.0.3.tgz#5c5b4a5d0198060c7c9bc6ef9420a7fed30f822d" - integrity sha512-rqRix3/TWzE9rIoFGIn8JmsVfhiuC8VIQ8IdX5TfzmeBucdY05/0UlzKaw0eVtpcN/OdVFpBk7CjKGo9iHJ/zA== - -marked@~12.0.2: - version "12.0.2" - resolved "https://registry.yarnpkg.com/marked/-/marked-12.0.2.tgz#b31578fe608b599944c69807b00f18edab84647e" - integrity sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q== +marked@^16.3.0, marked@~16.4.2: + version "16.4.2" + resolved "https://registry.yarnpkg.com/marked/-/marked-16.4.2.tgz#4959a64be6c486f0db7467ead7ce288de54290a3" + integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== math-intrinsics@^1.1.0: version "1.1.0" @@ -6982,14 +7770,29 @@ media-typer@0.3.0: resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== -memfs@^4.6.0: - version "4.12.0" - resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.12.0.tgz#76570478aee461695fb3336ca3356a7a8cfc26cc" - integrity sha512-74wDsex5tQDSClVkeK1vtxqYCAgCoXxx+K4NSHzgU/muYVYByFqa+0RnrPO9NM6naWm1+G9JmZ0p6QHhXmeYfA== - dependencies: - "@jsonjoy.com/json-pack" "^1.0.3" - "@jsonjoy.com/util" "^1.3.0" - tree-dump "^1.0.1" +media-typer@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-1.1.0.tgz#6ab74b8f2d3320f2064b2a87a38e7931ff3a5561" + integrity sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw== + +memfs@^4.43.1, memfs@^4.6.0: + version "4.56.10" + resolved "https://registry.yarnpkg.com/memfs/-/memfs-4.56.10.tgz#eaf2f6556db10f91f1e9ad9f1274fd988c646202" + integrity sha512-eLvzyrwqLHnLYalJP7YZ3wBe79MXktMdfQbvMrVD80K+NhrIukCVBvgP30zTJYEEDh9hZ/ep9z0KOdD7FSHo7w== + dependencies: + "@jsonjoy.com/fs-core" "4.56.10" + "@jsonjoy.com/fs-fsa" "4.56.10" + "@jsonjoy.com/fs-node" "4.56.10" + "@jsonjoy.com/fs-node-builtins" "4.56.10" + "@jsonjoy.com/fs-node-to-fsa" "4.56.10" + "@jsonjoy.com/fs-node-utils" "4.56.10" + "@jsonjoy.com/fs-print" "4.56.10" + "@jsonjoy.com/fs-snapshot" "4.56.10" + "@jsonjoy.com/json-pack" "^1.11.0" + "@jsonjoy.com/util" "^1.9.0" + glob-to-regex.js "^1.0.1" + thingies "^2.5.0" + tree-dump "^1.0.3" tslib "^2.0.0" merge-descriptors@1.0.3: @@ -6997,6 +7800,11 @@ merge-descriptors@1.0.3: resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz#d80319a65f3c7935351e5cfdac8f9318504dbed5" integrity sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ== +merge-descriptors@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-2.0.0.tgz#ea922f660635a2249ee565e0449f951e6b603808" + integrity sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g== + merge-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/merge-stream/-/merge-stream-2.0.0.tgz#52823629a14dd00c9770fb6ad47dc6310f2c1f60" @@ -7008,36 +7816,38 @@ merge2@^1.3.0: integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== "mermaid@>= 10.6.0 < 12.0.0": - version "11.2.1" - resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.2.1.tgz#b168c6f862268f77a0d3559926b193926ddc60bc" - integrity sha512-F8TEaLVVyxTUmvKswVFyOkjPrlJA5h5vNR1f7ZnSWSpqxgEZG1hggtn/QCa7znC28bhlcrNh10qYaIiill7q4A== - dependencies: - "@braintree/sanitize-url" "^7.0.1" - "@iconify/utils" "^2.1.32" - "@mermaid-js/parser" "^0.3.0" - cytoscape "^3.29.2" + version "11.14.0" + resolved "https://registry.yarnpkg.com/mermaid/-/mermaid-11.14.0.tgz#ce81b22bc10f3117ef7737406ef2d10ee1741769" + integrity sha512-GSGloRsBs+JINmmhl0JDwjpuezCsHB4WGI4NASHxL3fHo3o/BRXTxhDLKnln8/Q0lRFRyDdEjmk1/d5Sn1Xz8g== + dependencies: + "@braintree/sanitize-url" "^7.1.1" + "@iconify/utils" "^3.0.2" + "@mermaid-js/parser" "^1.1.0" + "@types/d3" "^7.4.3" + "@upsetjs/venn.js" "^2.0.0" + cytoscape "^3.33.1" cytoscape-cose-bilkent "^4.1.0" cytoscape-fcose "^2.2.0" d3 "^7.9.0" d3-sankey "^0.12.3" - dagre-d3-es "7.0.10" - dayjs "^1.11.10" - dompurify "^3.0.11" - katex "^0.16.9" + dagre-d3-es "7.0.14" + dayjs "^1.11.19" + dompurify "^3.3.1" + katex "^0.16.25" khroma "^2.1.0" - lodash-es "^4.17.21" - marked "^13.0.2" + lodash-es "^4.17.23" + marked "^16.3.0" roughjs "^4.6.6" - stylis "^4.3.1" + stylis "^4.3.6" ts-dedent "^2.2.0" - uuid "^9.0.1" + uuid "^11.1.0" methods@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== -micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: +micromatch@^4.0.2, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -7050,10 +7860,10 @@ mime-db@1.52.0: resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -"mime-db@>= 1.43.0 < 2": - version "1.53.0" - resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.53.0.tgz#3cb63cd820fc29896d9d4e8c32ab4fcd74ccb447" - integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.54.0.tgz#cddb3ee4f9c64530dff640236661d42cb6a314f5" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, mime-types@~2.1.24, mime-types@~2.1.34: version "2.1.35" @@ -7062,25 +7872,27 @@ mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.31, mime-types@~2.1.17, dependencies: mime-db "1.52.0" +mime-types@^3.0.0, mime-types@^3.0.1, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-3.0.2.tgz#39002d4182575d5af036ffa118100f2524b2e2ab" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" + mime@1.6.0, mime@^1.4.1: version "1.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mimic-fn@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" - integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== - mimic-function@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/mimic-function/-/mimic-function-5.0.1.tgz#acbe2b3349f99b9deaca7fb70e48b83e94e67076" integrity sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA== -mini-css-extract-plugin@2.9.0: - version "2.9.0" - resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz#c73a1327ccf466f69026ac22a8e8fd707b78a235" - integrity sha512-Zs1YsZVfemekSZG+44vBsYTLQORkPMwnlv+aehcxK/NLKC+EGhDB39/YePYYqx/sTk6NnYpuqikhSn7+JIevTA== +mini-css-extract-plugin@2.9.4: + version "2.9.4" + resolved "https://registry.yarnpkg.com/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.4.tgz#cafa1a42f8c71357f49cd1566810d74ff1cb0200" + integrity sha512-ZWYT7ln73Hptxqxk2DxPU9MmapXRhxkJD6tkSR04dnQxm8BGu2hzgKLugK5yySD97u/8yy7Ma7E76k9ZdvtjkQ== dependencies: schema-utils "^4.0.0" tapable "^2.2.1" @@ -7090,26 +7902,26 @@ minimalistic-assert@^1.0.0: resolved "https://registry.yarnpkg.com/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz#2e194de044626d4a10e7f7fbc00ce73e83e4d5c7" integrity sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A== -minimatch@9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.1.tgz#8a555f541cf976c622daf078bb28f29fb927c253" - integrity sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w== +minimatch@9.0.1, minimatch@^9.0.4, minimatch@^9.0.5, minimatch@^9.0.7: + version "9.0.7" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.7.tgz#d76c4d0b3b527877016d6cc1b9922fc8e0ffe7b0" + integrity sha512-MOwgjc8tfrpn5QQEvjijjmDVtMw2oL88ugTevzxQnzRLm6l3fVEF2gzU0kYeYYKD8C66+IdGX6peJ4MyUlUnPg== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^5.0.2" -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== +minimatch@^10.0.3, minimatch@^10.1.1: + version "10.2.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.3.tgz#c0ef582f21071b0123a5bbf275252ebda921fbf6" + integrity sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg== dependencies: - brace-expansion "^1.1.7" + brace-expansion "^5.0.2" -minimatch@^9.0.0, minimatch@^9.0.4: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== +minimatch@^3.1.2, minimatch@^3.1.5: + version "3.1.5" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e" + integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w== dependencies: - brace-expansion "^2.0.1" + brace-expansion "^1.1.7" minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" @@ -7123,14 +7935,14 @@ minipass-collect@^2.0.1: dependencies: minipass "^7.0.3" -minipass-fetch@^3.0.0: - version "3.0.5" - resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-3.0.5.tgz#f0f97e40580affc4a35cc4a1349f05ae36cb1e4c" - integrity sha512-2N8elDQAtSnFV0Dk7gt15KHsS0Fyz6CbYZ360h0WTYV1Ty46li3rAXVOQj1THMNLdmrD9Vt5pBPtWtVkpwGBqg== +minipass-fetch@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/minipass-fetch/-/minipass-fetch-5.0.0.tgz#644ed3fa172d43b3163bb32f736540fc138c4afb" + integrity sha512-fiCdUALipqgPWrOVTz9fw0XhcazULXOSU6ie40DDbX1F49p1dBrSRBuswndTx1x3vEb/g0FT7vC4c4C2u/mh3A== dependencies: minipass "^7.0.3" minipass-sized "^1.0.3" - minizlib "^2.1.2" + minizlib "^3.0.1" optionalDependencies: encoding "^0.1.13" @@ -7162,43 +7974,32 @@ minipass@^3.0.0: dependencies: yallist "^4.0.0" -minipass@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/minipass/-/minipass-5.0.0.tgz#3e9788ffb90b694a5d0ec94479a45b5d8738133d" - integrity sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ== - -"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.1.2: +"minipass@^5.0.0 || ^6.0.2 || ^7.0.0", minipass@^7.0.2, minipass@^7.0.3, minipass@^7.0.4, minipass@^7.1.2: version "7.1.2" resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.2.tgz#93a9626ce5e5e66bd4db86849e7515e92340a707" integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== -minizlib@^2.1.1, minizlib@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" - integrity sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg== +minizlib@^3.0.1, minizlib@^3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-3.1.0.tgz#6ad76c3a8f10227c9b51d1c9ac8e30b27f5a251c" + integrity sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw== dependencies: - minipass "^3.0.0" - yallist "^4.0.0" - -mkdirp@^1.0.3: - version "1.0.4" - resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e" - integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw== + minipass "^7.1.2" -mlly@^1.4.2, mlly@^1.7.1: - version "1.7.1" - resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.7.1.tgz#e0336429bb0731b6a8e887b438cbdae522c8f32f" - integrity sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA== +mlly@^1.7.4, mlly@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/mlly/-/mlly-1.8.2.tgz#e7f7919a82d13b174405613117249a3f449d78bb" + integrity sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA== dependencies: - acorn "^8.11.3" - pathe "^1.1.2" - pkg-types "^1.1.1" - ufo "^1.5.3" + acorn "^8.16.0" + pathe "^2.0.3" + pkg-types "^1.3.1" + ufo "^1.6.3" -moment-timezone@^0.5.45: - version "0.5.46" - resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.46.tgz#a21aa6392b3c6b3ed916cd5e95858a28d893704a" - integrity sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw== +moment-timezone@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.6.0.tgz#c5a6519171f31a64739ea75d33f5c136c08ff608" + integrity sha512-ldA5lRNm3iJCWZcBCab4pnNL3HSZYXVb/3TYr75/1WCTWYuTqYUb5f/S384pncYjJ88lbO8Z4uPDvmoluHJc8Q== dependencies: moment "^2.29.4" @@ -7217,10 +8018,10 @@ mousetrap@^1.6.5: resolved "https://registry.yarnpkg.com/mousetrap/-/mousetrap-1.6.5.tgz#8a766d8c272b08393d5f56074e0b5ec183485bf9" integrity sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA== -mrmime@2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.0.tgz#151082a6e06e59a9a39b46b3e14d5cfe92b3abb4" - integrity sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw== +mrmime@2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/mrmime/-/mrmime-2.0.1.tgz#bc3e87f7987853a54c9850eeb1f1078cd44adddc" + integrity sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ== ms@2.0.0: version "2.0.0" @@ -7246,10 +8047,10 @@ msgpackr-extract@^3.0.2: "@msgpackr-extract/msgpackr-extract-linux-x64" "3.0.3" "@msgpackr-extract/msgpackr-extract-win32-x64" "3.0.3" -msgpackr@^1.10.2: - version "1.11.2" - resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.2.tgz#4463b7f7d68f2e24865c395664973562ad24473d" - integrity sha512-F9UngXRlPyWCDEASDpTf6c9uNhGPTqnTeLVt7bN+bU1eajoR/8V9ys2BRaV5C/e5ihE6sJ9uPIKaYt6bFuO32g== +msgpackr@^1.11.2: + version "1.11.8" + resolved "https://registry.yarnpkg.com/msgpackr/-/msgpackr-1.11.8.tgz#8283c79eb6e5d488f6fb3fac4996006baa390614" + integrity sha512-bC4UGzHhVvgDNS7kn9tV8fAucIYUBuGojcaLiz7v+P63Lmtm0Xeji8B/8tYKddALXxJLpwIeBmUN3u64C4YkRA== optionalDependencies: msgpackr-extract "^3.0.2" @@ -7266,10 +8067,10 @@ murmurhash-js@^1.0.0: resolved "https://registry.yarnpkg.com/murmurhash-js/-/murmurhash-js-1.0.0.tgz#b06278e21fc6c37fa5313732b0412bcb6ae15f51" integrity sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw== -mute-stream@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-1.0.0.tgz#e31bd9fe62f0aed23520aa4324ea6671531e013e" - integrity sha512-avsJQhyd+680gKXyG/sQc0nXaC6rBkPOfyHYcFb9+hdkqQkR9bdnkJ0AMZhke0oesPqIO+mFFJ+IdBc7mst4IA== +mute-stream@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/mute-stream/-/mute-stream-2.0.0.tgz#a5446fc0c512b71c83c44d908d5c7b7b4c493b2b" + integrity sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA== mz@^2.7.0: version "2.7.0" @@ -7280,10 +8081,10 @@ mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nanoid@^3.3.7: - version "3.3.8" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.8.tgz#b1be3030bee36aaff18bacb375e5cce521684baf" - integrity sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w== +nanoid@^3.3.11: + version "3.3.11" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== natural-compare@^1.4.0: version "1.4.0" @@ -7298,11 +8099,16 @@ needle@^3.1.0: iconv-lite "^0.6.3" sax "^1.2.4" -negotiator@0.6.3, negotiator@^0.6.3: +negotiator@0.6.3: version "0.6.3" resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== +negotiator@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-1.0.0.tgz#b6c91bb47172d69f93cfd7c357bbb529019b5f6a" + integrity sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg== + neo-async@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f" @@ -7331,33 +8137,33 @@ ngx-daterangepicker-material@^6.0.4: dayjs "^1.11.5" tslib "^2.0.0" -ngx-drag-drop@^18.0.2: - version "18.0.2" - resolved "https://registry.yarnpkg.com/ngx-drag-drop/-/ngx-drag-drop-18.0.2.tgz#a8d805db20d75ad450db75f65d26fafdf0e2ec97" - integrity sha512-I2u5hhUGysRfAhxFCReG9DVUGnPZpu5r3qXZuU9dS22PfzriwtkGrBeD/pgGlUB1BREZd77fb8tY6okpUTvIBw== +ngx-drag-drop@^20.0.1: + version "20.0.1" + resolved "https://registry.yarnpkg.com/ngx-drag-drop/-/ngx-drag-drop-20.0.1.tgz#e8ddb8ee35225d500e7aec84c60ea3aef9f87dec" + integrity sha512-RYhZiLF+MiBv6xRwZwkdZBMxi6qP41hg7T/WMcvXsS6hwuSMJp7zFea2LYHFPgzGBWo1zNp+xzedXDivfaMOMw== dependencies: tslib "^2.3.0" -"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/3.0.0": - version "3.0.0" - resolved "https://github.com/thingsboard/ngx-flowchart.git#0160a1356129c2a3d4dcf7e064e4ba0a39ff668d" +"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/4.1.0": + version "4.1.0" + resolved "https://github.com/thingsboard/ngx-flowchart.git#a3dee1761ecaa96c7d0ca22b1c6538b9e27103af" dependencies: tslib "^2.3.0" -ngx-hm-carousel@^18.0.0: - version "18.0.0" - resolved "https://registry.yarnpkg.com/ngx-hm-carousel/-/ngx-hm-carousel-18.0.0.tgz#f5982b0c39bd5559e92081ae2c006221e6a2319e" - integrity sha512-ZxzlTBuMdm3PyO1E41q/XxNRNjXTTjEYL/L+1rlHa/T7v2+2UAgqz2RmjDWpGAVRUQUdZ+aaS7JJau65zrrYdg== +ngx-hm-carousel@^19.0.0: + version "19.0.0" + resolved "https://registry.yarnpkg.com/ngx-hm-carousel/-/ngx-hm-carousel-19.0.0.tgz#5a4f2999ebb05b1222702246cf06c79ecb08b66a" + integrity sha512-LjigrlIHPmdkLJME81iGGLa5gs2+UEQ051h6XEG22wTZUz5blKG6I+8KU4cY1oCPps10VkzbrTq+jGgvhndPTA== dependencies: "@nghedgehog/core" "^0.0.4" "@types/hammerjs" "^2.0.45" hammerjs "^2.0.8" tslib "^2.3.0" -ngx-markdown@^18.1.0: - version "18.1.0" - resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-18.1.0.tgz#940dde1bf1a5bd9450343aed2387006329b5e27d" - integrity sha512-n4HFSm5oqVMXFuD+WXIVkI6NyxD8Oubr4B3c9U1J7Ptr6t9DVnkNBax3yxWc+8Wli+FXTuGEnDXzB3sp7E9paA== +ngx-markdown@^20.1.0: + version "20.1.0" + resolved "https://registry.yarnpkg.com/ngx-markdown/-/ngx-markdown-20.1.0.tgz#e48b043e7ea00c63db2c3f48c5f45dbc7198d500" + integrity sha512-BLn6CTMO27cU0zeaJYoC1g5c1hAkrpE5oqVSQFGW0J5gq+gEuvTt4vrtNLc8Z+HYXtuuWmuhUWiXL/bYoiDJ+A== dependencies: tslib "^2.3.0" optionalDependencies: @@ -7365,21 +8171,23 @@ ngx-markdown@^18.1.0: emoji-toolkit ">= 8.0.0 < 10.0.0" katex "^0.16.0" mermaid ">= 10.6.0 < 12.0.0" - prismjs "^1.28.0" + prismjs "^1.30.0" -ngx-sharebuttons@^15.0.6: - version "15.0.6" - resolved "https://registry.yarnpkg.com/ngx-sharebuttons/-/ngx-sharebuttons-15.0.6.tgz#3347d71ef7aefdcb23d54b1ea453dd0fe8df5b87" - integrity sha512-majxKSfcPhPKNR+aUNn/dZWd7W9loFSuS2/beV8Bz2r2vd3FVjswvkGHPNO0q956x7KNZn18Wu+IUmuuF/lxyw== +ngx-sharebuttons@^17.0.0: + version "17.0.0" + resolved "https://registry.yarnpkg.com/ngx-sharebuttons/-/ngx-sharebuttons-17.0.0.tgz#d805e85671c210ed3a4a8dcd5e3672e62e436853" + integrity sha512-DlyfAAKLcUWajkCKvfwbEhyaD88JDp0O8CkW5ttro1gKnST5GEPmDRNz+dPGxa4NhjOmkQv2a9t8WppUK7giJw== dependencies: tslib "^2.3.0" -ngx-translate-messageformat-compiler@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/ngx-translate-messageformat-compiler/-/ngx-translate-messageformat-compiler-7.0.0.tgz#edcc412f26a8ccc43fc0fff94fa56f32fb1a2ce3" - integrity sha512-nWklMQ9kzRsCCFemfmzGHsvuD+MnAx1/bCLUCyEun0Xrgvph+TpMh59QczslO47swSzWJt/iF8QLVn3Vori/KA== +ngx-translate-messageformat-compiler@^7.2.0: + version "7.2.0" + resolved "https://registry.yarnpkg.com/ngx-translate-messageformat-compiler/-/ngx-translate-messageformat-compiler-7.2.0.tgz#35dad9122fa6dd0888c3075da285dabe970cc359" + integrity sha512-RxYPc5S75xdPzGP7/ON2fwvHlyD4S9tWaDuustqkYVsRSQ0lDNtIsNnTA5n8g1PodXfw7t+h6ha4cwcxMlRN5A== dependencies: - tslib "^2.5.0" + tslib "2.8.1" + optionalDependencies: + "@rollup/rollup-linux-x64-gnu" "4.46.2" ngx-window-token@>=7.0.0: version "7.0.0" @@ -7388,24 +8196,16 @@ ngx-window-token@>=7.0.0: dependencies: tslib "^2.0.0" -nice-napi@^1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/nice-napi/-/nice-napi-1.0.2.tgz#dc0ab5a1eac20ce548802fc5686eaa6bc654927b" - integrity sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA== - dependencies: - node-addon-api "^3.0.0" - node-gyp-build "^4.2.2" - -node-addon-api@^3.0.0: - version "3.2.1" - resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" - integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== - node-addon-api@^6.1.0: version "6.1.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-6.1.0.tgz#ac8470034e58e67d0c6f1204a18ae6995d9c0d76" integrity sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA== +node-addon-api@^7.0.0: + version "7.1.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-domexception@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/node-domexception/-/node-domexception-1.0.0.tgz#6888db46a1f71c0b76b3f7555016b63fe64766e5" @@ -7420,10 +8220,10 @@ node-fetch@^3.3.2: fetch-blob "^3.1.4" formdata-polyfill "^4.0.10" -node-forge@1.3.3, node-forge@^1: - version "1.3.3" - resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.3.tgz#0ad80f6333b3a0045e827ac20b7f735f93716751" - integrity sha512-rLvcdSyRCyouf6jcOIPe/BgwG/d7hKjzMKOas33/pHEr6gbq18IK9zV7DiPvzsz0oBJPme6qr6H6kGZuI9/DZg== +node-forge@^1: + version "1.4.0" + resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.4.0.tgz#1c7b7d8bdc2d078739f58287d589d903a11b2fc2" + integrity sha512-LarFH0+6VfriEhqMMcLX2F7SwSXeWwnEAJEsYm5QKWchiVYVvJyV9v7UDvUv+w5HO23ZpQTXDv/GxdDdMyOuoQ== node-gyp-build-optional-packages@5.2.2: version "5.2.2" @@ -7432,47 +8232,40 @@ node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc "^2.0.1" -node-gyp-build@^4.2.2: - version "4.8.2" - resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.8.2.tgz#4f802b71c1ab2ca16af830e6c1ea7dd1ad9496fa" - integrity sha512-IRUxE4BVsHWXkV/SFOut4qTlagw2aM8T5/vnTsmrHJvVoKueJHRc/JaFND7QDDc61kLYUJ6qlZM3sqTSyx2dTw== - -node-gyp@^10.0.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-10.2.0.tgz#80101c4aa4f7ab225f13fcc8daaaac4eb1a8dd86" - integrity sha512-sp3FonBAaFe4aYTcFdZUn2NYkbP7xroPGYvQmP4Nl5PxamznItBnNCgjrVTKrEfQynInMsJvZrdmqUnysCJ8rw== +node-gyp@^12.1.0: + version "12.1.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-12.1.0.tgz#302fc2d3fec36975cfb8bfee7a6bf6b7f0be9553" + integrity sha512-W+RYA8jBnhSr2vrTtlPYPc1K+CSjGpVDRZxcqJcERZ8ND3A1ThWPHRwctTx3qC3oW99jt726jhdz3Y6ky87J4g== dependencies: env-paths "^2.2.0" exponential-backoff "^3.1.1" - glob "^10.3.10" graceful-fs "^4.2.6" - make-fetch-happen "^13.0.0" - nopt "^7.0.0" - proc-log "^4.1.0" + make-fetch-happen "^15.0.0" + nopt "^9.0.0" + proc-log "^6.0.0" semver "^7.3.5" - tar "^6.2.1" - which "^4.0.0" + tar "^7.5.2" + tinyglobby "^0.2.12" + which "^6.0.0" -node-releases@^2.0.18: - version "2.0.18" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.18.tgz#f010e8d35e2fe8d6b2944f03f70213ecedc4ca3f" - integrity sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g== +node-releases@^2.0.27: + version "2.0.27" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.27.tgz#eedca519205cf20f650f61d56b070db111231e4e" + integrity sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA== -nopt@^7.0.0, nopt@^7.2.0: +nopt@^7.2.1: version "7.2.1" resolved "https://registry.yarnpkg.com/nopt/-/nopt-7.2.1.tgz#1cac0eab9b8e97c9093338446eddd40b2c8ca1e7" integrity sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w== dependencies: abbrev "^2.0.0" -normalize-package-data@^6.0.0: - version "6.0.2" - resolved "https://registry.yarnpkg.com/normalize-package-data/-/normalize-package-data-6.0.2.tgz#a7bc22167fe24025412bcff0a9651eb768b03506" - integrity sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g== +nopt@^9.0.0: + version "9.0.0" + resolved "https://registry.yarnpkg.com/nopt/-/nopt-9.0.0.tgz#6bff0836b2964d24508b6b41b5a9a49c4f4a1f96" + integrity sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw== dependencies: - hosted-git-info "^7.0.0" - semver "^7.3.5" - validate-npm-package-license "^3.0.4" + abbrev "^4.0.0" normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -7484,91 +8277,100 @@ normalize-range@^0.1.2: resolved "https://registry.yarnpkg.com/normalize-range/-/normalize-range-0.1.2.tgz#2d10c06bdfd312ea9777695a4d28439456b75942" integrity sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA== -npm-bundled@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-3.0.1.tgz#cca73e15560237696254b10170d8f86dad62da25" - integrity sha512-+AvaheE/ww1JEwRHOrn4WHNzOxGtVp+adrg2AeZS/7KuxGUYFuBta98wYpfHBbJp6Tg6j1NKSEVHNcfZzJHQwQ== +npm-bundled@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/npm-bundled/-/npm-bundled-5.0.0.tgz#5025d847cfd06c7b8d9432df01695d0133d9ee80" + integrity sha512-JLSpbzh6UUXIEoqPsYBvVNVmyrjVZ1fzEFbqxKkTJQkWBO3xFzFT+KDnSKQWwOQNbuWRwt5LSD6HOTLGIWzfrw== dependencies: - npm-normalize-package-bin "^3.0.0" + npm-normalize-package-bin "^5.0.0" -npm-install-checks@^6.0.0: - version "6.3.0" - resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-6.3.0.tgz#046552d8920e801fa9f919cad569545d60e826fe" - integrity sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw== +npm-install-checks@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/npm-install-checks/-/npm-install-checks-8.0.0.tgz#f5d18e909bb8318d85093e9d8f36ac427c1cbe30" + integrity sha512-ScAUdMpyzkbpxoNekQ3tNRdFI8SJ86wgKZSQZdUxT+bj0wVFpsEMWnkXP0twVe1gJyNF5apBWDJhhIbgrIViRA== dependencies: semver "^7.1.1" -npm-normalize-package-bin@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz#25447e32a9a7de1f51362c61a559233b89947832" - integrity sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ== +npm-normalize-package-bin@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/npm-normalize-package-bin/-/npm-normalize-package-bin-5.0.0.tgz#2b207ff260f2e525ddce93356614e2f736728f89" + integrity sha512-CJi3OS4JLsNMmr2u07OJlhcrPxCeOeP/4xq67aWNai6TNWWbTrlNDgl8NcFKVlcBKp18GPj+EzbNIgrBfZhsag== -npm-package-arg@11.0.3, npm-package-arg@^11.0.0: - version "11.0.3" - resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-11.0.3.tgz#dae0c21199a99feca39ee4bfb074df3adac87e2d" - integrity sha512-sHGJy8sOC1YraBywpzQlIKBE4pBbGbiF95U6Auspzyem956E0+FtDtsx1ZxlOJkQCZ1AFXAY/yuvtFYrOxF+Bw== +npm-package-arg@13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-13.0.0.tgz#be6fd7e60c6fd605b85f570e88cace45e2416c8b" + integrity sha512-+t2etZAGcB7TbbLHfDwooV9ppB2LhhcT6A+L9cahsf9mEUAoQ6CktLEVvEnpD0N5CkX7zJqnPGaFtoQDy9EkHQ== dependencies: - hosted-git-info "^7.0.0" - proc-log "^4.0.0" + hosted-git-info "^9.0.0" + proc-log "^5.0.0" semver "^7.3.5" - validate-npm-package-name "^5.0.0" + validate-npm-package-name "^6.0.0" -npm-packlist@^8.0.0: - version "8.0.2" - resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-8.0.2.tgz#5b8d1d906d96d21c85ebbeed2cf54147477c8478" - integrity sha512-shYrPFIS/JLP4oQmAwDyk5HcyysKW8/JLTEA32S0Z5TzvpaeeX2yMFfoK1fjEBnCBvVyIB/Jj/GBFdm0wsgzbA== +npm-package-arg@^13.0.0: + version "13.0.2" + resolved "https://registry.yarnpkg.com/npm-package-arg/-/npm-package-arg-13.0.2.tgz#72a80f2afe8329860e63854489415e9e9a2f78a7" + integrity sha512-IciCE3SY3uE84Ld8WZU23gAPPV9rIYod4F+rc+vJ7h7cwAJt9Vk6TVsK60ry7Uj3SRS3bqRRIGuTp9YVlk6WNA== dependencies: - ignore-walk "^6.0.4" + hosted-git-info "^9.0.0" + proc-log "^6.0.0" + semver "^7.3.5" + validate-npm-package-name "^7.0.0" -npm-pick-manifest@9.1.0, npm-pick-manifest@^9.0.0: - version "9.1.0" - resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-9.1.0.tgz#83562afde52b0b07cb6244361788d319ce7e8636" - integrity sha512-nkc+3pIIhqHVQr085X9d2JzPzLyjzQS96zbruppqC9aZRm/x8xx6xhI98gHtsfELP2bE+loHq8ZaHFHhe+NauA== +npm-packlist@^10.0.1: + version "10.0.3" + resolved "https://registry.yarnpkg.com/npm-packlist/-/npm-packlist-10.0.3.tgz#e22c039357faf81a75d1b0cdf53dd113f2bed9c7" + integrity sha512-zPukTwJMOu5X5uvm0fztwS5Zxyvmk38H/LfidkOMt3gbZVCyro2cD/ETzwzVPcWZA3JOyPznfUN/nkyFiyUbxg== + dependencies: + ignore-walk "^8.0.0" + proc-log "^6.0.0" + +npm-pick-manifest@^11.0.1: + version "11.0.3" + resolved "https://registry.yarnpkg.com/npm-pick-manifest/-/npm-pick-manifest-11.0.3.tgz#76cf6593a351849006c36b38a7326798e2a76d13" + integrity sha512-buzyCfeoGY/PxKqmBqn1IUJrZnUi1VVJTdSSRPGI60tJdUhUoSQFhs0zycJokDdOznQentgrpf8LayEHyyYlqQ== dependencies: - npm-install-checks "^6.0.0" - npm-normalize-package-bin "^3.0.0" - npm-package-arg "^11.0.0" + npm-install-checks "^8.0.0" + npm-normalize-package-bin "^5.0.0" + npm-package-arg "^13.0.0" semver "^7.3.5" -npm-registry-fetch@^17.0.0: - version "17.1.0" - resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-17.1.0.tgz#fb69e8e762d456f08bda2f5f169f7638fb92beb1" - integrity sha512-5+bKQRH0J1xG1uZ1zMNvxW0VEyoNWgJpY9UDuluPFLKDfJ9u2JmmjmTJV1srBGQOROfdBMiVvnH2Zvpbm+xkVA== +npm-registry-fetch@^19.0.0: + version "19.1.1" + resolved "https://registry.yarnpkg.com/npm-registry-fetch/-/npm-registry-fetch-19.1.1.tgz#51e96d21f409a9bc4f96af218a8603e884459024" + integrity sha512-TakBap6OM1w0H73VZVDf44iFXsOS3h+L4wVMXmbWOQroZgFhMch0juN6XSzBNlD965yIKvWg2dfu7NSiaYLxtw== dependencies: - "@npmcli/redact" "^2.0.0" + "@npmcli/redact" "^4.0.0" jsonparse "^1.3.1" - make-fetch-happen "^13.0.0" + make-fetch-happen "^15.0.0" minipass "^7.0.2" - minipass-fetch "^3.0.0" - minizlib "^2.1.2" - npm-package-arg "^11.0.0" - proc-log "^4.0.0" - -npm-run-path@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-4.0.1.tgz#b7ecd1e5ed53da8e37a55e1c2269e0b97ed748ea" - integrity sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw== - dependencies: - path-key "^3.0.0" + minipass-fetch "^5.0.0" + minizlib "^3.0.1" + npm-package-arg "^13.0.0" + proc-log "^6.0.0" -nth-check@^2.0.1: +nth-check@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/nth-check/-/nth-check-2.1.1.tgz#c9eab428effce36cd6b92c924bdb000ef1f1ed1d" integrity sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w== dependencies: boolbase "^1.0.0" -object-assign@^4.0.1: +object-assign@^4, object-assign@^4.0.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-deep-merge@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/object-deep-merge/-/object-deep-merge-2.0.0.tgz#94d24cf713d4a7a143653500ff4488a2d494681f" + integrity sha512-3DC3UMpeffLTHiuXSy/UG4NOIYTLlY9u3V82+djSCLYClWobZiS4ivYzpIUWrRY/nfsJ8cWsKyG3QfyLePmhvg== + object-hash@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/object-hash/-/object-hash-3.0.0.tgz#73f97f753e7baffc0e2cc9d6e079079744ac82e9" integrity sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw== -object-inspect@^1.13.1, object-inspect@^1.13.3: +object-inspect@^1.13.3, object-inspect@^1.13.4: version "1.13.4" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== @@ -7578,14 +8380,16 @@ object-keys@^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.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== +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== dependencies: - call-bind "^1.0.5" + call-bind "^1.0.8" + call-bound "^1.0.3" define-properties "^1.2.1" - has-symbols "^1.0.3" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" object-keys "^1.1.1" object.fromentries@^2.0.8: @@ -7607,12 +8411,13 @@ object.groupby@^1.0.3: define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" - integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.3" define-properties "^1.2.1" es-object-atoms "^1.0.0" @@ -7638,12 +8443,12 @@ on-headers@~1.0.2: resolved "https://registry.yarnpkg.com/on-headers/-/on-headers-1.0.2.tgz#772b0ae6aaa525c399e489adfad90c403eb3c28f" integrity sha512-pZAE+FJLoyITytdqK0U5s+FIpjN0JP3OzFi/u8Rx+EV5/W+JTWGXG8xFzevE7AjBfDqHv/8vL8qQsIhHnqRkrA== -onetime@^5.1.0, onetime@^5.1.2: - version "5.1.2" - resolved "https://registry.yarnpkg.com/onetime/-/onetime-5.1.2.tgz#d0e96ebb56b07476df1dd9c4806e5237985ca45e" - integrity sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg== +once@^1.4.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: - mimic-fn "^2.1.0" + wrappy "1" onetime@^7.0.0: version "7.0.0" @@ -7652,15 +8457,15 @@ onetime@^7.0.0: dependencies: mimic-function "^5.0.0" -open@10.1.0, open@^10.0.3: - version "10.1.0" - resolved "https://registry.yarnpkg.com/open/-/open-10.1.0.tgz#a7795e6e5d519abe4286d9937bb24b51122598e1" - integrity sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw== +open@10.2.0, open@^10.0.3: + version "10.2.0" + resolved "https://registry.yarnpkg.com/open/-/open-10.2.0.tgz#b9d855be007620e80b6fb05fac98141fe62db73c" + integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== dependencies: default-browser "^5.2.1" define-lazy-prop "^3.0.0" is-inside-container "^1.0.0" - is-wsl "^3.1.0" + wsl-utils "^0.1.0" open@^7.4.2: version "7.4.2" @@ -7682,30 +8487,34 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -ora@5.4.1: - version "5.4.1" - resolved "https://registry.yarnpkg.com/ora/-/ora-5.4.1.tgz#1b2678426af4ac4a509008e5e4ac9e9959db9e18" - integrity sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ== - dependencies: - bl "^4.1.0" - chalk "^4.1.0" - cli-cursor "^3.1.0" - cli-spinners "^2.5.0" - is-interactive "^1.0.0" - is-unicode-supported "^0.1.0" - log-symbols "^4.1.0" - strip-ansi "^6.0.0" - wcwidth "^1.0.1" +ora@8.2.0: + version "8.2.0" + resolved "https://registry.yarnpkg.com/ora/-/ora-8.2.0.tgz#8fbbb7151afe33b540dd153f171ffa8bd38e9861" + integrity sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw== + dependencies: + chalk "^5.3.0" + cli-cursor "^5.0.0" + cli-spinners "^2.9.2" + is-interactive "^2.0.0" + is-unicode-supported "^2.0.0" + log-symbols "^6.0.0" + stdin-discarder "^0.2.2" + string-width "^7.2.0" + strip-ansi "^7.1.0" -ordered-binary@^1.4.1: - version "1.5.3" - resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.5.3.tgz#8bee2aa7a82c3439caeb1e80c272fd4cf51170fb" - integrity sha512-oGFr3T+pYdTGJ+YFEILMpS3es+GiIbs9h/XQrclBXUtd44ey7XwfsMzM31f64I1SQOawDoDr/D823kNCADI8TA== +ordered-binary@^1.5.3: + version "1.6.1" + resolved "https://registry.yarnpkg.com/ordered-binary/-/ordered-binary-1.6.1.tgz#5ac240ea719d6a0e6d4f0485385d3f9cb1cd4432" + integrity sha512-QkCdPooczexPLiXIrbVOPYkR3VO3T6v2OyKRkR1Xbhpy7/LAVXwahnRCgRp78Oe/Ehf0C/HATAxfSr6eA1oX+w== -os-tmpdir@~1.0.2: - version "1.0.2" - resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" - integrity sha512-D2FR03Vir7FIu45XBY20mTb+/ZSWB00sjU9jdQXt83gDrI4Ztz5Fs7/yy74g2N5SVQY4xY1qDr4rNddwYRVX0g== +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" p-limit@^2.2.0: version "2.3.0" @@ -7721,13 +8530,6 @@ p-limit@^3.0.2: dependencies: yocto-queue "^0.1.0" -p-limit@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-4.0.0.tgz#914af6544ed32bfa54670b061cafcbd04984b644" - integrity sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ== - dependencies: - yocto-queue "^1.0.0" - p-locate@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-4.1.0.tgz#a3428bb7088b3a60292f66919278b7c297ad4f07" @@ -7742,19 +8544,10 @@ p-locate@^5.0.0: dependencies: p-limit "^3.0.2" -p-locate@^6.0.0: - version "6.0.0" - resolved "https://registry.yarnpkg.com/p-locate/-/p-locate-6.0.0.tgz#3da9a49d4934b901089dca3302fa65dc5a05c04f" - integrity sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw== - dependencies: - p-limit "^4.0.0" - -p-map@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/p-map/-/p-map-4.0.0.tgz#bb2f95a5eda2ec168ec9274e06a747c3e2904d2b" - integrity sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ== - dependencies: - aggregate-error "^3.0.0" +p-map@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/p-map/-/p-map-7.0.4.tgz#b81814255f542e252d5729dca4d66e5ec14935b8" + integrity sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ== p-retry@^6.2.0: version "6.2.0" @@ -7775,33 +8568,33 @@ package-json-from-dist@^1.0.0: resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== -package-manager-detector@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-0.2.0.tgz#160395cd5809181f5a047222319262b8c2d8aaea" - integrity sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog== - -pacote@18.0.6: - version "18.0.6" - resolved "https://registry.yarnpkg.com/pacote/-/pacote-18.0.6.tgz#ac28495e24f4cf802ef911d792335e378e86fac7" - integrity sha512-+eK3G27SMwsB8kLIuj4h1FUhHtwiEUo21Tw8wNjmvdlpOEr613edv+8FUsTj/4F/VN5ywGE19X18N7CC2EJk6A== - dependencies: - "@npmcli/git" "^5.0.0" - "@npmcli/installed-package-contents" "^2.0.1" - "@npmcli/package-json" "^5.1.0" - "@npmcli/promise-spawn" "^7.0.0" - "@npmcli/run-script" "^8.0.0" - cacache "^18.0.0" +package-manager-detector@^1.3.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/package-manager-detector/-/package-manager-detector-1.6.0.tgz#70d0cf0aa02c877eeaf66c4d984ede0be9130734" + integrity sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA== + +pacote@21.0.4: + version "21.0.4" + resolved "https://registry.yarnpkg.com/pacote/-/pacote-21.0.4.tgz#59cd2a2b5a4c8c1b625f33991a96b136d1c05d95" + integrity sha512-RplP/pDW0NNNDh3pnaoIWYPvNenS7UqMbXyvMqJczosiFWTeGGwJC2NQBLqKf4rGLFfwCOnntw1aEp9Jiqm1MA== + dependencies: + "@npmcli/git" "^7.0.0" + "@npmcli/installed-package-contents" "^4.0.0" + "@npmcli/package-json" "^7.0.0" + "@npmcli/promise-spawn" "^9.0.0" + "@npmcli/run-script" "^10.0.0" + cacache "^20.0.0" fs-minipass "^3.0.0" minipass "^7.0.2" - npm-package-arg "^11.0.0" - npm-packlist "^8.0.0" - npm-pick-manifest "^9.0.0" - npm-registry-fetch "^17.0.0" - proc-log "^4.0.0" + npm-package-arg "^13.0.0" + npm-packlist "^10.0.1" + npm-pick-manifest "^11.0.1" + npm-registry-fetch "^19.0.0" + proc-log "^6.0.0" promise-retry "^2.0.1" - sigstore "^2.2.0" - ssri "^10.0.0" - tar "^6.1.11" + sigstore "^4.0.0" + ssri "^13.0.0" + tar "^7.4.3" pako@~1.0.2: version "1.0.11" @@ -7815,13 +8608,12 @@ parent-module@^1.0.0: dependencies: callsites "^3.0.0" -parse-imports@^2.1.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/parse-imports/-/parse-imports-2.2.1.tgz#0a6e8b5316beb5c9905f50eb2bbb8c64a4805642" - integrity sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ== +parse-imports-exports@^0.2.4: + version "0.2.4" + resolved "https://registry.yarnpkg.com/parse-imports-exports/-/parse-imports-exports-0.2.4.tgz#e3fb3b5e264cfb55c25b5dfcbe7f410f8dc4e7af" + integrity sha512-4s6vd6dx1AotCx/RCI2m7t7GCh5bDRUtGNvRfHSP2wbBQdMi67pPe7mtzmgwcaQ8VKK/6IB7Glfyu3qdZJPybQ== dependencies: - es-module-lexer "^1.5.3" - slashes "^3.0.12" + parse-statements "1.0.11" parse-json@^5.2.0: version "5.2.0" @@ -7838,53 +8630,57 @@ parse-node-version@^1.0.1: resolved "https://registry.yarnpkg.com/parse-node-version/-/parse-node-version-1.0.1.tgz#e2b5dbede00e7fa9bc363607f53327e8b073189b" integrity sha512-3YHlOa/JgH6Mnpr05jP9eDG254US9ek25LyIxZlDItp2iJtwyaXQb57lBYLdT3MowkUFYEV2XXNAYIPlESvJlA== -parse5-html-rewriting-stream@7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz#e376d3e762d2950ccbb6bb59823fc1d7e9fdac36" - integrity sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg== +parse-statements@1.0.11: + version "1.0.11" + resolved "https://registry.yarnpkg.com/parse-statements/-/parse-statements-1.0.11.tgz#8787c5d383ae5746568571614be72b0689584344" + integrity sha512-HlsyYdMBnbPQ9Jr/VgJ1YF4scnldvJpJxCVx6KgqPL4dxppsWrJHCIIxQXMJrqGnsRkNPATbeMJ8Yxu7JMsYcA== + +parse5-html-rewriting-stream@8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-8.0.0.tgz#3f442e5b5811a5456e2a56b68ea44ef153b44d92" + integrity sha512-wzh11mj8KKkno1pZEu+l2EVeWsuKDfR5KNWZOTsslfUX8lPDZx77m9T0kIoAVkFtD1nx6YF8oh4BnPHvxMtNMw== dependencies: - entities "^4.3.0" - parse5 "^7.0.0" - parse5-sax-parser "^7.0.0" + entities "^6.0.0" + parse5 "^8.0.0" + parse5-sax-parser "^8.0.0" -parse5-sax-parser@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz#4c05064254f0488676aca75fb39ca069ec96dee5" - integrity sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg== +parse5-sax-parser@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/parse5-sax-parser/-/parse5-sax-parser-8.0.0.tgz#49755efbd2b63846c7b908a297a874af00760715" + integrity sha512-/dQ8UzHZwnrzs3EvDj6IkKrD/jIZyTlB+8XrHJvcjNgRdmWruNdN9i9RK/JtxakmlUdPwKubKPTCqvbTgzGhrw== dependencies: - parse5 "^7.0.0" + parse5 "^8.0.0" -parse5@^7.0.0, parse5@^7.1.2: - version "7.2.1" - resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.2.1.tgz#8928f55915e6125f430cc44309765bf17556a33a" - integrity sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ== +parse5@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/parse5/-/parse5-8.0.0.tgz#aceb267f6b15f9b6e6ba9e35bfdd481fc2167b12" + integrity sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA== dependencies: - entities "^4.5.0" + entities "^6.0.0" -parseurl@~1.3.2, parseurl@~1.3.3: +parseurl@^1.3.3, parseurl@~1.3.2, parseurl@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== -patch-package@^8.0.0: - version "8.0.0" - resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.0.tgz#d191e2f1b6e06a4624a0116bcb88edd6714ede61" - integrity sha512-da8BVIhzjtgScwDJ2TtKsfT5JFWz1hYoBl9rUQ1f38MC2HwnEIkK8VN3dKMKcP7P7bvvgzNDbfNHtx3MsQb5vA== +patch-package@^8.0.1: + version "8.0.1" + resolved "https://registry.yarnpkg.com/patch-package/-/patch-package-8.0.1.tgz#79d02f953f711e06d1f8949c8a13e5d3d7ba1a60" + integrity sha512-VsKRIA8f5uqHQ7NGhwIna6Bx6D9s/1iXlA1hthBVBEbkq+t4kXD0HHt+rJhf/Z+Ci0F/HCB2hvn0qLdLG+Qxlw== dependencies: "@yarnpkg/lockfile" "^1.1.0" chalk "^4.1.2" ci-info "^3.7.0" cross-spawn "^7.0.3" find-yarn-workspace-root "^2.0.0" - fs-extra "^9.0.0" + fs-extra "^10.0.0" json-stable-stringify "^1.0.2" klaw-sync "^6.0.0" minimist "^1.2.6" open "^7.4.2" - rimraf "^2.6.3" semver "^7.5.3" slash "^2.0.0" - tmp "^0.0.33" + tmp "^0.2.4" yaml "^2.2.2" path-data-parser@0.1.0, path-data-parser@^0.1.0: @@ -7897,12 +8693,7 @@ path-exists@^4.0.0: resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" integrity sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w== -path-exists@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-5.0.0.tgz#a6aad9489200b21fab31e49cf09277e5116fb9e7" - integrity sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ== - -path-key@^3.0.0, path-key@^3.1.0: +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== @@ -7920,20 +8711,28 @@ path-scurry@^1.11.1: lru-cache "^10.2.0" minipass "^5.0.0 || ^6.0.2 || ^7.0.0" -path-to-regexp@0.1.12, path-to-regexp@~0.1.12: - version "0.1.12" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.12.tgz#d5e1a12e478a976d432ef3c58d534b9923164bb7" - integrity sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ== +path-scurry@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.1.tgz#4b6572376cfd8b811fca9cd1f5c24b3cbac0fe10" + integrity sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA== + dependencies: + lru-cache "^11.0.0" + minipass "^7.1.2" -path-type@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-5.0.0.tgz#14b01ed7aea7ddf9c7c3f46181d4d04f9c785bb8" - integrity sha512-5HviZNaZcfqP95rwpv+1HDgUamezbqdSYTyzjTvwtJSnIH+3vnbmWsItli8OFEndS984VT55M3jduxZbX351gg== +path-to-regexp@^8.0.0: + version "8.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.0.tgz#8e98fcd94826aff01a90c544ef74ffbaca3a78ed" + integrity sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg== -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== +path-to-regexp@~0.1.12: + version "0.1.13" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz#9b22ec16bc3ab88d05a0c7e369869421401ab17d" + integrity sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA== + +pathe@^2.0.1, pathe@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/pathe/-/pathe-2.0.3.tgz#3ecbec55421685b70a9da872b2cff3e1cbed1716" + integrity sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w== pbf@^3.2.1, pbf@^3.3.0: version "3.3.0" @@ -7943,20 +8742,20 @@ pbf@^3.2.1, pbf@^3.3.0: ieee754 "^1.1.12" resolve-protobuf-schema "^2.1.0" -picocolors@^1.0.0, picocolors@^1.0.1, picocolors@^1.1.0, picocolors@^1.1.1: +picocolors@^1.0.0, picocolors@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.2.tgz#77c742931e8f3b8820946c76cd0c1f13730d1dab" - integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +picomatch@4.0.4, picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.4" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" + integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== 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== + version "2.3.2" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601" + integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== pify@^2.3.0: version "2.3.0" @@ -7969,38 +8768,43 @@ pify@^4.0.1: integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g== pirates@^4.0.1: - version "4.0.6" - resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.6.tgz#3018ae32ecfcff6c29ba2267cbf21166ac1f36b9" - integrity sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg== - -piscina@4.6.1: - version "4.6.1" - resolved "https://registry.yarnpkg.com/piscina/-/piscina-4.6.1.tgz#4de673b0ff84bf641b31b07b3348669383b51c9a" - integrity sha512-z30AwWGtQE+Apr+2WBZensP2lIvwoaMcOPkQlIEmSGMJNUvaYACylPYrQM6wSdUNJlnDVMSpLv7xTMJqlVshOA== + version "4.0.7" + resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.7.tgz#643b4a18c4257c8a65104b73f3049ce9a0a15e22" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== + +piscina@5.1.3: + version "5.1.3" + resolved "https://registry.yarnpkg.com/piscina/-/piscina-5.1.3.tgz#30a460500a08770fc97d622e63857a5978dfc466" + integrity sha512-0u3N7H4+hbr40KjuVn2uNhOcthu/9usKhnw5vT3J7ply79v3D3M8naI00el9Klcy16x557VsEkkUQaHCWFXC/g== optionalDependencies: - nice-napi "^1.0.2" + "@napi-rs/nice" "^1.0.4" -pkg-dir@^7.0.0: - version "7.0.0" - resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-7.0.0.tgz#8f0c08d6df4476756c5ff29b3282d0bab7517d11" - integrity sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA== - dependencies: - find-up "^6.3.0" +pkce-challenge@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/pkce-challenge/-/pkce-challenge-5.0.1.tgz#3b4446865b17b1745e9ace2016a31f48ddf6230d" + integrity sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ== -pkg-types@^1.0.3, pkg-types@^1.1.1: - version "1.2.0" - resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.2.0.tgz#d0268e894e93acff11a6279de147e83354ebd42d" - integrity sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA== +pkg-types@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/pkg-types/-/pkg-types-1.3.1.tgz#bd7cc70881192777eef5326c19deb46e890917df" + integrity sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ== dependencies: - confbox "^0.1.7" - mlly "^1.7.1" - pathe "^1.1.2" + confbox "^0.1.8" + mlly "^1.7.4" + pathe "^2.0.1" pngjs@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-5.0.0.tgz#e79dd2b215767fd9c04561c01236df960bce7fbb" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== +point-in-polygon-hao@^1.1.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/point-in-polygon-hao/-/point-in-polygon-hao-1.2.4.tgz#8662abdcc84bcca230cc3ecbb0b0ab1a306f1bd6" + integrity sha512-x2pcvXeqhRHlNRdhLs/tgFapAbSSe86wa/eqmj1G6pWftbEs5aVRJhRGM6FYSUERKu0PjekJzMq0gsI2XyiclQ== + dependencies: + robust-predicates "^3.0.2" + points-on-curve@0.2.0, points-on-curve@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/points-on-curve/-/points-on-curve-0.2.0.tgz#7dbb98c43791859434284761330fa893cb81b4d1" @@ -8014,13 +8818,13 @@ points-on-path@^0.2.1: path-data-parser "0.1.0" points-on-curve "0.2.0" -polyclip-ts@^0.16.5: - version "0.16.5" - resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.5.tgz#053e073e640449f1b1a1d88471f8758779d0b030" - integrity sha512-ZchnG0zGZReHgEo3EYzEUi6UmfQFFzNnj6AFU+gBm+IJJ4qG9gL4CwjtCV6oi/PittUPpJLiLJxcn/AgrCBO+g== +polyclip-ts@^0.16.8: + version "0.16.8" + resolved "https://registry.yarnpkg.com/polyclip-ts/-/polyclip-ts-0.16.8.tgz#503160d05e9d56380533aab0bc2dae835d6da5f9" + integrity sha512-JPtKbDRuPEuAjuTdhR62Gph7Is2BS1Szx69CFOO3g71lpJDFo78k4tFyi+qFOMVPePEzdSKkpGU3NBXPHHjvKQ== dependencies: bignumber.js "^9.1.0" - splaytree-ts "^1.0.1" + splaytree-ts "^1.0.2" possible-typed-array-names@^1.0.0: version "1.0.0" @@ -8037,19 +8841,18 @@ postcss-import@^15.1.0: resolve "^1.1.7" postcss-js@^4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.0.1.tgz#61598186f3703bab052f1c4f7d805f3991bee9d2" - integrity sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw== + version "4.1.0" + resolved "https://registry.yarnpkg.com/postcss-js/-/postcss-js-4.1.0.tgz#003b63c6edde948766e40f3daf7e997ae43a5ce6" + integrity sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw== dependencies: camelcase-css "^2.0.1" -postcss-load-config@^4.0.2: - version "4.0.2" - resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-4.0.2.tgz#7159dcf626118d33e299f485d6afe4aff7c4a3e3" - integrity sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ== +"postcss-load-config@^4.0.2 || ^5.0 || ^6.0": + version "6.0.1" + resolved "https://registry.yarnpkg.com/postcss-load-config/-/postcss-load-config-6.0.1.tgz#6fd7dcd8ae89badcf1b2d644489cbabf83aa8096" + integrity sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g== dependencies: - lilconfig "^3.0.0" - yaml "^2.3.4" + lilconfig "^3.1.1" postcss-loader@8.1.1: version "8.1.1" @@ -8113,21 +8916,12 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.1.0, postcss-value-parser@^ resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.41: - version "8.4.41" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.41.tgz#d6104d3ba272d882fe18fc07d15dc2da62fa2681" - integrity sha512-TesUflQ0WKZqAvg52PWL6kHgLKP6xB6heTOdoYM0Wt2UHyxNa4K25EZZMgKns3BH1RLVbZCREPpLY0rhnNoHVQ== - dependencies: - nanoid "^3.3.7" - picocolors "^1.0.1" - source-map-js "^1.2.0" - -postcss@^8.2.14, postcss@^8.4.23, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.43, postcss@^8.4.47, postcss@^8.4.49: - version "8.4.49" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.49.tgz#4ea479048ab059ab3ae61d082190fabfd994fe19" - integrity sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA== +postcss@8.5.6, postcss@^8.2.14, postcss@^8.4.33, postcss@^8.4.4, postcss@^8.4.47, postcss@^8.4.49, postcss@^8.5.6: + version "8.5.6" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - nanoid "^3.3.7" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -8137,35 +8931,35 @@ postinstall-prepare@^2.0.0: integrity sha512-lLFwEKdnGLAaRAm8OpXP6HwrXRW+b8Hh9vRhVHZKmCdobd+D21YM38BCDsi3zrePLSe8Tt0H/mbYkh7/ySQQMg== potpack@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/potpack/-/potpack-2.0.0.tgz#61f4dd2dc4b3d5e996e3698c0ec9426d0e169104" - integrity sha512-Q+/tYsFU9r7xoOJ+y/ZTtdVQwTWfzjbiXBDMM/JKUux3+QPP02iUuIoeBQ+Ot6oEDlC+/PGjB/5A3K7KKb7hcw== + version "2.1.0" + resolved "https://registry.yarnpkg.com/potpack/-/potpack-2.1.0.tgz#fe548e2f9061e9937f17191c1ab6dd98ca30e02f" + integrity sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ== 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== -prismjs@^1.27.0, prismjs@^1.28.0: - version "1.29.0" - resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" - integrity sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q== +prismjs@^1.30.0: + version "1.30.0" + resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.30.0.tgz#d9709969d9d4e16403f6f348c63553b19f0975a9" + integrity sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw== -proc-log@^4.0.0, proc-log@^4.1.0, proc-log@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-4.2.0.tgz#b6f461e4026e75fdfe228b265e9f7a00779d7034" - integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== +proc-log@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-5.0.0.tgz#e6c93cf37aef33f835c53485f314f50ea906a9d8" + integrity sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ== + +proc-log@^6.0.0, proc-log@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/proc-log/-/proc-log-6.1.0.tgz#18519482a37d5198e231133a70144a50f21f0215" + integrity sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ== process-nextick-args@~2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -promise-inflight@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/promise-inflight/-/promise-inflight-1.0.1.tgz#98472870bf228132fcbdd868129bad12c3c029e3" - integrity sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g== - promise-retry@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/promise-retry/-/promise-retry-2.0.1.tgz#ff747a13620ab57ba688f5fc67855410c370da22" @@ -8184,7 +8978,7 @@ protocol-buffers-schema@^3.3.1: resolved "https://registry.yarnpkg.com/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz#77bc75a48b2ff142c1ad5b5b90c94cd0fa2efd03" integrity sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw== -proxy-addr@~2.0.7: +proxy-addr@^2.0.7, proxy-addr@~2.0.7: version "2.0.7" resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== @@ -8211,7 +9005,7 @@ qrcode@^1.5.4: pngjs "^5.0.0" yargs "^15.3.1" -qs@~6.14.0: +qs@^6.14.0, qs@^6.14.1, qs@~6.14.0: version "6.14.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.14.1.tgz#a41d85b9d3902f31d27861790506294881871159" integrity sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ== @@ -8233,13 +9027,6 @@ quickselect@^3.0.0: resolved "https://registry.yarnpkg.com/quickselect/-/quickselect-3.0.0.tgz#a37fc953867d56f095a20ac71c6d27063d2de603" integrity sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g== -randombytes@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/randombytes/-/randombytes-2.1.0.tgz#df6f84372f0270dc65cdf6291349ab7a473d4f2a" - integrity sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ== - dependencies: - safe-buffer "^5.1.0" - range-parser@^1.2.1, range-parser@~1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" @@ -8252,6 +9039,16 @@ raphael@^2.3.0: dependencies: eve-raphael "0.5.0" +raw-body@^3.0.0, raw-body@^3.0.1: + version "3.0.2" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-3.0.2.tgz#3e3ada5ae5568f9095d84376fd3a49b8fb000a51" + integrity sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA== + dependencies: + bytes "~3.1.2" + http-errors "~2.0.1" + iconv-lite "~0.7.0" + unpipe "~1.0.0" + raw-body@~2.5.3: version "2.5.3" resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.3.tgz#11c6650ee770a7de1b494f197927de0c923822e2" @@ -8289,7 +9086,7 @@ readable-stream@^2.0.1, readable-stream@~2.3.6: string_decoder "~1.1.1" util-deprecate "~1.0.1" -readable-stream@^3.0.6, readable-stream@^3.4.0: +readable-stream@^3.0.6: version "3.6.2" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-3.6.2.tgz#56a9b36ea965c00c5a93ef31eb111a0f11056967" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -8299,9 +9096,9 @@ readable-stream@^3.0.6, readable-stream@^3.4.0: util-deprecate "^1.0.1" readdirp@^4.0.1: - version "4.0.2" - resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.0.2.tgz#388fccb8b75665da3abffe2d8f8ed59fe74c230a" - integrity sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA== + version "4.1.2" + resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-4.1.2.tgz#eb85801435fbf2a7ee58f19e0921b068fc69948d" + integrity sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg== readdirp@~3.6.0: version "3.6.0" @@ -8320,10 +9117,24 @@ reflect-metadata@^0.2.0: resolved "https://registry.yarnpkg.com/reflect-metadata/-/reflect-metadata-0.2.2.tgz#400c845b6cba87a21f2c65c4aeb158f4fa4d9c5b" integrity sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q== -regenerate-unicode-properties@^10.2.0: - version "10.2.0" - resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz#626e39df8c372338ea9b8028d1f99dc3fd9c3db0" - integrity sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA== +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + +regenerate-unicode-properties@^10.2.2: + version "10.2.2" + resolved "https://registry.yarnpkg.com/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.2.tgz#aa113812ba899b630658c7623466be71e1f86f66" + integrity sha512-m03P+zhBeQd1RGnYxrGyDAPpWX/epKirLrp8e3qevZdVkKtnCrjjWczIbYc8+xd6vcTStVlqfycTx1KR4LOr0g== dependencies: regenerate "^1.4.2" @@ -8332,56 +9143,46 @@ regenerate@^1.4.2: resolved "https://registry.yarnpkg.com/regenerate/-/regenerate-1.4.2.tgz#b9346d8827e8f5a32f7ba29637d398b69014848a" integrity sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A== -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== - -regenerator-transform@^0.15.2: - version "0.15.2" - resolved "https://registry.yarnpkg.com/regenerator-transform/-/regenerator-transform-0.15.2.tgz#5bbae58b522098ebdf09bca2f83838929001c7a4" - integrity sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg== - dependencies: - "@babel/runtime" "^7.8.4" - regex-parser@^2.2.11: version "2.3.0" resolved "https://registry.yarnpkg.com/regex-parser/-/regex-parser-2.3.0.tgz#4bb61461b1a19b8b913f3960364bb57887f920ee" integrity sha512-TVILVSz2jY5D47F4mA4MppkBrafEaiUWJO/TcZHEIuI13AqoZMkK1WMA4Om1YkYbTx+9Ki1/tSUXbceyr9saRg== -regexp.prototype.flags@^1.5.2: - version "1.5.3" - resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz#b3ae40b1d2499b8350ab2c3fe6ef3845d3a96f42" - integrity sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ== +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" define-properties "^1.2.1" es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" set-function-name "^2.0.2" -regexpu-core@^6.1.1: - version "6.1.1" - resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.1.1.tgz#b469b245594cb2d088ceebc6369dceb8c00becac" - integrity sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw== +regexpu-core@^6.3.1: + version "6.4.0" + resolved "https://registry.yarnpkg.com/regexpu-core/-/regexpu-core-6.4.0.tgz#3580ce0c4faedef599eccb146612436b62a176e5" + integrity sha512-0ghuzq67LI9bLXpOX/ISfve/Mq33a4aFRzoQYhnnok1JOFpmE/A2TBGkNVenOGEeSBCjIiWcc6MVOG5HEQv0sA== dependencies: regenerate "^1.4.2" - regenerate-unicode-properties "^10.2.0" + regenerate-unicode-properties "^10.2.2" regjsgen "^0.8.0" - regjsparser "^0.11.0" + regjsparser "^0.13.0" unicode-match-property-ecmascript "^2.0.0" - unicode-match-property-value-ecmascript "^2.1.0" + unicode-match-property-value-ecmascript "^2.2.1" regjsgen@^0.8.0: version "0.8.0" resolved "https://registry.yarnpkg.com/regjsgen/-/regjsgen-0.8.0.tgz#df23ff26e0c5b300a6470cad160a9d090c3a37ab" integrity sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q== -regjsparser@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.11.0.tgz#f01e6ccaba36d384fb0d00a06b78b372c8b681e8" - integrity sha512-vTbzVAjQDzwQdKuvj7qEq6OlAprCjE656khuGQ4QaBLg7abQ9I9ISpmLuc6inWe7zP75AECjqUa4g4sdQvOXhg== +regjsparser@^0.13.0: + version "0.13.0" + resolved "https://registry.yarnpkg.com/regjsparser/-/regjsparser-0.13.0.tgz#01f8351335cf7898d43686bc74d2dd71c847ecc0" + integrity sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q== dependencies: - jsesc "~3.0.2" + jsesc "~3.1.0" require-directory@^2.1.1: version "2.1.1" @@ -8403,6 +9204,11 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== +reserved-identifiers@^1.0.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/reserved-identifiers/-/reserved-identifiers-1.2.0.tgz#d2982cd698e317dd3dced1ee1c52412dbd64fc64" + integrity sha512-yE7KUfFvaBFzGPs5H3Ops1RevfUEsDc5Iz65rOwWg4lE8HJSYtle77uul3+573457oHvBKuHYDl/xqUkKpEEdw== + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -8426,22 +9232,23 @@ resolve-url-loader@5.0.0: postcss "^8.2.14" source-map "0.6.1" -resolve@1.22.8, resolve@^1.1.7, resolve@^1.14.2, resolve@^1.22.4, resolve@^1.22.8: - version "1.22.8" - resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.8.tgz#b6c87a9f2aa06dfab52e3d70ac8cde321fa5a48d" - integrity sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw== +resolve@1.22.10: + version "1.22.10" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.10.tgz#b663e83ffb09bbf2386944736baae803029b8b39" + integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== dependencies: - is-core-module "^2.13.0" + is-core-module "^2.16.0" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" -restore-cursor@^3.1.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-3.1.0.tgz#39f67c54b3a7a58cea5236d95cf0034239631f7e" - integrity sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA== +resolve@^1.1.7, resolve@^1.22.10, resolve@^1.22.4, resolve@^1.22.8: + version "1.22.11" + resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.11.tgz#aad857ce1ffb8bfa9b0b1ac29f1156383f68c262" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== dependencies: - onetime "^5.1.0" - signal-exit "^3.0.2" + is-core-module "^2.16.1" + path-parse "^1.0.7" + supports-preserve-symlinks-flag "^1.0.0" restore-cursor@^5.0.0: version "5.1.0" @@ -8471,48 +9278,43 @@ rfdc@^1.4.1: resolved "https://registry.yarnpkg.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== -rimraf@^2.6.3: - version "2.7.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" - integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== - dependencies: - glob "^7.1.3" - -rimraf@^5.0.5: - version "5.0.10" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-5.0.10.tgz#23b9843d3dc92db71f96e1a2ce92e39fd2a8221c" - integrity sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ== - dependencies: - glob "^10.3.7" - robust-predicates@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@4.22.4, rollup@^4.20.0: - version "4.22.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.22.4.tgz#4135a6446671cd2a2453e1ad42a45d5973ec3a0f" - integrity sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A== +rollup@4.59.0, rollup@^4.43.0: + version "4.59.0" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.59.0.tgz#cf74edac17c1486f562d728a4d923a694abdf06f" + integrity sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg== dependencies: - "@types/estree" "1.0.5" + "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.22.4" - "@rollup/rollup-android-arm64" "4.22.4" - "@rollup/rollup-darwin-arm64" "4.22.4" - "@rollup/rollup-darwin-x64" "4.22.4" - "@rollup/rollup-linux-arm-gnueabihf" "4.22.4" - "@rollup/rollup-linux-arm-musleabihf" "4.22.4" - "@rollup/rollup-linux-arm64-gnu" "4.22.4" - "@rollup/rollup-linux-arm64-musl" "4.22.4" - "@rollup/rollup-linux-powerpc64le-gnu" "4.22.4" - "@rollup/rollup-linux-riscv64-gnu" "4.22.4" - "@rollup/rollup-linux-s390x-gnu" "4.22.4" - "@rollup/rollup-linux-x64-gnu" "4.22.4" - "@rollup/rollup-linux-x64-musl" "4.22.4" - "@rollup/rollup-win32-arm64-msvc" "4.22.4" - "@rollup/rollup-win32-ia32-msvc" "4.22.4" - "@rollup/rollup-win32-x64-msvc" "4.22.4" + "@rollup/rollup-android-arm-eabi" "4.59.0" + "@rollup/rollup-android-arm64" "4.59.0" + "@rollup/rollup-darwin-arm64" "4.59.0" + "@rollup/rollup-darwin-x64" "4.59.0" + "@rollup/rollup-freebsd-arm64" "4.59.0" + "@rollup/rollup-freebsd-x64" "4.59.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.59.0" + "@rollup/rollup-linux-arm-musleabihf" "4.59.0" + "@rollup/rollup-linux-arm64-gnu" "4.59.0" + "@rollup/rollup-linux-arm64-musl" "4.59.0" + "@rollup/rollup-linux-loong64-gnu" "4.59.0" + "@rollup/rollup-linux-loong64-musl" "4.59.0" + "@rollup/rollup-linux-ppc64-gnu" "4.59.0" + "@rollup/rollup-linux-ppc64-musl" "4.59.0" + "@rollup/rollup-linux-riscv64-gnu" "4.59.0" + "@rollup/rollup-linux-riscv64-musl" "4.59.0" + "@rollup/rollup-linux-s390x-gnu" "4.59.0" + "@rollup/rollup-linux-x64-gnu" "4.59.0" + "@rollup/rollup-linux-x64-musl" "4.59.0" + "@rollup/rollup-openbsd-x64" "4.59.0" + "@rollup/rollup-openharmony-arm64" "4.59.0" + "@rollup/rollup-win32-arm64-msvc" "4.59.0" + "@rollup/rollup-win32-ia32-msvc" "4.59.0" + "@rollup/rollup-win32-x64-gnu" "4.59.0" + "@rollup/rollup-win32-x64-msvc" "4.59.0" fsevents "~2.3.2" roughjs@^4.6.6: @@ -8525,6 +9327,17 @@ roughjs@^4.6.6: points-on-curve "^0.2.0" points-on-path "^0.2.1" +router@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/router/-/router-2.2.0.tgz#019be620b711c87641167cc79b99090f00b146ef" + integrity sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ== + dependencies: + debug "^4.4.0" + depd "^2.0.0" + is-promise "^4.0.0" + parseurl "^1.3.3" + path-to-regexp "^8.0.0" + run-applescript@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/run-applescript/-/run-applescript-7.0.0.tgz#e5a553c2bffd620e169d276c1cd8f1b64778fbeb" @@ -8542,21 +9355,22 @@ rw@1, rw@^1.3.3: resolved "https://registry.yarnpkg.com/rw/-/rw-1.3.3.tgz#3f862dfa91ab766b14885ef4d01124bfda074fb4" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== -rxjs@7.8.1, rxjs@~7.8.1: - version "7.8.1" - resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.1.tgz#6f6f3d99ea8044291efd92e7c7fcf562c4057543" - integrity sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg== +rxjs@7.8.2, rxjs@~7.8.2: + version "7.8.2" + resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-7.8.2.tgz#955bc473ed8af11a002a2be52071bf475638607b" + integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== dependencies: tslib "^2.1.0" -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== +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== dependencies: - call-bind "^1.0.7" - get-intrinsic "^1.2.4" - has-symbols "^1.0.3" + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" isarray "^2.0.5" safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: @@ -8564,7 +9378,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.1.0, safe-buffer@~5.2.0: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -8574,35 +9388,45 @@ safe-identifier@^0.4.1: resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== -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== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== dependencies: - call-bind "^1.0.6" + call-bound "^1.0.2" es-errors "^1.3.0" - is-regex "^1.1.4" + is-regex "^1.2.1" "safer-buffer@>= 2.1.2 < 3", "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-loader@16.0.0: - version "16.0.0" - resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-16.0.0.tgz#9b8d497e24bc176dc368df2b5b9e90b4ad24bf4e" - integrity sha512-n13Z+3rU9A177dk4888czcVFiC8CL9dii4qpXWUg3YIIgZEvi9TCFKjOQcbK0kJM7DJu9VucrZFddvNfYCPwtw== +sass-loader@16.0.5: + version "16.0.5" + resolved "https://registry.yarnpkg.com/sass-loader/-/sass-loader-16.0.5.tgz#257bc90119ade066851cafe7f2c3f3504c7cda98" + integrity sha512-oL+CMBXrj6BZ/zOq4os+UECPL+bWqt6OAC6DWS8Ln8GZRcMDjlJ4JC3FBDuHJdYaFWIdKNIBYmtZtK2MaMkNIw== dependencies: neo-async "^2.6.2" -sass@1.77.6: - version "1.77.6" - resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.6.tgz#898845c1348078c2e6d1b64f9ee06b3f8bd489e4" - integrity sha512-ByXE1oLD79GVq9Ht1PeHWCPMPB8XHpBuz1r85oByKHjZY6qV6rWnQovQzXJXuQ/XyE1Oj3iPk3lo28uzaRA2/Q== +sass@1.90.0: + version "1.90.0" + resolved "https://registry.yarnpkg.com/sass/-/sass-1.90.0.tgz#d6fc2be49c7c086ce86ea0b231a35bf9e33cb84b" + integrity sha512-9GUyuksjw70uNpb1MTYWsH9MQHOHY6kwfnkafC24+7aOMZn9+rVMBxRbLvw756mrBFbIsFg6Xw9IkR2Fnn3k+Q== dependencies: - chokidar ">=3.0.0 <4.0.0" - immutable "^4.0.0" + chokidar "^4.0.0" + immutable "^5.0.2" source-map-js ">=0.6.2 <2.0.0" + optionalDependencies: + "@parcel/watcher" "^2.4.1" sax@^1.2.4: version "1.4.1" @@ -8616,19 +9440,10 @@ schema-inspector@^2.1.0: dependencies: async "~2.6.3" -schema-utils@^3.1.1, schema-utils@^3.2.0: - version "3.3.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-3.3.0.tgz#f50a88877c3c01652a15b622ae9e9795df7a60fe" - integrity sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg== - dependencies: - "@types/json-schema" "^7.0.8" - ajv "^6.12.5" - ajv-keywords "^3.5.2" - -schema-utils@^4.0.0, schema-utils@^4.2.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.2.0.tgz#70d7c93e153a273a805801882ebd3bff20d89c8b" - integrity sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw== +schema-utils@^4.0.0, schema-utils@^4.2.0, schema-utils@^4.3.0, schema-utils@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/schema-utils/-/schema-utils-4.3.3.tgz#5b1850912fa31df90716963d45d9121fdfc09f46" + integrity sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA== dependencies: "@types/json-schema" "^7.0.9" ajv "^8.9.0" @@ -8658,10 +9473,15 @@ selfsigned@^2.4.1: "@types/node-forge" "^1.3.0" node-forge "^1" -semver@7.6.3: - version "7.6.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" - integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== +semver@7.7.2: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +semver@7.7.3: + version "7.7.3" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== semver@^5.6.0: version "5.7.2" @@ -8673,10 +9493,27 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.6.3: - version "7.7.3" - resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.3.tgz#4b5f4143d007633a8dc671cd0a6ef9147b8bb946" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.7.3: + version "7.7.4" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.4.tgz#28464e36060e991fa7a11d0279d2d3f3b57a7e8a" + integrity sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA== + +send@^1.1.0, send@^1.2.0: + version "1.2.1" + resolved "https://registry.yarnpkg.com/send/-/send-1.2.1.tgz#9eab743b874f3550f40a26867bf286ad60d3f3ed" + integrity sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ== + dependencies: + debug "^4.4.3" + encodeurl "^2.0.0" + escape-html "^1.0.3" + etag "^1.8.1" + fresh "^2.0.0" + http-errors "^2.0.1" + mime-types "^3.0.2" + ms "^2.1.3" + on-finished "^2.4.1" + range-parser "^1.2.1" + statuses "^2.0.2" send@~0.19.0, send@~0.19.1: version "0.19.2" @@ -8697,12 +9534,10 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -serialize-javascript@^6.0.1, serialize-javascript@^6.0.2: - version "6.0.2" - resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-6.0.2.tgz#defa1e055c83bf6d59ea805d8da862254eb6a6c2" - integrity sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g== - dependencies: - randombytes "^2.1.0" +serialize-javascript@^7.0.3: + version "7.0.4" + resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-7.0.4.tgz#c517735bd5b7631dd1fc191ee19cbb713ff8e05c" + integrity sha512-DuGdB+Po43Q5Jxwpzt1lhyFSYKryqoNjQSA9M92tyw0lyHIOur+XCalOUe0KTJpyqzT8+fQ5A0Jf7vCx/NKmIg== serve-index@^1.9.1: version "1.9.1" @@ -8717,6 +9552,16 @@ serve-index@^1.9.1: mime-types "~2.1.17" parseurl "~1.3.2" +serve-static@^2.2.0: + version "2.2.1" + resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-2.2.1.tgz#7f186a4a4e5f5b663ad7a4294ff1bf37cf0e98a9" + integrity sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw== + dependencies: + encodeurl "^2.0.0" + escape-html "^1.0.3" + parseurl "^1.3.3" + send "^1.2.0" + serve-static@~1.16.2: version "1.16.3" resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.16.3.tgz#a97b74d955778583f3862a4f0b841eb4d5d78cf9" @@ -8732,7 +9577,7 @@ set-blocking@^2.0.0: resolved "https://registry.yarnpkg.com/set-blocking/-/set-blocking-2.0.0.tgz#045f9782d011ae9a6803ddd382b24392b3d890f7" integrity sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw== -set-function-length@^1.2.1: +set-function-length@^1.2.2: 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== @@ -8754,6 +9599,15 @@ set-function-name@^2.0.2: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" @@ -8822,7 +9676,7 @@ side-channel-weakmap@^1.0.2: object-inspect "^1.13.3" side-channel-map "^1.0.1" -side-channel@^1.0.4, side-channel@^1.1.0: +side-channel@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== @@ -8833,43 +9687,28 @@ side-channel@^1.0.4, side-channel@^1.1.0: side-channel-map "^1.0.1" side-channel-weakmap "^1.0.2" -signal-exit@^3.0.2, signal-exit@^3.0.3: - version "3.0.7" - resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" - integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== - signal-exit@^4.0.1, 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== -sigstore@^2.2.0: - version "2.3.1" - resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-2.3.1.tgz#0755dd2cc4820f2e922506da54d3d628e13bfa39" - integrity sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ== +sigstore@^4.0.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/sigstore/-/sigstore-4.1.0.tgz#d34b92a544a05e003a2430209d26d8dfafd805a0" + integrity sha512-/fUgUhYghuLzVT/gaJoeVehLCgZiUxPCPMcyVNY0lIf/cTCz58K/WTI7PefDarXxp9nUKpEwg1yyz3eSBMTtgA== dependencies: - "@sigstore/bundle" "^2.3.2" - "@sigstore/core" "^1.0.0" - "@sigstore/protobuf-specs" "^0.3.2" - "@sigstore/sign" "^2.3.2" - "@sigstore/tuf" "^2.3.4" - "@sigstore/verify" "^1.2.1" + "@sigstore/bundle" "^4.0.0" + "@sigstore/core" "^3.1.0" + "@sigstore/protobuf-specs" "^0.5.0" + "@sigstore/sign" "^4.1.0" + "@sigstore/tuf" "^4.0.1" + "@sigstore/verify" "^3.1.0" slash@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/slash/-/slash-2.0.0.tgz#de552851a1759df3a8f206535442f5ec4ddeab44" integrity sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A== -slash@^5.1.0: - version "5.1.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-5.1.0.tgz#be3adddcdf09ac38eebe8dcdc7b1a57a75b095ce" - integrity sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg== - -slashes@^3.0.12: - version "3.0.12" - resolved "https://registry.yarnpkg.com/slashes/-/slashes-3.0.12.tgz#3d664c877ad542dc1509eaf2c50f38d483a6435a" - integrity sha512-Q9VME8WyGkc7pJf6QEkj3wE+2CnvZMI+XJhwdTPR8Z/kWQRXi7boAWLDibRPyHRTUTPx5FaU7MsyrjI3yLB4HA== - slice-ansi@^5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-5.0.0.tgz#b73063c57aa96f9cd881654b15294d95d285c42a" @@ -8879,9 +9718,9 @@ slice-ansi@^5.0.0: is-fullwidth-code-point "^4.0.0" slice-ansi@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.0.tgz#cd6b4655e298a8d1bdeb04250a433094b347b9a9" - integrity sha512-bSiSngZ/jWeX93BqeIAbImyTbEihizcwNjFoRUIY/T1wWQsfsm2Vw1agPKylXvQTU7iASGdHhyqRlqQzfz+Htg== + version "7.1.2" + resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-7.1.2.tgz#adf7be70aa6d72162d907cd0e6d5c11f507b5403" + integrity sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w== dependencies: ansi-styles "^6.2.1" is-fullwidth-code-point "^5.0.0" @@ -8901,20 +9740,20 @@ sockjs@^0.3.24: websocket-driver "^0.7.4" socks-proxy-agent@^8.0.3: - version "8.0.4" - resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz#9071dca17af95f483300316f4b063578fa0db08c" - integrity sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw== + version "8.0.5" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz#b9cdb4e7e998509d7659d689ce7697ac21645bee" + integrity sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw== dependencies: - agent-base "^7.1.1" + agent-base "^7.1.2" debug "^4.3.4" socks "^2.8.3" socks@^2.8.3: - version "2.8.3" - resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" - integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== + version "2.8.7" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.7.tgz#e2fb1d9a603add75050a2067db8c381a0b5669ea" + integrity sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A== dependencies: - ip-address "^9.0.5" + ip-address "^10.0.1" smart-buffer "^4.2.0" sorted-btree@^1.8.1: @@ -8922,7 +9761,7 @@ sorted-btree@^1.8.1: resolved "https://registry.yarnpkg.com/sorted-btree/-/sorted-btree-1.8.1.tgz#6e6275f7955e5892bb8737149cbe495be10f426f" integrity sha512-395+XIP+wqNn3USkFSrNz7G3Ss/MXlZEqesxvzCRFwL14h6e8LukDHdLBePn5pwbm5OQ9vGu8mDyz2lLDIqamQ== -"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.0, source-map-js@^1.2.1: +"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== @@ -8948,10 +9787,10 @@ source-map@0.6.1, source-map@^0.6.0, source-map@~0.6.0: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== -source-map@0.7.4: - version "0.7.4" - resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.4.tgz#a9bbe705c9d8846f4e08ff6765acf0f1b0898656" - integrity sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA== +source-map@0.7.6: + version "0.7.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.6.tgz#a3658ab87e5b6429c8a1f3ba0083d4c61ca3ef02" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== spdx-correct@^3.0.0: version "3.2.0" @@ -9010,30 +9849,25 @@ spdy@^4.0.2: select-hose "^2.0.0" spdy-transport "^3.0.0" -splaytree-ts@^1.0.1: - version "1.0.1" - resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.1.tgz#4ddcfe2684da017d02b599d53d67f6d07a90745b" - integrity sha512-B+VzCm33/KEchi/fzT6/3NRHm8k5+Kf37SBQO3meHHS/tK2xBnIm4ZvusQ1wUpHgKMCCqEWgXnwFXAa1nD289g== +splaytree-ts@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/splaytree-ts/-/splaytree-ts-1.0.2.tgz#34963704587aff45eaa09c24713f552bbf56e8f0" + integrity sha512-0kGecIZNIReCSiznK3uheYB8sbstLjCZLiwcQwbmLhgHJj2gz6OnSPkVzJQCMnmEz1BQ4gPK59ylhBoEWOhGNA== split.js@^1.6.5: version "1.6.5" resolved "https://registry.yarnpkg.com/split.js/-/split.js-1.6.5.tgz#f7f61da1044c9984cb42947df4de4fadb5a3f300" integrity sha512-mPTnGCiS/RiuTNsVhCm9De9cCAUsrNFFviRbADdKiiV+Kk8HKp/0fWu7Kr8pi3/yBmsqLFHuXGT9UUZ+CNLwFw== -sprintf-js@^1.1.3: - version "1.1.3" - resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" - integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== - 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== -ssri@^10.0.0: - version "10.0.6" - resolved "https://registry.yarnpkg.com/ssri/-/ssri-10.0.6.tgz#a8aade2de60ba2bce8688e3fa349bad05c7dc1e5" - integrity sha512-MGrFH9Z4NP9Iyhqn16sDtBpRRNJ0Y2hNa6D65h736fVSaPCHr4DM4sWUNvVaSuC+0OBGhwsrydQwmgfg5LncqQ== +ssri@^13.0.0: + version "13.0.0" + resolved "https://registry.yarnpkg.com/ssri/-/ssri-13.0.0.tgz#4226b303dc474003d88905f9098cb03361106c74" + integrity sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng== dependencies: minipass "^7.0.3" @@ -9042,11 +9876,24 @@ ssri@^10.0.0: resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" integrity sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA== -statuses@~2.0.1, statuses@~2.0.2: +statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.1, statuses@~2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382" integrity sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw== +stdin-discarder@^0.2.2: + version "0.2.2" + resolved "https://registry.yarnpkg.com/stdin-discarder/-/stdin-discarder-0.2.2.tgz#390037f44c4ae1a1ae535c5fe38dc3aba8d997be" + integrity sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ== + +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" @@ -9056,7 +9903,7 @@ statuses@~2.0.1, statuses@~2.0.2: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.1" -string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +string-width@^4.1.0, string-width@^4.2.0: 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== @@ -9074,7 +9921,7 @@ string-width@^5.0.1, string-width@^5.1.2: emoji-regex "^9.2.2" strip-ansi "^7.0.1" -string-width@^7.0.0: +string-width@^7.0.0, string-width@^7.2.0: version "7.2.0" resolved "https://registry.yarnpkg.com/string-width/-/string-width-7.2.0.tgz#b5bb8e2165ce275d4d43476dd2700ad9091db6dc" integrity sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ== @@ -9083,22 +9930,26 @@ string-width@^7.0.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" -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== +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" define-properties "^1.2.1" - es-abstract "^1.23.0" + es-abstract "^1.23.5" es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" -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== +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.2" define-properties "^1.2.1" es-object-atoms "^1.0.0" @@ -9140,43 +9991,38 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: ansi-regex "^5.0.1" strip-ansi@^7.0.1, strip-ansi@^7.1.0: - version "7.1.0" - resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" - integrity sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ== + version "7.2.0" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.2.0.tgz#d22a269522836a627af8d04b5c3fd2c7fa3e32e3" + integrity sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w== dependencies: - ansi-regex "^6.0.1" + ansi-regex "^6.2.2" strip-bom@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/strip-bom/-/strip-bom-3.0.0.tgz#2334c18e9c759f7bdd56fdef7e9ae3d588e68ed3" integrity sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA== -strip-final-newline@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/strip-final-newline/-/strip-final-newline-2.0.0.tgz#89b852fb2fcbe936f6f4b3187afb0a12c1ab58ad" - integrity sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA== - strip-json-comments@3.1.1, 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== -stylis@^4.3.1: - version "4.3.4" - resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.3.4.tgz#ca5c6c4a35c4784e4e93a2a24dc4e9fa075250a4" - integrity sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now== +stylis@^4.3.6: + version "4.4.0" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.4.0.tgz#c5846c9345f4bfc51bd0cbd7ca35a0744f485a5d" + integrity sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA== sucrase@^3.35.0: - version "3.35.0" - resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.0.tgz#57f17a3d7e19b36d8995f06679d121be914ae263" - integrity sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA== + version "3.35.1" + resolved "https://registry.yarnpkg.com/sucrase/-/sucrase-3.35.1.tgz#4619ea50393fe8bd0ae5071c26abd9b2e346bfe1" + integrity sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw== dependencies: "@jridgewell/gen-mapping" "^0.3.2" commander "^4.0.0" - glob "^10.3.10" lines-and-columns "^1.1.6" mz "^2.7.0" pirates "^4.0.1" + tinyglobby "^0.2.11" ts-interface-checker "^0.1.9" supercluster@^8.0.1: @@ -9212,18 +10058,12 @@ supports-preserve-symlinks-flag@^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== -symbol-observable@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-4.0.0.tgz#5b425f192279e87f2f9b937ac8540d1984b39205" - integrity sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ== - -synckit@^0.9.1: - version "0.9.2" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.2.tgz#a3a935eca7922d48b9e7d6c61822ee6c3ae4ec62" - integrity sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw== +sweepline-intersections@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/sweepline-intersections/-/sweepline-intersections-1.5.0.tgz#85ab3629a291875926fae0acd508496430d8a647" + integrity sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ== dependencies: - "@pkgr/core" "^0.1.0" - tslib "^2.6.2" + tinyqueue "^2.0.0" systemjs@6.15.1: version "6.15.1" @@ -9240,10 +10080,10 @@ table-layout@^1.0.2: typical "^5.2.0" wordwrapjs "^4.0.0" -tailwindcss@^3.4.15: - version "3.4.15" - resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.15.tgz#04808bf4bf1424b105047d19e7d4bfab368044a9" - integrity sha512-r4MeXnfBmSOuKUWmXe6h2CcyfzJCEk4F0pptO5jlnYSIViUkVmsawj80N5h2lO3gwcmSb4n3PuN+e+GC1Guylw== +tailwindcss@^3.4.19: + version "3.4.19" + resolved "https://registry.yarnpkg.com/tailwindcss/-/tailwindcss-3.4.19.tgz#af2a0a4ae302d52ebe078b6775e799e132500ee2" + integrity sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ== dependencies: "@alloc/quick-lru" "^5.2.0" arg "^5.0.2" @@ -9253,8 +10093,8 @@ tailwindcss@^3.4.15: fast-glob "^3.3.2" glob-parent "^6.0.2" is-glob "^4.0.3" - jiti "^1.21.6" - lilconfig "^2.1.0" + jiti "^1.21.7" + lilconfig "^3.1.3" micromatch "^4.0.8" normalize-path "^3.0.0" object-hash "^3.0.0" @@ -9262,57 +10102,55 @@ tailwindcss@^3.4.15: postcss "^8.4.47" postcss-import "^15.1.0" postcss-js "^4.0.1" - postcss-load-config "^4.0.2" + postcss-load-config "^4.0.2 || ^5.0 || ^6.0" postcss-nested "^6.2.0" postcss-selector-parser "^6.1.2" resolve "^1.22.8" sucrase "^3.35.0" -tapable@^2.1.1, tapable@^2.2.0, tapable@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.2.1.tgz#1967a73ef4060a82f12ab96af86d52fdb76eeca0" - integrity sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ== - -tar@^6.1.11, tar@^6.2.1: - version "6.2.1" - resolved "https://registry.yarnpkg.com/tar/-/tar-6.2.1.tgz#717549c541bc3c2af15751bea94b1dd068d4b03a" - integrity sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A== - dependencies: - chownr "^2.0.0" - fs-minipass "^2.0.0" - minipass "^5.0.0" - minizlib "^2.1.1" - mkdirp "^1.0.3" - yallist "^4.0.0" +tapable@^2.2.1, tapable@^2.3.0: + version "2.3.0" + resolved "https://registry.yarnpkg.com/tapable/-/tapable-2.3.0.tgz#7e3ea6d5ca31ba8e078b560f0d83ce9a14aa8be6" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== + +tar@^7.4.3, tar@^7.5.2: + version "7.5.11" + resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.11.tgz#1250fae45d98806b36d703b30973fa8e0a6d8868" + integrity sha512-ChjMH33/KetonMTAtpYdgUFr0tbz69Fp2v7zWxQfYZX4g5ZN2nOBXm1R2xyA+lMIKrLKIoKAwFj93jE/avX9cQ== + dependencies: + "@isaacs/fs-minipass" "^4.0.0" + chownr "^3.0.0" + minipass "^7.1.2" + minizlib "^3.1.0" + yallist "^5.0.0" -terser-webpack-plugin@^5.3.10: - version "5.3.10" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz#904f4c9193c6fd2a03f693a2150c62a92f40d199" - integrity sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w== +terser-webpack-plugin@^5.3.16: + version "5.4.0" + resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz#95fc4cf4437e587be11ecf37d08636089174d76b" + integrity sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g== dependencies: - "@jridgewell/trace-mapping" "^0.3.20" + "@jridgewell/trace-mapping" "^0.3.25" jest-worker "^27.4.5" - schema-utils "^3.1.1" - serialize-javascript "^6.0.1" - terser "^5.26.0" + schema-utils "^4.3.0" + terser "^5.31.1" -terser@5.31.6: - version "5.31.6" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.31.6.tgz#c63858a0f0703988d0266a82fcbf2d7ba76422b1" - integrity sha512-PQ4DAriWzKj+qgehQ7LK5bQqCFNMmlhjR2PFFLuqGCpuCAauxemVBWwWOxo3UIwWQx8+Pr61Df++r76wDmkQBg== +terser@5.43.1: + version "5.43.1" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.43.1.tgz#88387f4f9794ff1a29e7ad61fb2932e25b4fdb6d" + integrity sha512-+6erLbBm0+LROX2sPXlUYx/ux5PyE9K/a92Wrt6oA+WDAoFTdpHE5tCYCI5PNzq2y8df4rA+QgHLJuR4jNymsg== dependencies: "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" + acorn "^8.14.0" commander "^2.20.0" source-map-support "~0.5.20" -terser@^5.26.0: - version "5.34.1" - resolved "https://registry.yarnpkg.com/terser/-/terser-5.34.1.tgz#af40386bdbe54af0d063e0670afd55c3105abeb6" - integrity sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA== +terser@^5.31.1: + version "5.46.0" + resolved "https://registry.yarnpkg.com/terser/-/terser-5.46.0.tgz#1b81e560d584bbdd74a8ede87b4d9477b0ff9695" + integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== dependencies: "@jridgewell/source-map" "^0.3.3" - acorn "^8.8.2" + acorn "^8.15.0" commander "^2.20.0" source-map-support "~0.5.20" @@ -9337,10 +10175,10 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" -thingies@^1.20.0: - version "1.21.0" - resolved "https://registry.yarnpkg.com/thingies/-/thingies-1.21.0.tgz#e80fbe58fd6fdaaab8fad9b67bd0a5c943c445c1" - integrity sha512-hsqsJsFMsV+aD4s3CWKk85ep/3I9XzYV/IXaSouJMYIoDlgyi11cBhsqYe9/geRfB0YIikBQg6raRaM+nIMP9g== +thingies@^2.5.0: + version "2.5.0" + resolved "https://registry.yarnpkg.com/thingies/-/thingies-2.5.0.tgz#5f7b882c933b85989f8466b528a6247a6881e04f" + integrity sha512-s+2Bwztg6PhWUD7XMfeYm5qliDdSiZm7M7n8KjTkIsm3l/2lgVRc2/Gx/v+ZX8lT4FMA+i8aQvhcWylldc+ZNw== thunky@^1.0.2: version "1.1.0" @@ -9357,27 +10195,46 @@ tinycolor2@^1.6.0: resolved "https://registry.yarnpkg.com/tinycolor2/-/tinycolor2-1.6.0.tgz#f98007460169b0263b97072c5ae92484ce02d09e" integrity sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw== -tinyexec@^0.3.0: - version "0.3.0" - resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-0.3.0.tgz#ed60cfce19c17799d4a241e06b31b0ec2bee69e6" - integrity sha512-tVGE0mVJPGb0chKhqmsoosjsS+qUnJVGJpZgsHYQcGoPlG3B51R3PouqTgEGH2Dc9jjFyOqOpix6ZHNMXp1FZg== +tinyexec@^1.0.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/tinyexec/-/tinyexec-1.1.1.tgz#e1ff45dfa60d1dedb91b734956b78f6c2a3e821b" + integrity sha512-VKS/ZaQhhkKFMANmAOhhXVoIfBXblQxGX1myCQ2faQrfmobMftXeJPcZGp0gS07ocvGJWDLZGyOZDadDBqYIJg== + +tinyglobby@0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.14.tgz#5280b0cf3f972b050e74ae88406c0a6a58f4079d" + integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + dependencies: + fdir "^6.4.4" + picomatch "^4.0.2" -tinymce@6.8.5, "tinymce@^7.0.0 || ^6.0.0 || ^5.5.0", tinymce@~6.8.5: - version "6.8.5" - resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.5.tgz#aa9a711c4e0b59d506dd281bade857d35a7b3c59" - integrity sha512-qAL/FxL7cwZHj4BfaF818zeJJizK9jU5IQzTcSLL4Rj5MaJdiVblEj7aDr80VCV1w9h4Lak9hlnALhq/kVtN1g== +tinyglobby@^0.2.11, tinyglobby@^0.2.12, tinyglobby@^0.2.15: + version "0.2.15" + resolved "https://registry.yarnpkg.com/tinyglobby/-/tinyglobby-0.2.15.tgz#e228dd1e638cea993d2fdb4fcd2d4602a79951c2" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== + dependencies: + fdir "^6.5.0" + picomatch "^4.0.3" + +tinymce@6.8.6, "tinymce@^7.0.0 || ^6.0.0 || ^5.5.0", tinymce@~6.8.6: + version "6.8.6" + resolved "https://registry.yarnpkg.com/tinymce/-/tinymce-6.8.6.tgz#799e4f03eeb4399399dfdeb12ba17b3b91887adf" + integrity sha512-++XYEs8lKWvZxDCjrr8Baiw7KiikraZ5JkLMg6EdnUVNKJui0IsrAADj5MsyUeFkcEryfn2jd3p09H7REvewyg== + +tinyqueue@^2.0.0: + version "2.0.3" + resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-2.0.3.tgz#64d8492ebf39e7801d7bd34062e29b45b2035f08" + integrity sha512-ppJZNDuKGgxzkHihX8v9v9G5f+18gzaTfrukGrq6ueg0lmH4nqVnA2IPG0AEH3jKEk2GRJCUhDoqpoiw3PHLBA== tinyqueue@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/tinyqueue/-/tinyqueue-3.0.0.tgz#101ea761ccc81f979e29200929e78f1556e3661e" integrity sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g== -tmp@^0.0.33: - version "0.0.33" - resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.0.33.tgz#6d34335889768d21b2bcda0aa277ced3b1bfadf9" - integrity sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw== - dependencies: - os-tmpdir "~1.0.2" +tmp@^0.2.4: + version "0.2.5" + resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.2.5.tgz#b06bcd23f0f3c8357b426891726d16015abfd8f8" + integrity sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow== to-regex-range@^5.0.1: version "5.0.1" @@ -9386,6 +10243,14 @@ to-regex-range@^5.0.1: dependencies: is-number "^7.0.0" +to-valid-identifier@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/to-valid-identifier/-/to-valid-identifier-1.0.0.tgz#27337955333c3c517feb60bea533cf27ce54b79f" + integrity sha512-41wJyvKep3yT2tyPqX/4blcfybknGB4D+oETKLs7Q76UiPqRpUJK3hr1nxelyYO0PHKVzJwlu0aCeEAsGI6rpw== + dependencies: + "@sindresorhus/base62" "^1.0.0" + reserved-identifiers "^1.0.0" + toidentifier@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" @@ -9396,20 +10261,20 @@ tooltipster@^4.2.8: resolved "https://registry.yarnpkg.com/tooltipster/-/tooltipster-4.2.8.tgz#ad1970dd71ad853034e64e3fdd1745f7f3485071" integrity sha512-Znmbt5UMzaiFCRlVaRtfRZYQqxrmNlj1+3xX/aT0OiA3xkQZhXYGbLJmZPigx0YiReYZpO7Lm2XKbUxXsiU/pg== -tree-dump@^1.0.1: - version "1.0.2" - resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.0.2.tgz#c460d5921caeb197bde71d0e9a7b479848c5b8ac" - integrity sha512-dpev9ABuLWdEubk+cIaI9cHwRNNDjkBBLXTwI4UCUFdQ5xXKqNXoK4FEciw/vxf+NQ7Cb7sGUyeUtORvHIdRXQ== +tree-dump@^1.0.3, tree-dump@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/tree-dump/-/tree-dump-1.1.0.tgz#ab29129169dc46004414f5a9d4a3c6e89f13e8a4" + integrity sha512-rMuvhU4MCDbcbnleZTFezWsaZXRFemSqAM+7jPnzUl1fo9w3YEKOxAeui0fz3OI4EU4hf23iyA7uQRVko+UaBA== tree-kill@1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/tree-kill/-/tree-kill-1.2.2.tgz#4ca09a9092c88b73a7cdc5e8a01b507b0790a0cc" integrity sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A== -ts-api-utils@^1.3.0: - version "1.4.2" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.4.2.tgz#a6a6dff26117ac7965624fc118525971edc6a82a" - integrity sha512-ZF5gQIQa/UmzfvxbHZI3JXN0/Jt+vnAfAviNRAMc491laiK6YCLpCW9ft8oaCRFOTxCZtUTE6XB0ZQAe3olntw== +ts-api-utils@^2.1.0, ts-api-utils@^2.4.0: + version "2.4.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.4.0.tgz#2690579f96d2790253bdcf1ca35d569ad78f9ad8" + integrity sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA== ts-dedent@^2.2.0: version "2.2.0" @@ -9450,7 +10315,7 @@ tsconfig-paths@^3.15.0: minimist "^1.2.6" strip-bom "^3.0.0" -tsconfig-paths@^4.1.0: +tsconfig-paths@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz#ef78e19039133446d244beac0fd6a1632e2d107c" integrity sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg== @@ -9464,24 +10329,19 @@ tslib@2.3.0: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== -tslib@2.6.3: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== - -tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.5.0, tslib@^2.6.2, tslib@^2.6.3, tslib@^2.7.0: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.0, tslib@^2.3.1, tslib@^2.4.0, tslib@^2.8.1, tslib@~2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== -tuf-js@^2.2.1: - version "2.2.1" - resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-2.2.1.tgz#fdd8794b644af1a75c7aaa2b197ddffeb2911b56" - integrity sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA== +tuf-js@^4.1.0: + version "4.1.0" + resolved "https://registry.yarnpkg.com/tuf-js/-/tuf-js-4.1.0.tgz#ae4ef9afa456fcb4af103dc50a43bc031f066603" + integrity sha512-50QV99kCKH5P/Vs4E2Gzp7BopNV+KzTXqWeaxrfu5IQJBOULRsTIS9seSsOVT8ZnGXzCyx55nYWAi4qJzpZKEQ== dependencies: - "@tufjs/models" "2.0.1" - debug "^4.3.4" - make-fetch-happen "^13.0.1" + "@tufjs/models" "4.1.0" + debug "^4.4.3" + make-fetch-happen "^15.0.1" type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" @@ -9490,10 +10350,14 @@ type-check@^0.4.0, type-check@~0.4.0: dependencies: prelude-ls "^1.2.1" -type-fest@^0.21.3: - version "0.21.3" - resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" - integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-is@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-2.0.1.tgz#64f6cf03f92fce4015c2b224793f6bdd4b068c97" + integrity sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw== + dependencies: + content-type "^1.0.5" + media-typer "^1.1.0" + mime-types "^3.0.0" type-is@~1.6.18: version "1.6.18" @@ -9503,49 +10367,50 @@ type-is@~1.6.18: media-typer "0.3.0" mime-types "~2.1.24" -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== +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== dependencies: - call-bind "^1.0.7" + call-bound "^1.0.3" es-errors "^1.3.0" - is-typed-array "^1.1.13" + is-typed-array "^1.1.14" -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== +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" -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== +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.7" + call-bind "^1.0.8" for-each "^0.3.3" - gopd "^1.0.1" - has-proto "^1.0.3" - is-typed-array "^1.1.13" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" -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== +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== 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" + reflect.getprototypeof "^1.0.6" typed-assert@^1.0.8: version "1.0.9" @@ -9557,19 +10422,20 @@ typeface-roboto@^1.1.13: resolved "https://registry.yarnpkg.com/typeface-roboto/-/typeface-roboto-1.1.13.tgz#9c4517cb91e311706c74823e857b4bac9a764ae5" integrity sha512-YXvbd3a1QTREoD+FJoEkl0VQNJoEjewR2H11IjVv4bp6ahuIcw0yyw/3udC4vJkHw3T3cUh85FTg8eWef3pSaw== -typescript-eslint@^8.18.1: - version "8.18.1" - resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.18.1.tgz#197b284b6769678ed77d9868df180eeaf61108eb" - integrity sha512-Mlaw6yxuaDEPQvb/2Qwu3/TfgeBHy9iTJ3mTwe7OvpPmF6KPQjVOfGyEJpPv6Ez2C34OODChhXrzYw/9phI0MQ== +typescript-eslint@^8.54.0: + version "8.54.0" + resolved "https://registry.yarnpkg.com/typescript-eslint/-/typescript-eslint-8.54.0.tgz#f4ef3b8882a5ddc2a41968e014194c178ab23f6a" + integrity sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ== dependencies: - "@typescript-eslint/eslint-plugin" "8.18.1" - "@typescript-eslint/parser" "8.18.1" - "@typescript-eslint/utils" "8.18.1" + "@typescript-eslint/eslint-plugin" "8.54.0" + "@typescript-eslint/parser" "8.54.0" + "@typescript-eslint/typescript-estree" "8.54.0" + "@typescript-eslint/utils" "8.54.0" -typescript@~5.5.4: - version "5.5.4" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.5.4.tgz#d9852d6c82bad2d2eda4fd74a5762a8f5909e9ba" - integrity sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q== +typescript@~5.9.3: + version "5.9.3" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.9.3.tgz#5b4f59e15310ab17a216f5d6cf53ee476ede670f" + integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== typical@^4.0.0: version "4.0.0" @@ -9581,25 +10447,25 @@ typical@^5.2.0: resolved "https://registry.yarnpkg.com/typical/-/typical-5.2.0.tgz#4daaac4f2b5315460804f0acf6cb69c52bb93066" integrity sha512-dvdQgNDNJo+8B2uBQoqdb11eUCE1JQXhvjC/CZtgvZseVd5TYMXnq0+vuUemXbd/Se29cTaUuPX3YIc2xgbvIg== -ufo@^1.5.3: - version "1.5.4" - resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.5.4.tgz#16d6949674ca0c9e0fbbae1fa20a71d7b1ded754" - integrity sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ== +ufo@^1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.3.tgz#799666e4e88c122a9659805e30b9dc071c3aed4f" + integrity sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q== -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== +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== dependencies: - call-bind "^1.0.2" + call-bound "^1.0.3" has-bigints "^1.0.2" - has-symbols "^1.0.3" - which-boxed-primitive "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" -undici-types@~6.19.2, undici-types@~6.19.8: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== unicode-canonical-property-names-ecmascript@^2.0.0: version "2.0.1" @@ -9614,32 +10480,27 @@ unicode-match-property-ecmascript@^2.0.0: unicode-canonical-property-names-ecmascript "^2.0.0" unicode-property-aliases-ecmascript "^2.0.0" -unicode-match-property-value-ecmascript@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz#a0401aee72714598f739b68b104e4fe3a0cb3c71" - integrity sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg== +unicode-match-property-value-ecmascript@^2.2.1: + version "2.2.1" + resolved "https://registry.yarnpkg.com/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.1.tgz#65a7adfad8574c219890e219285ce4c64ed67eaa" + integrity sha512-JQ84qTuMg4nVkx8ga4A16a1epI9H6uTXAknqxkGF/aFfRLw1xC/Bp24HNLaZhHSkWd3+84t8iXnp1J0kYcZHhg== unicode-property-aliases-ecmascript@^2.0.0: version "2.1.0" resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== -unicorn-magic@^0.1.0: - version "0.1.0" - resolved "https://registry.yarnpkg.com/unicorn-magic/-/unicorn-magic-0.1.0.tgz#1bb9a51c823aaf9d73a8bfcd3d1a23dde94b0ce4" - integrity sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ== - -unique-filename@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" - integrity sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g== +unique-filename@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-5.0.0.tgz#8b17bbde1a7ca322dd1a1d23fe17c2b798c43f8f" + integrity sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg== dependencies: - unique-slug "^4.0.0" + unique-slug "^6.0.0" -unique-slug@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-4.0.0.tgz#6bae6bb16be91351badd24cdce741f892a6532e3" - integrity sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ== +unique-slug@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/unique-slug/-/unique-slug-6.0.0.tgz#f46fd688a9bd972fd356c23d95812a3a4862ed88" + integrity sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw== dependencies: imurmurhash "^0.1.4" @@ -9653,13 +10514,13 @@ unpipe@~1.0.0: resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== -update-browserslist-db@^1.1.1: - version "1.1.1" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz#80846fba1d79e82547fb661f8d141e0945755fe5" - integrity sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A== +update-browserslist-db@^1.2.0: + version "1.2.3" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" + integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== dependencies: escalade "^3.2.0" - picocolors "^1.1.0" + picocolors "^1.1.1" uri-js@^4.2.2: version "4.4.1" @@ -9685,16 +10546,16 @@ utrie@^1.0.2: dependencies: base64-arraybuffer "^1.0.2" +uuid@^11.1.0: + version "11.1.0" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-11.1.0.tgz#9549028be1753bb934fc96e2bca09bb4105ae912" + integrity sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A== + uuid@^8.3.2: version "8.3.2" resolved "https://registry.yarnpkg.com/uuid/-/uuid-8.3.2.tgz#80d5b5ced271bb9af6c445f21a1a04c606cefbe2" integrity sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg== -uuid@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/uuid/-/uuid-9.0.1.tgz#e188d4c8853cc722220392c424cd637f32293f30" - integrity sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA== - v8-compile-cache-lib@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" @@ -9708,24 +10569,32 @@ validate-npm-package-license@^3.0.4: spdx-correct "^3.0.0" spdx-expression-parse "^3.0.0" -validate-npm-package-name@^5.0.0: - version "5.0.1" - resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-5.0.1.tgz#a316573e9b49f3ccd90dbb6eb52b3f06c6d604e8" - integrity sha512-OljLrQ9SQdOUqTaQxqL5dEfZWrXExyyWsozYlAWFawPVNuD83igl7uJD2RTkNMbniIYgt8l81eCJGIdQF7avLQ== +validate-npm-package-name@^6.0.0: + version "6.0.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-6.0.2.tgz#4e8d2c4d939975a73dd1b7a65e8f08d44c85df96" + integrity sha512-IUoow1YUtvoBBC06dXs8bR8B9vuA3aJfmQNKMoaPG/OFsPmoQvw8xh+6Ye25Gx9DQhoEom3Pcu9MKHerm/NpUQ== -vary@~1.1.2: +validate-npm-package-name@^7.0.0: + version "7.0.2" + resolved "https://registry.yarnpkg.com/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz#e57c3d721a4c8bbff454a246e7f7da811559ea0d" + integrity sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A== + +vary@^1, vary@^1.1.2, vary@~1.1.2: version "1.1.2" resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== -vite@5.4.6: - version "5.4.6" - resolved "https://registry.yarnpkg.com/vite/-/vite-5.4.6.tgz#85a93a1228a7fb5a723ca1743e337a2588ed008f" - integrity sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q== - dependencies: - esbuild "^0.21.3" - postcss "^8.4.43" - rollup "^4.20.0" +vite@7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/vite/-/vite-7.3.2.tgz#cb041794d4c1395e28baea98198fd6e8f4b96b5c" + integrity sha512-Bby3NOsna2jsjfLVOHKes8sGwgl4TT0E6vvpYgnAYDIF/tie7MRaFthmKuHx1NSXjiTueXH3do80FMQgvEktRg== + dependencies: + esbuild "^0.27.0" + fdir "^6.5.0" + picomatch "^4.0.3" + postcss "^8.5.6" + rollup "^4.43.0" + tinyglobby "^0.2.15" optionalDependencies: fsevents "~2.3.3" @@ -9759,10 +10628,10 @@ vscode-languageserver@~9.0.1: dependencies: vscode-languageserver-protocol "3.17.5" -vscode-uri@~3.0.8: - version "3.0.8" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.0.8.tgz#1770938d3e72588659a172d0fd4642780083ff9f" - integrity sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw== +vscode-uri@~3.1.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" + integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== vt-pbf@^3.1.3: version "3.1.3" @@ -9773,18 +10642,18 @@ vt-pbf@^3.1.3: "@mapbox/vector-tile" "^1.3.1" pbf "^3.2.1" -watchpack@2.4.1: - version "2.4.1" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.1.tgz#29308f2cac150fa8e4c92f90e0ec954a9fed7fff" - integrity sha512-8wrBCMtVhqcXP2Sup1ctSkga6uc2Bx0IIvKyT7yTFier5AXHooSI+QyQQAtTb7+E0IUCCKyTFmXqdqgum2XWGg== +watchpack@2.4.4: + version "2.4.4" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.4.tgz#473bda72f0850453da6425081ea46fc0d7602947" + integrity sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -watchpack@^2.4.1: - version "2.4.2" - resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.4.2.tgz#2feeaed67412e7c33184e5a79ca738fbd38564da" - integrity sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw== +watchpack@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/watchpack/-/watchpack-2.5.1.tgz#dd38b601f669e0cbf567cb802e75cead82cde102" + integrity sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg== dependencies: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" @@ -9813,7 +10682,7 @@ web-streams-polyfill@^3.0.3: resolved "https://registry.yarnpkg.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz#2073b91a2fdb1fbfbd401e7de0ac9f8214cecb4b" integrity sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw== -webpack-dev-middleware@7.4.2, webpack-dev-middleware@^7.1.0: +webpack-dev-middleware@7.4.2: version "7.4.2" resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-7.4.2.tgz#40e265a3d3d26795585cff8207630d3a8ff05877" integrity sha512-xOO8n6eggxnwYpy1NlzUKpvrjfJTvae5/D6WOK0S2LSo7vjmo5gCM1DbLUmFqrMTJP+W/0YZNctm7jasWvLuBA== @@ -9825,14 +10694,27 @@ webpack-dev-middleware@7.4.2, webpack-dev-middleware@^7.1.0: range-parser "^1.2.1" schema-utils "^4.0.0" -webpack-dev-server@5.0.4: - version "5.0.4" - resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.0.4.tgz#cb6ea47ff796b9251ec49a94f24a425e12e3c9b8" - integrity sha512-dljXhUgx3HqKP2d8J/fUMvhxGhzjeNVarDLcbO/EWMSgRizDkxHQDZQaLFL5VJY9tRBj2Gz+rvCEYYvhbqPHNA== +webpack-dev-middleware@^7.4.2: + version "7.4.5" + resolved "https://registry.yarnpkg.com/webpack-dev-middleware/-/webpack-dev-middleware-7.4.5.tgz#d4e8720aa29cb03bc158084a94edb4594e3b7ac0" + integrity sha512-uxQ6YqGdE4hgDKNf7hUiPXOdtkXvBJXrfEGYSx7P7LC8hnUYGK70X6xQXUvXeNyBDDcsiQXpG2m3G9vxowaEuA== + dependencies: + colorette "^2.0.10" + memfs "^4.43.1" + mime-types "^3.0.1" + on-finished "^2.4.1" + range-parser "^1.2.1" + schema-utils "^4.0.0" + +webpack-dev-server@5.2.2: + version "5.2.2" + resolved "https://registry.yarnpkg.com/webpack-dev-server/-/webpack-dev-server-5.2.2.tgz#96a143d50c58fef0c79107e61df911728d7ceb39" + integrity sha512-QcQ72gh8a+7JO63TAx/6XZf/CWhgMzu5m0QirvPfGvptOusAxG12w2+aua1Jkjr7hzaWDnJ2n6JFeexMHI+Zjg== dependencies: "@types/bonjour" "^3.5.13" "@types/connect-history-api-fallback" "^1.5.4" "@types/express" "^4.17.21" + "@types/express-serve-static-core" "^4.17.21" "@types/serve-index" "^1.9.4" "@types/serve-static" "^1.15.5" "@types/sockjs" "^0.3.36" @@ -9843,23 +10725,20 @@ webpack-dev-server@5.0.4: colorette "^2.0.10" compression "^1.7.4" connect-history-api-fallback "^2.0.0" - default-gateway "^6.0.3" - express "^4.17.3" + express "^4.21.2" graceful-fs "^4.2.6" - html-entities "^2.4.0" - http-proxy-middleware "^2.0.3" + http-proxy-middleware "^2.0.9" ipaddr.js "^2.1.0" launch-editor "^2.6.1" open "^10.0.3" p-retry "^6.2.0" - rimraf "^5.0.5" schema-utils "^4.2.0" selfsigned "^2.4.1" serve-index "^1.9.1" sockjs "^0.3.24" spdy "^4.0.2" - webpack-dev-middleware "^7.1.0" - ws "^8.16.0" + webpack-dev-middleware "^7.4.2" + ws "^8.18.0" webpack-merge@6.0.1: version "6.0.1" @@ -9870,10 +10749,10 @@ webpack-merge@6.0.1: flat "^5.0.2" wildcard "^2.0.1" -webpack-sources@^3.0.0, webpack-sources@^3.2.3: - version "3.2.3" - resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" - integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== +webpack-sources@^3.0.0, webpack-sources@^3.3.3: + version "3.3.3" + resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.3.3.tgz#d4bf7f9909675d7a070ff14d0ef2a4f3c982c723" + integrity sha512-yd1RBzSGanHkitROoPFd6qsrxt+oFhg/129YzheDGqeustzX0vTZJZsSsQjVQC4yzBQ56K55XU8gaNCtIzOnTg== webpack-subresource-integrity@5.1.0: version "5.1.0" @@ -9882,34 +10761,36 @@ webpack-subresource-integrity@5.1.0: dependencies: typed-assert "^1.0.8" -webpack@5.94.0: - version "5.94.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" - integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== - dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" +webpack@5.105.0: + version "5.105.0" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.105.0.tgz#38b5e6c5db8cbe81debbd16e089335ada05ea23a" + integrity sha512-gX/dMkRQc7QOMzgTe6KsYFM7DxeIONQSui1s0n/0xht36HvrgbxtM1xBlgx596NbpHuQU8P7QpKwrZYwUX48nw== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.8" + "@types/json-schema" "^7.0.15" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.15.0" + acorn-import-phases "^1.0.3" + browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" - es-module-lexer "^1.2.1" + enhanced-resolve "^5.19.0" + es-module-lexer "^2.0.0" eslint-scope "5.1.1" events "^3.2.0" glob-to-regexp "^0.4.1" graceful-fs "^4.2.11" json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" + loader-runner "^4.3.1" mime-types "^2.1.27" neo-async "^2.6.2" - schema-utils "^3.2.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.10" - watchpack "^2.4.1" - webpack-sources "^3.2.3" + schema-utils "^4.3.3" + tapable "^2.3.0" + terser-webpack-plugin "^5.3.16" + watchpack "^2.5.1" + webpack-sources "^3.3.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: version "0.7.4" @@ -9925,31 +10806,62 @@ websocket-extensions@>=0.1.1: resolved "https://registry.yarnpkg.com/websocket-extensions/-/websocket-extensions-0.1.4.tgz#7f8473bc839dfd87608adb95d7eb075211578a42" integrity sha512-OqedPIGOfsDlo31UNwYbCFMSaO9m9G/0faIHj5/dZFDMFqPTcx6UwqyOy3COEaEOg/9VsGIpdqn62W5KhoKSpg== -which-boxed-primitive@^1.0.2: +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^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== + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== 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" + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" which-module@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/which-module/-/which-module-2.0.1.tgz#776b1fe35d90aebe99e8ac15eb24093389a4a409" integrity sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ== -which-typed-array@^1.1.14, which-typed-array@^1.1.15: - 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== +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.20" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.20.tgz#3fdb7adfafe0ea69157b1509f3a1cd892bd1d122" + integrity sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg== dependencies: available-typed-arrays "^1.0.7" - call-bind "^1.0.7" - for-each "^0.3.3" - gopd "^1.0.1" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" has-tostringtag "^1.0.2" which@^2.0.1: @@ -9966,6 +10878,13 @@ which@^4.0.0: dependencies: isexe "^3.1.1" +which@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/which/-/which-6.0.0.tgz#a3a721a14cdd9b991a722e493c177eeff82ff32a" + integrity sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg== + dependencies: + isexe "^3.1.1" + wildcard@^2.0.1: version "2.0.1" resolved "https://registry.yarnpkg.com/wildcard/-/wildcard-2.0.1.tgz#5ab10d02487198954836b6349f74fff961e10f67" @@ -10002,15 +10921,6 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" -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" - wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -10021,18 +10931,30 @@ wrap-ansi@^8.1.0: strip-ansi "^7.0.1" wrap-ansi@^9.0.0: - version "9.0.0" - resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.0.tgz#1a3dc8b70d85eeb8398ddfb1e4a02cd186e58b3e" - integrity sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q== + version "9.0.2" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-9.0.2.tgz#956832dea9494306e6d209eb871643bb873d7c98" + integrity sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww== dependencies: ansi-styles "^6.2.1" string-width "^7.0.0" strip-ansi "^7.1.0" -ws@^8.16.0: - version "8.18.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.18.0.tgz#0d7505a6eafe2b0e712d232b42279f53bc289bbc" - integrity sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw== +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.18.0: + version "8.19.0" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.19.0.tgz#ddc2bdfa5b9ad860204f5a72a4863a8895fd8c8b" + integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== + +wsl-utils@^0.1.0: + version "0.1.0" + resolved "https://registry.yarnpkg.com/wsl-utils/-/wsl-utils-0.1.0.tgz#8783d4df671d4d50365be2ee4c71917a0557baab" + integrity sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw== + dependencies: + is-wsl "^3.1.0" y18n@^4.0.0: version "4.0.3" @@ -10054,7 +10976,12 @@ yallist@^4.0.0: resolved "https://registry.yarnpkg.com/yallist/-/yallist-4.0.0.tgz#9bb92790d9c0effec63be73519e11a35019a3a72" integrity sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A== -yaml@^2.2.2, yaml@^2.3.4: +yallist@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-5.0.0.tgz#00e2de443639ed0d78fd87de0d27469fbcffb533" + integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== + +yaml@^2.2.2: version "2.6.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.6.1.tgz#42f2b1ba89203f374609572d5349fb8686500773" integrity sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg== @@ -10067,23 +10994,22 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -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-parser@^22.0.0: + version "22.0.0" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-22.0.0.tgz#87b82094051b0567717346ecd00fd14804b357c8" + integrity sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw== -yargs@17.7.2, yargs@^17.2.1: - version "17.7.2" - resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.7.2.tgz#991df39aca675a192b816e1e0363f9d75d2aa269" - integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== +yargs@18.0.0, yargs@^18.0.0: + version "18.0.0" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-18.0.0.tgz#6c84259806273a746b09f579087b68a3c2d25bd1" + integrity sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg== dependencies: - cliui "^8.0.1" + cliui "^9.0.1" escalade "^3.1.1" get-caller-file "^2.0.5" - require-directory "^2.1.1" - string-width "^4.2.3" + string-width "^7.2.0" y18n "^5.0.5" - yargs-parser "^21.1.1" + yargs-parser "^22.0.0" yargs@^15.3.1: version "15.4.1" @@ -10112,20 +11038,30 @@ yocto-queue@^0.1.0: resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== -yocto-queue@^1.0.0: - version "1.1.1" - resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-1.1.1.tgz#fef65ce3ac9f8a32ceac5a634f74e17e5b232110" - integrity sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g== - -yoctocolors-cjs@^2.1.2: - version "2.1.2" - resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.2.tgz#f4b905a840a37506813a7acaa28febe97767a242" - integrity sha512-cYVsTjKl8b+FrnidjibDWskAv7UKOfcwaVZdp/it9n1s9fU3IkgDbhdIRKCW4JDsAlECJY0ytoVPT3sK6kideA== - -zone.js@~0.14.10: - version "0.14.10" - resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.14.10.tgz#23b8b29687c6bffece996e5ee5b854050e7775c8" - integrity sha512-YGAhaO7J5ywOXW6InXNlLmfU194F8lVgu7bRntUF3TiG8Y3nBK0x1UJJuHUP/e8IyihkjCYqhCScpSwnlaSRkQ== +yoctocolors-cjs@^2.1.3: + version "2.1.3" + resolved "https://registry.yarnpkg.com/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz#7e4964ea8ec422b7a40ac917d3a344cfd2304baa" + integrity sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw== + +zod-to-json-schema@^3.25.1: + version "3.25.1" + resolved "https://registry.yarnpkg.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz#7f24962101a439ddade2bf1aeab3c3bfec7d84ba" + integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== + +zod@4.1.13: + version "4.1.13" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.13.tgz#93699a8afe937ba96badbb0ce8be6033c0a4b6b1" + integrity sha512-AvvthqfqrAhNH9dnfmrfKzX5upOdjUVJYFqNSlkmGf64gRaTzlPwz99IHYnVs28qYAybvAlBV+H7pn0saFY4Ig== + +"zod@^3.25 || ^4.0": + version "4.3.6" + resolved "https://registry.yarnpkg.com/zod/-/zod-4.3.6.tgz#89c56e0aa7d2b05107d894412227087885ab112a" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + +zone.js@~0.15.1: + version "0.15.1" + resolved "https://registry.yarnpkg.com/zone.js/-/zone.js-0.15.1.tgz#1e109adb75f80e9e004ee8e0d4a0a52e0a336481" + integrity sha512-XE96n56IQpJM7NAoXswY3XRLcWFW83xe0BiAOeMD7K5k5xecOeul3Qcpx6GqEeeHNkW5DWL5zOyTbEfB4eti8w== "zrender@https://github.com/thingsboard/zrender/archive/5.5.0-TB.tar.gz": version "5.5.0-TB"