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 777a52efc7..ee8c78dad3 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ rebuild-docker.sh */.run/** .run/** .run -.claude/ +.claude +.planning diff --git a/TEST_FAST.md b/TEST_FAST.md index 4d1e9dc9a7..eb2013c601 100644 --- a/TEST_FAST.md +++ b/TEST_FAST.md @@ -6,7 +6,9 @@ export MAVEN_OPTS="-Xmx1024m" export NODE_OPTIONS="--max_old_space_size=4096" export SUREFIRE_JAVA_OPTS="-Xmx1200m -Xss256k -XX:+ExitOnOutOfMemoryError" -mvn clean install -T6 -DskipTests +# 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 @@ -31,6 +33,18 @@ mvn test -pl application -Dtest=' ' -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 diff --git a/application/pom.xml b/application/pom.xml index e66a5abaf3..67b61a5737 100644 --- a/application/pom.xml +++ b/application/pom.xml @@ -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 @@ -493,10 +497,6 @@ org.apache.maven.plugins maven-assembly-plugin - - org.apache.maven.plugins - maven-install-plugin - org.xolstice.maven.plugins protobuf-maven-plugin @@ -507,6 +507,82 @@ + + + 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-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_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/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 a2dfeac358..2f9c0cd302 100644 --- a/application/src/main/data/upgrade/basic/schema_update.sql +++ b/application/src/main/data/upgrade/basic/schema_update.sql @@ -14,3 +14,14 @@ -- limitations under the License. -- +-- CALCULATED FIELD ADDITIONAL INFO ADDITION START + +ALTER TABLE calculated_field ADD COLUMN IF NOT EXISTS additional_info varchar; + +-- CALCULATED FIELD ADDITIONAL INFO ADDITION END + +-- RULE CHAIN NOTES MIGRATION START + +ALTER TABLE rule_chain ADD COLUMN IF NOT EXISTS notes varchar(1000000); + +-- RULE CHAIN NOTES MIGRATION 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 20782cf071..67ef7f6ab1 100644 --- a/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java +++ b/application/src/main/java/org/thingsboard/server/actors/ActorSystemContext.java @@ -31,6 +31,7 @@ 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; @@ -144,6 +145,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; @@ -614,9 +616,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}") 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..f93bb34af8 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 @@ -184,11 +184,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))); } 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..1a65232296 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,15 @@ */ 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.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 +35,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 +44,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 +60,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 +98,28 @@ 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<>(); + @Value("${swagger.api_path:/api/**}") private String apiPath; @Value("${swagger.security_path_regex}") @@ -157,9 +184,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 +240,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 +263,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 +289,6 @@ public class SwaggerConfiguration { return GroupedOpenApi.builder() .group(groupName) .pathsToMatch(apiPath) - .addRouterOperationCustomizer(routerOperationCustomizer(localSpringDocParameterNameDiscoverer)) .addOperationCustomizer(operationCustomizer()) .addOpenApiCustomizer(customOpenApiCustomizer()) .build(); @@ -270,9 +298,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 +335,22 @@ 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); + 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 +361,19 @@ 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)"); + Schema errorCodeSchema = new Schema<>() + .type("integer") + .description("Platform error code") + ._enum(Arrays.stream(ThingsboardErrorCode.values()) + .map(ThingsboardErrorCode::getErrorCode) + .collect(Collectors.toList())); 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); - } - } - } - if (!requestParams.isEmpty()) { - var path = routerOperation.getPath() + "{?" + String.join(",", requestParams) + "}"; - routerOperation.setPath(path); - } - return routerOperation; - }; + .addSchemas("ThingsboardCredentialsExpiredResponse", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(ThingsboardCredentialsExpiredResponse.class)).schema) + .addSchemas("ThingsboardErrorCode", errorCodeSchema) + .addSchemas("AiChatModelConfig", ModelConverters.getInstance().readAllAsResolvedSchema(new AnnotatedType().type(AiChatModelConfig.class)).schema); } private OperationCustomizer operationCustomizer() { @@ -370,17 +413,131 @@ 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); + }); + + // 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 +545,118 @@ 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; + } + } + 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 +746,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 +792,238 @@ 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()); + } + } + } + } + + @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) { + StringBuilder sb = new StringBuilder(javaType.getRawClass().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.getField() != null && prop.couldSerialize()) { + fieldToJsonName.put(prop.getField().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); } } 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/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 09972bd6c2..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( 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..50766106d5 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, 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 8c0d0d2243..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; @@ -74,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, @@ -107,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, @@ -141,8 +138,7 @@ public class AuditLogController extends BaseController { "For example to see when a device was created, updated, assigned to some customer, or even deleted from the system. " + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs/entity/{entityType}/{entityId}", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @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, @@ -176,8 +172,7 @@ public class AuditLogController extends BaseController { notes = "Returns a page of audit logs related to all entities in the scope of the current user's Tenant. " + PAGE_DATA_PARAMETERS + SYSTEM_OR_TENANT_AUTHORITY_PARAGRAPH) @PreAuthorize("hasAnyAuthority('SYS_ADMIN', 'TENANT_ADMIN')") - @RequestMapping(value = "/audit/logs", params = {"pageSize", "page"}, method = RequestMethod.GET) - @ResponseBody + @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 48e0f11552..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); } 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..04e750d0e4 100644 --- a/application/src/main/java/org/thingsboard/server/controller/DomainController.java +++ b/application/src/main/java/org/thingsboard/server/controller/DomainController.java @@ -81,12 +81,12 @@ 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); 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..a1d6f667f9 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; @@ -132,15 +133,23 @@ public class OAuth2Controller extends BaseController { 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/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 18bc488394..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; @@ -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..1796e959ab 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,7 +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)", + @ApiOperation(value = "Get information about last visited and starred dashboards (getUserDashboardsInfo)", 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/dashboards") @@ -583,12 +598,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 +612,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..cfc50543a1 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 = "/widgetsBundles/{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..48d3a907bc 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; @@ -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 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/service/cf/CalculatedFieldCache.java b/application/src/main/java/org/thingsboard/server/service/cf/CalculatedFieldCache.java index 75da5c5d7a..3ea6925695 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); 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..8a7d38970f 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 @@ -152,9 +152,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())); } 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/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 3003c046b9..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 @@ -41,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; @@ -48,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; @@ -60,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; @@ -97,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; @@ -193,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)) @@ -205,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(); @@ -218,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(); @@ -231,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(); @@ -244,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(); @@ -257,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(); @@ -270,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()) @@ -387,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(); @@ -486,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(); } @@ -503,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)) @@ -520,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() @@ -640,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(); } @@ -648,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(); @@ -665,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()) @@ -680,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(); @@ -694,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(); @@ -707,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(); @@ -719,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/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/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/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/install/DefaultDatabaseSchemaSettingsService.java b/application/src/main/java/org/thingsboard/server/service/install/DefaultDatabaseSchemaSettingsService.java index ec2db21f01..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 @@ -31,7 +31,8 @@ public class DefaultDatabaseSchemaSettingsService implements DatabaseSchemaSetti // 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.0", "4.3.0.x", + "4.3.1", "4.3.1.x" ); private final ProjectInfo projectInfo; 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/query/DefaultEntityQueryService.java b/application/src/main/java/org/thingsboard/server/service/query/DefaultEntityQueryService.java index 4d76989a92..50f0f0e75a 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; @@ -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/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/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/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/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/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 611eae26fe..15d6afb37d 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}" @@ -78,6 +79,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 +110,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 +136,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 +145,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 +188,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 +253,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: @@ -211,6 +277,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: @@ -222,6 +289,7 @@ ui: 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: @@ -230,6 +298,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}" @@ -341,11 +410,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: @@ -426,10 +498,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 @@ -438,6 +511,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 @@ -447,6 +521,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 @@ -509,6 +584,16 @@ 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:}" rpc: # Maximum number of persistent RPC call retries in case of failed request delivery. max_retries: "${ACTORS_RPC_MAX_RETRIES:5}" @@ -541,6 +626,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. @@ -548,6 +635,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}" @@ -707,6 +795,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: @@ -743,9 +832,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:}" @@ -790,27 +879,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 @@ -829,6 +927,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: @@ -879,6 +978,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}" @@ -921,6 +1021,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). @@ -951,7 +1052,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}" @@ -980,6 +1082,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}" @@ -1027,6 +1130,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. @@ -1337,6 +1441,7 @@ transport: fetch_frequency: "${TB_GATEWAY_DASHBOARD_SYNC_FETCH_FREQUENCY:24}" # CoAP server parameters +# Configures the standalone CoAP server bind address, port, DTLS encryption, and credential settings. coap: server: # Enable/disable coap server. @@ -1421,6 +1526,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: @@ -1474,6 +1580,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}" @@ -1495,10 +1602,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. @@ -1535,6 +1649,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}" @@ -1542,6 +1657,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/**}" @@ -1573,6 +1689,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). @@ -1986,12 +2103,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) @@ -2005,6 +2124,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}" @@ -2018,6 +2138,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}" @@ -2028,6 +2149,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}" @@ -2036,6 +2158,7 @@ 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: @@ -2048,6 +2171,7 @@ management: 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}" @@ -2056,6 +2180,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..7041a71086 100644 --- a/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java +++ b/application/src/test/java/org/thingsboard/server/cf/AlarmRulesTest.java @@ -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); @@ -424,12 +423,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 +461,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 +500,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 +509,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 +564,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 +595,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 +627,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 +661,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 +675,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 +718,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 +754,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 +764,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 +804,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 +837,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 +850,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 +860,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 +894,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/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..d17dd9d0f6 --- /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.AlarmConditionValueAlarmRuleSchedule; +import org.thingsboard.client.model.AlarmRuleDefinition; +import org.thingsboard.client.model.AlarmRuleSimpleCondition; +import org.thingsboard.client.model.AlarmRuleSpecificTimeSchedule; +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.SimpleCalculatedFieldConfiguration; +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;"); + AlarmRuleSimpleCondition createCondition = new AlarmRuleSimpleCondition(); + createCondition.setExpression(createExpression); + AlarmRuleSpecificTimeSchedule specificTimeSchedule = new AlarmRuleSpecificTimeSchedule().addDaysOfWeekItem(3); + AlarmConditionValueAlarmRuleSchedule schedule = new AlarmConditionValueAlarmRuleSchedule().staticValue(specificTimeSchedule); + createCondition.setSchedule(schedule); + AlarmRuleDefinition createRule = new AlarmRuleDefinition(); + 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;"); + AlarmRuleSimpleCondition clearCondition = new AlarmRuleSimpleCondition(); + clearCondition.setExpression(clearExpression); + AlarmRuleDefinition clearRule = new AlarmRuleDefinition(); + 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(); + AlarmConditionValueAlarmRuleSchedule createdSchedule = configuration.getCreateRules().get(AlarmSeverity.CRITICAL.name()).getCondition().getSchedule(); + AlarmRuleSpecificTimeSchedule staticSchedule = (AlarmRuleSpecificTimeSchedule) 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;"); + AlarmRuleSimpleCondition criticalCondition = new AlarmRuleSimpleCondition(); + criticalCondition.setExpression(criticalExpression); + AlarmRuleDefinition criticalRule = new AlarmRuleDefinition(); + 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..bd10a3deca --- /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.getTenantDomainInfos(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.getTenantDomainInfos(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..63f05710ae --- /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.findTenantOAuth2ClientInfos(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.findTenantOAuth2ClientInfos(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/AbstractWebTest.java b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java index eb053cff76..055e1e19a0 100644 --- a/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/AbstractWebTest.java @@ -94,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; @@ -123,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; @@ -177,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; @@ -422,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(); @@ -454,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); @@ -718,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); } @@ -731,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) { @@ -1142,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 { @@ -1219,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; }); @@ -1411,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(); } @@ -1425,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 { @@ -1452,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/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/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 af5df3d733..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; @@ -61,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; @@ -112,17 +116,14 @@ public class AuditLogControllerTest extends AbstractControllerTest { doPost("/api/device", device, Device.class); } - List loadedAuditLogs = getAuditLogs(5, "/api/audit/logs?"); - - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); - - loadedAuditLogs = getAuditLogs(5, "/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?"); - - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs?")).hasSize(11 + 1)); - loadedAuditLogs = getAuditLogs(5, "/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?"); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/customer/" + ModelConstants.NULL_UUID + "?")).hasSize(11 + 1)); - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + Awaitility.await().atMost(TIMEOUT, TimeUnit.SECONDS).untilAsserted(() -> + assertThat(getAuditLogs(SMALL_PAGE_SIZE, "/api/audit/logs/user/" + tenantAdmin.getId().getId().toString() + "?")).hasSize(11 + 1)); } @Test @@ -136,9 +137,10 @@ public class AuditLogControllerTest extends AbstractControllerTest { doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); } - List loadedAuditLogs = getAuditLogs(100, "/api/audit/logs?"); - - Assert.assertEquals("Have X audit log before this test + New tenant profiles in the test", loadedAuditLogsBefore.size() + 3, loadedAuditLogs.size()); + 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 { @@ -168,9 +170,9 @@ public class AuditLogControllerTest extends AbstractControllerTest { savedDevice = doPost("/api/device", savedDevice, Device.class); } - List loadedAuditLogs = getAuditLogs(5, "/api/audit/logs/entity/DEVICE/" + savedDevice.getId().getId() + "?"); - - Assert.assertEquals(11 + 1, loadedAuditLogs.size()); + 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 @@ -186,9 +188,10 @@ public class AuditLogControllerTest extends AbstractControllerTest { tenantProfile.setName(tenantProfile.getName() + "(old)"); tenantProfile = doPost("/api/tenantProfile", tenantProfile, TenantProfile.class); - List loadedAuditLogs = getAuditLogs(5, "/api/audit/logs/entity/" +tenantProfile.getId().getEntityType()+ "/" + tenantProfile.getId().getId() + "?"); - - Assert.assertEquals("Audit logs count by Tenant Profile entity", 2, loadedAuditLogs.size()); + 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)); //cleanup doDelete("/api/tenantProfile/" + tenantProfile.getId().getId().toString()); 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/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..835f00985b 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityQueryControllerTest.java @@ -17,7 +17,11 @@ 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; @@ -43,12 +47,21 @@ 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.DeviceTypeFilter; import org.thingsboard.server.common.data.query.DynamicValue; import org.thingsboard.server.common.data.query.DynamicValueSourceType; @@ -84,6 +97,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; @@ -1329,7 +1343,7 @@ public class EntityQueryControllerTest extends AbstractControllerTest { //assign dashboard doPost("/api/customer/" + savedCustomer.getId().getId().toString() - + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); + + "/dashboard/" + savedDashboard.getId().getId().toString(), Dashboard.class); // check entity data query by customer User customerUser = new User(); @@ -1454,6 +1468,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 +1512,221 @@ 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); + } + } 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 a4d6b62a0c..277ec687f1 100644 --- a/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java +++ b/application/src/test/java/org/thingsboard/server/controller/EntityViewControllerTest.java @@ -41,6 +41,7 @@ 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; @@ -90,8 +91,6 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.thingsboard.server.dao.model.ModelConstants.NULL_UUID; -import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_PORT; -import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest.MQTT_URL; @TestPropertySource(properties = { "transport.mqtt.enabled=true", @@ -101,6 +100,15 @@ import static org.thingsboard.server.transport.mqtt.AbstractMqttIntegrationTest. @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); 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/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..accbbd1d29 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,25 @@ 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.adapter.NativeWebSocketSession; import org.thingsboard.common.util.ThingsBoardThreadFactory; +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.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 +193,101 @@ class TbWebSocketHandlerTest { assertThat(msgs).map(Integer::parseInt).doesNotHaveDuplicates().hasSize(100); } + 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 260476fe4d..d75c7b8f59 100644 --- a/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/AbstractEdgeTest.java @@ -39,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; @@ -605,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; } @@ -775,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 9349f79c02..369cda37aa 100644 --- a/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java +++ b/application/src/test/java/org/thingsboard/server/edge/DeviceEdgeTest.java @@ -141,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()); @@ -222,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); @@ -272,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(); @@ -387,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/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/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 8cf2b309f6..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; @@ -375,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())); } 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/EntityServiceTest.java b/application/src/test/java/org/thingsboard/server/service/entitiy/EntityServiceTest.java index c54ffe4a07..05ab1165f6 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 @@ -115,6 +115,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; @@ -1749,13 +1751,13 @@ public class EntityServiceTest extends AbstractControllerTest { } @Test - public void testFindTenantTelemetry() { + 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)); 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 e3342dbf43..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<>(); @@ -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); @@ -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); 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/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/transport/lwm2m/client/FwLwM2MDevice.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/client/FwLwM2MDevice.java index 9265893fa2..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 @@ -140,22 +140,22 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { } private void startDownloading() { - long delay = 0; + long delay = 500; // Step 1: state = 1 scheduler.schedule(() -> { - state.set(1); + state.set(1); // DOWNLOADING fireResourceChange(3); log.info("Downloading started: state=[{}]", state.get()); - }, delay, TimeUnit.MILLISECONDS); + }, delay, TimeUnit.MILLISECONDS); // 500 ms - delay += 100; // next step after 100 ms + delay += 1000; // next step after 100 ms // Step 2: state = 2 scheduler.schedule(() -> { - state.set(2); + state.set(2); // DOWNLOADED fireResourceChange(3); log.info("Downloading in progress: state=[{}]", state.get()); - }, delay, TimeUnit.MILLISECONDS); + }, delay, TimeUnit.MILLISECONDS); // 1500 ms } @@ -171,12 +171,21 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { 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(); // 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); @@ -186,12 +195,12 @@ public class FwLwM2MDevice extends BaseInstanceEnabler implements Destroyable { log.info("FW resources updating to new values: pkgName=[{}], pkgVersion=[{}]", this.pkgName, this.pkgVersion); - }, 15, TimeUnit.SECONDS); // 15 sec — safe timing + }, 5, TimeUnit.SECONDS); // 5 sec — safe timing } } catch (Exception e) { log.error("Error during firmware update", e); } - }, 0, TimeUnit.SECONDS); // start immediately, without further delay + }, 1, TimeUnit.SECONDS); // delay 1 sec to allow CoAP Execute response to be delivered before client stops } protected void setLeshanClient(LeshanClient leshanClient) { 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/sql/PskLwm2mIntegrationTest.java b/application/src/test/java/org/thingsboard/server/transport/lwm2m/security/sql/PskLwm2mIntegrationTest.java index 6083966837..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 @@ -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/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/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 9cca1a1a54..9b6ef16e52 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 @@ -200,6 +200,7 @@ public abstract class AbstractMqttV5ClientSparkplugTest extends AbstractMqttInte AtomicReference device = new AtomicReference<>(); await(alias + "find device [" + deviceIdName + "] after created") .atMost(200, TimeUnit.SECONDS) + .ignoreExceptions() .until(() -> { device.set(doGet("/api/tenant/devices?deviceName=" + deviceName, Device.class)); return device.get() != null; @@ -337,6 +338,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/resources/application-test.properties b/application/src/test/resources/application-test.properties index e79289340c..7f0ab964d6 100644 --- a/application/src/test/resources/application-test.properties +++ b/application/src/test/resources/application-test.properties @@ -44,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 6faaf97536..9409eed7bd 100644 --- a/application/src/test/resources/logback-test.xml +++ b/application/src/test/resources/logback-test.xml @@ -18,9 +18,6 @@ - - - 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/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/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/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/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/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/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/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/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/rule/AlarmRule.java b/common/data/src/main/java/org/thingsboard/server/common/data/alarm/rule/AlarmRule.java index 93370c0c30..b7dc5059a7 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(name = "AlarmRuleDefinition") @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..88dc7f5859 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,15 @@ 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( + name = "AlarmRuleCondition", + 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..bee112d25d 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(name = "AlarmRuleDurationCondition") @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..ca03d257a1 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(name = "AlarmRuleRepeatingCondition") @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..38e0434b1d 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(name = "AlarmRuleSimpleCondition") 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..75849967a0 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; @@ -25,9 +26,12 @@ import org.thingsboard.server.common.data.alarm.rule.condition.expression.predic import org.thingsboard.server.common.data.alarm.rule.condition.expression.predicate.KeyFilterPredicate; 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(name = "AlarmRuleConditionFilter") @Data public class AlarmConditionFilter implements Serializable { @@ -36,6 +40,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..390bbb3230 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,6 +15,7 @@ */ 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; 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..0ff72c4297 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,6 +31,7 @@ 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; 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..17c5279b3e 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 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..6463aa7f81 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(name = "AlarmRuleNoDataFilterPredicate") @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..2fb249fc96 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,6 +35,7 @@ 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; 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..3af7d9c083 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,6 +32,7 @@ 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; 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..c77eaf4fa5 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,20 @@ 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 = "AlarmRuleSchedule", + 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..b2655f58d5 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(name = "AlarmRuleScheduleType") 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..4c1b9fdc75 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(name = "AlarmRuleAnyTimeSchedule") 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..1530de019b 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(name = "AlarmRuleCustomTimeSchedule") @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..0029ea9e24 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(name = "AlarmRuleCustomTimeScheduleItem") @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..1a888a1e87 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(name = "AlarmRuleSpecificTimeSchedule") @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/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 a1296a9f0c..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 { 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 9718d996e6..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 { 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..fc0c4fdd2c 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; @@ -30,7 +31,7 @@ import java.util.List; 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/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/AlarmSchedule.java b/common/data/src/main/java/org/thingsboard/server/common/data/device/profile/AlarmSchedule.java index d46fedf87b..5509f7e896 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,21 @@ 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.DiscriminatorMapping; +import io.swagger.v3.oas.annotations.media.Schema; import org.thingsboard.server.common.data.query.DynamicValue; import java.io.Serializable; +@Schema( + description = "Configuration for alarm schedule", + 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, 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/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/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/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..808738a78b 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,7 @@ public abstract class TaskResult { @JsonIgnore public abstract JobType getJobType(); + @JsonIgnore + 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..0475a9a02d 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) @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/query/AlarmCountQuery.java b/common/data/src/main/java/org/thingsboard/server/common/data/query/AlarmCountQuery.java index f9d2e8d3d8..8431d7a303 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; 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..557f08eb2b 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,6 +34,7 @@ public class ComplexFilterPredicate implements KeyFilterPredicate { return FilterPredicateType.COMPLEX; } + @Schema 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/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/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..3e04074236 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; @@ -36,17 +50,50 @@ import java.util.List; import java.util.Map; @JsonIgnoreProperties(ignoreUnknown = true) -@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true, defaultImpl = EntityExportData.class) +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "entityType", include = As.EXISTING_PROPERTY, visible = true) @JsonSubTypes({ @Type(name = "DEVICE", value = DeviceExportData.class), @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/transport/resource/ResourceType.java b/common/data/src/main/java/org/thingsboard/server/common/data/transport/resource/ResourceType.java index 973b4807d3..4cd0e51925 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(name = "TransportResourceType") 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/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/edge-api/src/main/proto/edge.proto b/common/edge-api/src/main/proto/edge.proto index 472ce8e8dd..bcea3c30d9 100644 --- a/common/edge-api/src/main/proto/edge.proto +++ b/common/edge-api/src/main/proto/edge.proto @@ -47,8 +47,12 @@ enum EdgeVersion { 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 = 99999; @@ -146,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; @@ -456,6 +467,7 @@ message UplinkMsg { repeated AiModelUpdateMsg aiModelUpdateMsg = 27; repeated UserUpdateMsg userUpdateMsg = 28; repeated UserCredentialsUpdateMsg userCredentialsUpdateMsg = 29; + repeated ApiKeyUpdateMsg apiKeyUpdateMsg = 30; } message UplinkResponseMsg { @@ -507,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/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..3955abb63f 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, @@ -263,6 +262,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/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/test/java/org/thingsboard/script/api/tbel/TbDateTest.java b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java index 3529c94ee4..494eb64cde 100644 --- a/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java +++ b/common/script/script-api/src/test/java/org/thingsboard/script/api/tbel/TbDateTest.java @@ -209,7 +209,9 @@ class TbDateTest { .put("timeZone", "America/New_York") .put("dateStyle", "full") .toString())); - Assertions.assertEquals("середа, 6 вересня 2023 р.", d.toLocaleDateString("uk-UA", JacksonUtil.newObjectNode() + // Java 21+ uses narrow no-break space (U+202F) before "р." per CLDR 42+ + String expectedDate_ukUA = Runtime.version().feature() >= 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/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..56e90e4b7c 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 @@ -213,7 +213,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 +224,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/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/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/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..10245c5a24 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 @@ -88,20 +88,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/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..a8f8a8b863 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 @@ -161,5 +161,5 @@ 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/config/ssl/SslCredentialsWebServerCustomizer.java b/common/transport/transport-api/src/main/java/org/thingsboard/server/common/transport/config/ssl/SslCredentialsWebServerCustomizer.java index e5f81dafc4..34cc1151c4 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 @@ -30,6 +30,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.stereotype.Component; import java.util.List; +import java.util.function.BiConsumer; import java.util.function.Consumer; @Component @@ -88,6 +89,11 @@ public class SslCredentialsWebServerCustomizer implements WebServerFactoryCustom public void addBundleUpdateHandler(String name, Consumer handler) { // no-op } + + @Override + public void addBundleRegisterHandler(BiConsumer handler) { + // no-op + } }; } 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..e8ade498db 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 @@ -1257,9 +1257,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}") 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/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 928a107079..d526eeed4f 100644 --- a/dao/pom.xml +++ b/dao/pom.xml @@ -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/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/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 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 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/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/Oauth2ClientDataValidator.java b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java index d7984109ab..289220710c 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java +++ b/dao/src/main/java/org/thingsboard/server/dao/service/validator/Oauth2ClientDataValidator.java @@ -17,6 +17,7 @@ package org.thingsboard.server.dao.service.validator; import lombok.AllArgsConstructor; import org.springframework.stereotype.Component; +import org.thingsboard.common.util.SsrfProtectionValidator; import org.thingsboard.server.common.data.StringUtils; import org.thingsboard.server.common.data.id.TenantId; import org.thingsboard.server.common.data.oauth2.MapperType; @@ -28,6 +29,8 @@ import org.thingsboard.server.common.data.oauth2.TenantNameStrategyType; import org.thingsboard.server.exception.DataValidationException; import org.thingsboard.server.dao.service.DataValidator; +import java.net.URI; + @Component @AllArgsConstructor public class Oauth2ClientDataValidator extends DataValidator { @@ -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/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/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/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/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/CalculatedFieldServiceTest.java b/dao/src/test/java/org/thingsboard/server/dao/service/CalculatedFieldServiceTest.java index c9524ba7b5..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 @@ -18,6 +18,7 @@ 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; @@ -83,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"); @@ -90,6 +92,7 @@ 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); } @@ -495,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/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/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/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/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/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/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 1081b89276..abb4fbe68b 100644 --- a/edqs/pom.xml +++ b/edqs/pom.xml @@ -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 010994dc88..93914fffbe 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. diff --git a/monitoring/pom.xml b/monitoring/pom.xml index 391347e56b..9b66b64009 100644 --- a/monitoring/pom.xml +++ b/monitoring/pom.xml @@ -33,7 +33,6 @@ UTF-8 ${basedir}/.. java - false process-resources package tb-monitoring @@ -154,8 +153,8 @@ maven-assembly-plugin - org.apache.maven.plugins - maven-install-plugin + org.codehaus.mojo + build-helper-maven-plugin diff --git a/msa/black-box-tests/pom.xml b/msa/black-box-tests/pom.xml index b08d05ed35..141ee1adac 100644 --- a/msa/black-box-tests/pom.xml +++ b/msa/black-box-tests/pom.xml @@ -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/edqs/pom.xml b/msa/edqs/pom.xml index e6adae1f87..b1034afe4e 100644 --- a/msa/edqs/pom.xml +++ b/msa/edqs/pom.xml @@ -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/default.yml b/msa/js-executor/config/default.yml index be49ece5a6..3a08495069 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: none, gzip + 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 cf7310a084..b857f1e085 100644 --- a/msa/js-executor/package.json +++ b/msa/js-executor/package.json @@ -39,7 +39,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/js-executor/pom.xml b/msa/js-executor/pom.xml index 0e75d24919..66a629f550 100644 --- a/msa/js-executor/pom.xml +++ b/msa/js-executor/pom.xml @@ -37,7 +37,6 @@ tb-js-executor tb-js-executor js - false process-resources package ${project.build.directory}/package/linux @@ -71,7 +70,7 @@ install-node-and-yarn - v22.18.0 + v22.22.2 v1.22.22 @@ -81,7 +80,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 @@ -96,6 +95,17 @@ + + org.apache.maven.plugins + maven-clean-plugin + + + + ${basedir}/node_modules + + + + org.apache.maven.plugins maven-dependency-plugin @@ -135,33 +145,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 +225,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/yarn.lock b/msa/js-executor/yarn.lock index 7a54682fa7..7e2fe58a13 100644 --- a/msa/js-executor/yarn.lock +++ b/msa/js-executor/yarn.lock @@ -363,9 +363,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" @@ -1036,9 +1036,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 +1052,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 +1064,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 +1179,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 +1189,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 +1248,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 +1548,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 +1634,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 27b05f525d..a968d693df 100644 --- a/msa/monitoring/pom.xml +++ b/msa/monitoring/pom.xml @@ -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 04b1814063..f852773b8c 100644 --- a/msa/pom.xml +++ b/msa/pom.xml @@ -31,11 +31,15 @@ ${basedir}/.. + + package thingsboard thingsboard/openjdk25:trixie-slim true true - 1.4.13 + 3.5.1 none @@ -53,6 +57,37 @@ + + + skip-deb + + + pkg.skip.deb + true + + + + none + + + + + skip-pkg + + + pkg.skip + true + + + + none + + black-box-tests @@ -213,19 +248,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 2244241347..6f28a25c51 100644 --- a/msa/tb-node/pom.xml +++ b/msa/tb-node/pom.xml @@ -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..59e3a2898f 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,33 @@ 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/ 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} \ && 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 1bf32786e7..86651cd311 100644 --- a/msa/tb/pom.xml +++ b/msa/tb/pom.xml @@ -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 e89a10e5a1..60aa546e1d 100644 --- a/msa/transport/coap/pom.xml +++ b/msa/transport/coap/pom.xml @@ -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 76b7ff9602..b0c0e7b625 100644 --- a/msa/transport/http/pom.xml +++ b/msa/transport/http/pom.xml @@ -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 9c10edfbb7..296a754c38 100644 --- a/msa/transport/lwm2m/pom.xml +++ b/msa/transport/lwm2m/pom.xml @@ -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 ff759a52ff..dc8fc5b39a 100644 --- a/msa/transport/mqtt/pom.xml +++ b/msa/transport/mqtt/pom.xml @@ -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/snmp/pom.xml b/msa/transport/snmp/pom.xml index 049cbad4ec..ed441a2208 100644 --- a/msa/transport/snmp/pom.xml +++ b/msa/transport/snmp/pom.xml @@ -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 183a82a170..2eef220b7d 100644 --- a/msa/vc-executor-docker/pom.xml +++ b/msa/vc-executor-docker/pom.xml @@ -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 6b0277c2c6..028b84f98e 100644 --- a/msa/vc-executor/pom.xml +++ b/msa/vc-executor/pom.xml @@ -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 56e15d4b4d..188583dc7e 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). @@ -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: @@ -221,6 +227,7 @@ usage: 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}" @@ -229,6 +236,7 @@ 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: @@ -237,12 +245,14 @@ management: 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 4544aece37..9e9b643402 100644 --- a/msa/web-ui/package.json +++ b/msa/web-ui/package.json @@ -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 7dfc4c0aee..4ad7aef8c8 100644 --- a/msa/web-ui/pom.xml +++ b/msa/web-ui/pom.xml @@ -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 @@ -105,6 +104,17 @@ + + 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..f421a62684 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" @@ -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 6856da9233..c23aaabf80 100644 --- a/netty-mqtt/pom.xml +++ b/netty-mqtt/pom.xml @@ -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 c4cfec0ad3..d946117aa5 100755 --- a/pom.xml +++ b/pom.xml @@ -28,19 +28,43 @@ 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-SNAPSHOT + 3.5.13 2.4.0-b180830.0359 - 5.1.5 0.12.5 0.10 4.17.0 @@ -61,16 +85,18 @@ 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.9 + 1.18.44 1.2.5 1.2.5 1.7.1 - 3.5.4 - 3.4.0 + 3.5.0 + 3.5.4 + 3.1.4 + 3.1.4 2.8.8TB 2.2.30 0.8 @@ -89,7 +115,6 @@ 1.10.1 8.10.1 3.5.3 - 2.2 1.12.701 1.128.1 @@ -111,6 +136,7 @@ 4.0.2 1.7.5 3.8.0 + 1.18.4 1.8.0-TB 2.38.0 1.24 @@ -147,7 +173,6 @@ 9.2.0 1.1.10.5 9.10.0 - 4.1.128.Final @@ -182,6 +207,43 @@ + + + skip-deb + + + pkg.skip.deb + true + + + + none + + + + + + skip-pkg + + + pkg.skip + true + + + + true + true + true + true + none + + + packaging @@ -487,7 +549,12 @@ org.springframework.boot spring-boot-maven-plugin - ${pkg.disabled} + + ${pkg.skip.bootjar} ${pkg.mainClass} boot ZIP @@ -531,6 +598,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 @@ -548,6 +619,7 @@ org.apache.maven.plugins maven-assembly-plugin + ${pkg.skip.zip} ${pkg.name} ${main.dir}/packaging/${pkg.type}/assembly/windows.xml @@ -564,23 +636,24 @@ - 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 +678,7 @@ maven-compiler-plugin 3.11.0 - 17 + 25 -Xlint:deprecation -Xlint:removal @@ -634,7 +707,7 @@ org.apache.maven.plugins maven-jar-plugin - 3.1.1 + ${jar-plugin.version} org.apache.maven.plugins @@ -649,20 +722,21 @@ 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=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" @@ -672,12 +746,17 @@ 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 @@ -837,6 +916,7 @@ **/test/resources/lwm2m/** **/resources/lwm2m/models/** src/main/data/resources/** + .claude/** JAVADOC_STYLE @@ -886,6 +966,21 @@ com.mycila license-maven-plugin + + org.apache.maven.plugins + maven-clean-plugin + false + + + + ${main.dir}/packaging/java/.gradle + + + ${main.dir}/packaging/js/.gradle + + + + @@ -899,13 +994,6 @@ - - io.netty - netty-bom - ${netty.version} - pom - import - org.springframework.boot spring-boot-dependencies @@ -913,6 +1001,16 @@ pom import + + net.bytebuddy + byte-buddy + ${bytebuddy.version} + + + net.bytebuddy + byte-buddy-agent + ${bytebuddy.version} + org.thingsboard.langchain4j langchain4j-bom @@ -1072,6 +1170,12 @@ ${project.version} test + + org.thingsboard.client + thingsboard-ce-client + ${thingsboard.client.version} + test + org.thingsboard.msa js-executor @@ -1179,11 +1283,6 @@ jjwt ${jjwt.version} - - org.yaml - snakeyaml - ${snakeyaml.version} - antlr antlr @@ -1484,12 +1583,6 @@ ${dbunit.version} test - - org.projectlombok - lombok - ${lombok.version} - provided - org.eclipse.paho org.eclipse.paho.client.mqttv3 @@ -1535,11 +1628,6 @@ bcprov-ext-jdk18on ${bouncycastle.version} - - redis.clients - jedis - ${jedis.version} - com.sun.winsw winsw @@ -1929,7 +2017,7 @@ https://repo1.maven.org/maven2/ - thingsboard-repo + thingsboard-public-repo https://repo.thingsboard.io/artifactory/libs-release-public @@ -1955,4 +2043,10 @@ + + + thingsboard-repo + https://repo.thingsboard.io/artifactory/libs-release-public + + 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 edef60f30d..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 @@ -22,7 +22,6 @@ import com.google.common.base.Strings; import lombok.Getter; import lombok.SneakyThrows; import org.apache.commons.io.IOUtils; -import org.apache.hc.core5.net.URIBuilder; import org.apache.commons.lang3.concurrent.LazyInitializer; import org.apache.hc.core5.net.URIBuilder; import org.springframework.core.ParameterizedTypeReference; @@ -172,6 +171,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.EntityDataQuery; @@ -1898,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") @@ -1909,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", diff --git a/rest-client/src/main/resources/logback.xml b/rest-client/src/main/resources/logback.xml deleted file mode 100644 index f8c9b51bd0..0000000000 --- a/rest-client/src/main/resources/logback.xml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - %d{ISO8601} [%thread] %-5level %logger{36} - %msg%n - - - - - - - - - - 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-components/pom.xml b/rule-engine/rule-engine-components/pom.xml index 4e092211b8..5e7cb48970 100644 --- a/rule-engine/rule-engine-components/pom.xml +++ b/rule-engine/rule-engine-components/pom.xml @@ -161,6 +161,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..8db6a3ccaf 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,7 @@ 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.TbContext; import org.thingsboard.rule.engine.api.TbNodeException; import org.thingsboard.rule.engine.api.util.TbNodeUtils; @@ -39,6 +41,7 @@ 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.netty.http.client.HttpClient; @@ -47,6 +50,7 @@ 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; @@ -102,6 +106,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 +133,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() @@ -213,8 +218,22 @@ public class TbHttpClient { } String endpointUrl = TbNodeUtils.processPattern(config.getRestEndpointUrlPattern(), msg); + + List> processedQueryParams; + if (config.getQueryParams() != null) { + processedQueryParams = config.getQueryParams().stream() + .map(param -> { + var processedParamName = TbNodeUtils.processPattern(param.key(), msg); + var processedParamValue = TbNodeUtils.processPattern(param.value(), msg); + return new KeyValueEntry<>(processedParamName, processedParamValue); + }) + .toList(); + } else { + processedQueryParams = null; + } + HttpMethod method = HttpMethod.valueOf(config.getRequestMethod()); - URI uri = buildEncodedUri(endpointUrl); + URI uri = buildEncodedUri(endpointUrl, processedQueryParams); RequestBodySpec request = webClient .method(method) @@ -262,7 +281,7 @@ public class TbHttpClient { 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 +289,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 +306,40 @@ 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 getData(TbMsg tbMsg, boolean parseToPlainText) { String data = tbMsg.getData(); return parseToPlainText ? JacksonUtil.toPlainText(data) : JacksonUtil.toJsonNode(data); 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..5ffe37b102 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 @@ -26,14 +26,19 @@ 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." + @@ -57,7 +62,15 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { @Override public void init(TbContext ctx, TbNodeConfiguration configuration) throws TbNodeException { super.init(ctx); - TbRestApiCallNodeConfiguration config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); + + var config = TbNodeUtils.convert(configuration, TbRestApiCallNodeConfiguration.class); + String errorPrefix = "'" + ctx.getSelf().getName() + "' node configuration is invalid: "; + try { + validateFields(config, errorPrefix); + } catch (DataValidationException e) { + throw new TbNodeException(e, true); + } + httpClient = new TbHttpClient(config, ctx.getSharedEventLoop()); } @@ -96,6 +109,21 @@ public class TbRestApiCallNode extends TbAbstractExternalNode { hasChanges = true; ((ObjectNode) oldConfiguration).put(MAX_IN_MEMORY_BUFFER_SIZE_IN_KB, 256); } + case 3: + Set knownProperties = Set.of( + "restEndpointUrlPattern", "requestMethod", "headers", + "readTimeoutMs", "maxParallelRequestsCount", "parseToPlainText", + "enableProxy", "useSystemProxyProperties", "proxyHost", "proxyPort", + "proxyUser", "proxyPassword", "credentials", "ignoreRequestBody", + "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..9377d83949 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,31 @@ 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 +85,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..deffe7da7d 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,55 @@ */ 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.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 +96,461 @@ 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.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" + ) ); } 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 a93417275f..3d3abde30c 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,6 +25,7 @@ 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.params.provider.Arguments; @@ -43,25 +44,35 @@ 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.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.util.ArrayList; import java.util.Collections; +import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.stream.Stream; +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.Mockito.lenient; import static org.mockito.Mockito.timeout; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { + private RuleNode ruleNode; + @Spy private TbRestApiCallNode restNode; @@ -95,6 +106,14 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { } } + @BeforeEach + public void setup() { + 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) { @@ -102,6 +121,98 @@ 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); @@ -214,7 +325,6 @@ public class TbRestApiCallNodeTest extends AbstractRuleNodeUpgradeTest { 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," + @@ -224,13 +334,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 Arguments.of(1, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -241,13 +350,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 Arguments.of(2, "{\"restEndpointUrlPattern\":\"http://localhost/api\",\"requestMethod\": \"POST\"," + "\"useSimpleClientHttpFactory\": false,\"parseToPlainText\": false,\"ignoreRequestBody\": false," + @@ -257,12 +365,62 @@ 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}"), + 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" + }, + "maxInMemoryBufferSizeInKb": 256 + }""") ); } 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/transport/coap/pom.xml b/transport/coap/pom.xml index c012e863f9..b17cc4e888 100644 --- a/transport/coap/pom.xml +++ b/transport/coap/pom.xml @@ -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 376075a0a4..c618a921aa 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:}" @@ -172,6 +177,7 @@ transport: print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" # 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 +259,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). @@ -406,12 +413,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: @@ -427,11 +436,13 @@ usage: 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: @@ -440,6 +451,7 @@ management: 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 87f3ccec21..ae2af1e29b 100644 --- a/transport/http/pom.xml +++ b/transport/http/pom.xml @@ -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 1f6a251324..5173f5c26d 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 @@ -203,6 +209,7 @@ transport: print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" # 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) . @@ -355,12 +362,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: @@ -376,11 +385,13 @@ usage: 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: @@ -389,6 +400,7 @@ management: 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 d92d41388e..045c7181f2 100644 --- a/transport/lwm2m/pom.xml +++ b/transport/lwm2m/pom.xml @@ -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 b4d8d6c7eb..2711eae3ec 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. @@ -303,6 +309,7 @@ transport: print-interval-ms: "${TB_TRANSPORT_STATS_PRINT_INTERVAL_MS:60000}" # 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). @@ -456,12 +463,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: @@ -477,11 +486,13 @@ usage: 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: @@ -490,6 +501,7 @@ management: 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 f99409c470..4e68cbb49e 100644 --- a/transport/mqtt/pom.xml +++ b/transport/mqtt/pom.xml @@ -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 0b96c57c13..02c451fd69 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 @@ -236,6 +242,7 @@ transport: ip_block_timeout: "${TB_TRANSPORT_IP_BLOCK_TIMEOUT:60000}" # 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). @@ -389,12 +396,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: @@ -410,11 +419,13 @@ usage: 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: @@ -423,6 +434,7 @@ management: 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/snmp/pom.xml b/transport/snmp/pom.xml index 54a949f320..1575d25bdb 100644 --- a/transport/snmp/pom.xml +++ b/transport/snmp/pom.xml @@ -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 71524ba47e..028e9e2714 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). @@ -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: @@ -365,11 +374,13 @@ usage: 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: @@ -378,6 +389,7 @@ management: 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/package.json b/ui-ngx/package.json index 6867a6c952..4d001423cd 100644 --- a/ui-ngx/package.json +++ b/ui-ngx/package.json @@ -13,16 +13,16 @@ }, "private": true, "dependencies": { - "@angular/animations": "20.3.16", + "@angular/animations": "20.3.18", "@angular/cdk": "20.2.14", - "@angular/common": "20.3.16", - "@angular/compiler": "20.3.16", - "@angular/core": "20.3.16", - "@angular/forms": "20.3.16", + "@angular/common": "20.3.18", + "@angular/compiler": "20.3.18", + "@angular/core": "20.3.18", + "@angular/forms": "20.3.18", "@angular/material": "20.2.14", - "@angular/platform-browser": "20.3.16", - "@angular/platform-browser-dynamic": "20.3.16", - "@angular/router": "20.3.16", + "@angular/platform-browser": "20.3.18", + "@angular/platform-browser-dynamic": "20.3.18", + "@angular/router": "20.3.18", "@auth0/angular-jwt": "^5.2.0", "@flowjs/flow.js": "^2.14.1", "@flowjs/ngx-flow": "20.0.2", @@ -71,7 +71,7 @@ "ngx-clipboard": "^16.0.0", "ngx-daterangepicker-material": "^6.0.4", "ngx-drag-drop": "^20.0.1", - "ngx-flowchart": "https://github.com/thingsboard/ngx-flowchart.git#release/4.0.0", + "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", @@ -94,13 +94,13 @@ }, "devDependencies": { "@angular-builders/custom-esbuild": "20.0.0", - "@angular-devkit/build-angular": "20.3.15", - "@angular-devkit/core": "20.3.15", - "@angular-devkit/schematics": "20.3.15", - "@angular/build": "20.3.15", - "@angular/cli": "20.3.15", - "@angular/compiler-cli": "20.3.16", - "@angular/language-service": "20.3.16", + "@angular-devkit/build-angular": "20.3.22", + "@angular-devkit/core": "20.3.22", + "@angular-devkit/schematics": "20.3.22", + "@angular/build": "20.3.22", + "@angular/cli": "20.3.22", + "@angular/compiler-cli": "20.3.18", + "@angular/language-service": "20.3.18", "@types/ace-diff": "^2.1.4", "@types/canvas-gauges": "^2.1.8", "@types/flot": "^0.0.36", @@ -121,7 +121,7 @@ "angular-eslint": "~20.7.0", "autoprefixer": "^10.4.23", "directory-tree": "^3.5.2", - "eslint": "~9.39.2", + "eslint": "9.39.4", "eslint-plugin-import": "^2.32.0", "eslint-plugin-jsdoc": "^62.4.1", "eslint-plugin-prefer-arrow": "^1.2.3", @@ -139,6 +139,9 @@ "ace-builds": "1.43.6", "tinymce": "6.8.6", "@babel/core": "7.28.3", - "esbuild": "0.25.9" + "esbuild": "0.25.9", + "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+20.3.15.patch b/ui-ngx/patches/@angular+build+20.3.22.patch similarity index 99% rename from ui-ngx/patches/@angular+build+20.3.15.patch rename to ui-ngx/patches/@angular+build+20.3.22.patch index 1931d8d2b1..6c88eb6de2 100644 --- a/ui-ngx/patches/@angular+build+20.3.15.patch +++ b/ui-ngx/patches/@angular+build+20.3.22.patch @@ -12,7 +12,7 @@ index 53168ea..f07c80a 100755 // 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 ee68408..ac15cbf 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 @@ -90,7 +90,7 @@ function createCompilerPlugin(pluginOptions, compilationOrFactory, stylesheetBun diff --git a/ui-ngx/patches/@angular+core+20.3.16.patch b/ui-ngx/patches/@angular+core+20.3.18.patch similarity index 92% rename from ui-ngx/patches/@angular+core+20.3.16.patch rename to ui-ngx/patches/@angular+core+20.3.18.patch index c3f01041e5..12ceb3739d 100644 --- a/ui-ngx/patches/@angular+core+20.3.16.patch +++ b/ui-ngx/patches/@angular+core+20.3.18.patch @@ -1,8 +1,8 @@ diff --git a/node_modules/@angular/core/fesm2022/debug_node.mjs b/node_modules/@angular/core/fesm2022/debug_node.mjs -index cdeef4b..2c8101f 100755 +index 35c61af..d89462b 100755 --- a/node_modules/@angular/core/fesm2022/debug_node.mjs +++ b/node_modules/@angular/core/fesm2022/debug_node.mjs -@@ -9421,13 +9421,13 @@ function findDirectiveDefMatches(tView, tNode) { +@@ -9428,13 +9428,13 @@ function findDirectiveDefMatches(tView, tNode) { if (isNodeMatchingSelectorList(tNode, def.selectors, /* isProjectionMode */ false)) { matches ??= []; if (isComponentDef(def)) { 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 index 211a3c8b0b..b0087a67e9 100644 --- a/ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch +++ b/ui-ngx/patches/@iplab+ngx-color-picker+20.0.0.patch @@ -1,46 +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..a3d709a 100644 +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 -@@ -1129,11 +1129,11 @@ class RgbaComponent { - this.color.set(newColor); +@@ -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); } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: RgbaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", 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: "20.0.4", 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: "20.0.4", ngImport: i0, type: RgbaComponent, decorators: [{ - type: Component, -- args: [{ selector: `rgba-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, 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, 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 { -@@ -1155,11 +1155,11 @@ class HslaComponent { - this.color.set(newColor); - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HslaComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", 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: "20.0.4", 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: "20.0.4", ngImport: i0, type: HslaComponent, decorators: [{ - type: Component, -- args: [{ selector: `hsla-input-component`, changeDetection: ChangeDetectionStrategy.OnPush, 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, 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 { -@@ -1190,11 +1190,11 @@ class HexComponent { - } - } - static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.4", ngImport: i0, type: HexComponent, deps: [], target: i0.ɵɵFactoryTarget.Component }); } -- static { this.ɵcmp = i0.ɵɵngDeclareComponent({ minVersion: "17.0.0", version: "20.0.4", 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: "20.0.4", 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: "20.0.4", 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', [ + rgbaToHsva(color) { + const red = color.red / 255; diff --git a/ui-ngx/pom.xml b/ui-ngx/pom.xml index 11ce2dcf5f..03416b1c32 100644 --- a/ui-ngx/pom.xml +++ b/ui-ngx/pom.xml @@ -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 + + + +
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/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/resources.service.ts b/ui-ngx/src/app/core/services/resources.service.ts index 2929de8b6c..93b1b616b9 100644 --- a/ui-ngx/src/app/core/services/resources.service.ts +++ b/ui-ngx/src/app/core/services/resources.service.ts @@ -24,7 +24,9 @@ import { ɵCssSelectorList, ɵNG_COMP_DEF, ɵNG_MOD_DEF, + ɵNG_PIPE_DEF, ɵNgModuleDef, + ɵPipeDef, DOCUMENT } from '@angular/core'; @@ -302,6 +304,12 @@ export class ResourcesService { (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); 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 566def77eb..8f721b87dd 100644 --- a/ui-ngx/src/app/modules/common/modules-map.ts +++ b/ui-ngx/src/app/modules/common/modules-map.ts @@ -16,6 +16,7 @@ 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'; @@ -73,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'; @@ -351,6 +356,7 @@ class ModulesMap implements IModulesMap { private modulesMap: {[key: string]: any} = { '@angular/animations': AngularAnimations, '@angular/core': this.angularCoreModule20to18Patch(AngularCore), + '@angular/core/rxjs-interop': AngularCoreRxjsInterop, '@angular/common': AngularCommon, '@angular/common/http': HttpClientModule, '@angular/forms': AngularForms, @@ -407,8 +413,12 @@ class ModulesMap implements IModulesMap { '@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, 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/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/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-rules-table-config.ts b/ui-ngx/src/app/modules/home/components/alarm-rules/alarm-rules-table-config.ts index 039ee7d5fd..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); @@ -159,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)); } @@ -216,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 { @@ -260,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: { @@ -353,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]; @@ -375,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 a13aa2fac7..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'; @@ -59,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, @@ -77,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-schedule.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html index f1d3ae9277..8858198d34 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/cf-alarm-schedule.component.html @@ -41,10 +41,12 @@ @if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.SPECIFIC_TIME) {
- - {{ dayOfWeekTranslationsArray[day] | translate }} - + @for (day of allDays; track day; let i = $index) { + + {{ dayOfWeekTranslationsArray[day] | translate }} + + } @@ -71,30 +73,32 @@ } @if (alarmScheduleForm.get('staticValue.type').value === alarmScheduleType.CUSTOM) {
-
-
- - {{ dayOfWeekTranslationsArray[day] | translate }} - -
- - alarm-rule.schedule-time-from - - - - - - alarm-rule.schedule-time-to - - - - -
-
+ @for (day of allDays; track day) { +
+
+ + {{ dayOfWeekTranslationsArray[day] | translate }} + +
+ + alarm-rule.schedule-time-from + + + + + + alarm-rule.schedule-time-to + + + + +
+
+
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html index 18ac406c97..bcf044e998 100644 --- a/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm-rules/create-cf-alarm-rules.component.html @@ -26,30 +26,34 @@ - - {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | translate }} - + @for (alarmSeverity of alarmSeverities; track alarmSeverity) { + + {{ alarmSeverityTranslationMap.get(alarmSeverityEnum[alarmSeverity]) | 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/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-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.component.html b/ui-ngx/src/app/modules/home/components/alarm-rules/filter/alarm-rule-filter-predicate.component.html index 273535d394..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,9 +53,11 @@
- - {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} - + @for (operation of booleanOperations; track operation) { + + {{booleanOperationTranslations.get(booleanOperationEnum[operation]) | translate}} + + }
diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html index c0f983e6d0..7adea3ffc1 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-panel.component.html @@ -26,32 +26,42 @@ #userAutocomplete="matAutocomplete" [displayWith]="displayUserFn" (optionSelected)="selected($event)"> - - account_circle - {{ assigneeNotSetText | translate }} - - - account_circle - {{ assignedToCurrentUserText | translate }} - - - - -
- - -
-
- -
- - {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} + @if (displayAssigneeNotSet) { + + account_circle + {{ assigneeNotSetText | translate }} + + } + @if (displayAssignedToCurrentUser) { + + account_circle + {{ assignedToCurrentUserText | translate }} + + } + @for (user of filteredUsers | async; track user) { + + -
-
+
+ @if (user.firstName || user.lastName) { + + } + +
+ + } + @if (!(filteredUsers | async)?.length) { + +
+ + {{ translate.get('user.no-users-matching', {entity: searchText}) | async }} + +
+
+ } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-select.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-select.component.html index 8367d0eb4a..6148e955ad 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-select.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee-select.component.html @@ -19,13 +19,21 @@ - alarm.assignee + @if (!inline) { + alarm.assignee + } - - {{ getUserInitials() }} - - account_circle - arrow_drop_down + @if (assignee) { + + {{ getUserInitials() }} + + } + @if (!assignee) { + account_circle + } + @if (!disabled) { + arrow_drop_down + } diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.html index 783a763031..a566e9afb7 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-assignee.component.html @@ -20,12 +20,13 @@ subscriptSizing="dynamic"> alarm.assignee - - {{ getUserInitials(alarm.assignee) }} - - + @if (userAssigned) { + + {{ getUserInitials(alarm.assignee) }} + + } @else { {{ alarm?.assigneeId ? 'no_accounts' : 'account_circle' }} - + } arrow_drop_down diff --git a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html index 651a1dd186..a5935e1f2b 100644 --- a/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html +++ b/ui-ngx/src/app/modules/home/components/alarm/alarm-comment-dialog.component.html @@ -22,9 +22,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
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-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-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/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-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.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/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-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/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/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/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 { +
+ }
close - - + @if (isLoading$ | async) { + + + }
@@ -46,9 +48,11 @@ 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/attribute-table.component.html b/ui-ngx/src/app/modules/home/components/attribute/attribute-table.component.html index c9247f5549..6f7946a6f6 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 b26eee40cf..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 @@ -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,10 +90,10 @@ 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', @@ -144,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); @@ -186,6 +192,8 @@ export class AttributeTableComponent extends PageComponent implements AfterViewI textSearch = this.fb.control('', {nonNullable: true}); + isSysAdmin = false; + private destroy$ = new Subject(); selectAllModel: boolean = false; @@ -207,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); 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..e2aca6e139 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 @@ -28,28 +28,34 @@ 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/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/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/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/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/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/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-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/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/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 230d930b05..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 @@ -30,22 +30,26 @@ layout.breakpoint {{ getName(addBreakpointFormGroup.get('newBreakpointId').value) }} - - {{ getIcon(breakpoint) }} -
{{ getName(breakpoint) }}
-
{{ getSizeDescription(breakpoint) }}
-
+ @for (breakpoint of allowBreakpointIds; track breakpoint) { + + {{ getIcon(breakpoint) }} +
{{ getName(breakpoint) }}
+
{{ getSizeDescription(breakpoint) }}
+
+ }
layout.copy-from {{ getName(addBreakpointFormGroup.get('copyFrom').value) }} - - {{ getIcon(breakpoint) }} -
{{ getName(breakpoint) }}
-
{{ getSizeDescription(breakpoint) }}
-
+ @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/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/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/states/dashboard-state-dialog.component.html b/ui-ngx/src/app/modules/home/components/dashboard-page/states/dashboard-state-dialog.component.html index c06d184162..ad0aeb097c 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/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/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/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/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/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-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/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/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.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-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-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/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/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/entities-table.component.html b/ui-ngx/src/app/modules/home/components/entity/entities-table.component.html index 5b290980c6..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,37 +16,43 @@ --> - - - - + @if (entitiesTableConfig.detailsPanelEnabled) { + + + + + }
- {{ entitiesTableConfig.tableTitle }} + @if (entitiesTableConfig.tableTitle) { + {{ entitiesTableConfig.tableTitle }} + } - + @if (entitiesTableConfig.useTimePageLink) { + + }
- + @if (!entitiesTableConfig.addActionDescriptors.length) { - - - - - - + } @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) { + + }
@@ -182,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) : '' }}
@@ -298,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) { + + }
-
+ }
@@ -341,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 6999c76180..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, @@ -89,8 +89,7 @@ export class EntitiesTableComponent extends PageComponent implements IEntitiesTa cellActionDescriptors: Array>>; actionColumns: Array>>; - entityColumns: Array>>; - + entityColumns: EntityColumnsType; displayedColumns: string[]; headerCellStyleCache: Array = []; @@ -594,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>); @@ -624,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}; @@ -650,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; @@ -665,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; @@ -682,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) { @@ -702,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-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-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.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/event/event-filter-panel.component.html b/ui-ngx/src/app/modules/home/components/event/event-filter-panel.component.html index df073b8702..f59cac1d7f 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}} - - - + } + } + }
- + @if (!data.readonly) { + + }
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..295b0c1d05 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 @@ -25,8 +25,10 @@ close - - + @if (isLoading$ | async) { + + + }
@@ -34,12 +36,16 @@ filter.name - - {{ 'filter.name-required' | translate }} - - - {{ 'filter.duplicate-filter' | translate }} - + @if (filterFormGroup.get('filter').hasError('required')) { + + {{ 'filter.name-required' | translate }} + + } + @if (filterFormGroup.get('filter').hasError('duplicateFilterName')) { + + {{ 'filter.duplicate-filter' | translate }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html index ff4f5de857..e4261076d5 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filter-predicate-list.component.html @@ -28,49 +28,57 @@
- + @if (valueType === valueTypeEnum.STRING) { + + }
- + @if (displayUserParameters) { + + }  
-
-
- {{ complexOperationTranslations.get(operation) | translate }} -
-
-
- - - + @for (predicateControl of predicatesFormArray.controls; track predicateControl) { +
+
+ @if ($index > 0) { + {{ complexOperationTranslations.get(operation) | translate }} + } +
+
+
+ + + +
-
+ } filter.no-filters + class="no-data-found flex items-center justify-center" translate>filter.no-filters
- + @if (!onlyUserDynamicSource) { + + }
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..037510fc9e 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 @@ -16,46 +16,50 @@ -->
-
- - - - - - - - - - - - - - - - +
+ @switch (type) { + @case (filterPredicateType.STRING) { + + + } + @case (filterPredicateType.NUMERIC) { + + + } + @case (filterPredicateType.BOOLEAN) { + + + } + @case (filterPredicateType.COMPLEX) { + + + } + }
- - + @if (type !== filterPredicateType.COMPLEX && displayUserParameters) { + + + }
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/filters-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-dialog.component.html index 5251d87c4d..59c4c47eea 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,62 +25,68 @@ close - - -
+ @if (isLoading$ | async) { + + + } + @if ((isLoading$ | async) === false) { +
+ }
filter.filter
filter.editable
+ style="padding-left: 5px;">filter.editable
-
- {{$index + 1}}. -
- {{filterControl.get('filter').value}} -
- - -
- - - + @for (filterControl of filtersFormArray().controls; track filterControl) { +
+ {{$index + 1}}. +
+ {{filterControl.get('filter').value}} +
+ + +
+ + + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.html b/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.html index 6481c90d9c..3d4cdc1b77 100644 --- a/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.html +++ b/ui-ngx/src/app/modules/home/components/filter/filters-edit-panel.component.html @@ -16,18 +16,22 @@ -->
-
-
- {{filter.value.filter}} - + @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.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/key-filter-dialog.component.html b/ui-ngx/src/app/modules/home/components/filter/key-filter-dialog.component.html index d8e4264a8e..74d2e79290 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 @@ -33,13 +33,15 @@ 'max-w-45%': isConstantKeyType, 'max-w-70%': !isConstantKeyType }" - formGroupName="key"> + formGroupName="key"> filter.key-type.key-type - - {{entityKeyTypeTranslations.get(type) | translate}} - + @for (type of entityKeyTypes; track type) { + + {{entityKeyTypeTranslations.get(type) | translate}} + + } @@ -49,20 +51,26 @@ (focusin)="onFocus()" [matAutocomplete]="keyName" [matAutocompleteDisabled]="!showAutocomplete"> - + @if (keyFilterFormGroup.get('key.key').value && showAutocomplete) { + + } - - - + @for (keyName of filteredKeysName | async; track keyName) { + + + + } - - {{ 'filter.key-name-required' | translate }} - + @if (keyFilterFormGroup.get('key.key').hasError('required')) { + + {{ 'filter.key-name-required' | translate }} + + } {{ entityKeyValueTypes.get(keyFilterFormGroup.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 (keyFilterFormGroup.get('valueType').hasError('required')) { + + {{ 'filter.value-type-required' | translate }} + + } -
-
- - - filter.value - - - - - - filter.value - - - - - - - - - {{ (keyFilterFormGroup.get('value').value ? 'value.true' : 'value.false') | 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) { + + + }
@@ -130,11 +148,12 @@ cdkFocusInitial> {{ (data.readonly ? 'action.close' : 'action.cancel') | translate }} - + @if (!data.readonly) { + + }
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..7f29298ba3 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 @@ -34,38 +34,42 @@
-
-
- filter.operation.and -
-
-
-
{{ keyFilterControl.value.key.key }}
-
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
- - + @for (keyFilterControl of keyFiltersFormArray.controls; track keyFilterControl) { +
+
+ @if ($index > 0) { + filter.operation.and + } +
+
+
+
{{ keyFilterControl.value.key.key }}
+
{{ entityKeyTypeTranslations.get(keyFilterControl.value.key.type) | translate }}
+ + +
+
-
-
+ } filter.no-key-filters + class="no-data-found flex items-center justify-center" translate>filter.no-key-filters
- - + @if (isLoading$ | async) { + + + }
-
-
- - - {{ userInputControl.get('label').value | customTranslate }} - - - - - - {{ userInputControl.get('label').value | customTranslate }} - -
- {{ userInputControl.get('unitSymbol').value }} -
-
-
- - - - - - - {{ userInputControl.get('label').value | customTranslate }} - - + @for (userInputControl of userInputsFormArray().controls; track userInputControl) { +
+
+ @switch (userInputControl.get('valueType').value) { + @case (valueTypeEnum.STRING) { + + {{ userInputControl.get('label').value | customTranslate }} + + + } + @case (valueTypeEnum.NUMERIC) { + + {{ userInputControl.get('label').value | customTranslate }} + +
+ {{ userInputControl.get('unitSymbol').value }} +
+
+ } + @case (valueTypeEnum.DATE_TIME) { + + + } + @case (valueTypeEnum.BOOLEAN) { + + {{ userInputControl.get('label').value | customTranslate }} + + } + } +
-
+ }
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/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 f9e4969788..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 @@ -46,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())) ); @@ -91,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 e5b3d57368..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/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-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.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-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.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 90caf04b70..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 @@ -63,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/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-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/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/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 79b4c6ed4d..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,7 +44,7 @@ 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'; @@ -98,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 { +
+ }
-
- - - - -
- + + + +
+ +
-
+}
-
- +
+ 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/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-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 { +
+ }
- + device-profile.provision-strategy - - {{deviceProvisionTypeTranslateMap.get(type) | translate}} - + @for (type of deviceProvisionTypes; track type) { + + {{ getDeviceProvisionType(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 1da59bbc5c..4065dc602d 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 @@ -213,4 +213,8 @@ export class DeviceProfileProvisionConfigurationComponent implements ControlValu this.provisionConfigurationFormGroup.get('provisionDeviceKey').reset({value: null, disabled: true}, {emitEvent: false}); } } + + getDeviceProvisionType(type: string): string { + return this.deviceProvisionTypeTranslateMap.get(type as DeviceProvisionType); + } } 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/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/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/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 709d4ececa..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; @@ -105,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/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-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/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-key-list.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-attributes-key-list.component.html index 7d82ef2581..e2ac4da12b 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,64 @@
-
-
- - - - {{ 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-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-config-servers.component.html b/ui-ngx/src/app/modules/home/components/profile/device/lwm2m/lwm2m-bootstrap-config-servers.component.html index d738e0447a..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 2f0fd69102..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'; @@ -104,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-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-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 { +
+ }
{{ '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-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 3aafd2d56f..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,6 +35,7 @@ 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', @@ -74,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 f432533614..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-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.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/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 bc997d6960..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 @@ -252,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 544fcfdac6..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'; @@ -79,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..acd00e3707 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,71 @@
-
-
- - - - {{ 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-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/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/tenant-profile-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant-profile-autocomplete.component.html index 791b8c1096..7b639f134c 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 @@ -26,53 +26,68 @@ (keypress)="tenantProfileEnter($event)" [matAutocomplete]="tenantProfileAutocomplete" [class.!hidden]="showDetailsPageLink && disabled && selectTenantProfileFormGroup.get('tenantProfile').value"> - - {{ 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-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 { +
+ }
-
- - - -
- + + +
+ +
-
+}
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} }} + + + + + + + } 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..7100fce348 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 @@ -26,12 +26,16 @@ - - {{ '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}} + + } @@ -39,12 +43,16 @@ - - {{ '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}} + + }
@@ -54,12 +62,16 @@ - - {{ '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}} + + } @@ -67,12 +79,16 @@ - - {{ '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}} + + }
@@ -89,12 +105,16 @@ - - {{ '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}} + + } @@ -102,12 +122,16 @@ - - {{ '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}} + + }
@@ -117,12 +141,16 @@ - - {{ '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 }} + + }
@@ -141,12 +169,16 @@ - - {{ '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}} + + } @@ -154,12 +186,16 @@ - - {{ '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}} + + } @@ -176,12 +212,16 @@ - - {{ '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}} + + } @@ -189,12 +229,16 @@ - - {{ '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}} + + } @@ -204,12 +248,16 @@ - - {{ '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}} + + } @@ -217,12 +265,16 @@ - - {{ '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}} + + } @@ -239,12 +291,16 @@ - - {{ '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}} + + } @@ -252,12 +308,16 @@ - - {{ '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}} + + } @@ -267,12 +327,16 @@ - - {{ '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}} + + }
@@ -290,12 +354,16 @@ - - {{ '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}} + + } @@ -303,12 +371,16 @@ - - {{ '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}} + + } @@ -318,12 +390,16 @@ - - {{ '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}} + + } @@ -331,12 +407,16 @@ - - {{ '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}} + + } @@ -346,12 +426,16 @@ - - {{ '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}} + + } @@ -359,12 +443,16 @@ - - {{ '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}} + + } @@ -374,12 +462,16 @@ - - {{ '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}} + + } @@ -387,12 +479,16 @@ - - {{ '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}} + + } @@ -402,12 +498,16 @@ - - {{ '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
@@ -426,12 +526,16 @@ - - {{ '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}} + + } @@ -439,12 +543,16 @@ - - {{ '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}} + + } @@ -454,12 +562,16 @@ - - {{ '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}} + + } @@ -467,12 +579,16 @@ - - {{ '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}} + + } @@ -482,12 +598,16 @@ - - {{ '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}} + + } @@ -495,12 +615,16 @@ - - {{ '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,31 +637,41 @@ {{ '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}} + + } @@ -545,12 +679,16 @@ - - {{ '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}} + + }
@@ -560,12 +698,16 @@ - - {{ '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}} + + }
@@ -582,9 +724,11 @@ - - {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} - + @if (tenantProfileConfigurationForm.get('maxDebugModeDurationMinutes').hasError('min')) { + + {{ 'tenant-profile.maximum-debug-duration-min-range' | translate }} + + }
@@ -601,12 +745,16 @@ - - {{ '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}} + + } @@ -614,12 +762,16 @@ - - {{ '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}} + + } @@ -629,12 +781,16 @@ - - {{ '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}} + + }
@@ -649,32 +805,40 @@ 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}} + + }
@@ -688,41 +852,51 @@ 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/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-list.component.html b/ui-ngx/src/app/modules/home/components/profile/tenant/rate-limits/rate-limits-list.component.html index 617a8beaee..d24cc15a4b 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.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/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/queue/queue-form.component.html b/ui-ngx/src/app/modules/home/components/queue/queue-form.component.html index 1dd2a79e27..de7b1de846 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 @@ -19,15 +19,21 @@ 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 }} - + } +
@@ -95,55 +111,73 @@ 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 }} + + }
@@ -163,24 +197,32 @@ 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 }} + + } @@ -193,13 +235,17 @@ 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 }} + + } 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..896adb0c46 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,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{(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-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-table.component.html b/ui-ngx/src/app/modules/home/components/relation/relation-table.component.html index 5335f36ff4..3d6e96f3b6 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 @@ -27,9 +27,11 @@ 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/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 { +
+ }
-
- - - -
- + + +
+ +
-
+}
- + 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/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/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 b0d83cac46..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,6 +31,7 @@ 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', @@ -58,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.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/advanced-processing-setting.component.html index 9f51ca5189..5b403ff611 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,36 @@ + > - - - - - + @if (timeseries) { + + } + @if (attributes) { + + } + @if (latest) { + + } + @if (webSockets) { + + } + @if (calculatedFields) { + + }
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..2ac22c6284 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 @@ -20,10 +20,12 @@ 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/attributes-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/attributes-config.component.html index ef4c5ee8c2..50444e3a87 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 @@ -67,9 +67,11 @@ {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } @@ -98,25 +100,27 @@
+ class="tb-form-row no-border no-padding"> {{ 'rule-node-config.update-attributes-only-on-value-change' | 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 ab43627104..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 @@ -41,7 +41,7 @@ import { 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..4248d39f6e 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,41 +16,47 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (clearAlarmConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
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..eba427c385 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 cd2122a8dd..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 @@ -42,7 +42,7 @@ 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..817ff84b53 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 @@ -22,9 +22,11 @@ relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | translate }} + + } - - {{ 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 5d3e85e565..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 @@ -28,7 +28,7 @@ import { EntityType } from '@app/shared/models/entity-type.models'; }) 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..cb0b89d1ed 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 @@ -24,9 +24,11 @@ {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } @@ -50,19 +52,22 @@ {{ '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 565b5e9dea..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 @@ -33,7 +33,7 @@ export class DeleteAttributesConfigComponent extends RuleNodeConfigurationCompon 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..7899e4773d 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 @@ -22,9 +22,11 @@ relation.direction - - {{ directionTypeTranslations.get(type) | translate }} - + @for (type of directionTypes; track type) { + + {{ directionTypeTranslations.get(type) | 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 606035bd08..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 @@ -28,7 +28,7 @@ import { RuleNodeConfiguration, RuleNodeConfigurationComponent } from '@app/shar }) 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-state-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/device-state-config.component.html index c2e9a00a6e..cb3d6d2b7e 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/generator-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/generator-config.component.html index 4463973874..cea850d0be 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 @@ -22,22 +22,30 @@ 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 }} + + } @@ -57,56 +65,62 @@ 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 }} + + + + + }
rule-node-config.coordinate-field-hint
@@ -44,9 +48,11 @@ {{ 'rule-node-config.perimeter-type' | translate }} - - {{ perimeterTypeTranslationMap.get(type) | translate }} - + @for (type of perimeterTypes; track type) { + + {{ perimeterTypeTranslationMap.get(type) | 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 (geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { + + {{ 'rule-node-config.perimeter-key-name' | translate }} + + @if (geoActionConfigForm.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 (geoActionConfigForm.get('perimeterType').value === perimeterType.CIRCLE && + !geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { +
+
+ + {{ 'rule-node-config.circle-center-latitude' | translate }} + + @if (geoActionConfigForm.get('centerLatitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-latitude-required' | translate }} + + } + + + {{ 'rule-node-config.circle-center-longitude' | translate }} + + @if (geoActionConfigForm.get('centerLongitude').hasError('required')) { + + {{ 'rule-node-config.circle-center-longitude-required' | translate }} + + } + +
+
+ + {{ 'rule-node-config.range' | translate }} + + @if (geoActionConfigForm.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 (geoActionConfigForm.get('rangeUnit').hasError('required')) { + + {{ 'rule-node-config.range-units-required' | translate }} + + } + +
-
- - {{ 'rule-node-config.range' | translate }} - - - {{ 'rule-node-config.range-required' | translate }} - - - - {{ 'rule-node-config.range-units' | translate }} - - - {{ rangeUnitTranslationMap.get(type) | translate }} - - - - {{ 'rule-node-config.range-units-required' | translate }} - + } + @if (geoActionConfigForm.get('perimeterType').value === perimeterType.POLYGON && + !geoActionConfigForm.get('fetchPerimeterInfoFromMessageMetadata').value) { +
+ + rule-node-config.polygon-definition + + + help + + + @if (geoActionConfigForm.get('polygonsDefinition').hasError('required')) { + + {{ 'rule-node-config.polygon-definition-required' | translate }} + + }
-
-
- - rule-node-config.polygon-definition - - - help - - - - {{ 'rule-node-config.polygon-definition-required' | translate }} - - -
+ }
{{ 'rule-node-config.presence-monitoring-strategy' | translate }}
- - {{ presenceMonitoringStrategies.get(strategy).name | translate }} - + @for (strategy of presenceMonitoringStrategyKeys; track strategy) { + + {{ presenceMonitoringStrategies.get(strategy).name | translate }} + + }
{{ geoActionConfigForm.get('reportPresenceStatusOnEachMessage').value === false ? - ('rule-node-config.presence-monitoring-strategy-on-first-message-hint' | translate) : - ('rule-node-config.presence-monitoring-strategy-on-each-message-hint' | translate) }} + ('rule-node-config.presence-monitoring-strategy-on-first-message-hint' | translate) : + ('rule-node-config.presence-monitoring-strategy-on-each-message-hint' | translate) }}
-
-
- - rule-node-config.min-inside-duration - - - {{ 'rule-node-config.min-inside-duration-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - rule-node-config.min-inside-duration-time-unit - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - -
-
- - rule-node-config.min-outside-duration - - - {{ 'rule-node-config.min-outside-duration-value-required' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - {{ 'rule-node-config.time-value-range' | translate }} - - - - rule-node-config.min-outside-duration-time-unit - - - {{ timeUnitsTranslationMap.get(timeUnit) | translate }} - - - + @if (geoActionConfigForm.get('reportPresenceStatusOnEachMessage').value === false) { +
+
+ + rule-node-config.min-inside-duration + + @if (geoActionConfigForm.get('minInsideDuration').hasError('required')) { + + {{ 'rule-node-config.min-inside-duration-value-required' | translate }} + + } + @if (geoActionConfigForm.get('minInsideDuration').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (geoActionConfigForm.get('minInsideDuration').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + rule-node-config.min-inside-duration-time-unit + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
+
+ + rule-node-config.min-outside-duration + + @if (geoActionConfigForm.get('minOutsideDuration').hasError('required')) { + + {{ 'rule-node-config.min-outside-duration-value-required' | translate }} + + } + @if (geoActionConfigForm.get('minOutsideDuration').hasError('min')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + @if (geoActionConfigForm.get('minOutsideDuration').hasError('max')) { + + {{ 'rule-node-config.time-value-range' | translate }} + + } + + + rule-node-config.min-outside-duration-time-unit + + @for (timeUnit of timeUnits; track timeUnit) { + + {{ timeUnitsTranslationMap.get(timeUnit) | translate }} + + } + + +
-
+ }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.ts index b74efbe677..fe6bb80a97 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/gps-geo-action-config.component.ts @@ -38,16 +38,16 @@ export class GpsGeoActionConfigComponent extends RuleNodeConfigurationComponent geoActionConfigForm: UntypedFormGroup; perimeterType = PerimeterType; - perimeterTypes = Object.keys(PerimeterType); + perimeterTypes = Object.values(PerimeterType); perimeterTypeTranslationMap = perimeterTypeTranslations; - rangeUnits = Object.keys(RangeUnit); + rangeUnits = Object.values(RangeUnit); rangeUnitTranslationMap = rangeUnitTranslations; presenceMonitoringStrategies = PresenceMonitoringStrategiesData; presenceMonitoringStrategyKeys = Array.from(this.presenceMonitoringStrategies.keys()); - timeUnits = Object.keys(TimeUnit); + timeUnits = Object.values(TimeUnit); timeUnitsTranslationMap = timeUnitTranslations; public defaultPaddingEnable = true; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/log-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/log-config.component.html index 0d2aad6135..3998c9b42d 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/log-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/log-config.component.html @@ -16,41 +16,47 @@ -->
- - - - - - - + @if (tbelEnabled) { + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.JS) { + + + + } + @if (logConfigForm.get('scriptLang').value === scriptLanguage.TBEL) { + + + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html index 980a370cb3..31010602c3 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-count-config.component.html @@ -19,18 +19,24 @@ rule-node-config.interval-seconds - - {{ 'rule-node-config.interval-seconds-required' | translate }} - - - {{ 'rule-node-config.min-interval-seconds-message' | translate }} - + @if (msgCountConfigForm.get('interval').hasError('required')) { + + {{ 'rule-node-config.interval-seconds-required' | translate }} + + } + @if (msgCountConfigForm.get('interval').hasError('min')) { + + {{ 'rule-node-config.min-interval-seconds-message' | translate }} + + } rule-node-config.output-timeseries-key-prefix - - {{ 'rule-node-config.output-timeseries-key-prefix-required' | translate }} - + @if (msgCountConfigForm.get('telemetryPrefix').hasError('required')) { + + {{ 'rule-node-config.output-timeseries-key-prefix-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html index 9610a0e82a..a7dc7b7b1b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/msg-delay-config.component.html @@ -20,38 +20,51 @@ {{ 'rule-node-config.use-metadata-period-in-seconds-patterns' | translate }}
rule-node-config.use-metadata-period-in-seconds-patterns-hint
- - rule-node-config.period-seconds - - - {{ 'rule-node-config.period-seconds-required' | translate }} - - - {{ 'rule-node-config.min-period-0-seconds-message' | translate }} - - - + @if (msgDelayConfigForm.get('useMetadataPeriodInSecondsPatterns').value !== true) { + + rule-node-config.period-seconds + + @if (msgDelayConfigForm.get('periodInSeconds').hasError('required')) { + + {{ 'rule-node-config.period-seconds-required' | translate }} + + } + @if (msgDelayConfigForm.get('periodInSeconds').hasError('min')) { + + {{ 'rule-node-config.min-period-0-seconds-message' | translate }} + + } + + } @else { rule-node-config.period-in-seconds-pattern - - {{ 'rule-node-config.period-in-seconds-pattern-required' | translate }} - + @if (msgDelayConfigForm.get('periodInSecondsPattern').hasError('required')) { + + {{ 'rule-node-config.period-in-seconds-pattern-required' | translate }} + + } rule-node-config.general-pattern-hint - + } rule-node-config.max-pending-messages - - {{ 'rule-node-config.max-pending-messages-required' | translate }} - - - {{ 'rule-node-config.max-pending-messages-range' | translate }} - - - {{ 'rule-node-config.max-pending-messages-range' | translate }} - + @if (msgDelayConfigForm.get('maxPendingMsgs').hasError('required')) { + + {{ 'rule-node-config.max-pending-messages-required' | translate }} + + } + @if (msgDelayConfigForm.get('maxPendingMsgs').hasError('min')) { + + {{ 'rule-node-config.max-pending-messages-range' | translate }} + + } + @if (msgDelayConfigForm.get('maxPendingMsgs').hasError('max')) { + + {{ 'rule-node-config.max-pending-messages-range' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html index a52e015a8b..5a39dae562 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.html @@ -24,9 +24,11 @@ {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.ts index a7a5542b98..fccc665210 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-cloud-config.component.ts @@ -27,7 +27,7 @@ import { AttributeScope, telemetryTypeTranslations } from '@shared/models/teleme }) export class PushToCloudConfigComponent extends RuleNodeConfigurationComponent { - attributeScopes = Object.keys(AttributeScope); + attributeScopes = Object.values(AttributeScope); telemetryTypeTranslationsMap = telemetryTypeTranslations; pushToCloudConfigForm: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.html index 128dbf5ef3..4f7f19bbbf 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.html @@ -24,9 +24,11 @@ {{ 'rule-node-config.attributes-scope' | translate }} - - {{ telemetryTypeTranslationsMap.get(scope) | translate }} - + @for (scope of attributeScopes; track scope) { + + {{ telemetryTypeTranslationsMap.get(scope) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.ts b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.ts index 5192161cc8..872e7a1f46 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.ts +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/push-to-edge-config.component.ts @@ -27,7 +27,7 @@ import { AttributeScope, telemetryTypeTranslations } from '@shared/models/teleme }) export class PushToEdgeConfigComponent extends RuleNodeConfigurationComponent { - attributeScopes = Object.keys(AttributeScope); + attributeScopes = Object.values(AttributeScope); telemetryTypeTranslationsMap = telemetryTypeTranslations; pushToEdgeConfigForm: UntypedFormGroup; diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/rpc-request-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/rpc-request-config.component.html index 7394f04eff..053668a773 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/rpc-request-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/rpc-request-config.component.html @@ -19,11 +19,15 @@ rule-node-config.timeout-sec - - {{ 'rule-node-config.timeout-required' | translate }} - - - {{ 'rule-node-config.min-timeout-message' | translate }} - + @if (rpcRequestConfigForm.get('timeoutInSeconds').hasError('required')) { + + {{ 'rule-node-config.timeout-required' | translate }} + + } + @if (rpcRequestConfigForm.get('timeoutInSeconds').hasError('min')) { + + {{ 'rule-node-config.min-timeout-message' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html index 86fe2c0c84..5302d2233b 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/save-to-custom-table-config.component.html @@ -27,10 +27,12 @@ matTooltip="{{ 'rule-node-config.custom-table-hint' | translate }}"> help - - {{ 'rule-node-config.custom-table-name-required' | translate }} - + @if (saveToCustomTableConfigForm.get('tableName').hasError('required') || + saveToCustomTableConfigForm.get('tableName').hasError('pattern')) { + + {{ 'rule-node-config.custom-table-name-required' | translate }} + + } rule-node-config.default-ttl rule-node-config.default-ttl-zero-hint - - {{ 'rule-node-config.min-default-ttl-message' | translate }} - - - {{ 'rule-node-config.default-ttl-required' | translate }} - + @if (saveToCustomTableConfigForm.get('defaultTtl').hasError('min')) { + + {{ 'rule-node-config.min-default-ttl-message' | translate }} + + } + @if (saveToCustomTableConfigForm.get('defaultTtl').hasError('required')) { + + {{ 'rule-node-config.default-ttl-required' | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html index e20dc783cc..91a0ff8dc9 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/action/unassign-customer-config.component.html @@ -25,15 +25,19 @@ {{ 'rule-node-config.unassign-from-customer' | translate }}
- - 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/common/arguments-map-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/arguments-map-config.component.html index 9250c5b544..ee56981b6a 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,131 @@ [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 +
+ }
-
- - {{ 'alias.last-level-relation' | translate }} - -
+ @if (deviceRelationsQueryFormGroup.get('maxLevel').value > 1) { +
+ + {{ 'alias.last-level-relation' | translate }} + +
+ } @@ -61,6 +68,6 @@ [filledInputPlaceholder]="'rule-node-config.add-device-profile' | translate" formControlName="deviceTypes"> help + matTooltip="{{ 'rule-node-config.chip-help' | translate: { inputName: 'rule-node-config.device-profile' | translate } }}">help diff --git a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.html index a54fa0d8b9..c332677027 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/common/example-hint.component.html @@ -18,12 +18,14 @@
-
-
+ class="w-full">
+ @if (popupHelpLink) { +
+
+ }
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..3e75dc69ec 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,28 @@
-
- - - - - - -
- + @for (keyValControl of keyValsFormArray().controls; track keyValControl) { +
+ + + + + + +
+ +
-
+ }
@@ -68,5 +76,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/math-function-autocomplete.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/math-function-autocomplete.component.html index c342149422..8dfbbaf1d6 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 @@ -23,21 +23,27 @@ #operationInput matInput formControlName="operation" [matAutocomplete]="auto"> - + @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/message-types-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/message-types-config.component.html index 7ad685d8e2..dbd1ab1e29 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 @@ -16,15 +16,18 @@ --> - {{ 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/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/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..138d45700b 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 @@ -19,32 +19,39 @@ {{'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/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 83ed4a435f..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 @@ -49,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..5f8754c723 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 @@ -22,9 +22,11 @@ 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 + + } @@ -36,21 +38,26 @@ inputmode="numeric" placeholder="{{ 'rule-node-config.unlimited-level' | translate }}" formControlName="maxLevel"> - - {{ '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/select-attributes.component.html b/ui-ngx/src/app/modules/home/components/rule-node/common/select-attributes.component.html index 9665fff5ce..3d93ab0b11 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,7 +17,7 @@ -->
+ [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/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/enrichment/calculate-delta-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/calculate-delta-config.component.html index e39b97a19d..dcc5fee728 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 @@ -20,29 +20,37 @@ {{ '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/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/get-telemetry-from-database-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.html index d28aa22624..89e8a0090e 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/enrichment/get-telemetry-from-database-config.component.html @@ -26,7 +26,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 +39,180 @@ {{ '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/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/external/ai-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/ai-config.component.html index 73605e0767..7167b68132 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,25 +50,35 @@ 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 }} + + } @@ -53,71 +59,78 @@ 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/kafka-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/kafka-config.component.html index 965a0ca8b8..ffbc99fe57 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 @@ -19,9 +19,11 @@ 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 @@ -33,44 +35,56 @@ 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 }} + + } @@ -86,12 +100,16 @@ {{ '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/lambda-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/lambda-config.component.html index 1839464bbe..fba6b87d72 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 @@ -27,9 +27,11 @@ {{'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}} @@ -48,23 +50,29 @@ 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 }} + + } @@ -79,28 +87,36 @@ 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 }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.html index 1e485288e0..68245324ba 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/pubsub-config.component.html @@ -19,16 +19,20 @@ 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 }} + + } 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 }} + + }
@@ -73,16 +83,20 @@ 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 }} + + } -
- - 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 }} + +
+ + 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..a5ed420c88 --- /dev/null +++ b/ui-ngx/src/app/modules/home/components/rule-node/external/rest-api-call-config.component.scss @@ -0,0 +1,29 @@ +/** + * 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; + } + } + } + } + } +} 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 4723a0c510..c9694ccd94 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,22 +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: [], + 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(); } @@ -46,12 +49,10 @@ 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, []], 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, []], @@ -60,16 +61,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; @@ -80,12 +92,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..295de6912a 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 }} - - - - 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 }} - + @if (sendEmailConfigForm.get('useSystemSmtpSettings').value === false) { +
+ + rule-node-config.smtp-protocol + + @for (smtpProtocol of smtpProtocols; track smtpProtocol) { + + {{ smtpProtocol.toUpperCase() }} + + } + -
- - 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.proxy-port - - - {{ 'rule-node-config.proxy-port-required' | translate }} - - - {{ 'rule-node-config.proxy-port-range' | 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-user - + rule-node-config.timeout-msec + + @if (sendEmailConfigForm.get('timeout').hasError('required')) { + + {{ 'rule-node-config.timeout-required' | translate }} + + } + @if (sendEmailConfigForm.get('timeout').hasError('min')) { + + {{ 'rule-node-config.min-timeout-msec-message' | translate }} + + } - - rule-node-config.proxy-password - + + {{ '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.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.password + + -
- - rule-node-config.username - - - - rule-node-config.password - - - -
+
+ } 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..ff9a77a3f5 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 @@ -19,25 +19,30 @@ 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/slack-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/slack-config.component.html index 2cf15b05e7..9ef122ad9e 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 @@ -19,26 +19,34 @@ 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/sqs-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/external/sqs-config.component.html index 8e3de40601..0e07578f19 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 @@ -19,29 +19,39 @@ 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 }} + + } + + }
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 ee6cfac5da..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 @@ -30,7 +30,7 @@ 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-relation-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/check-relation-config.component.html index 07a1d1068a..3e2773037d 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 @@ -21,9 +21,11 @@ {{ '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 + + }
-
- - - - -
+ @if (checkRelationConfigForm.get('checkForSingleEntity').value) { +
+ + + @if (checkRelationConfigForm.get('entityType').value) { + + + } +
+ } 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..aee5db1a19 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 @@ -23,19 +23,23 @@ {{ '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
@@ -44,13 +48,15 @@ {{ '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/script-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.html index bc0ce9bfcb..4146e478ea 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/filter/script-config.component.html @@ -16,41 +16,47 @@ -->
- - - - - - - + @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/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/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..22d58fb9fc 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 @@ -20,47 +20,56 @@ 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/deduplication-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html index 001c0aadac..63abc20253 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/deduplication-config.component.html @@ -19,41 +19,55 @@ {{'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) { +
+ + +
+ }
@@ -64,34 +78,46 @@ {{'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 77c95515d3..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 @@ -31,7 +31,7 @@ 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/node-json-path-config.component.html b/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.html index f2d4cc6e5f..673a8b74ad 100644 --- a/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.html +++ b/ui-ngx/src/app/modules/home/components/rule-node/transformation/node-json-path-config.component.html @@ -20,6 +20,8 @@ {{ '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/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/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) { + + + + }
- - {{ 'rule-node-config.from-template-required' | translate }} - + @if (toEmailConfigForm.get('fromTemplate').hasError('required')) { + + {{ 'rule-node-config.from-template-required' | translate }} + + } @@ -46,7 +48,7 @@
rule-node-config.recipients
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
@@ -57,10 +59,12 @@ class="tb-enable-vertical-resize" formControlName="toTemplate" cdkTextareaAutosize - cdkAutosizeMinRows="1"> - - {{ '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 @@ -68,7 +72,7 @@ formControlName="ccTemplate" class="tb-enable-vertical-resize" cdkTextareaAutosize - cdkAutosizeMinRows="1"> + cdkAutosizeMinRows="1"> rule-node-config.bcc-template @@ -76,14 +80,14 @@ formControlName="bccTemplate" class="tb-enable-vertical-resize" cdkTextareaAutosize - cdkAutosizeMinRows="1"> + cdkAutosizeMinRows="1">
rule-node-config.message-subject-and-content
+ popupHelpLink="rulenode/to_email_node_fields_templatization">
@@ -93,47 +97,57 @@ formControlName="subjectTemplate" class="tb-enable-vertical-resize" cdkTextareaAutosize - cdkAutosizeMinRows="1"> - - {{ '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/sms/aws-sns-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/aws-sns-provider-configuration.component.html index 3beaa93ed1..fea9dafc09 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 @@ -19,23 +19,29 @@ 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/smpp-sms-provider-configuration.component.html b/ui-ngx/src/app/modules/home/components/sms/smpp-sms-provider-configuration.component.html index bc1e9430c7..08545e3a9b 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 @@ -20,41 +20,51 @@ 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}} + + }
@@ -72,9 +82,11 @@ admin.smpp-provider.bind-type - - {{bindTypesTranslation.get(bindType) | translate}} - + @for (bindType of bindTypes; track bindType) { + + {{bindTypesTranslation.get(bindType) | translate}} + + } @@ -97,17 +109,21 @@ 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}} + + } @@ -122,17 +138,21 @@ 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}} + + } @@ -151,9 +171,11 @@ 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 35111be91b..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 @@ -68,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..620748406e 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 @@ -19,32 +19,38 @@ 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 adb477d8fb..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 @@ -47,7 +47,7 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; 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..f3c1261ab6 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 @@ -19,27 +19,35 @@ 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/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-versions-table.component.html b/ui-ngx/src/app/modules/home/components/vc/entity-versions-table.component.html index 829c1414db..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 @@ -34,13 +34,15 @@
- + @if (singleEntityMode) { + + }
- + @if (!singleEntityMode) { + + }
@@ -129,31 +133,37 @@ + [class.min-w-10]="!singleEntityMode" [class.max-w-10]="!singleEntityMode" [class.w-10]="!singleEntityMode">
- - - + @if (singleEntityMode) { + + } + @if (singleEntityMode) { + + } + @if (!singleEntityMode) { + + }
@@ -162,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/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/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/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 { +
+ }
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/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/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..71edff2c83 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 @@ -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/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/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/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/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/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/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-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/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 9204a901b2..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,7 +45,10 @@ 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({ @@ -76,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, @@ -167,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-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/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/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 0527045650..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, @@ -289,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/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-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/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/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/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/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/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/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..450f8227fa 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 @@ -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/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) { + + + } + 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/slider-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/rpc/slider-basic-config.component.html index b6566c181e..d9bd4b82f2 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 @@ -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/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..d2d1e65491 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 @@ -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/weather/wind-speed-direction-basic-config.component.html b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html index 3727cd0952..af1cb2d514 100644 --- a/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/config/basic/weather/wind-speed-direction-basic-config.component.html @@ -16,10 +16,12 @@ --> - - + @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/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/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/target-device.component.html b/ui-ngx/src/app/modules/home/components/widget/config/target-device.component.html index d44c668be8..7c8a8b4a8b 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,19 @@ {{ '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/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/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/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/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/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/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/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-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/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/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/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 e68cddacf0..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'; @@ -230,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 226b5668bf..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 @@ -21,35 +21,35 @@
- - + @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-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/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.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/bar-chart-with-labels-widget.component.ts index 1f63cd8638..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 @@ -134,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/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/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.ts b/ui-ngx/src/app/modules/home/components/widget/lib/chart/range-chart-widget.component.ts index 2412d51a83..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 @@ -162,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.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/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..6683fa8c4b 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..3b5990a268 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 @@ -20,7 +20,9 @@ [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}} @@ -28,14 +30,20 @@ - 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}} + + } @@ -44,11 +52,15 @@ keyboard_arrow_left - 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-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/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/quick-link.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/home-page/quick-link.component.html index 9fa8ea6b13..6692c97e46 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/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/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/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/status-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/indicator/status-widget.component.html index 0cc88f5404..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 @@ -25,9 +25,15 @@ {{ 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/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/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/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/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/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/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/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/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/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/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/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/rpc/persistent-add-dialog.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/persistent-add-dialog.component.html index 1b5b3f1992..5f7d83fbad 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 { +
+ }
@@ -39,12 +42,16 @@ 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 { +
+ }
@@ -55,11 +58,13 @@ widgets.persistent-table.method - - widgets.persistent-table.retries - - + @if (persistentFormGroup.get('retries').value) { + + widgets.persistent-table.retries + + + }
@@ -69,7 +74,7 @@ {{ 'widgets.persistent-table.response' | translate }} - + @@ -98,14 +103,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/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/single-switch-widget.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/rpc/single-switch-widget.component.html index b54bc91b6f..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 @@ -21,20 +21,32 @@
-
- {{ 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/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/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/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/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/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/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/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/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/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 }} - - + @if (dashboardStateWidgetSettingsForm.get('stateId').value) { + + } - - - + @for (state of filteredDashboardStates | async; track state) { + + + + }
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..767be11007 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,13 +24,15 @@ - + @if (!disabled) { + + } @@ -40,9 +42,11 @@ 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 }} + + }
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/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.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/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-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/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 6290572770..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,6 +31,7 @@ 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', @@ -123,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/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-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-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-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 65fc17df26..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 @@ -424,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/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/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 703c621d62..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, @@ -269,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-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-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/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-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/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 +
+ } +
+
+
+
+
+ + +
+ }
- - {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }} - + @for (actionType of mobileActionTypes; track actionType) { + + {{ mobileActionTypeTranslations.get(mobileActionType[actionType]) | translate }} + + } - - warning - + @if (mobileActionFormGroup.get('type').hasError('required') + && mobileActionFormGroup.get('type').touched) { + + 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 b836fe2ade..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 @@ -76,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/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/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/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] }}
+
-
+ }
- - - - - - - + @if (limitForm.get('type').value === ValueSourceType.constant) { + + + + } + @if (limitForm.get('type').value === ValueSourceType.latestKey) { + + + } + @if (limitForm.get('type').value === ValueSourceType.entity) { + + + }
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 24037f2170..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 @@ -97,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]); @@ -169,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/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 26835e857a..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-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.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.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) { + + } - - {{ '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-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-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-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 694e856bfa..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,7 +52,7 @@ 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', @@ -127,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); } ); @@ -150,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); @@ -197,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); @@ -212,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 5298b7f45b..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,7 +29,7 @@ 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'; @@ -136,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, @@ -252,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/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-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 ecbb03b580..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,6 +34,7 @@ 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', @@ -107,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/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/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-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/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/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-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-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..00d5c32170 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-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-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-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.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/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/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/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/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/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/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.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/key/data-key-config.component.html index 85acdab5cf..d5aa8967c7 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/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/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) { + + + }
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/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-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/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/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 421d518a90..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,14 +18,15 @@
- - warning - + @if (mapActionButton.hasError('invalidButtonConfig')) { + + warning + + } 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') { + + + }
+ @if (['trips', 'markers'].includes(dataLayerType)) { + - - - -
- - - - - - - - - - + [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-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) { + + + }
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-layer-row.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layer-row.component.html index d59fe2d283..4416ab2d6c 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,67 @@ - - {{ 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.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) { + + + + }
- - {{ mapProviderTranslationMap.get(provider) | translate }} - + @for (provider of mapProviders; track provider) { + + {{ mapProviderTranslationMap.get(provider) | translate }} + + }
{{ (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
-
-
widgets.maps.layer.credentials.api-key
- - + @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 }} + + }
diff --git a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html index 9f4072fc12..9c824c71c4 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/map/map-layers.component.html @@ -23,28 +23,34 @@
widgets.maps.layer.layer
-
-
- -
- -
+ @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-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-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 1ca905dbe7..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/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-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-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-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/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/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">
-
-
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/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.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/widget-font.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/common/widget-font.component.html index edba87c0db..4768ed4920 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 @@ -75,12 +75,13 @@ icon="format_color_fill" label="{{ 'widgets.widget-font.color' | translate }}" openOnInput colorClearButton> - - + @if (hasShadowColor) { + + + } 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..219ef4485c 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 @@ -17,9 +17,13 @@ -->
-
{{definedDirectiveError}}
- - + @if (definedDirectiveError) { +
{{definedDirectiveError}}
+ } + @if (useDynamicForm()) { + + + }
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..41931d50cf 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 @@ -19,39 +19,48 @@ [subscriptSizing]="inlineField ? 'dynamic' : 'fixed'" [class]="{'tb-inline-field': inlineField, 'flex': inlineField, 'tb-suffix-absolute': (inlineField && !deviceKeyFormGroup.get('key').value)}" - [formGroup]="deviceKeyFormGroup"> - {{ (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/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..4dda766e04 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,9 +92,11 @@
widgets.table.default-page-size
- - {{ size }} - + @for (size of pageStepSizeValues; track size) { + + {{ size }} + + } @@ -112,11 +116,13 @@ cdkDropList cdkDropListOrientation="horizontal" (cdkDropListDropped)="columnDrop($event)"> - - {{ 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/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/slider-widget-settings.component.html b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.html index 5d1d987a76..01edd30eb3 100644 --- a/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.html +++ b/ui-ngx/src/app/modules/home/components/widget/lib/settings/control/slider-widget-settings.component.html @@ -26,7 +26,7 @@ [aliasController]="aliasController" [targetDevice]="targetDevice" [widgetType]="widgetType" - formControlName="initialState"> + 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/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/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/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..5df07f2820 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,8 +16,8 @@ -->
- 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/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/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..8863b76249 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 @@ -28,19 +28,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) { + + }
@@ -65,13 +69,14 @@ - - + @if (hasColor) { + + + }
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) { + + }
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-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..4d3b384386 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 @@ -102,31 +102,33 @@
-
- - 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 }} + + + +
+ }
-
- - -
-
-
- - {{ (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 }} + +
+ }
+ class="fields-group"> widgets.input-widgets.icon-settings {{ 'widgets.input-widgets.use-custom-icon' | translate }} @@ -286,8 +290,8 @@ formControlName="icon"> + 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 c007c51ef8..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 @@ -256,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/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/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/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/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/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/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}} + + } - - - - + @if (!routeMap) { + + + } + @if (routeMap) { + + + } 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/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/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/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/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/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..d240db2726 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 @@ -27,11 +27,13 @@ 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/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/table-widget.models.ts b/ui-ngx/src/app/modules/home/components/widget/lib/table-widget.models.ts index 3a3c4b5150..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,13 +451,9 @@ 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-active-label-text-color: ${mdDark}; - --mat-tab-inactive-label-text-color: ${mdDark}; - --mat-tab-pagination-icon-color: ${mdDark}; - --mat-tab-pagination-disabled-icon-color: ${mdDarkDisabled2}; --mat-table-header-headline-color: ${mdDarkSecondary}; --mat-table-row-item-label-text-color: ${mdDark}; --mat-icon-color: ${mdDarkSecondary}; @@ -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/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 d363b6c56e..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 @@ -379,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/widget-config.component.html b/ui-ngx/src/app/modules/home/components/widget/widget-config.component.html index 166c32a7fd..3b248f086e 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-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 3553ee3ae4..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 @@ -397,37 +397,39 @@ export class WidgetContainerComponent extends PageComponent implements OnInit, O @Component({ template: `
-
- {{ 'widget.reference' | translate }} - -
+ @if (container.widget.isReference) { +
+ {{ 'widget.reference' | translate }} + +
+ }
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/wizard/device-wizard-dialog.component.html b/ui-ngx/src/app/modules/home/components/wizard/device-wizard-dialog.component.html index ef80df1e9a..c121678b42 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,18 +72,20 @@ {{ '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/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-edge-dialog.component.html b/ui-ngx/src/app/modules/home/dialogs/add-entities-to-edge-dialog.component.html index 6a4be4aeb4..599b217e61 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,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToEdgeText | translate }} 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..1e105ff86a 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,9 +25,12 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ assignToCustomerText | translate }}
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/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-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/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/models/entity/entities-table-config.models.ts b/ui-ngx/src/app/modules/home/models/entity/entities-table-config.models.ts index 336ac035da..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> { 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..4cf6eb68ec 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 @@ -68,7 +68,7 @@ import { formatValue, getEntityDetailsPageURL, hasDatasourceLabelsVariables, - isDefined + isDefined, isDefinedAndNotNull } from '@core/utils'; import { Store } from '@ngrx/store'; import { AppState } from '@core/core.state'; @@ -555,7 +555,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/general-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/general-settings.component.html index 4b122194e8..e6dc6b74b9 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,9 +23,12 @@
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -33,9 +36,11 @@ 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,18 +86,18 @@
- + @if (protocol === 'http') { - - + } + @if (protocol === 'mqtt') { - - + } + @if (protocol === 'coap') { - + }
@@ -103,13 +111,17 @@ 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/home-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/home-settings.component.html index 9ebfd0369b..a653845ca3 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) { +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html index 2ba7adda3b..cea12d5ef8 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/mail-server.component.html @@ -24,26 +24,34 @@
- - -
+ @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' }} + + } @@ -57,90 +65,114 @@ 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 + + + +
-
+ } @@ -156,161 +188,196 @@ {{ '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.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/clients/client.component.html index 12f522de96..6c60e36cf1 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 @@ -20,12 +20,16 @@ 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 }} + + }
@@ -33,18 +37,22 @@ 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 }} + + }
@@ -52,22 +60,30 @@ 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,297 @@ {{ '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 }} - - -
-
-
+ @if (generalSettingsMode) { +
+
- admin.oauth2.type - - - {{ mapperType }} - - - -
-
-
- - admin.oauth2.email-attribute-key - - - {{ 'admin.oauth2.email-attribute-key-required' | translate }} + 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 }} + + +
+ } + @if (!generalSettingsMode) { +
+
+ + admin.oauth2.user-name-attribute-name + + @if (entityForm.get('userNameAttributeName').hasError('required')) { + + {{ 'admin.oauth2.user-name-attribute-name-required' | translate }} - + } + +
+
+
- common.password - - - {{ 'admin.oauth2.password-max-length' | 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/domains/domain.component.html b/ui-ngx/src/app/modules/home/pages/admin/oauth2/domains/domain.component.html index 0816cb6f5a..fb72798997 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 @@ -20,15 +20,21 @@ 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 }} + + }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html index 360b3b50da..1daeab7f89 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-library-table-header.component.html @@ -23,8 +23,10 @@ {{ "javascript.all-types" | translate }} - - {{ resourceSubTypesTranslationMap.get(jsResourceSubType) | translate }} - + @for (jsResourceSubType of jsResourceSubTypes; track jsResourceSubType) { + + {{ resourceSubTypesTranslationMap.get(jsResourceSubType) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html index 6c85aee415..009e549554 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.html @@ -48,71 +48,82 @@
- + javascript.javascript-type - - {{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }} - + @for (resourceSubType of jsResourceSubTypes; track resourceSubType) { + + {{ ResourceSubTypeTranslationMap.get(resourceSubType) | translate }} + + } - + resource.title - - {{ 'resource.title-required' | translate }} - - - {{ 'resource.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + + {{ 'resource.title-required' | translate }} + + } + @if (entityForm.get('title').hasError('maxlength')) { + + {{ 'resource.title-max-length' | translate }} + + } - - - -
- - - + @if ((isAdd || isEdit) && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION) { + + + } + @if (entityForm.get('resourceSubType').value === ResourceSubType.MODULE) { + +
+ + @if ((isAdd || isEdit)) { + + + } +
+
+ } + @if (!isAdd && !isEdit && entityForm.get('resourceSubType').value === ResourceSubType.EXTENSION) { +
+ + resource.file-name + +
- -
- - resource.file-name - - -
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts index 0c87d02563..2ede591e36 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/js-resource.component.ts @@ -173,4 +173,6 @@ export class JsResourceComponent extends EntityComponent implements On this.entityForm.get('content').enable({ emitEvent: false }); } } + + protected readonly resourceType = ResourceType; } diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html index 901eb78135..78e1ac048b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-library-tabs.component.html @@ -15,9 +15,11 @@ limitations under the License. --> - - - +@if (entity && entity.tenantId.id !== NULL_UUID && authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-tabs.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-tabs.component.html index 1d98f4be18..94ff5f2be5 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resource-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resource-tabs.component.html @@ -15,13 +15,17 @@ limitations under the License. --> - - - - - - +@if (entity && entity.tenantId.id !== NULL_UUID && authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + +} +@if (entity && entity.tenantId.id !== NULL_UUID && authUser.authority === authorities.TENANT_ADMIN && !isEdit) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.html b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.html index 05c6dfed73..5c8d9e4d6b 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/resource/resources-table-header.component.html @@ -23,8 +23,10 @@ {{ "resource.all-types" | translate }} - - {{ resourceTypesTranslationMap.get(resourceType) | translate }} - + @for (resourceType of resourceTypes; track resourceType) { + + {{ resourceTypesTranslationMap.get(resourceType) | translate }} + + } diff --git a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html index 24ee53842d..b3b126b5d7 100644 --- a/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html +++ b/ui-ngx/src/app/modules/home/pages/admin/security-settings.component.html @@ -23,9 +23,12 @@
- - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
@@ -37,17 +40,21 @@ formControlName="maxFailedLoginAttempts" step="1" min="0"/> - - {{ 'admin.minimum-max-failed-login-attempts-range' | translate }} - + @if (securitySettingsFormGroup.get('maxFailedLoginAttempts').hasError('min')) { + + {{ 'admin.minimum-max-failed-login-attempts-range' | translate }} + + } admin.user-lockout-notification-email - - {{ 'login.invalid-email-format' | translate }} - + @if (securitySettingsFormGroup.get('userLockoutNotificationEmail').invalid) { + + {{ 'login.invalid-email-format' | translate }} + + } admin.user-activation-token-ttl @@ -56,26 +63,34 @@ step="1" min="1" max="24"/> - - {{ 'admin.user-activation-token-ttl-range' | translate }} - - - {{ 'admin.user-activation-token-ttl-range' | translate }} - + @if (securitySettingsFormGroup.get('userActivationTokenTtl').hasError('min')) { + + {{ 'admin.user-activation-token-ttl-range' | translate }} + + } + @if (securitySettingsFormGroup.get('userActivationTokenTtl').hasError('max')) { + + {{ 'admin.user-activation-token-ttl-range' | translate }} + + } - + admin.password-reset-token-ttl - - {{ 'admin.password-reset-token-ttl-range' | translate }} - - - {{ 'admin.password-reset-token-ttl-range' | translate }} - + formControlName="passwordResetTokenTtl" + step="1" + min="1" + max="24"/> + @if (securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('min')) { + + {{ 'admin.password-reset-token-ttl-range' | translate }} + + } + @if (securitySettingsFormGroup.get('passwordResetTokenTtl').hasError('max')) { + + {{ 'admin.password-reset-token-ttl-range' | translate }} + + } admin.mobile-secret-key-length @@ -83,9 +98,11 @@ formControlName="mobileSecretKeyLength" step="1" min="1"/> - - {{ 'admin.mobile-secret-key-length-range' | translate }} - + @if (securitySettingsFormGroup.get('mobileSecretKeyLength').hasError('min')) { + + {{ 'admin.mobile-secret-key-length-range' | translate }} + + }
@@ -101,26 +118,36 @@ min="6" max="50" required/> - - {{ 'admin.minimum-password-length-required' | translate }} - - - {{ 'admin.minimum-password-length-range' | translate }} - - - {{ 'admin.minimum-password-length-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.minimumLength').hasError('required')) { + + {{ 'admin.minimum-password-length-required' | translate }} + + } + @if (securitySettingsFormGroup.get('passwordPolicy.minimumLength').hasError('min')) { + + {{ 'admin.minimum-password-length-range' | translate }} + + } + @if (securitySettingsFormGroup.get('passwordPolicy.minimumLength').hasError('max')) { + + {{ 'admin.minimum-password-length-range' | translate }} + + } admin.maximum-password-length - - {{ 'admin.maximum-password-length-min' | translate }} - - - {{ 'admin.maximum-password-length-less-min' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.maximumLength').hasError('min')) { + + {{ 'admin.maximum-password-length-min' | translate }} + + } + @if (securitySettingsFormGroup.get('passwordPolicy.maximumLength').hasError('lessMin')) { + + {{ 'admin.maximum-password-length-less-min' | translate }} + + }
@@ -130,10 +157,12 @@ formControlName="minimumUppercaseLetters" step="1" min="0"/> - - {{ 'admin.minimum-uppercase-letters-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.minimumUppercaseLetters').hasError('min')) { + + {{ 'admin.minimum-uppercase-letters-range' | translate }} + + } admin.minimum-lowercase-letters @@ -141,10 +170,12 @@ formControlName="minimumLowercaseLetters" step="1" min="0"/> - - {{ 'admin.minimum-lowercase-letters-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.minimumLowercaseLetters').hasError('min')) { + + {{ 'admin.minimum-lowercase-letters-range' | translate }} + + }
@@ -154,9 +185,11 @@ formControlName="minimumDigits" step="1" min="0"/> - - {{ 'admin.minimum-digits-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.minimumDigits').hasError('min')) { + + {{ 'admin.minimum-digits-range' | translate }} + + } admin.minimum-special-characters @@ -164,10 +197,12 @@ formControlName="minimumSpecialCharacters" step="1" min="0"/> - - {{ 'admin.minimum-special-characters-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.minimumSpecialCharacters').hasError('min')) { + + {{ 'admin.minimum-special-characters-range' | translate }} + + }
@@ -177,10 +212,12 @@ formControlName="passwordExpirationPeriodDays" step="1" min="0"/> - - {{ 'admin.password-expiration-period-days-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.passwordExpirationPeriodDays').hasError('min')) { + + {{ 'admin.password-expiration-period-days-range' | translate }} + + } admin.password-reuse-frequency-days @@ -188,10 +225,12 @@ formControlName="passwordReuseFrequencyDays" step="1" min="0"/> - - {{ 'admin.password-reuse-frequency-days-range' | translate }} - + @if (securitySettingsFormGroup.get('passwordPolicy.passwordReuseFrequencyDays').hasError('min')) { + + {{ 'admin.password-reuse-frequency-days-range' | translate }} + + }
@@ -235,9 +274,11 @@ admin.jwt.issuer-name - - {{ 'admin.jwt.issuer-name-required' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('tokenIssuer').hasError('required')) { + + {{ 'admin.jwt.issuer-name-required' | translate }} + + } admin.jwt.signings-key @@ -251,15 +292,21 @@ {{ 'admin.jwt.generate-key' | translate }} admin.jwt.signings-key-hint - - {{ 'admin.jwt.signings-key-required' | translate }} - - - {{ 'admin.jwt.signings-key-base64' | translate }} - - - {{ 'admin.jwt.signings-key-min-length' | translate }} - + @if (jwtSecuritySettingsFormGroup.get('tokenSigningKey').hasError('required')) { + + {{ 'admin.jwt.signings-key-required' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenSigningKey').hasError('base64')) { + + {{ 'admin.jwt.signings-key-base64' | translate }} + + } + @if (jwtSecuritySettingsFormGroup.get('tokenSigningKey').hasError('minLength')) { + + {{ 'admin.jwt.signings-key-min-length' | translate }} + + }
@@ -270,15 +317,21 @@ step="1" min="60"/> - - {{ '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 @@ -287,18 +340,26 @@ step="1" min="900"/> - - {{ '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/send-test-sms-dialog.component.html b/ui-ngx/src/app/modules/home/pages/admin/send-test-sms-dialog.component.html index c36923dc84..3245013330 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,9 +25,12 @@ 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/sms-provider.component.html b/ui-ngx/src/app/modules/home/pages/admin/sms-provider.component.html index 2c38ad4ab8..a723b89043 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/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 }} + + } = (_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 ae4ee4001d..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,9 +18,9 @@ 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({ @@ -29,7 +29,7 @@ import { debugCfActionEnabled } from '@shared/models/calculated-field.models'; styleUrls: [], standalone: false }) -export class AlarmRulesTabsComponent extends EntityTabsComponent { +export class AlarmRulesTabsComponent extends EntityTabsComponent { readonly DebugEventType = DebugEventType; readonly EventType = EventType; @@ -43,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/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.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/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.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()) { + + }
- dashboard.assignedToCustomers @@ -98,7 +98,7 @@ shareHashTags="thingsboard, iot">
- + dashboard.public-link @@ -113,35 +113,31 @@
-
- +
+ dashboard.title - - {{ 'dashboard.title-required' | translate }} - - - {{ 'dashboard.title-max-length' | translate }} - + @if (entityForm.get('title').hasError('required')) { + {{ 'dashboard.title-required' | translate }} + } + @if (entityForm.get('title').hasError('maxlength')) { + {{ 'dashboard.title-max-length' | translate }} + } -
- - dashboard.description - - -
- - + @if (isAdd) { + + + }
dashboard.mobile-app-settings
{{ 'dashboard.mobile-hide' | translate }} - + dashboard.mobile-order @@ -150,6 +146,12 @@ formControlName="image">
+
+ + dashboard.description + + +
diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html index 73d78d43d4..e2a325095e 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/dashboard-tabs.component.html @@ -15,13 +15,17 @@ limitations under the License. --> - - - - - - +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} +@if (entity && authUser.authority === authorities.TENANT_ADMIN) { + + + +} diff --git a/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html index 304ea22c33..3286044628 100644 --- a/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html +++ b/ui-ngx/src/app/modules/home/pages/dashboard/import-dashboard-file-dialog.component.html @@ -25,19 +25,22 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- - + +
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/device-profile/device-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/device-profile/device-profile-tabs.component.html index 69dd15b60f..db5287fd68 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/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-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-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/snmp-device-transport-configuration.component.html b/ui-ngx/src/app/modules/home/pages/device/data/snmp-device-transport-configuration.component.html index 631c28ad98..992a88ab16 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 @@ -20,114 +20,146 @@ 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/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-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.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/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-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.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/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 @@ --> - - - - - - - - - - - - - - - - - - - - - - - +@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.component.html b/ui-ngx/src/app/modules/home/pages/entity-view/entity-view.component.html index 15b0b3da8b..170ae4d6bf 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">
@@ -114,21 +115,24 @@ [entityId]="selectedEntityId | async" formControlName="cs" keysText="entity-view.client-attributes-placeholder" - [dataKeyType]="dataKeyType.attribute"> + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> + [dataKeyType]="dataKeyType.attribute" + appearance="outline"> @@ -143,7 +147,8 @@ [entityId]="selectedEntityId | async" formControlName="timeseries" keysText="entity-view.timeseries-placeholder" - [dataKeyType]="dataKeyType.timeseries"> + [dataKeyType]="dataKeyType.timeseries" + appearance="outline"> @@ -152,16 +157,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/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/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.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 3f114c7707..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 @@ -109,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); } 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/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/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/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-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/mobile-bundle-dialog.component.html b/ui-ngx/src/app/modules/home/pages/mobile/bundes/mobile-bundle-dialog.component.html index 4259899d46..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 - + @@ -106,8 +114,10 @@
- + @if (selectedIndex > 0) { + + } - + @if (!disabled) { + + }
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/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/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/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/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) { + + } - - +@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 51108c1c5e..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 @@ -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/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-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/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-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/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 @@ -78,20 +86,22 @@ } -
- - - {{ '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 }} + + }
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..d243baaa29 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 @@ -18,13 +18,16 @@ 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/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-details.component.html b/ui-ngx/src/app/modules/home/pages/rulechain/rule-node-details.component.html index 6afce790b6..a59ad5c547 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,24 +15,29 @@ 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 }} + + }
- + /> + @if (isSingleton()) { + + }
- - + @if (isAddQueue()) { + + + }
- + rulenode.rule-node-description - +
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/pages/rulechain/rule-note-editor.component.scss b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss new file mode 100644 index 0000000000..6d89be41ad --- /dev/null +++ b/ui-ngx/src/app/modules/home/pages/rulechain/rule-note-editor.component.scss @@ -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. + */ +: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 8707e42cac..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,10 +100,10 @@ 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', @@ -132,8 +135,6 @@ export class RuleChainPageComponent extends PageComponent @ViewChild('drawer') drawer: MatDrawer; - eventTypes = EventType; - debugEventTypes = DebugEventType; ruleChainMenuPosition = { x: '0px', y: '0px' }; @@ -163,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; @@ -180,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), @@ -204,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 + '')); @@ -238,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; } }; @@ -267,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, @@ -282,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( @@ -363,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(); } @@ -389,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; @@ -400,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; } @@ -465,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')) + ); } } @@ -523,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) { @@ -657,6 +687,7 @@ export class RuleChainPageComponent extends PageComponent } }); } + this.ruleChainModel.notes = deepClone(this.ruleChainMetaData.notes) || []; if (this.ruleChainCanvas) { this.ruleChainCanvas.adjustCanvasSize(true); } @@ -688,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); } @@ -705,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', @@ -721,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 @@ -907,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; @@ -925,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; @@ -993,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[] = []; @@ -1039,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); @@ -1090,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(); }); @@ -1146,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); @@ -1170,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; @@ -1188,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) { @@ -1215,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 ) { @@ -1251,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); } @@ -1419,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() { @@ -1477,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[] = []; @@ -1535,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; @@ -1796,7 +1985,7 @@ export interface AddRuleNodeLinkDialogData { standalone: false }) export class AddRuleNodeLinkDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { ruleNodeLinkFormGroup: UntypedFormGroup; @@ -1826,9 +2015,6 @@ export class AddRuleNodeLinkDialogComponent extends DialogComponent - implements OnInit, ErrorStateMatcher { + implements ErrorStateMatcher { @ViewChild('tbRuleNode', {static: true}) ruleNodeDetailsComponent: RuleNodeDetailsComponent; @@ -1883,9 +2069,6 @@ export class AddRuleNodeDialogComponent 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.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.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/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-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-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-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.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-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.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/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-tooltip.components.ts b/ui-ngx/src/app/modules/home/pages/scada-symbol/scada-symbol-tooltip.components.ts index 60baf0e63b..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 @@ -184,30 +184,36 @@ class ScadaSymbolTagInputPanelComponent extends ScadaSymbolPanelComponent implem @Component({ template: `
- {{ symbolElement?.element?.type }}{{ symbolElement?.invisible ? ' (' + ('scada.hidden' | translate) + ')' : '' }}: - {{ symbolElement?.tag }} - - - -
`, + {{ 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 @@ -360,37 +366,49 @@ class ScadaSymbolRemoveTagConfirmComponent extends ScadaSymbolPanelComponent imp @Component({ template: `
-
scada.state-render-function
- - -
scada.tag.on-click-action
- - -
`, + @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 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/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 { +
+}
@@ -41,12 +44,16 @@ - - {{ '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 { +
+}
@@ -64,9 +67,11 @@ inputmode="numeric" pattern="[0-9]*" autocomplete="off" placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}"> - - {{ 'security.2fa.dialog.verification-code-invalid' | translate }} - + @if (smsVerificationForm.get('verificationCode').invalid) { + + {{ 'security.2fa.dialog.verification-code-invalid' | translate }} + + }
- - -
+@if (isLoading$ | async) { + + +} @else { +
+}
@@ -72,9 +75,11 @@ inputmode="numeric" pattern="[0-9]*" autocomplete="off" placeholder="{{ 'security.2fa.dialog.verification-code' | translate }}"> - - {{ '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/security.component.html b/ui-ngx/src/app/modules/home/pages/security/security.component.html index 4fca803187..2488f1846c 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 @@ -54,30 +54,40 @@ 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 }} + + }
@@ -86,9 +96,11 @@ 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/tenant-profile/tenant-profile-tabs.component.html b/ui-ngx/src/app/modules/home/pages/tenant-profile/tenant-profile-tabs.component.html index b24e5e247a..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,8 +31,10 @@ [entityName]="entity.name"> - - - + @if (!isEdit) { + + + + } } 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..0e9dea874a 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 @@ -51,12 +51,16 @@ 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 }} + + } tenant.description -
-
- - - {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} - + @if (entity?.id) { +
+
+ + + {{ 'dashboard.home-dashboard-hide-toolbar' | translate }} + +
-
+ }
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/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/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.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/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..b76b922008 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,17 +25,22 @@ close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
{{ dialogTitle }} widget.title - - {{ 'widget.title-required' | translate }} - + @if (saveWidgetTypeAsFormGroup.get('title').hasError('required')) { + + {{ 'widget.title-required' | translate }} + + } close - - -
+ @if (isLoading$ | async) { + + + } @else { +
+ }
- + @for (type of allWidgetTypes; track type) { + + }
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..84744f6217 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 }} + + +
+ }
@@ -315,10 +327,12 @@
-
- -
+ @if (!iframeWidgetEditModeInited) { +
+ +
+ }
diff --git a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html index e365b185e8..f6bb65468b 100644 --- a/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html +++ b/ui-ngx/src/app/modules/home/pages/widget/widget-type-autocomplete.component.html @@ -17,7 +17,9 @@ --> - {{ label }} + @if (label) { + {{ label }} + } - + @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-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.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/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 { +
+ }
- - - +@if (isTenantWidgetsBundle() && authUser.authority === authorities.TENANT_ADMIN) { + + + +} 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.component.html b/ui-ngx/src/app/modules/home/pages/widget/widgets-bundle.component.html index 6546118554..89f2547118 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,44 +15,50 @@ 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 }} + + } 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/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..12e027ee73 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)'}" - > + >
@@ -167,12 +167,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 +271,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/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/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/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/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/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.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/cheatsheet.component.ts b/ui-ngx/src/app/shared/components/cheatsheet.component.ts index 00fe6e5790..41fa0ce39f 100644 --- a/ui-ngx/src/app/shared/components/cheatsheet.component.ts +++ b/ui-ngx/src/app/shared/components/cheatsheet.component.ts @@ -115,17 +115,23 @@ import Mousetrap from 'mousetrap'; } } `], template: ``, +

{{ title }}

+ @if (helpVisible) { + + @for (hotkey of hotkeysList; track hotkey) { + + + + + } +
+ @for (key of hotkey.formatted; track key) { + {{ key }} + } + {{ hotkey.description }}
+ } +
×
+
`, standalone: false }) export class TbCheatSheetComponent implements OnInit, OnDestroy { 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..d2f21661bc 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-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/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/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/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/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) { + + }
- {{ 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-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.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 49c8835986..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,7 +24,7 @@ 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({ @@ -51,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/dynamic/dynamic-dialog.module.ts b/ui-ngx/src/app/shared/components/dialog/dynamic/dynamic-dialog.module.ts index 078234dbda..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 @@ -15,36 +15,47 @@ /// import { Overlay, OverlayContainer, OverlayModule } from '@angular/cdk/overlay'; -import { NgModule } from '@angular/core'; +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, - { provide: OverlayContainer, useExisting: DynamicOverlayContainer }, - DynamicOverlay, - { provide: Overlay, useExisting: DynamicOverlay }, - DynamicDialog, - { provide: Dialog, useExisting: 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 f717ccb0a7..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 @@ -34,20 +34,13 @@ export class DynamicMatDialog extends MatDialog { config.containerElement.style.transform = 'translateZ(0)'; 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; } } 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/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 fe173be672..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'; @@ -34,9 +33,8 @@ interface EntityConflictDialogData { templateUrl: 'entity-conflict-dialog.component.html', styleUrls: ['./entity-conflict-dialog.component.scss'], imports: [ - CommonModule, - SharedModule, - ] + SharedModule +] }) export class EntityConflictDialogComponent { 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/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-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-key-autocomplete.component.ts b/ui-ngx/src/app/shared/components/entity/entity-key-autocomplete.component.ts index 03d110c2f0..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 @@ -140,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..53c26e0bff 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,18 @@ limitations under the License. --> - + - - {{key}} - close - + @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 a034af3825..17629da30e 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,6 +29,7 @@ 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', @@ -65,6 +66,9 @@ export class EntityKeysListComponent implements ControlValueAccessor, OnInit, Af @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..6a94f1fc04 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,29 @@ -->
- - - - + @if (displayEntityTypeSelect) { + + + } + @if (modelValue.entityType) { + + + }
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-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-select.component.html index 59d5972c65..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-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-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-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-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-select.component.html b/ui-ngx/src/app/shared/components/entity/entity-type-select.component.html index 65e3615887..e13e11bde0 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,19 @@ - {{ label }} + @if (showLabel && !inlineField) { + {{ label }} + } - - {{ displayEntityTypeFn(type) }} - + @for (type of entityTypes; track type) { + + {{ displayEntityTypeFn(type) }} + + } - - {{ 'entity.type-required' | translate }} - + @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 ffc62ee7c2..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 @@ -173,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/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/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/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/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/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/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) { + + }
- + @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/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/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/image-dialog.component.html b/ui-ngx/src/app/shared/components/image/image-dialog.component.html index 0eefce39cf..ee830aa102 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-gallery.component.html b/ui-ngx/src/app/shared/components/image/image-gallery.component.html index b31dc16920..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 }} + }
@@ -94,9 +98,11 @@
- {{ (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-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/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/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/upload-image-dialog.component.html b/ui-ngx/src/app/shared/components/image/upload-image-dialog.component.html index 61da3149b9..edde51a8a7 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/js-func-module-row.component.html b/ui-ngx/src/app/shared/components/js-func-module-row.component.html index 2b60f9071c..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-info' | translate }}">
+ 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.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/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-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 2c44104caf..72d9c9064b 100644 --- a/ui-ngx/src/app/shared/components/markdown-editor.component.ts +++ b/ui-ngx/src/app/shared/components/markdown-editor.component.ts @@ -55,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.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/material-icon-select.component.html b/ui-ngx/src/app/shared/components/material-icon-select.component.html index 59566607be..dc22921c7e 100644 --- a/ui-ngx/src/app/shared/components/material-icon-select.component.html +++ b/ui-ngx/src/app/shared/components/material-icon-select.component.html @@ -15,20 +15,23 @@ limitations under the License. --> -
- {{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-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/message-type-autocomplete.component.html b/ui-ngx/src/app/shared/components/message-type-autocomplete.component.html index b073d93a4e..fb82ed8032 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 @@ -23,21 +23,27 @@ (focusin)="onFocus()" [required]="required" [matAutocomplete]="messageTypeAutocomplete"> - + @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/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/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 7a4de670fc..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,6 +33,7 @@ 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', @@ -71,7 +72,8 @@ export class NotificationComponent implements OnInit { constructor( private utils: UtilsService, - private router: Router + private router: Router, + private sanitizer: DomSanitizer ) { } @@ -79,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.no-notification-templates + @for (template of (filteredTemplate | async); track template) { + + + + @for (method of template.configuration.deliveryMethodsTemplates | keyvalue; track method) { + + {{ getNotificationDeliveryMethodInfoMap(method.key).name | translate }} + + } + + + } + @if (!(filteredTemplate | async)?.length) { + +
+ @if (!textIsNotEmpty(searchText)) { +
+ notification.no-notification-templates +
+ } @else { + {{ 'notification.no-template-matching' | translate : {template: truncate.transform(searchText, true, 6,'...')} }} + @if (allowCreate) { + + notification.create-new-template + + } + }
- - {{ 'notification.no-template-matching' | translate : {template: truncate.transform(searchText, true, 6, '...')} }} - - notification.create-new-template - - -
- + + } - - {{ '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 61b5b1c973..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,6 +42,7 @@ 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', @@ -71,6 +73,8 @@ export class TemplateAutocompleteComponent implements ControlValueAccessor, OnIn @coerceBoolean() allowEdit = false; + @Input() + appearance: MatFormFieldAppearance = 'fill'; @Input() disabled: boolean; @@ -217,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({ @@ -265,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 6ccefe94ea..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 339550e18a..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,6 +35,7 @@ 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', @@ -93,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/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/popover.component.ts b/ui-ngx/src/app/shared/components/popover.component.ts index 7b74c90bfd..f971d94ec3 100644 --- a/ui-ngx/src/app/shared/components/popover.component.ts +++ b/ui-ngx/src/app/shared/components/popover.component.ts @@ -327,9 +327,9 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit { (overlayOutsideClick)="onClickOutside($event)" (detach)="hide()" (positionChange)="onPositionChange($event)" - > + >
+ (@popoverMotion.done)="animationDone()">
+ >
@@ -366,7 +370,7 @@ export class TbPopoverDirective implements OnChanges, OnDestroy, AfterViewInit {
- `, + `, standalone: false }) export class TbPopoverComponent implements OnDestroy, OnInit { 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) { + + }
+ 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 f687681ee3..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,7 +31,7 @@ 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', @@ -77,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/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/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/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/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 0087afeda5..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,6 +27,7 @@ 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', @@ -67,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/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-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 c7f905f68b..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 { @@ -37,18 +37,18 @@ export interface StringItemsOption { value: any; } @Component({ - selector: 'tb-string-items-list', - templateUrl: './string-items-list.component.html', - styleUrls: [], - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => StringItemsListComponent), - multi: true - } - ], - encapsulation: ViewEncapsulation.None, - standalone: false + selector: 'tb-string-items-list', + templateUrl: './string-items-list.component.html', + styleUrls: [], + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => StringItemsListComponent), + multi: true + } + ], + encapsulation: ViewEncapsulation.None, + standalone: false }) export class StringItemsListComponent implements ControlValueAccessor, OnInit { @@ -149,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(); } }), @@ -209,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) { @@ -295,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/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) { + + }
@if (inlineField) { - - warning - + @if (hasError) { + + warning + + } + } + @if ((subscriptSizing === 'fixed') && !inlineField) { + {{ hintText }} + } + @if (hasError && !inlineField) { + + {{ hasError }} + } - {{ hintText }} - - {{ hasError }} -
- - {{ 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-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/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/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/history-selector/history-selector.component.html b/ui-ngx/src/app/shared/components/time/history-selector/history-selector.component.html index c7c983c371..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 @@ -->
-
- - -
- - -
- {{ 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/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/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/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/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-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.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/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.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/toast.directive.ts b/ui-ngx/src/app/shared/components/toast.directive.ts index ac3d284474..773a6d298e 100644 --- a/ui-ngx/src/app/shared/components/toast.directive.ts +++ b/ui-ngx/src/app/shared/components/toast.directive.ts @@ -135,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[] = [ 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/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-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/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/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/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/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-select.component.html b/ui-ngx/src/app/shared/components/widgets-bundle-select.component.html index 04355f2bc1..5a69f8ddc3 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 @@ -27,20 +27,29 @@
{{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/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/import-dialog-csv.component.html b/ui-ngx/src/app/shared/import-export/import-dialog-csv.component.html index eec9ee4e39..d2cd853e02 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,11 +49,11 @@
+ (click)="cancel()">{{ 'action.cancel' | translate }} + (click)="nextStep(2)">{{ 'action.continue' | translate }}
@@ -61,9 +63,11 @@ 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: statistical.created}) }} -

-

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

-

- {{ translate.instant('import.message.error-entities', {count: 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.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/table-columns-assignment.component.html b/ui-ngx/src/app/shared/import-export/table-columns-assignment.component.html index 46be8d8c57..10fa39c547 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/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/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/shared.module.ts b/ui-ngx/src/app/shared/shared.module.ts index dfe86539bc..cefc1c8824 100644 --- a/ui-ngx/src/app/shared/shared.module.ts +++ b/ui-ngx/src/app/shared/shared.module.ts @@ -239,6 +239,9 @@ 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; @@ -466,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/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 1583faa8e0..ebd4d4df2d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-el_GR.json +++ b/ui-ngx/src/assets/locale/locale.constant-el_GR.json @@ -803,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": "Αρχείο περιουσιακών στοιχείων", @@ -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": "Πρέπει να επιλεγεί τουλάχιστον ένας τύπος οντότητας.", @@ -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-en_US.json b/ui-ngx/src/assets/locale/locale.constant-en_US.json index 5c527c9698..b848e5808f 100644 --- a/ui-ngx/src/assets/locale/locale.constant-en_US.json +++ b/ui-ngx/src/assets/locale/locale.constant-en_US.json @@ -5165,7 +5165,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", @@ -5510,7 +5520,6 @@ "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", "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\"", @@ -5666,7 +5675,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", @@ -5950,6 +5958,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.", @@ -7482,7 +7501,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", 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 28e4484572..c919107272 100644 --- a/ui-ngx/src/assets/locale/locale.constant-es_ES.json +++ b/ui-ngx/src/assets/locale/locale.constant-es_ES.json @@ -803,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", @@ -910,9 +910,9 @@ "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", @@ -1095,6 +1095,7 @@ "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", @@ -2124,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.", @@ -3039,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.", @@ -3242,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.", @@ -7465,7 +7466,7 @@ "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", + "save-to-gallery": "Guardar en la 'Galería de imágenes'", "provision-type": "Tipo de aprovisionamiento", "auto": "Automático", "wi-fi": "Wi-Fi", 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 ef5707a0bd..36b77be8c6 100644 --- a/ui-ngx/src/assets/locale/locale.constant-hi_IN.json +++ b/ui-ngx/src/assets/locale/locale.constant-hi_IN.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": "कम से कम एक एंटिटी प्रकार चुना जाना चाहिए.", @@ -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-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 0f8b4adea2..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", @@ -1095,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", @@ -2124,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.", @@ -3039,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.", @@ -3242,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.", @@ -7465,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 9b2c33dae9..be53c9624d 100644 --- a/ui-ngx/src/assets/locale/locale.constant-no_NO.json +++ b/ui-ngx/src/assets/locale/locale.constant-no_NO.json @@ -803,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", @@ -910,9 +910,9 @@ "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", @@ -1095,6 +1095,7 @@ "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", @@ -2124,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.", @@ -3039,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.", @@ -3242,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.", @@ -7465,7 +7466,7 @@ "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 galleri", + "save-to-gallery": "Lagre i 'Bildegalleriet'", "provision-type": "Klargjøringstype", "auto": "Auto", "wi-fi": "Wi-Fi", 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/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 d36c9c8684..e99905fb9e 100644 --- a/ui-ngx/yarn.lock +++ b/ui-ngx/yarn.lock @@ -160,24 +160,24 @@ "@angular-devkit/core" "^20.0.0" "@angular/build" "^20.0.0" -"@angular-devkit/architect@0.2003.15", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": - version "0.2003.15" - resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.15.tgz#09d4e520205dcfe44ae0dd5e4f865596c1fdec98" - integrity sha512-HmGnUTLVwpvOFilc3gTP6CL9o+UbkVyu9S4WENkQbInbW3zp54lkzY71uWJIP7QvuXPa+bS4WHEmoGNQtNvv1A== +"@angular-devkit/architect@0.2003.22", "@angular-devkit/architect@>= 0.2000.0 < 0.2100.0", "@angular-devkit/architect@>=0.2000.0 < 0.2100.0": + version "0.2003.22" + resolved "https://registry.yarnpkg.com/@angular-devkit/architect/-/architect-0.2003.22.tgz#cb660579890be1d0622339bc74a85b6f1697ae5f" + integrity sha512-gxVOslVweD+Co6gpRVlByHus/3HVAnsl99MobS9PBh8vh2g6bJ011PBgl0TKsP/pqBGawZOkJXYrRPeMKnobYA== dependencies: - "@angular-devkit/core" "20.3.15" + "@angular-devkit/core" "20.3.22" rxjs "7.8.2" -"@angular-devkit/build-angular@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.15.tgz#144b7ca4cec60aebaa09453e731fe58207955c11" - integrity sha512-md5ikmdH0rM4jkVFwNQhN++onUUZPUDUfKijnb1XZArQisT9hPiNqTPbO+OZTBK/hQYYV/GfTOhDH30whG32xQ== +"@angular-devkit/build-angular@20.3.22": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-angular/-/build-angular-20.3.22.tgz#a96ba0d41a708ed3402f2568db343b1e85fe0e4e" + integrity sha512-PnKIRue/j30sWLWC9q2T3WwE5+GJekDX/zCXTYgjW6u0y/kt+7G/sR0uxXGkX2NwSVGnIz0ucVOBsbSl9PKd6A== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.15" - "@angular-devkit/build-webpack" "0.2003.15" - "@angular-devkit/core" "20.3.15" - "@angular/build" "20.3.15" + "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/build-webpack" "0.2003.22" + "@angular-devkit/core" "20.3.22" + "@angular/build" "20.3.22" "@babel/core" "7.28.3" "@babel/generator" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" @@ -188,12 +188,12 @@ "@babel/preset-env" "7.28.3" "@babel/runtime" "7.28.3" "@discoveryjs/json-ext" "0.6.3" - "@ngtools/webpack" "20.3.15" + "@ngtools/webpack" "20.3.22" ansi-colors "4.1.3" autoprefixer "10.4.21" babel-loader "10.0.0" browserslist "^4.21.5" - copy-webpack-plugin "13.0.1" + copy-webpack-plugin "14.0.0" css-loader "7.1.2" esbuild-wasm "0.25.9" fast-glob "3.3.3" @@ -208,7 +208,7 @@ mini-css-extract-plugin "2.9.4" open "10.2.0" ora "8.2.0" - picomatch "4.0.3" + picomatch "4.0.4" piscina "5.1.3" postcss "8.5.6" postcss-loader "8.1.1" @@ -222,7 +222,7 @@ terser "5.43.1" tree-kill "1.2.2" tslib "2.8.1" - webpack "5.104.1" + webpack "5.105.0" webpack-dev-middleware "7.4.2" webpack-dev-server "5.2.2" webpack-merge "6.0.1" @@ -230,32 +230,32 @@ optionalDependencies: esbuild "0.25.9" -"@angular-devkit/build-webpack@0.2003.15": - version "0.2003.15" - resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.15.tgz#780e56a2ab48cea75de73386baeda3600c36ca93" - integrity sha512-u1gjLH0T+s4PiwbSUhzYZUKCsIF9CvTGJoN+Ki+CQqZAlTGLOp2y55VXmdrRX4e3tsKkS+chpQ8sN2b5vbzv9w== +"@angular-devkit/build-webpack@0.2003.22": + version "0.2003.22" + resolved "https://registry.yarnpkg.com/@angular-devkit/build-webpack/-/build-webpack-0.2003.22.tgz#9769c91cfd0203b1136cdd6b6ed4d205e17da70b" + integrity sha512-ad4iW5CDGDYFXR9ZpdY8O2n4mx+tJtjx4mfC9Z+0ikwA4ir2IW2BOUhWvJ6IDq3f7kulFxP8S4zWN9r7jsZw3A== dependencies: - "@angular-devkit/architect" "0.2003.15" + "@angular-devkit/architect" "0.2003.22" rxjs "7.8.2" -"@angular-devkit/core@20.3.15", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.15.tgz#d61e2b915b2347da9f33c37f39c6c163cc5d29ea" - integrity sha512-s7sE4S5Hy62dLrtHwizbZaMcupAE8fPhm6rF+jBkhHZ75zXGhGzXP8WKFztYCAuGnis4pPnGSEKP/xVTc2lw6Q== +"@angular-devkit/core@20.3.22", "@angular-devkit/core@>= 20.0.0 < 21.0.0", "@angular-devkit/core@^20.0.0": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-20.3.22.tgz#6040c5673c4f4bb2eaaaac0bb9801ea6fa0886ea" + integrity sha512-1vZnZTAjGcCM+86v2al+2eiROiSw0uAWeVllfHSQe0KsKOP1FE8UUUiWChhxVn7vIxypphlfGunkeeIn1C/ZFw== dependencies: - ajv "8.17.1" + ajv "8.18.0" ajv-formats "3.0.1" jsonc-parser "3.3.1" - picomatch "4.0.3" + picomatch "4.0.4" rxjs "7.8.2" source-map "0.7.6" -"@angular-devkit/schematics@20.3.15", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.15.tgz#7dd2f966a530fadb0c425ed01fb3cbb303df868a" - integrity sha512-xMN1fyuhhP8Y5sNlmQvl4nMiOouHTKPkLR0zlhu5z6fHuwxxlverh31Gpq3eFzPHqmOzzb2TkgYCptCFXsXcrg== +"@angular-devkit/schematics@20.3.22", "@angular-devkit/schematics@>= 20.0.0 < 21.0.0": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-20.3.22.tgz#e1aab6eaa431f323db2425af05276dbe45c63fc2" + integrity sha512-gN2XSXRn3eErGEJlH0iSfQZZ7NdxVZNdjSxuVEGBEFhe3cVeC21LzM3GTWW6xwtBb4pxHglFyc7BUFiYtZiYtg== dependencies: - "@angular-devkit/core" "20.3.15" + "@angular-devkit/core" "20.3.22" jsonc-parser "3.3.1" magic-string "0.30.17" ora "8.2.0" @@ -321,20 +321,20 @@ dependencies: "@angular-eslint/bundled-angular-compiler" "20.7.0" -"@angular/animations@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.16.tgz#10ecaa0bf5245c490ded05c6008fb1d0905c46af" - integrity sha512-N83/GFY5lKNyWgPV3xHHy2rb3/eP1ZLzSVI+dmMVbf3jbqwY1YPQcMiAG8UDzaILY1Dkus91kWLF8Qdr3nHAzg== +"@angular/animations@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/animations/-/animations-20.3.18.tgz#e675e045839d559b4917053eb0f74313bf3235ef" + integrity sha512-XFxgSyjfs0SRD2vQVFJljmM4z9nTvUoI8TRqSre/+l8D2FgzD5pG67Aj2BgDgpSFAUkIcI37G48ijK7a3ZZ3WA== dependencies: tslib "^2.3.0" -"@angular/build@20.3.15", "@angular/build@^20.0.0": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.15.tgz#fa1c4661a1ca7991cd8c19957442076a7266bf18" - integrity sha512-DMp/wb3I9/izveXRuOkCTYEQlEzvNlJVnqA215tijOSiJGjYoUsQLazTCxtEx/trftOhVpnMP/2OvvMQVAJJoQ== +"@angular/build@20.3.22", "@angular/build@^20.0.0": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@angular/build/-/build-20.3.22.tgz#c29b7980a96f6353b2d1c5cc2d7bcaf82c46d181" + integrity sha512-sxjVZU6AZHXyKRHJUMawXOj4qMf3vm8XK6wUejr01UKj6BqW2YWaQO26RpRJssXD2ITTqn6+UBwL7pEwe2a4Jg== dependencies: "@ampproject/remapping" "2.3.0" - "@angular-devkit/architect" "0.2003.15" + "@angular-devkit/architect" "0.2003.22" "@babel/core" "7.28.3" "@babel/helper-annotate-as-pure" "7.27.3" "@babel/helper-split-export-declaration" "7.24.7" @@ -350,9 +350,9 @@ magic-string "0.30.17" mrmime "2.0.1" parse5-html-rewriting-stream "8.0.0" - picomatch "4.0.3" + picomatch "4.0.4" piscina "5.1.3" - rollup "4.52.3" + rollup "4.59.0" sass "1.90.0" semver "7.7.2" source-map-support "0.5.21" @@ -370,18 +370,18 @@ parse5 "^8.0.0" tslib "^2.3.0" -"@angular/cli@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.15.tgz#13eec3cf73beb400348a74decacfe2e8bf908580" - integrity sha512-OgPMhXtNLXds0wIw6YU5/X3dU8TlAZbmPy6LYHs9ifF8K4pXpbm27vWGSZhUevSf66dMvfz8wB/aE2e0s2e5Ng== +"@angular/cli@20.3.22": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@angular/cli/-/cli-20.3.22.tgz#ce00d20d55f458de39c80b080a749d4d03e4f9b6" + integrity sha512-0uyQPF0gGuzioWJKNyOzWSQrrC5GiidR+8gz1lODoJTnJZZdsP5n3nvccbcRmhy55B1WByHvQBE+6eDBbh06/g== dependencies: - "@angular-devkit/architect" "0.2003.15" - "@angular-devkit/core" "20.3.15" - "@angular-devkit/schematics" "20.3.15" + "@angular-devkit/architect" "0.2003.22" + "@angular-devkit/core" "20.3.22" + "@angular-devkit/schematics" "20.3.22" "@inquirer/prompts" "7.8.2" "@listr2/prompt-adapter-inquirer" "3.0.1" - "@modelcontextprotocol/sdk" "1.25.2" - "@schematics/angular" "20.3.15" + "@modelcontextprotocol/sdk" "1.26.0" + "@schematics/angular" "20.3.22" "@yarnpkg/lockfile" "1.1.0" algoliasearch "5.35.0" ini "5.0.0" @@ -394,17 +394,17 @@ yargs "18.0.0" zod "4.1.13" -"@angular/common@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.16.tgz#d2f5f1bbc8d65b81989b16b02c74b2529f4541e0" - integrity sha512-GRAziNlntwdnJy3F+8zCOvDdy7id0gITjDnM6P9+n2lXvtDuBLGJKU3DWBbvxcCjtD6JK/g/rEX5fbCxbUHkQQ== +"@angular/common@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/common/-/common-20.3.18.tgz#311dc0658be69f1368db2eeea92ea1b940329975" + integrity sha512-M62oQbSTRmnGavIVCwimoadg/PDWadgNhactMm9fgH0eM9rx+iWBAYJk4VufO0bwOhysFpRZpJgXlFjOifz/Jw== dependencies: tslib "^2.3.0" -"@angular/compiler-cli@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.16.tgz#cc3de2ccc20e75322e4d8cd071e461d7116ae03a" - integrity sha512-l3xF/fXfJAl/UrNnH9Ufkr79myjMgXdHq1mmmph2UnpeqilRB1b8lC9sLBV9MipQHVn3dwocxMIvtrcryfOaXw== +"@angular/compiler-cli@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/compiler-cli/-/compiler-cli-20.3.18.tgz#0f4726d1624c9def0f60c65f60e61a226676459c" + integrity sha512-zsoEgLgnblmRbi47YwMghKirJ8IBKJ3+I8TxLBRIBrhx+KHFp+6oeDeLyu9H+djdyk88zexVd09wzR/YK73F0g== dependencies: "@babel/core" "7.28.3" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -415,31 +415,31 @@ tslib "^2.3.0" yargs "^18.0.0" -"@angular/compiler@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.16.tgz#9970a72d4c7dac5b5bf1b2e9292e55eb22046623" - integrity sha512-Pt9Ms9GwTThgzdxWBwMfN8cH1JEtQ2DK5dc2yxYtPSaD+WKmG9AVL1PrzIYQEbaKcWk2jxASUHpEWSlNiwo8uw== +"@angular/compiler@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/compiler/-/compiler-20.3.18.tgz#5370dc1a24d55623828a2b0875c776c8da13fdc6" + integrity sha512-AaP/LCiDNcYmF135EEozjyR04NRBT38ZfBHQwjhgwiBBTejmvcpHwJaHSkraLpZqZzE4BQqqmgiQ1EJqxEwLVA== dependencies: tslib "^2.3.0" -"@angular/core@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.16.tgz#c30038600e190ece0fa38452ef729e7b46416897" - integrity sha512-KSFPKvOmWWLCJBbEO+CuRUXfecX2FRuO0jNi9c54ptXMOPHlK1lIojUnyXmMNzjdHgRug8ci9qDuftvC2B7MKg== +"@angular/core@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/core/-/core-20.3.18.tgz#af91805841184cd87e862134a806bba019d170ea" + integrity sha512-B+NQQngd/aDbcfW0zGLis3wTLDeHTeTYMl/mGKQH+HwdPaRCKI1wEtaXaOYVJXkP2FeThocPevB8gLwNlPQUUw== dependencies: tslib "^2.3.0" -"@angular/forms@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.16.tgz#dcb055688ea6d4646229733fa23498ca6d1c5a48" - integrity sha512-1yzbXpExTqATpVcqA3wGrq4ACFIP3mRxA4pbso5KoJU+/4JfzNFwLsDaFXKpm5uxwchVnj8KM2vPaDOkvtp7NA== +"@angular/forms@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/forms/-/forms-20.3.18.tgz#ebd249a381dd3f6e5af3f760d7c3a8a6a76531ac" + integrity sha512-x6/99LfxolyZIFUL3Wr0OrtuXHEDwEz/rwx+WzE7NL+n35yO40t3kp0Sn5uMFwI94i91QZJmXHltMpZhrVLuYg== dependencies: tslib "^2.3.0" -"@angular/language-service@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.16.tgz#8d570c24ca2347a2c884b96be34077e181e08ff3" - integrity sha512-0A/tSQPq5geIz2mMcZA5fzzbzT39v+ADQksnfPr8htNxtkYWy+EI5+d0+++k59NuvjLY4uTBqhRTRB9b1PKrjw== +"@angular/language-service@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/language-service/-/language-service-20.3.18.tgz#b0c8c9bcf085907765e3cb7fbf0e14e79259e3c0" + integrity sha512-V1ZBqeTtZYH9H8/G1qCw6gafsJmhMIMFjLX0Hv2KpTpmfK9nxIHPEVnshr3xT+qKYJIrMV/cU5YOzInEapLpuQ== "@angular/material@20.2.14": version "20.2.14" @@ -448,24 +448,24 @@ dependencies: tslib "^2.3.0" -"@angular/platform-browser-dynamic@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.16.tgz#95cd996ca4ed9154e080e97313418bd23f068ffd" - integrity sha512-5mECCV9YeKH6ue239GXRTGeDSd/eTbM1j8dDejhm5cGnPBhTxRw4o+GgSrWTYtb6VmIYdwUGBTC+wCBphiaQ2A== +"@angular/platform-browser-dynamic@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/platform-browser-dynamic/-/platform-browser-dynamic-20.3.18.tgz#c4c633fcc4782e1a361a0904d2bc3f180bbf0f81" + integrity sha512-NyTobOGYVzGmPmtI+3lxMzxi0TbLq4SRNQ2ENEJAt6k2JnMmHBm483ppLRAM47nGlDdiraW0IX93EtYYNkiK3g== dependencies: tslib "^2.3.0" -"@angular/platform-browser@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.16.tgz#08eb56c9ba35b19399a15531422edd597734e082" - integrity sha512-YsrLS6vyS77i4pVHg4gdSBW74qvzHjpQRTVQ5Lv/OxIjJdYYYkMmjNalCNgy1ZuyY6CaLIB11ccxhrNnxfKGOQ== +"@angular/platform-browser@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/platform-browser/-/platform-browser-20.3.18.tgz#33b5d5cdddc4a0f60a9ddf886b941587a348d304" + integrity sha512-q6s5rEN1yYazpHYp+k4pboXRzMsRB9auzTRBEhyXSGYxqzrnn3qHN0DqgsLC9WAdyhCgnIEMFA8kRT+W277DqQ== dependencies: tslib "^2.3.0" -"@angular/router@20.3.16": - version "20.3.16" - resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.16.tgz#c986039bf5546ffe59314bbf139d4561cbed78b6" - integrity sha512-e1LiQFZaajKqc00cY5FboIrWJZSMnZ64GDp5R0UejritYrqorQQQNOqP1W85BMuY2owibMmxVfX+dJg/Mc8PuQ== +"@angular/router@20.3.18": + version "20.3.18" + resolved "https://registry.yarnpkg.com/@angular/router/-/router-20.3.18.tgz#276fe53975ceb858f4e76405f8219fbe5cb20204" + integrity sha512-3CWejsEYr+ze+ktvWN/qHdyq5WLrj96QZpGYJyxh1pchIcpMPE9MmLpdjf0CUrWYB7g/85u0Geq/xsz72JrGng== dependencies: tslib "^2.3.0" @@ -489,19 +489,19 @@ dependencies: tslib "^2.0.0" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1", "@babel/code-frame@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.28.6.tgz#72499312ec58b1e2245ba4a4f550c132be4982f7" - integrity sha512-JYgintcMjRiCvS8mMECzaEn+m3PfoQiyqukOMCCVQtoJGYJw8j/8LBJEiqkHLkfwCcs74E3pbAUFNg7d9VNJ+Q== +"@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.28.5" js-tokens "^4.0.0" picocolors "^1.1.1" "@babel/compat-data@^7.27.7", "@babel/compat-data@^7.28.0", "@babel/compat-data@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.28.6.tgz#103f466803fa0f059e82ccac271475470570d74c" - integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== + 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.28.3", "@babel/core@^7.23.9": version "7.28.3" @@ -535,13 +535,13 @@ "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" -"@babel/generator@^7.28.3", "@babel/generator@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.28.6.tgz#48dcc65d98fcc8626a48f72b62e263d25fc3c3f1" - integrity sha512-lOoVRwADj8hjf7al89tvQ2a1lf53Z+7tiXMgpZJL3maQPDxh0DgLMN62B2MKUOFcoodBHLMbDM6WAbKgNy5Suw== +"@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/parser" "^7.28.6" - "@babel/types" "^7.28.6" + "@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" @@ -704,12 +704,12 @@ "@babel/template" "^7.28.6" "@babel/types" "^7.28.6" -"@babel/parser@^7.23.9", "@babel/parser@^7.28.3", "@babel/parser@^7.28.6": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.28.6.tgz#f01a8885b7fa1e56dd8a155130226cd698ef13fd" - integrity sha512-TeR9zWR18BvbfPmGbLampPMW+uW1NZnJlRuuHso8i87QZNq2JRF9i6RgxRqtEq+wQGsS19NNTWr2duhnE49mfQ== +"@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.28.6" + "@babel/types" "^7.29.0" "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.27.1": version "7.28.5" @@ -1304,22 +1304,22 @@ "@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.28.6" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.28.6.tgz#871ddc79a80599a5030c53b1cc48cbe3a5583c2e" - integrity sha512-fgWX62k02qtjqdSNTAGxmKYY/7FSL9WAS1o2Hu5+I5m9T0yxZzr4cnrfXQ/MX0rIifthCSs6FKTlzYbJcPtMNg== + 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.28.6" - "@babel/generator" "^7.28.6" + "@babel/code-frame" "^7.29.0" + "@babel/generator" "^7.29.0" "@babel/helper-globals" "^7.28.0" - "@babel/parser" "^7.28.6" + "@babel/parser" "^7.29.0" "@babel/template" "^7.28.6" - "@babel/types" "^7.28.6" + "@babel/types" "^7.29.0" debug "^4.3.1" -"@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.4.4": - version "7.28.6" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.28.6.tgz#c3e9377f1b155005bcc4c46020e7e394e13089df" - integrity sha512-0ZrskXVEHSWIqZM/sQZ4EV3jZJXRkio/WCxaqKZP1g//CEWEPSfeZFcms4XeKBCHU0ZKnIkdJeU/kF+eRp5lBg== +"@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.27.1" "@babel/helper-validator-identifier" "^7.28.5" @@ -1531,14 +1531,14 @@ 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.21.1": - version "0.21.1" - resolved "https://registry.yarnpkg.com/@eslint/config-array/-/config-array-0.21.1.tgz#7d1b0060fea407f8301e932492ba8c18aff29713" - integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== +"@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.7" debug "^4.3.1" - minimatch "^3.1.2" + minimatch "^3.1.5" "@eslint/config-helpers@^0.4.2": version "0.4.2" @@ -1554,25 +1554,25 @@ dependencies: "@types/json-schema" "^7.0.15" -"@eslint/eslintrc@^3.3.1": - version "3.3.3" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.3.tgz#26393a0806501b5e2b6a43aa588a4d8df67880ac" - integrity sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ== +"@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.1" - minimatch "^3.1.2" + minimatch "^3.1.5" strip-json-comments "^3.1.1" -"@eslint/js@9.39.2": - version "9.39.2" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-9.39.2.tgz#2d4b8ec4c3ea13c1b3748e0c97ecd766bdd80599" - integrity sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA== +"@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.7": version "2.1.7" @@ -1611,10 +1611,10 @@ lodash "4.17.21" polyclip-ts "^0.16.5" -"@hono/node-server@^1.19.7": - version "1.19.9" - resolved "https://registry.yarnpkg.com/@hono/node-server/-/node-server-1.19.9.tgz#8f37119b1acf283fd3f6035f3d1356fdb97a09ac" - integrity sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw== +"@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" @@ -1822,18 +1822,6 @@ dependencies: tslib "^2.3.0" -"@isaacs/balanced-match@^4.0.1": - version "4.0.1" - resolved "https://registry.yarnpkg.com/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz#3081dadbc3460661b751e7591d7faea5df39dd29" - integrity sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ== - -"@isaacs/brace-expansion@^5.0.0": - version "5.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz#4b3dabab7d8e75a429414a96bd67bf4c1d13e0f3" - integrity sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA== - dependencies: - "@isaacs/balanced-match" "^4.0.1" - "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1885,9 +1873,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" @@ -2220,12 +2208,12 @@ dependencies: make-plural "^7.0.0" -"@modelcontextprotocol/sdk@1.25.2": - version "1.25.2" - resolved "https://registry.yarnpkg.com/@modelcontextprotocol/sdk/-/sdk-1.25.2.tgz#2284560b4e044b4ce5f328ee180931110cb8c5cf" - integrity sha512-LZFeo4F9M5qOhC/Uc1aQSrBHxMrvxett+9KLHt7OhcExtoiRN9DKgbZffMP/nxjutWDQpfMDfP3nkHI4X9ijww== +"@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.7" + "@hono/node-server" "^1.19.9" ajv "^8.17.1" ajv-formats "^3.0.1" content-type "^1.0.5" @@ -2233,14 +2221,15 @@ cross-spawn "^7.0.5" eventsource "^3.0.2" eventsource-parser "^3.0.0" - express "^5.0.1" - express-rate-limit "^7.5.0" - jose "^6.1.1" + 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.0" + zod-to-json-schema "^3.25.1" "@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3": version "3.0.3" @@ -2408,10 +2397,10 @@ dependencies: tslib "^2.0.0" -"@ngtools/webpack@20.3.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.15.tgz#77161b676ff8f98bf8a3549acb3abb710f874f0b" - integrity sha512-7bH91SdVN9xawMg4G+OoQG+nFz9/huUXx/MptR5iOsuRUpNo3PQwMNr07VTRV8o4YAtTTRIf2Yam4fNkb3rRAg== +"@ngtools/webpack@20.3.22": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@ngtools/webpack/-/webpack-20.3.22.tgz#65c7eefb187516f00d2268c60613294cf8d55028" + integrity sha512-EgOiRjYpNG5Mu/WAhMwQrAB1BBwWrApbYm2hTU9KjaxOZvtWHjeFfsiULgd1T76GpiF4t3Iw1GtNljzgD6fT/A== "@ngx-translate/core@^17.0.0": version "17.0.0" @@ -2622,245 +2611,135 @@ resolved "https://registry.yarnpkg.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz#a77ea742fab25775145434eb1d2328cf5013ac33" integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== -"@rollup/rollup-android-arm-eabi@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.3.tgz#7050c2acdc1214a730058e21f613ab0e1fe1ced9" - integrity sha512-h6cqHGZ6VdnwliFG1NXvMPTy/9PS3h8oLh7ImwR+kl+oYnQizgjxsONmmPSb2C66RksfkfIxEVtDSEcJiO0tqw== - -"@rollup/rollup-android-arm-eabi@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.56.0.tgz#067cfcd81f1c1bfd92aefe3ad5ef1523549d5052" - integrity sha512-LNKIPA5k8PF1+jAFomGe3qN3bbIgJe/IlpDBwuVjrDKrJhVWywgnJvflMt/zkbVNLFtF1+94SljYQS6e99klnw== - -"@rollup/rollup-android-arm64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.3.tgz#3f5b2afbfcbe9021649701cf6ff0d54b1fb7e4a5" - integrity sha512-wd+u7SLT/u6knklV/ifG7gr5Qy4GUbH2hMWcDauPFJzmCZUAJ8L2bTkVXC2niOIxp8lk3iH/QX8kSrUxVZrOVw== - -"@rollup/rollup-android-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.56.0.tgz#85e39a44034d7d4e4fee2a1616f0bddb85a80517" - integrity sha512-lfbVUbelYqXlYiU/HApNMJzT1E87UPGvzveGg2h0ktUNlOCxKlWuJ9jtfvs1sKHdwU4fzY7Pl8sAl49/XaEk6Q== - -"@rollup/rollup-darwin-arm64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.3.tgz#70a1679fb4393ba7bafb730ee56a5278cbcdafb0" - integrity sha512-lj9ViATR1SsqycwFkJCtYfQTheBdvlWJqzqxwc9f2qrcVrQaF/gCuBRTiTolkRWS6KvNxSk4KHZWG7tDktLgjg== - -"@rollup/rollup-darwin-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.56.0.tgz#17d92fe98f2cc277b91101eb1528b7c0b6c00c54" - integrity sha512-EgxD1ocWfhoD6xSOeEEwyE7tDvwTgZc8Bss7wCWe+uc7wO8G34HHCUH+Q6cHqJubxIAnQzAsyUsClt0yFLu06w== - -"@rollup/rollup-darwin-x64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.3.tgz#ae75aec88fa72069de9bca3a3ec22bf4e6a962bf" - integrity sha512-+Dyo7O1KUmIsbzx1l+4V4tvEVnVQqMOIYtrxK7ncLSknl1xnMHLgn7gddJVrYPNZfEB8CIi3hK8gq8bDhb3h5A== - -"@rollup/rollup-darwin-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.56.0.tgz#89ae6c66b1451609bd1f297da9384463f628437d" - integrity sha512-1vXe1vcMOssb/hOF8iv52A7feWW2xnu+c8BV4t1F//m9QVLTfNVpEdja5ia762j/UEJe2Z1jAmEqZAK42tVW3g== - -"@rollup/rollup-freebsd-arm64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.3.tgz#8a2bda997faa1d7e335ce1961ce71d1a76ac6288" - integrity sha512-u9Xg2FavYbD30g3DSfNhxgNrxhi6xVG4Y6i9Ur1C7xUuGDW3banRbXj+qgnIrwRN4KeJ396jchwy9bCIzbyBEQ== - -"@rollup/rollup-freebsd-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.56.0.tgz#cdbdb9947b26e76c188a31238c10639347413628" - integrity sha512-bof7fbIlvqsyv/DtaXSck4VYQ9lPtoWNFCB/JY4snlFuJREXfZnm+Ej6yaCHfQvofJDXLDMTVxWscVSuQvVWUQ== - -"@rollup/rollup-freebsd-x64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.3.tgz#fc287bcc39b9a9c0df97336d68fd5f4458f87977" - integrity sha512-5M8kyi/OX96wtD5qJR89a/3x5x8x5inXBZO04JWhkQb2JWavOWfjgkdvUqibGJeNNaz1/Z1PPza5/tAPXICI6A== - -"@rollup/rollup-freebsd-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.56.0.tgz#9b1458d07b6e040be16ee36d308a2c9520f7f7cc" - integrity sha512-KNa6lYHloW+7lTEkYGa37fpvPq+NKG/EHKM8+G/g9WDU7ls4sMqbVRV78J6LdNuVaeeK5WB9/9VAFbKxcbXKYg== - -"@rollup/rollup-linux-arm-gnueabihf@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.3.tgz#5b5a2a55dffaa64d7c7a231e80e491219e33d4f3" - integrity sha512-IoerZJ4l1wRMopEHRKOO16e04iXRDyZFZnNZKrWeNquh5d6bucjezgd+OxG03mOMTnS1x7hilzb3uURPkJ0OfA== - -"@rollup/rollup-linux-arm-gnueabihf@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.56.0.tgz#1d50ded7c965d5f125f5832c971ad5b287befef7" - integrity sha512-E8jKK87uOvLrrLN28jnAAAChNq5LeCd2mGgZF+fGF5D507WlG/Noct3lP/QzQ6MrqJ5BCKNwI9ipADB6jyiq2A== - -"@rollup/rollup-linux-arm-musleabihf@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.3.tgz#979eab95003c21837ea0fdd8a721aa3e69fa4aa3" - integrity sha512-ZYdtqgHTDfvrJHSh3W22TvjWxwOgc3ThK/XjgcNGP2DIwFIPeAPNsQxrJO5XqleSlgDux2VAoWQ5iJrtaC1TbA== - -"@rollup/rollup-linux-arm-musleabihf@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.56.0.tgz#53597e319b7e65990d3bc2a5048097384814c179" - integrity sha512-jQosa5FMYF5Z6prEpTCCmzCXz6eKr/tCBssSmQGEeozA9tkRUty/5Vx06ibaOP9RCrW1Pvb8yp3gvZhHwTDsJw== - -"@rollup/rollup-linux-arm64-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.3.tgz#53b89f1289cbeca5ed9b6ca1602a6fe1a29dd4e2" - integrity sha512-NcViG7A0YtuFDA6xWSgmFb6iPFzHlf5vcqb2p0lGEbT+gjrEEz8nC/EeDHvx6mnGXnGCC1SeVV+8u+smj0CeGQ== - -"@rollup/rollup-linux-arm64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.56.0.tgz#597002909dec198ca4bdccb25f043d32db3d6283" - integrity sha512-uQVoKkrC1KGEV6udrdVahASIsaF8h7iLG0U0W+Xn14ucFwi6uS539PsAr24IEF9/FoDtzMeeJXJIBo5RkbNWvQ== - -"@rollup/rollup-linux-arm64-musl@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.3.tgz#3bbcf5e13c09d0c4c55bd9c75ec6a7aeee56fe28" - integrity sha512-d3pY7LWno6SYNXRm6Ebsq0DJGoiLXTb83AIPCXl9fmtIQs/rXoS8SJxxUNtFbJ5MiOvs+7y34np77+9l4nfFMw== - -"@rollup/rollup-linux-arm64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.56.0.tgz#286f0e0f799545ce288bdc5a7c777261fcba3d54" - integrity sha512-vLZ1yJKLxhQLFKTs42RwTwa6zkGln+bnXc8ueFGMYmBTLfNu58sl5/eXyxRa2RarTkJbXl8TKPgfS6V5ijNqEA== - -"@rollup/rollup-linux-loong64-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.3.tgz#1cc71838465a8297f92ccc5cc9c29756b71f6e73" - integrity sha512-3y5GA0JkBuirLqmjwAKwB0keDlI6JfGYduMlJD/Rl7fvb4Ni8iKdQs1eiunMZJhwDWdCvrcqXRY++VEBbvk6Eg== - -"@rollup/rollup-linux-loong64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.56.0.tgz#1fab07fa1a4f8d3697735b996517f1bae0ba101b" - integrity sha512-FWfHOCub564kSE3xJQLLIC/hbKqHSVxy8vY75/YHHzWvbJL7aYJkdgwD/xGfUlL5UV2SB7otapLrcCj2xnF1dg== - -"@rollup/rollup-linux-loong64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.56.0.tgz#efc2cb143d6c067f95205482afb177f78ed9ea3d" - integrity sha512-z1EkujxIh7nbrKL1lmIpqFTc/sr0u8Uk0zK/qIEFldbt6EDKWFk/pxFq3gYj4Bjn3aa9eEhYRlL3H8ZbPT1xvA== - -"@rollup/rollup-linux-ppc64-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.3.tgz#fe3fdf2ef57dc2d58fedd4f1e0678660772c843a" - integrity sha512-AUUH65a0p3Q0Yfm5oD2KVgzTKgwPyp9DSXc3UA7DtxhEb/WSPfbG4wqXeSN62OG5gSo18em4xv6dbfcUGXcagw== - -"@rollup/rollup-linux-ppc64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.56.0.tgz#e8de8bd3463f96b92b7dfb7f151fd80ffe8a937c" - integrity sha512-iNFTluqgdoQC7AIE8Q34R3AuPrJGJirj5wMUErxj22deOcY7XwZRaqYmB6ZKFHoVGqRcRd0mqO+845jAibKCkw== - -"@rollup/rollup-linux-ppc64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.56.0.tgz#8c508fe28a239da83b3a9da75bcf093186e064b4" - integrity sha512-MtMeFVlD2LIKjp2sE2xM2slq3Zxf9zwVuw0jemsxvh1QOpHSsSzfNOTH9uYW9i1MXFxUSMmLpeVeUzoNOKBaWg== - -"@rollup/rollup-linux-riscv64-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.3.tgz#eebc99e75832891d58532501879ca749b1592f93" - integrity sha512-1makPhFFVBqZE+XFg3Dkq+IkQ7JvmUrwwqaYBL2CE+ZpxPaqkGaiWFEWVGyvTwZace6WLJHwjVh/+CXbKDGPmg== - -"@rollup/rollup-linux-riscv64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.56.0.tgz#ff6d51976e0830732880770a9e18553136b8d92b" - integrity sha512-in+v6wiHdzzVhYKXIk5U74dEZHdKN9KH0Q4ANHOTvyXPG41bajYRsy7a8TPKbYPl34hU7PP7hMVHRvv/5aCSew== - -"@rollup/rollup-linux-riscv64-musl@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.3.tgz#9a2df234d61763a44601eba17c36844a18f20539" - integrity sha512-OOFJa28dxfl8kLOPMUOQBCO6z3X2SAfzIE276fwT52uXDWUS178KWq0pL7d6p1kz7pkzA0yQwtqL0dEPoVcRWg== - -"@rollup/rollup-linux-riscv64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.56.0.tgz#325fb35eefc7e81d75478318f0deee1e4a111493" - integrity sha512-yni2raKHB8m9NQpI9fPVwN754mn6dHQSbDTwxdr9SE0ks38DTjLMMBjrwvB5+mXrX+C0npX0CVeCUcvvvD8CNQ== - -"@rollup/rollup-linux-s390x-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.3.tgz#f0e45ea7e41ee473c85458b1ec8fab9572cc1834" - integrity sha512-jMdsML2VI5l+V7cKfZx3ak+SLlJ8fKvLJ0Eoa4b9/vCUrzXKgoKxvHqvJ/mkWhFiyp88nCkM5S2v6nIwRtPcgg== - -"@rollup/rollup-linux-s390x-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.56.0.tgz#37410fabb5d3ba4ad34abcfbe9ba9b6288413f30" - integrity sha512-zhLLJx9nQPu7wezbxt2ut+CI4YlXi68ndEve16tPc/iwoylWS9B3FxpLS2PkmfYgDQtosah07Mj9E0khc3Y+vQ== +"@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.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.3.tgz#ed63dec576799fa5571eee5b2040f65faa82b49b" - integrity sha512-tPgGd6bY2M2LJTA1uGq8fkSPK8ZLYjDjY+ZLK9WHncCnfIz29LIXIqUgzCR0hIefzy6Hpbe8Th5WOSwTM8E7LA== - -"@rollup/rollup-linux-x64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.56.0.tgz#8ef907a53b2042068fc03fcc6a641e2b02276eca" - integrity sha512-MVC6UDp16ZSH7x4rtuJPAEoE1RwS8N4oK9DLHy3FTEdFoUTCFVzMfJl/BVJ330C+hx8FfprA5Wqx4FhZXkj2Kw== - -"@rollup/rollup-linux-x64-musl@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.3.tgz#755c56ac79b17fbdf0359bce7e2293a11de30ad0" - integrity sha512-BCFkJjgk+WFzP+tcSMXq77ymAPIxsX9lFJWs+2JzuZTLtksJ2o5hvgTdIcZ5+oKzUDMwI0PfWzRBYAydAHF2Mw== - -"@rollup/rollup-linux-x64-musl@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.56.0.tgz#61b9ba09ea219e0174b3f35a6ad2afc94bdd5662" - integrity sha512-ZhGH1eA4Qv0lxaV00azCIS1ChedK0V32952Md3FtnxSqZTBTd6tgil4nZT5cU8B+SIw3PFYkvyR4FKo2oyZIHA== - -"@rollup/rollup-openbsd-x64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.56.0.tgz#fc4e54133134c1787d0b016ffdd5aeb22a5effd3" - integrity sha512-O16XcmyDeFI9879pEcmtWvD/2nyxR9mF7Gs44lf1vGGx8Vg2DRNx11aVXBEqOQhWb92WN4z7fW/q4+2NYzCbBA== - -"@rollup/rollup-openharmony-arm64@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.3.tgz#84b4170fe28c2b41e406add6ccf8513bf91195ea" - integrity sha512-KTD/EqjZF3yvRaWUJdD1cW+IQBk4fbQaHYJUmP8N4XoKFZilVL8cobFSTDnjTtxWJQ3JYaMgF4nObY/+nYkumA== - -"@rollup/rollup-openharmony-arm64@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.56.0.tgz#959ae225b1eeea0cc5b7c9f88e4834330fb6cd09" - integrity sha512-LhN/Reh+7F3RCgQIRbgw8ZMwUwyqJM+8pXNT6IIJAqm2IdKkzpCh/V9EdgOMBKuebIrzswqy4ATlrDgiOwbRcQ== - -"@rollup/rollup-win32-arm64-msvc@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.3.tgz#4fb0cd004183da819bec804eba70f1ef6936ccbf" - integrity sha512-+zteHZdoUYLkyYKObGHieibUFLbttX2r+58l27XZauq0tcWYYuKUwY2wjeCN9oK1Um2YgH2ibd6cnX/wFD7DuA== - -"@rollup/rollup-win32-arm64-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.56.0.tgz#842acd38869fa1cbdbc240c76c67a86f93444c27" - integrity sha512-kbFsOObXp3LBULg1d3JIUQMa9Kv4UitDmpS+k0tinPBz3watcUiV2/LUDMMucA6pZO3WGE27P7DsfaN54l9ing== - -"@rollup/rollup-win32-ia32-msvc@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.3.tgz#1788ba80313477a31e6214390906201604ee38eb" - integrity sha512-of1iHkTQSo3kr6dTIRX6t81uj/c/b15HXVsPcEElN5sS859qHrOepM5p9G41Hah+CTqSh2r8Bm56dL2z9UQQ7g== - -"@rollup/rollup-win32-ia32-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.56.0.tgz#7ab654def4042df44cb29f8ed9d5044e850c66d5" - integrity sha512-vSSgny54D6P4vf2izbtFm/TcWYedw7f8eBrOiGGecyHyQB9q4Kqentjaj8hToe+995nob/Wv48pDqL5a62EWtg== - -"@rollup/rollup-win32-x64-gnu@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.3.tgz#867222f288a9557487900c7836998123ebbadc9d" - integrity sha512-s0hybmlHb56mWVZQj8ra9048/WZTPLILKxcvcq+8awSZmyiSUZjjem1AhU3Tf4ZKpYhK4mg36HtHDOe8QJS5PQ== - -"@rollup/rollup-win32-x64-gnu@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.56.0.tgz#7426cdec1b01d2382ffd5cda83cbdd1c8efb3ca6" - integrity sha512-FeCnkPCTHQJFbiGG49KjV5YGW/8b9rrXAM2Mz2kiIoktq2qsJxRD5giEMEOD2lPdgs72upzefaUvS+nc8E3UzQ== - -"@rollup/rollup-win32-x64-msvc@4.52.3": - version "4.52.3" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.3.tgz#3f55b6e8fe809a7d29959d6bc686cce1804581f0" - integrity sha512-zGIbEVVXVtauFgl3MRwGWEN36P5ZGenHRMgNw88X5wEhEBpq0XrMEZwOn07+ICrwM17XO5xfMZqh0OldCH5VTA== - -"@rollup/rollup-win32-x64-msvc@4.56.0": - version "4.56.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.56.0.tgz#9eec0212732a432c71bde0350bc40b673d15b2db" - integrity sha512-H8AE9Ur/t0+1VXujj90w0HrSOuv0Nq9r1vSZF2t5km20NTfosQsGGUXDaKdQZzwuLts7IyL1fYT4hM95TI9c4g== +"@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" @@ -2872,13 +2751,13 @@ 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.15": - version "20.3.15" - resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.15.tgz#b66d9c3b5550bc660fb80ec8fdeead43df9a776d" - integrity sha512-WkhW1HO8pA8JT8e27tvjQHQg8eO5KaOz+WsGkN00RyL5DwHgPSzu4a3eYug+b3rW7OGFub7jadXBuGSrzqgonA== +"@schematics/angular@20.3.22": + version "20.3.22" + resolved "https://registry.yarnpkg.com/@schematics/angular/-/angular-20.3.22.tgz#acd7a990148a99f04793ee2e92a56694667790a4" + integrity sha512-wXTdFaPIBnSSNj/m0kclvPCYQOc2EGTQN1+Q3j9RIghS9gKgPxI1unSfgieJldZWKzcl8+WdB2zUuDzE7tEshQ== dependencies: - "@angular-devkit/core" "20.3.15" - "@angular-devkit/schematics" "20.3.15" + "@angular-devkit/core" "20.3.22" + "@angular-devkit/schematics" "20.3.22" jsonc-parser "3.3.1" "@sigstore/bundle@^4.0.0": @@ -3817,7 +3696,7 @@ adjust-sourcemap-loader@^4.0.0: loader-utils "^2.0.0" regex-parser "^2.2.11" -agent-base@^7.1.0, agent-base@^7.1.1, agent-base@^7.1.2: +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== @@ -3843,20 +3722,20 @@ ajv-keywords@^5.1.0: dependencies: fast-deep-equal "^3.1.3" -ajv@8.17.1, ajv@^8.0.0, ajv@^8.17.1, 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: - 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" @@ -3920,9 +3799,9 @@ ansi-colors@4.1.3: integrity sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw== 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" @@ -3936,10 +3815,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" @@ -3956,9 +3835,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" @@ -4181,15 +4060,20 @@ balanced-match@^1.0.0: 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== baseline-browser-mapping@^2.9.0: - version "2.9.17" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.9.17.tgz#9d6019766cd7eba738cb5f32c84b9f937cc87780" - integrity sha512-agD0MgJFUP/4nvjqzIB29zRPUuCF7Ge6mEv9s8dHrtYD7QWXRcx75rOADE/d5ah1NI+0vkDl0yorDd5U852IQQ== + 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" @@ -4272,19 +4156,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" @@ -4385,9 +4269,9 @@ camelcase@^5.0.0: integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== caniuse-lite@^1.0.30001702, caniuse-lite@^1.0.30001759, caniuse-lite@^1.0.30001760: - version "1.0.30001766" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001766.tgz#b6f6b55cb25a2d888d9393104d14751c6a7d6f7a" - integrity sha512-4C0lfJ0/YPjJQHagaE9x2Elb69CIqEPZeG0anQt9SIvIoOH4a4uaRl73IavyO+0qZh6MDLH//DrXThEYKHkmYA== + 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" @@ -4456,9 +4340,9 @@ chokidar@^3.6.0: 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" @@ -4721,15 +4605,15 @@ copy-anything@^2.0.1: dependencies: is-what "^3.14.1" -copy-webpack-plugin@13.0.1: - version "13.0.1" - resolved "https://registry.yarnpkg.com/copy-webpack-plugin/-/copy-webpack-plugin-13.0.1.tgz#fba18c22bcab3633524e1b652580ff4489eddc0d" - integrity sha512-J+YV3WfhY6W/Xf9h+J1znYuqTye2xkBUIGyTPWuBAT27qajBa5mR4f8WBmfDY3YjRftT2kqZZiLi1qf0H+UOFw== +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: glob-parent "^6.0.1" 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.43.0: @@ -5428,14 +5312,14 @@ ee-first@1.1.1: integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== electron-to-chromium@^1.5.263: - version "1.5.278" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.278.tgz#807a5e321f012a41bfd64e653f35993c9af95493" - integrity sha512-dQ0tM1svDRQOwxnXxm+twlGTjr9Upvt8UFWAgmLsxEzFQxhbti4VwxmMjsDxVC51Zo84swW7FVCXEV+VAkhuPw== + 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" @@ -5469,13 +5353,13 @@ encoding@^0.1.13: dependencies: iconv-lite "^0.6.2" -enhanced-resolve@^5.17.4: - version "5.18.4" - resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.18.4.tgz#c22d33055f3952035ce6a144ce092447c525f828" - integrity sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q== +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: version "4.5.0" @@ -5801,24 +5685,24 @@ eslint-visitor-keys@^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.39.2: - version "9.39.2" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-9.39.2.tgz#cb60e6d16ab234c0f8369a3fe7cc87967faf4b6c" - integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== +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.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.21.1" + "@eslint/config-array" "^0.21.2" "@eslint/config-helpers" "^0.4.2" "@eslint/core" "^0.17.0" - "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.39.2" + "@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.2" "@types/estree" "^1.0.6" - ajv "^6.12.4" + ajv "^6.14.0" chalk "^4.0.0" cross-spawn "^7.0.6" debug "^4.3.2" @@ -5837,7 +5721,7 @@ eslint@~9.39.2: 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" @@ -5904,9 +5788,9 @@ 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" @@ -5930,10 +5814,12 @@ exponential-backoff@^3.1.1: resolved "https://registry.yarnpkg.com/exponential-backoff/-/exponential-backoff-3.1.1.tgz#64ac7526fe341ab18a39016cd22c787d01e00bf6" integrity sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw== -express-rate-limit@^7.5.0: - version "7.5.1" - resolved "https://registry.yarnpkg.com/express-rate-limit/-/express-rate-limit-7.5.1.tgz#8c3a42f69209a3a1c969890070ece9e20a879dec" - integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== +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" @@ -5972,7 +5858,7 @@ express@^4.21.2: utils-merge "1.0.1" vary "~1.1.2" -express@^5.0.1: +express@^5.2.1: version "5.2.1" resolved "https://registry.yarnpkg.com/express/-/express-5.2.1.tgz#8f21d15b6d327f92b4794ecf8cb08a72f956ac04" integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== @@ -6033,9 +5919,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" @@ -6147,9 +6033,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" @@ -6184,13 +6070,15 @@ foreground-child@^3.1.0: cross-spawn "^7.0.0" signal-exit "^4.0.1" -form-data@4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" - integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== +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" + es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" formdata-polyfill@^4.0.10: @@ -6299,10 +6187,10 @@ 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.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" @@ -6494,6 +6382,11 @@ hasown@^2.0.2: dependencies: function-bind "^1.1.2" +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" @@ -6682,9 +6575,9 @@ immediate@~3.0.5: integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== immutable@^5.0.2: - version "5.1.4" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.4.tgz#e3f8c1fe7b567d56cf26698f31918c241dae8c1f" - integrity sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA== + 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" @@ -6748,13 +6641,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" @@ -6875,11 +6765,11 @@ 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" @@ -7120,7 +7010,7 @@ jiti@^1.20.0, jiti@^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.1: +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== @@ -7182,11 +7072,6 @@ js-yaml@^4.1.0, js-yaml@^4.1.1: 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@~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" @@ -7849,34 +7734,27 @@ 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@^10.0.3, minimatch@^10.1.1: - version "10.1.1" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.1.1.tgz#e6e61b9b0c1dcab116b5a7d1458e8b6ae9e73a55" - integrity sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ== + version "10.2.3" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.3.tgz#c0ef582f21071b0123a5bbf275252ebda921fbf6" + integrity sha512-Rwi3pnapEqirPSbWbrZaa6N3nmqq4Xer/2XooiOKyV3q12ML06f7MOuc5DVH8ONZIFhwIYQ3yzPH4nt7iWHaTg== dependencies: - "@isaacs/brace-expansion" "^5.0.0" + 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@^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 "^1.1.7" -minimatch@^9.0.4, minimatch@^9.0.5: - version "9.0.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-9.0.5.tgz#d74f9dd6b57d83d8e98cfb82133b03978bc929e5" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== - dependencies: - brace-expansion "^2.0.1" - minimist@1.2.8, minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" @@ -8098,9 +7976,9 @@ ngx-drag-drop@^20.0.1: dependencies: tslib "^2.3.0" -"ngx-flowchart@https://github.com/thingsboard/ngx-flowchart.git#release/4.0.0": - version "4.0.0" - resolved "https://github.com/thingsboard/ngx-flowchart.git#735bc818ef218a169ac50e87edc6a093b3e97715" +"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" @@ -8175,9 +8053,9 @@ node-fetch@^3.3.2: formdata-polyfill "^4.0.10" 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== + 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" @@ -8674,14 +8552,14 @@ path-scurry@^2.0.0: minipass "^7.1.2" path-to-regexp@^8.0.0: - version "8.3.0" - resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.3.0.tgz#aa818a6981f99321003a08987d3cec9c3474cd1f" - integrity sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA== + version "8.4.0" + resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-8.4.0.tgz#8e98fcd94826aff01a90c544ef74ffbaca3a78ed" + integrity sha512-PuseHIvAnz3bjrM2rGJtSgo1zjgxapTLZ7x2pjhzWwlp4SJQgK3f3iZIQwkpEnBaKz6seKBADpM4B4ySkuYypg== 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== + 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@^1.1.2: version "1.1.2" @@ -8701,15 +8579,15 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@4.0.3, picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +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" @@ -8974,13 +8852,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" @@ -9050,9 +8921,9 @@ readable-stream@^3.0.6: 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" @@ -9237,69 +9108,38 @@ robust-predicates@^3.0.2: resolved "https://registry.yarnpkg.com/robust-predicates/-/robust-predicates-3.0.2.tgz#d5b28528c4824d20fc48df1928d41d9efa1ad771" integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== -rollup@4.52.3: - version "4.52.3" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.52.3.tgz#cc5c28d772b022ce48b235a97b347ccd9d88c1a3" - integrity sha512-RIDh866U8agLgiIcdpB+COKnlCreHJLfIhWC3LVflku5YHfpnsIKigRZeFfMfCc4dVcqNVfQQ5gO/afOck064A== +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.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.52.3" - "@rollup/rollup-android-arm64" "4.52.3" - "@rollup/rollup-darwin-arm64" "4.52.3" - "@rollup/rollup-darwin-x64" "4.52.3" - "@rollup/rollup-freebsd-arm64" "4.52.3" - "@rollup/rollup-freebsd-x64" "4.52.3" - "@rollup/rollup-linux-arm-gnueabihf" "4.52.3" - "@rollup/rollup-linux-arm-musleabihf" "4.52.3" - "@rollup/rollup-linux-arm64-gnu" "4.52.3" - "@rollup/rollup-linux-arm64-musl" "4.52.3" - "@rollup/rollup-linux-loong64-gnu" "4.52.3" - "@rollup/rollup-linux-ppc64-gnu" "4.52.3" - "@rollup/rollup-linux-riscv64-gnu" "4.52.3" - "@rollup/rollup-linux-riscv64-musl" "4.52.3" - "@rollup/rollup-linux-s390x-gnu" "4.52.3" - "@rollup/rollup-linux-x64-gnu" "4.52.3" - "@rollup/rollup-linux-x64-musl" "4.52.3" - "@rollup/rollup-openharmony-arm64" "4.52.3" - "@rollup/rollup-win32-arm64-msvc" "4.52.3" - "@rollup/rollup-win32-ia32-msvc" "4.52.3" - "@rollup/rollup-win32-x64-gnu" "4.52.3" - "@rollup/rollup-win32-x64-msvc" "4.52.3" - fsevents "~2.3.2" - -rollup@^4.43.0: - version "4.56.0" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.56.0.tgz#65959d13cfbd7e48b8868c05165b1738f0143862" - integrity sha512-9FwVqlgUHzbXtDg9RCMgodF3Ua4Na6Gau+Sdt9vyCN4RhHfVKX2DCHy3BjMLTDd47ITDhYAnTwGulWTblJSDLg== - dependencies: - "@types/estree" "1.0.8" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.56.0" - "@rollup/rollup-android-arm64" "4.56.0" - "@rollup/rollup-darwin-arm64" "4.56.0" - "@rollup/rollup-darwin-x64" "4.56.0" - "@rollup/rollup-freebsd-arm64" "4.56.0" - "@rollup/rollup-freebsd-x64" "4.56.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.56.0" - "@rollup/rollup-linux-arm-musleabihf" "4.56.0" - "@rollup/rollup-linux-arm64-gnu" "4.56.0" - "@rollup/rollup-linux-arm64-musl" "4.56.0" - "@rollup/rollup-linux-loong64-gnu" "4.56.0" - "@rollup/rollup-linux-loong64-musl" "4.56.0" - "@rollup/rollup-linux-ppc64-gnu" "4.56.0" - "@rollup/rollup-linux-ppc64-musl" "4.56.0" - "@rollup/rollup-linux-riscv64-gnu" "4.56.0" - "@rollup/rollup-linux-riscv64-musl" "4.56.0" - "@rollup/rollup-linux-s390x-gnu" "4.56.0" - "@rollup/rollup-linux-x64-gnu" "4.56.0" - "@rollup/rollup-linux-x64-musl" "4.56.0" - "@rollup/rollup-openbsd-x64" "4.56.0" - "@rollup/rollup-openharmony-arm64" "4.56.0" - "@rollup/rollup-win32-arm64-msvc" "4.56.0" - "@rollup/rollup-win32-ia32-msvc" "4.56.0" - "@rollup/rollup-win32-x64-gnu" "4.56.0" - "@rollup/rollup-win32-x64-msvc" "4.56.0" + "@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: @@ -9363,7 +9203,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== @@ -9463,7 +9303,7 @@ semver@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, semver@^7.0.0, semver@^7.1.1, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.7.3: +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== @@ -9478,6 +9318,11 @@ 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.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" @@ -9514,12 +9359,10 @@ send@~0.19.0, send@~0.19.1: range-parser "~1.2.1" statuses "~2.0.2" -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" @@ -9700,9 +9543,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" @@ -9722,20 +9565,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: @@ -9841,11 +9684,6 @@ split.js@^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" @@ -9978,11 +9816,11 @@ 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" @@ -10088,15 +9926,15 @@ tailwindcss@^3.4.19: resolve "^1.22.8" sucrase "^3.35.0" -tapable@^2.2.0, tapable@^2.2.1, tapable@^2.3.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.6" - resolved "https://registry.yarnpkg.com/tar/-/tar-7.5.6.tgz#2db7a210748a82f0a89cc31527b90d3a24984fb7" - integrity sha512-xqUeu2JAIJpXyvskvU3uvQW8PAmHrtXp2KDuMJwQqW8Sqq0CaZBAQ+dKS3RBXVhU4wC5NjAdKrmh84241gO9cA== + 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" @@ -10105,14 +9943,13 @@ tar@^7.4.3, tar@^7.5.2: yallist "^5.0.0" terser-webpack-plugin@^5.3.16: - version "5.3.16" - resolved "https://registry.yarnpkg.com/terser-webpack-plugin/-/terser-webpack-plugin-5.3.16.tgz#741e448cc3f93d8026ebe4f7ef9e4afacfd56330" - integrity sha512-h9oBFCWrq78NyWWVcSwZarJkZ01c2AyGrzs1crmHZO3QUg9D61Wu4NPjBy69n7JqylFF5y+CsUZYmYEIZ3mR+Q== + 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.25" jest-worker "^27.4.5" schema-utils "^4.3.0" - serialize-javascript "^6.0.2" terser "^5.31.1" terser@5.43.1: @@ -10626,7 +10463,7 @@ watchpack@2.4.4: glob-to-regexp "^0.4.1" graceful-fs "^4.1.2" -watchpack@^2.4.4: +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== @@ -10737,10 +10574,10 @@ webpack-subresource-integrity@5.1.0: dependencies: typed-assert "^1.0.8" -webpack@5.104.1: - version "5.104.1" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.104.1.tgz#94bd41eb5dbf06e93be165ba8be41b8260d4fb1a" - integrity sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA== +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" @@ -10752,7 +10589,7 @@ webpack@5.104.1: acorn-import-phases "^1.0.3" browserslist "^4.28.1" chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.4" + enhanced-resolve "^5.19.0" es-module-lexer "^2.0.0" eslint-scope "5.1.1" events "^3.2.0" @@ -10765,7 +10602,7 @@ webpack@5.104.1: schema-utils "^4.3.3" tapable "^2.3.0" terser-webpack-plugin "^5.3.16" - watchpack "^2.4.4" + watchpack "^2.5.1" webpack-sources "^3.3.3" websocket-driver@>=0.5.1, websocket-driver@^0.7.4: @@ -10907,9 +10744,9 @@ 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" @@ -11019,7 +10856,7 @@ yoctocolors-cjs@^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.0: +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==